호댕의 iOS 개발

[TWL] 22.01.03 ~ 22.01.07 (CollectionView, Date, XCTFail, KeyDecodingStrategy, Result, URLSession 등) 본문

Software Engineering/TIL

[TWL] 22.01.03 ~ 22.01.07 (CollectionView, Date, XCTFail, KeyDecodingStrategy, Result, URLSession 등)

호르댕댕댕 2022. 1. 10. 22:48

이번 주는 컬렉션 뷰, URLSession에 대해 중점적으로 공부했다. 

테이블 뷰도 배울 때 어렵다고 생각했었는데 컬렉션 뷰가 테이블 뷰보다 복잡한 것 같다. 그래도 컬렉션 뷰는 테이블 뷰와 닮은 점도 많고 공통된 메서드도 많아 공부하다 보면 잘 알게 되겠지?

 


ViewController와 TableViewController를 사용하는 것의 차이

일단 UITableVIewController를 사용하면 커스텀을 하여 사용하기가 어려워진다. 즉, 화면에 TableView만 있다면 TableViewController를 사용하는 것이 좋지만, TableView 이외에 다른 복합적인 view가 있는 경우 UIViewController를 사용하는 것이 낫다.

하지만 항상 기획이 변경될 수 있는 만큼 UIViewController를 사용하는 것이 더 나은 선택일 수 있다.

UICollectionViewLayout

셀을 나타내는 것은 TableView처럼 항상 리스트 형식만 있는 것은 아니다. Collection View에선 따로 레이아웃이 존재한다.

UICollectionViewLayout는 이를 구현해줄 수 있는 추상 클래스이다. (여기서 추상 클래스란 이를 상속받아서 반드시 그 위에 구현해줘야 하는 것을 의미한다)

컬렉션 뷰의 경우 기본으로 제공되는 셀의 스타일이 존재하지 않는다. 따라서 처음부터 셀 모양이나 크기를 지정해주고 만들어서 사용해야 하기 때문에, 테이블 뷰보다 해줘야 하는 일이 많다.

indexPath의 경우 컬렉션 뷰에서도 동일하게 사용하긴 하나 row대신 item을 사용한다.

컬렉션 뷰와 테이블 뷰의 차이점

공통점 테이블뷰 컬렉션뷰
1.indexPath.section이 존재한다. (+header, footer)
2. 둘다 delegate와 datasource를 사용한다.
1. indexPath.row로 접근한다.
2. 수직 스크롤만 가능하다.
3. 기본 셀이 존재한다.
1. indexPath.item으로 접근한다.
2. 수평 스크롤만 가능하다.
3. layout 개념이 존재한다.
4. prefetch 개념이 존재한다.
  • 모드가 바뀔 수 있고, 기기마다 다른 레이아웃을 사용한다. -> Collection View
  • 항상 List로만 보일 예정이다. -> Table View

Drag & Drop

하나의 테이블뷰, 컬렉션뷰에서의 이동은 단순히 셀의 이동(Move)이라고 볼 수 있다.

Drag & Drop은 하나의 테이블 뷰에서 다른 테이블 뷰로 보낼 때, 하나의 신에서 다른 신(scene)으로 이동할 때, 하나의 앱에서 다른 앱으로 보낼 때를 의미한다.

JSON 데이터에 있는 타입과 받아야 하는 타입이 다른 경우

API 명세에는 createdAt, issueAt이 Date 타입으로 되어있지만 Mock 데이터인 JSON 데이터에는 String으로 되어 있는 문제가 있었다.

이 때 init(from decoder: Decoder)를 따로 작성해주었다. 만약 타입이 동일했다면 init을 생략해도 괜찮았지만 String 타입을 Date로 바꿔줘야 했기에 따로 작성했다. (key 값을 바꾸는 경우 따로 CodingKeys를 적어주는 것도 같은 맥락이다)

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Self.CodingKeys)
        id = try container.decode(Int.self, forKey: .id)
        vendorId = try container.decode(Int.self, forKey: .vendorId)
        name = try container.decode(String.self, forKey: .name)
        thumbnail = try container.decode(String.self, forKey: .thumbnail)
        currency = try container.decode(Currency.self, forKey: .currency)
        price = try container.decode(Int.self, forKey: .price)
        bargainPrice = try container.decode(Int.self, forKey: .bargainPrice)
        discountedPrice = try container.decode(Int.self, forKey: .discountedPrice)
        stock = try container.decode(Int.self, forKey: .stock)

        let createdAt = try container.decode(String.self, forKey: .createdAt)
        let issuedAt = try container.decode(String.self, forKey: .issuedAt)

        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
        guard let formattedCreatedAt = formatter.date(from: createdAt),
              let formattedIssuedAt = formatter.date(from: issuedAt) else {
                  throw FormattingError.dateFormattingFail
              }
        self.issuedAt = formattedIssuedAt
        self.createdAt = formattedCreatedAt
    }

여기서 container(keyedBy: )는 주어진 key의 타입대로 키가 지정된 container에 표시된 대로 디코더에 저장된 데이터를 반환해주는 메서드이다.

Returns the data stored in this decoder as represented in a container keyed by the given key type.

즉 여기선 해당 타입의 CodingKey대로 디코더에 저장된 데이터를 반환해줄 수 있도록 하는 것이다. 반환한 값을 container 상수에 넣어준 뒤 여기서 decode를 해주게 된다.

일단 여기서 decode를 하게 되면 JSON 데이터처럼 String 타입이 된다. 따라서 여기서 String을 Date 타입으로 변환해주어 이를 해결했다.

Date

Date는 코드 스타터를 할 때 잠깐 배우고 오랫동안 사용하지 않았었는데 다시 만나게 되었다.

