README 작성 완료했습니다😆 팀원: idinaloq🐥, Mary🐿️ https://github.com/MaryJo-github/ios-box-office/blob/II-step2/README.md # README.md # 🎬 Box Office ## 🍀 소개 > `idinaloq`와 `Mary`가 만든 박스오피스 입니다. 영화진흥위원회 API를 활용하여 일일 박스오피스 조회 및 영화 개별 상세 조회를 진행합니다. 사용자에게 날짜를 입력받아 해당 날짜의 박스오피스를 보여주고, 클릭하면 영화의 상세 정보를 보여줍니다. * 주요 개념: `JSON Decoder`, `URLComponents`, `URLSession`, `Fetching Website Data into Memory`, `escaping closure`, `completionHandler`, `UICollectionView`, `refreshControl`, `URLRequest`, `UICalendarView`, `Dynamic Type`, `delegate pattern`, `Navigation ToolBar`, `DateInterval` <br> ## 📖 목차 [1. 팀원](#-팀원) <br> [2. 시각화된 프로젝트 구조](#-시각화된-프로젝트-구조) <br> [3. 실행 화면](#-실행-화면) <br> [4. 고민했던 점](#-고민했던-점) <br> [5. 트러블 슈팅](#-트러블-슈팅) <br> [6. 타임라인](#-타임라인) <br> [7. 참고 링크](#-참고-링크) <br> <br> ## 👨‍💻 팀원 | **idinaloq** | **Mary** | | :------: | :------: | |<Img src = "https://user-images.githubusercontent.com/109963294/235301015-b81055d2-8618-433c-b680-58b6a38047d9.png" width = "200" height="200"/> | <img src="https://i.imgur.com/8mg0oKy.jpg" width="150"> | |[<img src="https://hackmd.io/_uploads/SJEQuLsEh.png" width="20"/> **GitHub**](https://github.com/DasanKim) |[<img src="https://hackmd.io/_uploads/SJEQuLsEh.png" width="20"/> **GitHub**](https://github.com/MaryJo-github) <br> ## 👀 시각화된 프로젝트 구조 ### Class Diagram <p> <img width="700" src="https://hackmd.io/_uploads/rkJX-P3hn.jpg"> </p> <br> ### File Tree ``` . ├── BoxOffice │   ├── Model │   │   ├── Dapi │   │   │   └── KakaoAPI.swift │   │   ├── DateManager.swift │   │   ├── Kobis │   │   │ ├── KobisOpenAPI.swift │   │   │ └── KobisServiceType.swift │   │   └── DataTransferObject │   │       ├── DailyBoxOffice │   │       │   ├── BoxOffice.swift │   │      │   ├── BoxOfficeResult.swift │   │      │   └── DailyBoxOffice.swift │   │      ├── MovieInformation │   │      │ ├── DetailInformation.swift │   │      │ ├── MovieInformation.swift │   │      │ └── MovieInformationResult.swift │   │      └── ImageSearch │   │          ├── Document.swift │   │         └── ImageSearch.swift │   ├── View │   │ ├── Base.lproj │   │ │   └── LaunchScreen.storyboard │   │   ├── DailyBoxOfficeCollectionViewGridCell.swift │   │   ├── DailyBoxOfficeCollectionViewListCell.swift │   │   ├── LoadingView.swift │   │   └── MovieInformationScrollView.swift │   ├── Controller │   │   ├── CalendarViewController.swift │   │   ├── DailyBoxOfficeViewController.swift │   │   └── MovieInformationViewController.swift │   ├── Network │   │   └── NetworkService.swift │   └── protocol │   │ ├── CalendarDelegate.swift │   │ └── URLSessionProtocol.swift │   ├── Error │   │   ├── NetworkError.swift │   │   ├── StringError.swift │   │   └── URLError.swift │   ├── Extension │   │   ├── Array+.swift │   │   ├── CALayer+.swift │   │   ├── String+.swift │   │   └── URLSession+.swift │   ├── Application │   │   ├── AppDelegate.swift │   │   └── SceneDelegate.swift │   ├── Resource │   │   ├── Assets.xcassets │   │   └── Info.plist └── BoxOfficeTests    └── BoxOfficeTests.swift ``` <br> ## 💻 실행 화면 **추가 예정** |실행 화면| |:--:| |<img src="https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/63e1b1ad324ce85b6f356cee/20230818/64df031545e6bf50b2f4346d.gif" width="300">| ![https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/63e1b1ad324ce85b6f356cee/20230818/64df031545e6bf50b2f4346d.gif](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/63e1b1ad324ce85b6f356cee/20230818/64df031545e6bf50b2f4346d.gif) </br> ## 🧠 고민했던 점 ### 1️⃣ Nested Data Parsing - 영화진흥위원회 API 문서를 확인해보니 다음과 같이 데이터가 여러번 중첩되어있는 형태였습니다. 이와같이 복잡한 형태를 가진 데이터의 DTO는 어떻게 만들어야할지 고민하였습니다. ```json { "boxOfficeResult": { "boxofficeType": "일별 박스오피스", "showRange": "20220105~20220105", "dailyBoxOfficeList": [ { "rnum": "1", "rank": "1", ... }, { "rnum": "1", "rank": "1", ... }, ... ] } } ``` - [공식문서](https://developer.apple.com/documentation/foundation/archives_and_serialization/using_json_with_custom_types#3540681)를 참고하여 다음과 같이 여러개의 DTO타입을 만들어주어 해결하였습니다. ```swift struct BoxOffice: Decodable { let boxOfficeResult: DailyBoxOffice } struct DailyBoxOffice: Decodable { let boxOfficeType: String let showRange: String let dailyBoxOfficeList: [MovieInformation] ... } struct MovieInformation: Decodable { let rowNumber: String let rank: String let rankChangeValue: String ... } ``` ### 2️⃣ 실패하는 Test case - 성공하는 테스트 케이스가 아닌, 실패하는 케이스에 대해서도 다음과 같이 작성을 했습니다. - 실패하는 테스트케이스는 특정 기능이 제대로 작동하지 않는지를 확인하는 데 도움이 되기 때문이라고 생각했습니다. ```swift func test_boxofficesample프로퍼티와_DailyBoxOffice프로퍼티가다르면_파싱에실패한다() { let test = try? JSONDecoder().decode(BoxOffice.self, from: dataAsset) ... //then XCTAssertNil(test) } ``` ### 3️⃣ Completion Handler - `fetchData(url: URL, completion: @escaping NetworkResult)`메서드에서 `dataTask()`메서드로 데이터를 비동기로 가져와도 반환할 때는 비동기적으로 데이터를 넘겨주지 않는것을 확인했습니다. - `completion Handler`인 `escaping closure`를 사용해서 비동기로 데이터를 반환할 수 있었습니다. ### 4️⃣ Deployment target version - `tableViewCell`을 사용할 때 `accessory`타입의 `.disclosureIndicator`를 활용해서 각각의 셀에 `>`모양을 표시했습니다. 하지만 `UICollectionViewCell`은 해당 기능이 없었고, 검색해 본 결과 `UICollectionViewListCell`이 있었습니다. 하지만 이는 **iOS14**부터 지원합니다. - 사용자에게 날짜를 입력받을 때 `UICalendarView`를 활용하라는 요구사항이 있었고, 이는 **iOS16**부터 지원합니다. - 처음에 만들어진 프로젝트의 버전은 **iOS13**이며, 위 두 기능은 기존 버전보다 높아서 사용할 수 없었습니다. - 처음에는 기존 프로젝트 설정 그대로 가려고 했지만, 프로젝트에서 요구사항에는 `iOS`버전에 대한 이야기가 없었습니다. 찾아본 결과 공식페이지에 [iOS점유율](https://developer.apple.com/kr/support/app-store/)을 확인하는 곳이 있었고 아이폰의 81%가 **iOS16**을 사용하고 있다는 통계를 찾았고 이것이 저희가 버전을 수정하려는 이유가 될 수 있다고 생각했기 때문에 프로젝트 버전을 **iOS16**으로 변경하였습니다. ### 5️⃣ 여러개의 비동기 작업 끝나는 시점 - `MovieInformationViewController`클래스에서 `receiveImageData()`메서드와 `receiveBoxOfficeData()` 메서드에서 비동기로 데이터를 처리하고 있습니다. - 뷰가 업데이트가 되는 시점(로딩뷰가 사라지는 시점)이 두 비동기 작업 처리가 끝날 때 이루어져야 하기 때문에 해당 시점을 어떻게 알 수 있을지 고민하였습니다. - 결과적으로 프로퍼티 값의 변화를 관찰할 수 있는 `프로퍼티 옵저버`를 활용하였습니다. - 완료된 task의 개수를 셀 `completionCount` 프로퍼티를 생성하여 각 task가 끝날 때 `completionCount` 값을 1 증가시킵니다. - `completionCount` 값의 변화가 있을 때마다 완료된 task가 2개인지 확인합니다. 해당 조건이 true이면 로딩 뷰가 사라지도록 설정했습니다. ``` swift private var completionCount: Int = 0 { didSet { if completionCount == 2 { loadingView.hide() } } } private func receiveBoxOfficeData() { guard let urlRequest = receiveBoxOfficeURLRequest() else { return } networkService.fetchData(urlRequest: urlRequest) { result in switch result { case .success(let data): self.decodeBoxOfficeData(data) self.updateScrollView() self.completionCount += 1 case .failure(let error): print(error.localizedDescription) } } } ``` ### 6️⃣ ViewController 데이터 전달 - `CalendarViewController`에서 사용자가 선택한 날짜에 따라 BoxOffice를 업데이트하기위해 날짜를 전달해야했습니다. `CalendarDelegate` 프로토콜을 활용하여 `CalendarViewController`에서 날짜가 변경되었을 때 `DailyBoxOfficeViewController`의 `updateBoxOffice(date: Date)`메서드를 호출하도록 구현하였습니다. ```swift protocol CalendarDelegate: AnyObject { func updateBoxOffice(date: Date) } final class DailyBoxOfficeViewController: UIViewController { ... extension DailyBoxOfficeViewController: CalendarDelegate { func updateBoxOffice(date: Date) { targetDate = date receiveData() setNavigationTitle() } } ... } final class CalendarViewController: UIViewController { weak var delegate: CalendarDelegate? ... } extension CalendarViewController: UICalendarSelectionSingleDateDelegate { func dateSelection(_ selection: UICalendarSelectionSingleDate, didSelectDate dateComponents: DateComponents?) { guard let date = dateComponents?.date else { return } delegate?.updateBoxOffice(date: date) dismiss(animated: true) } } ``` <br> ## 🧨 트러블 슈팅 ### 1️⃣ testable한 코드 ⚠️ **문제점** <br> - 서버에 데이터를 요청할 때 여러 개의 URLSession객체를 만들 필요가 없다고 생각하여 URLSession.shared를 사용하였습니다. - 하지만 아래와 같은 구조면 네트워크 무관 테스트를 진행하고싶을 때, Session을 주입받는 형태가 아니기 때문에 실제로 통신을 하지 않는 가짜 session으로 변경할 수 없어 테스트가 불가능하였습니다. ``` swift enum NetworkManager { typealias NetworkResult = (Result<Data, NetworkError>) -> Void static func fetchData(url: URL, completion: @escaping NetworkResult) { let task = URLSession.shared.dataTask(with: url) { data, response, error in ... } } } ``` ✅ **해결방법** <br> - session을 주입받는 형태로 변경하면서 네트워크 무관 테스트가 가능해졌습니다. - 기본값을 shared 싱글톤으로 설정하여 불필요한 URLSession 객체 생성을 방지했습니다. ``` swift class NetworkManager { typealias NetworkResult = (Result<Data, NetworkError>) -> Void let session: URLSessionProtocol init(session: URLSessionProtocol = URLSession.shared) { self.session = session } func fetchData(url: URL, completion: @escaping NetworkResult) { let task = session.dataTask(with: url) { data, response, error in ... } } } ``` ### 2️⃣ reusable, massive type - 코드를 작성하면서 항상 고민했던 점은 코드의 재사용성과 관련된 부분 이였습니다. 기존에는 재사용성에만 중점을 두었기 때문에 결국 해당 타입을 가져다 쓰기 위해 관리하는 타입의 크기가 관리할 수 없을정도로 커질 가능성이 있었습니다. ⚠️ **문제점** <br> **기존코드** ```swift enum QueryItem { static let targetDate: String = "targetDt" static let itemPerPage: String = "itemPerPage" static let multiMovie: String = "multiMovieYn" static let nationCode: String = "repNationCd" static let widewAreaCode: String = "wideAreaCd" static let movieCode: String = "movieCd" static let key: String = "key" static let value: String = "c824c74a1ff9ed62089a9a0bcc0d3211" } ``` - 기존 코드는 같은 API 타입을 재사용하기 위해 따로 관리를 하기위한 열거형이 있었습니다. 예를들어 `targetDt`는 `targetDate`를 나타내기 위함이였습니다. 하지만 API가 많아지고 그에따라 관리해야되는 쿼리도 많아지게 된다면 추후에 관리가 어려워 질 수 있는 문제가 있다고 생각했습니다. ✅ **해결방법** <br> **현재코드** ```swift struct KobisOpenAPI { ... private enum APIKey { static let key: String = "key" static let value: String = "c824c74a1ff9ed62089a9a0bcc0d3211" } private enum Components { static let scheme: String = "http" static let host: String = "www.kobis.or.kr" static let path: String = "/kobisopenapi/webservice/rest" } } ``` - 쿼리 값들 중 `value` 같은 값을 제외하고 나머지 값들은 리터럴하게 사용하도록 수정했고, 공통으로 사용할 API타입이 아닌 하나의 API타입을 만들고 그 안에에서 고정적인 값 들만 열거형으로 사용하도록 다음과 같이 수정을 했습니다. - 이와 같은 방식으로 수정해서 공통적으로 사용되는 값을 여러번 쓸 필요 없게 되었고, 관리해야되는 코드의 양도 줄일 수 있게 되었습니다. <br> ### 3️⃣ cell 재사용으로 인한 text 색상 문제 ⚠️ **문제점** <br> - 영화 순위 등락을 표시하는 `rankChangeValueLabel`은 등락에 따라 텍스트의 색상을 변경해주었습니다. 첫 실행화면은 정상적으로 보여지지만, 화면을 아래로 드래그하여 새로고침을 하거나, `collectionView`를 아래로 스크롤하면 다음과 같이 색상이 잘못 나타나는 현상이 발생하였습니다. <img width="300" src="https://user-images.githubusercontent.com/42026766/258487087-fd4cf6dd-9219-4239-9770-590c08e8fa05.png"> ✅ **해결방법** <br> - 해당 문제는 셀을 재사용하기 전에 초기화를 해주지 않았기 때문에 발생한 문제였습니다. `UICollectionViewCell`의 `prepareForReuse`메서드를 오버라이딩하여 `Label`의 `text`와 `textColor`를 초기화 해줌으로써 문제를 해결하였습니다. ```swift override func prepareForReuse() { super.prepareForReuse() rankLabel.text = nil titleLabel.text = nil visitorLabel.text = nil rankChangeValueLabel.text = nil rankChangeValueLabel.textColor = nil } ``` <br> ## ⏰ 타임라인 |날짜|내용| |:--:|--| |2023.07.24.(월)|일별 박스오피스를 담을DailyBoxOffice타입 생성<br> BoxOfficeTest 추가| |2023.07.25.(화)|STEP1 PR작성| |2023.07.26.(수)|영화 상세정보를 담을 DetailInformation타입 생성<br>APIConstants타입 구현| |2023.07.27.(목)|MovieService타입 생성<br> APIConstants타입 구조변경<br> MovieService테스트코드 작성| |2023.07.28.(금)|전체적인 리팩토링<br> README작성| |2023.07.31.(월)| completion handler 구현 및 오류 처리| |2023.08.01.(화)|APIConstants+삭제<br>APIConstants타입 이름변경<br>Error 연관값 추가 및 네이밍 수정<br>의존성 주입을 위해 URLSessionProtocol 추가<br>KobisOpenAPI의 QueryKey 타입 삭제<br>URLError 타입 생성| |2023.08.02.(수)|NetworkService타입에 final 키워드 추가| |2023.08.03.(목)|StoryBoard 삭제 및 CollectionView 생성<br>커스텀 셀 DailyBoxOfficeCollectionViewCell 타입 추가<br>Cell 추가 및 autolayout 설정<br>Date+ 추가| |2023.08.04.(금)|README작성| |2023.08.07.(월)|폴더 구조 변경<br>리팩토링| |2023.08.08.(화)|array subscript구현<br>동적cell구현<br>MovieInformation스크롤뷰, MovieInformation뷰컨트롤러 추가 및 데이터 다운로드 처리부 구현| |2023.08.09.(수)|이미지 다운로드, 다운로드 된 이미지 뷰에 표시하도록 추가| |2023.08.10.(목)|loadingView구현<br> 에러타입 추가<br> 컨벤션 수정| |2023.08.11.(금)|README작성| |2023.08.14.(월)|UICalendarView추가<br>ViewController 추가<br>날짜 변경 기능 구현<br>파일분할 및 코드 리팩토링| |2023.08.16.(수)|화면모드 변경기능 추가<br>다이나믹타입 적용<br>컨벤션 리팩토링| |2023.08.18(금)|README작성| ## 📚 참고 링크 - [🍎 Apple Docs: `JSONDecoder`](https://developer.apple.com/documentation/foundation/jsondecoder) - [🍎 Apple Docs: `URLComponents`](https://developer.apple.com/documentation/foundation/urlcomponents) - [🍎 Apple Docs: `URLSession`](https://developer.apple.com/documentation/foundation/urlsession) - [🍎 Apple Docs: `Fetching Website Data into Memory`](https://developer.apple.com/documentation/foundation/url_loading_system/fetching_website_data_into_memory) - [🍎 Apple Docs: `dataTask with completionHandler`](https://developer.apple.com/documentation/foundation/urlsession/1410330-datatask?changes=_8) - [🍎 Apple Docs: `refreshControl`](https://developer.apple.com/documentation/uikit/uirefreshcontrol) - [🍎 Apple Docs: `URLRequest`](https://developer.apple.com/documentation/foundation/urlrequest) - [🍎 Apple Docs: `UICollectionView`](https://developer.apple.com/documentation/uikit/uicollectionview) - [🍎 Apple Docs: `UICalendarView`](https://developer.apple.com/documentation/uikit/uicalendarview) - [🍎 Apple Docs: `DateInterval`](https://developer.apple.com/documentation/foundation/dateinterval) - [🍎 Apple Docs: `UIToolbar`](https://developer.apple.com/documentation/uikit/uitoolbar) - [🌐 Blog: `escaping closure`](https://jusung.github.io/Escaping-Closure/) - [🌐 Blog: `iOS 서버통신 연결하기`](https://vanillacreamdonut.tistory.com/254) - [🌐 Blog: `subscript`](https://limjs-dev.tistory.com/104) - [🌐 Blog: `Dynamic Type`](https://limjs-dev.tistory.com/103) <br> ## 👥 팀 회고 ### 칭찬할 부분 - 코드를 작성함에 있어서 왜 그렇게 하는것이 좋은지, 개선할 부분이 있을지에 대해 계속해서 토론, 토의한 점 - apple 공식문서를 많이 참고한 점 - 적용해야할 기술이 많아 프로젝트 방향성이 흐려졌을 때 외부에 도움을 구한 점 ### 아쉬웠던 부분 - 프로젝트에 집중하여 개인공부 시간을 많이 갖지 못했던 점 ### 서로에게 하고 싶은 말 - To. Mary - 한 달 동안 고생 많으셨습니다. 이번에 코드 리뷰어 신청하셨던데 도움이 필요할 때 언제든지 연락 주시면 마음껏 참견하겠습니다 😄 - To. idinaloq - 가장 어렵고 빡센(?) 프로젝트였던 것 같은데 이디나로크와 함께해서 잘 마무리했던 것 같습니다. 고생하셨습니다! (⬆️ 연락드리겠습니다😁) ---