일단 String 타입을 Date 타입으로 변환해주기 위해 date(from:)메서드를 사용했다.

Returns a date representation of a specified string that the system interprets using the receiver’s current settings.

이는 특정 String을 Date로 보여주는 메서드로 수신자의 현재 세팅을 사용하여 변환을 해준다.

참고 링크

XCTFail()

만약 XCTAssert~ 코드가 아예 실행이 되지 않으면 테스트가 통과했다고 나오게 된다. 즉, func test_~의 내부에 아무 코드를 작성하지 않아도 테스트는 통과하게 된다.

    func test_decode가_잘_되는지() {
        do {
            let decoded: ProductsList? = try JSONParser.decode(from: "products")
            XCTAssertEqual(decoded?.pages[0].currency, Currency.krw)
        } catch {
            print(error)
            XCTFail()
        }
    }

따라서 위 코드를 테스트할 때 문제가 있었다. JSONParser를 통해 decode가 아예 안되는 상황에서 바로 catch로 넘어가 XCTAssertEqual이 아예 실행이 되지 않았고 테스트가 통과해버리는 문제가 있었다.

따라서 XCTFail()를 사용해주었다. 이는 무조건 테스트를 즉시 실패로 만들어주는 Macro이다.

위 코드에서 catch 문으로 내려온다면 테스트가 실패한 것이기 때문에 이를 구분하기 위해 해당 코드를 작성해주었다.

keyDecodingStrategy

이름 그대로 타입의 codingkey를 JSONkey에서 어떻게 디코드할 지 정해주는 프로퍼티이다.

여기에는 3가지 enum이 존재한다.

  • useDefaultKeys
  • convertFromSnakeCase
  • custom(([CodingKey]) -> CodingKey)

여기서 이번 프로젝트에 사용한 것은 convertFromSnakeCase였다. SnakeCase를 Camel-Case로 변환해주는 것이었다.

JSON 데이터가 기본적으로 Snake Case로 작성되어 있어 사용을 했다.

다만 이를 사용할 때 주의할 점은 CodingKeys를 활용해 key를 변경한다면 이 때도 rawValue에 Snake Case 방식으로 작성하는 것이 아니라 Camel Case로 작성을 해줘야 한다.

    private enum CodingKeys: String, CodingKey {
        case pageNumber = "pageNo"
        case itemsPerPage, totalCount, offset, limit, lastPage, hasNext, hasPrev, pages
    }

이 것 때문에 한참을 헤맸다...

 

Result

A value that represents either a success or a failure, including an associated value in each case.

    private func dataTask(
        with request: URLRequest,
        completionHandler: @escaping (Result<Data, Error>) -> Void
    ) -> URLSessionDataTask {
        let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                completionHandler(.failure(error))
                return
            }
            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                      completionHandler(.failure(NetworkError.httpError))
                      return
                  }
            guard let data = data else { return }
            completionHandler(.success(data))
        }
        return dataTask
    }

completionHandler에서 data, response, error를 처리하는 과정에서 error가 발생하거나, 잘못된 response가 나왔을 때 어떤 식으로 이를 처리해줘야 할 지 고민을 했었다.

이 때 Result를 사용해주었다. (알려주신 아리 감사해요 :pray:!)

Generic으로 성공했을 때와 실패했을 때 2가지로 나눠서 이 때 저장할 Value의 타입을 미리 지정해두고 .failure, .success의 상황에 따라 대처를 할 수 있도록 한다.

프로젝트 코드에선 성공할 경우 Data를 completionHandler에 넣어줄 수 있도록 했고 실패했을 경우 Error 타입이 나올 수 있도록 구현을 했다.

UUID

multipart/form-data를 구성할 때 body에 정해진 양식이 필요했다.

  1. 바운더리를 구분하기 위한 문자열을 임의로 정한다.
  2. 각 폼 필드 요소의 값은 --바운더리 모양의 라인 하나로 구분된다.
  3. 이후 해당 필드 요소 데이터에 대한 헤더를 정의한다.
  4. 헤더와 내용에는 반드시 빈 줄 1개가 있어야 한다.
  5. 모든 요소의 기입이 끝났으면 줄을 바꾸고 --바운더리--의 모양으로 데이터를 기록하고 끝낸다.

여기서 바운더리를 만들어줘야 했는데 이때 본문의 내용과 바운더리가 겹치면 안됐다.

만약 본문과 내용이 똑같다면 본문의 내용도 바운더리로 인식하게 된다.

물론 클라이언트가 임의로 바운더리를 작성해줄 수도 있지만 대부분의 사람들이 사용하는 UUID를 사용했다.

A universally unique value that can be used to identify types, interfaces, and other items.

즉 타입, 인터페이스, 다른 Item들을 구분하기 위한 고유의 값으로 랜덤한 값이 생성되게 된다.

여기서 uuidString 프로퍼티를 사용하면 String으로 UUID를 반환하게 된다.

왜 dataTask의 completionHandler 뒤에 task.resume()을 해주는 것일까?

참고 링크

completionHandler 내의 모든 작업은 기본적으로 일시정지 상태로 시작하게 된다. 따라서 resume()을 호출하면 dataTask가 시작되게 된다.

After you create the task, you must start it by calling its resume() method.

dataTask의 공식문서에서도 반드시 작업을 만들고 resume()메서드를 호출하라고 하고 있다.

URLRequest의 헤더와 바디를 구성하는 방법

일단 request의 경우 url을 받아 생성할 수 있다.

  • URLRequest(url:)
  • .addValue(_:, forHTTPHeaderField:): headerField에 값을 넣어주게 된다.
  • .httpMethod: request method가 뭔지를 작성해주게 된다. (노랑 부분)
  • .httpBody: 이름 그대로 request의 바디를 작성해준다.
Comments