# 🎥 박스오피스 🍿
## 📖 목차
1. [📢 소개](#1.)
2. [👤 팀원](#2.)
3. [⏱️ 타임라인](#3.)
4. [📊 UML & 파일트리](#4.)
5. [📱 실행 화면](#5.)
6. [🧨 트러블 슈팅](#6.)
7. [👬 팀 회고](#7.)
8. [🔗 참고 링크](#8.)
<br>
<a id="1."></a>
## 1. 📢 소개
`일별 박스오피스 API`를 이용해서 박스오피스 정보를 받아오고 원하는 영화의 정보를 보여드립니다!
> **핵심 개념 및 경험**
>
> - **Networking**
> - `URLSession`을 이용한 네트워킹
> - **TestDouble**
> - `TestDouble` 객체를 이용하여 네트워크가 연결되어 있지 않은 경우에도 테스트
> - **DTO**
> - `JSON` 데이터를 디코딩할 `DTO` 객체 구현
> - 사용할 데이터만으로 이루어진 `Model` 객체 구현
> - **CollectionView**
> - `CollectionView`를 활용한 UI구현
> - `CompositionalLayout`를 활용한 `Layout` 설정
> - `DiffableDataSource`를 활용한 UI 업데이트
> - `Grid` 모드 구현
> - **MVC**
> - `MVC`패턴을 활용하여 객체를 역할에 알맞게 분리하여 프로젝트 구현
> - **NSCache**
> - `NSCache`를 활용하여 받아온 이미지를 재사용
> - **UICalendarView**
> - `UICalendarView`를 활용하여 달력 구현
> - **UIAlertController**
> - `actionSheet` 활용
<br>
<a id="2."></a>
## 2. 👤 팀원
| [kyungmin 🐼](https://github.com/YaRkyungmin) | [Erick 🐶](https://github.com/h-suo) |
| :--------: | :--------: |
| <Img src = "https://cdn.discordapp.com/attachments/1100965172086046891/1108927085713563708/admin.jpeg" width="350"/>| <Img src = "https://user-images.githubusercontent.com/109963294/235300758-fe15d3c5-e312-41dd-a9dd-d61e0ab354cf.png" width="350"/>|
<br>
<a id="3."></a>
## 3. ⏱️ 타임라인
> 프로젝트 기간 : 2023.07.24 ~ 2023.08.18
|날짜|내용|
|:---:|---|
| **2023.07.24** |▫️ `JSON` 데이터를 디코딩할 `Entity` 객체 구현 <br>|
| **2023.07.25** |▫️ 코드 개선을 위한 리펙토링 <br> |
| **2023.07.26** |▫️ 네트워킹 작업을 할 `NetworkManager` 구현 <br>|
| **2023.07.27** |▫️ `TestDouble`을 이용한 테스트 생성 <br> |
| **2023.07.28** |▫️ 코드 개선을 위한 리펙토링 <br> ▫️ README 작성 <br>|
| **2023.07.31** |▫️ `CollectionViewCell` 구현 <br>|
| **2023.08.01** |▫️ `CollectionView`로 UI 구현 <br> ▫️ `BoxOffice`의 데이터를 가져와 관리하는 `BoxOfficeManager`구현 <br> ▫️ 실제 사용할 데이터만으로 이루어진 `Entity` 객체 `DailyBoxOffice` 구현 <br> ▫️ `refreshControl`을 이용한 데이터 리로드 로직 구현 <br>|
| **2023.08.02** |▫️ 데이터 로드에 실패했을 때 `Alert`가 뜨도록 구현 <br>|
| **2023.08.03** |▫️ 코드 개선을 위한 리펙토링 <br> |
| **2023.08.04** |▫️ 코드 개선을 위한 리펙토링 <br> ▫️ README 작성 <br>|
| **2023.08.05** |▫️ `MoiveDetailView` 및 `Controller` 구현 <br> ▫️`BoxOfficeManager`에 `fetchPosterImage`추가 <br>|
| **2023.08.08** |▫️ 이미지 캐싱을 위한 `CacheManager` 구현 <br> ▫️ `CalendarView`및 `Controller` 구현 <br>|
| **2023.08.09** |▫️ 코드 개선을 위한 리펙토링 <br> |
| **2023.08.11** |▫️ 코드 개선을 위한 리펙토링 <br> ▫️ README 작성 <br>|
| **2023.08.15** |▫️ `CollectionView`의 `listMode`와 `gridMode` 구현 <br> ▫️ `actionSheet`으로 사용자가 `listMode`와 `gridMode`를 선택할 수 있도록 구현 <br> ▫️ `listMode` 및 `DetailView`의 다이나믹 타입 구현 <br>|
| **2023.08.17** |▫️ 코드 개선을 위한 리펙토링 <br> |
| **2023.08.18** |▫️ README 작성 <br> |
<br>
<a id="4."></a>
## 4. 📊 UML & 파일트리
### UML
<Img src = "https://hackmd.io/_uploads/BypKlumh3.png" width=""/>
<br>
### 파일트리
```
BoxOffice
├── App
│ ├── AppDelegate.swift
│ └── SceneDelegate.swift
├── Controller
│ ├── BoxOfficeViewController.swift
│ ├── CalendarViewController.swift
│ └── MovieDetailViewController.swift
├── Model
│ ├── Manager
│ │ ├── BoxOfficeManager.swift
│ │ ├── CacheManager.swift
│ │ └── DataManager.swift
│ ├── DTO
│ │ ├── BoxOffice.swift
│ │ ├── Movie.swift
│ │ └── PosterImageInformation.swift
│ ├── DailyBoxOffice.swift
│ ├── MovieInformation.swift
│ └── TestDouble
│ ├── StubURLSession.swift
│ └── URLSessionProtocol.swift
├── View
│ ├── BoxOfficeCollectionViewGridCell.swift
│ ├── BoxOfficeCollectionViewListCell.swift
│ └── MovieDetailScrollView.swift
├── Extension
│ ├── Date+.swift
│ ├── DateFormatter+.swift
│ ├── NumberFormatter+.swift
│ ├── UIAlertController+.swift
│ ├── UILabel+.swift
│ ├── URL+.swift
│ ├── URLRequest+.swift
│ └── URLSession+.swift
├── Error
│ ├── DataError.swift
│ └── NetworkError.swift
├── NetWork
│ └── NetworkManager.swift
└── Util
└── Path.swift
```
<br>
<a id="5."></a>
## 5. 📱 실행 화면
| 박스오피스 데이터 로드 | 화면을 당겨 데이터 리로드 |
| :--------------: | :-------: |
| <Img src = "https://hackmd.io/_uploads/BJdHyz9i3.gif" width="300"/> | <Img src = "https://hackmd.io/_uploads/ByZ1xfqi2.gif" width="300"/> |
| **데이터 로드에 실패했을 때 알림화면** | **영화 상세화면** |
| <Img src = "https://hackmd.io/_uploads/H12xlf5jn.gif" width="300"/> | <Img src = "https://hackmd.io/_uploads/HJd7lDQ2n.gif" width="300"/> |
| **날짜선택** | **화면 모드 변경** |
| <Img src = "https://hackmd.io/_uploads/r1FOxvQ3n.gif" width="300"/> | <Img src = "https://hackmd.io/_uploads/r1PEKBh3n.gif" width="300"/> |
<br>
<a id="6."></a>
## 6. 🧨 트러블 슈팅
### 1️⃣ 객체 분리
#### 🔥 문제점
`Controller`나 하나의 `Model`에서 많은 로직을 처리하는 것보단 자신의 역할을 가진 객체들을 잘 구현하여 필요할때만 불러 사용한다면 객체간의 의존성도 낮추고 코드를 재사용하는 측면에 좋을 것 같아 객체 분리에 대한 고민을 많이 했습니다.
많은 기능들을 구현하다 보니 `Model`쪽에서 기능 분리를 많이 했음에도 불구하고 `VC`의 코드가 길어져 가독성이 떨어지는 문제가 있었습니다.
#### 🧯 해결방법
**1. Model의 기능 분리**
다음과 같이 자신의 역할을 가진 객체를 만들어 프로젝트를 구현하였습니다.
> - **DTO** : 서버로부터 받은 `Data`를 `Decode`할때 쓰는 타입
> - **Model Data** : 실제 `App`에서 사용할 `Data` 타입
> - **DataManager** : `DTO`를 `Model Data`로 변환해주는 역활
> - **NetworkManager** : 서버로부터 `Data`를 받아오는 역할
> - **BoxOfficeManager** : `Controller`에서 필요한 데이터를 받아 관리하는 타입
> - **Extension**
> - URLSession : 테스트를 위해 `URLSeesionProtocol`을 채택하기 위한 `extension`
> - URLRequest : `Header`값을 지정한 `URLRequest`를 반환해주는 `kakaoURLRequest` 메서드 추가
> - URL : `URL`을 생성하는 `kobisURL`, `kakaoURL` 메서드 추가
> - UILabel : `attributedText`의 부분 색을 변경해주는 `convertColor`메서드 추가
> - UIAlertController : 커스텀한 `UIAlertController`을 반환하는 `customAlert` 메서드 추가
> - Date : `Date`를 반환해주는 `date`메서드 추가
> - DateFormatter : 오늘로부터 원하는 만큼 떨어진 날짜를 원하는 포멧으로 반환하는 `dateString` 메서드 추가
> - NumberFormatter : 숫자로 이루어진 `String`을 받아 `Decimal`스타일로 포멧해주는 `decimalString` 메서드 추가
**2. VC 기능 분리**
`extension`과 `MARK`를 이용해 기능 분리를 해서 코드의 가독성을 높였습니다.
>#### 🔺extension 하지 않은 부분
>- stored property
>- initializer
>- viewDidLoad
>#### 🔺extension-setupComponents
>- component관련 세팅
>#### 🔺extension-configureUI
>- 각각의 UI객체의 addSubview
>#### 🔺extension-setupConstraint
>- 각각의 UI객체의 constraint
>#### 🔺extension-buttonAction
>- 버튼 메서드
>#### 🔺그 외 기능
>- 델리게이트, 데이터소스 등..
<br>
### 2️⃣ Test Double
#### 🔥 문제점
네트워크가 연결되어 있지 않은 상황에서도 `NetworkManager`의 `startLoad` 로직이 잘 작동하는지 테스트를 하고 싶었습니다. 하지만 `URLSession`의 `dataTask` 메서드를 사용하고 있었기 때문에 네트워크가 연결되어 있지 않은 상황에서는 테스트가 어려웠습니다.
#### 🧯 해결방법
테스트가 어려운 경우 사용할 수 있는 `Test Double` 객체 `StubURLSession`을 구현하여 테스트를 진행하였습니다. `StubURLSession`는 실제 `dataTask`의 역할을 수행하진 않지만 껍데기 `dataTask`를 가지고 있어 넣어준 `Dummy Data`를 반환하는 객체입니다.
**💉 의존성 주입**
```swift
protocol URLSessionProtocol {
func dataTask(with urlRequest: URLRequest, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask
}
```
```swift
struct NetworkManager {
private let urlSession: URLSessionProtocol
// ...
}
```
- `URLSessionProtocol`을 만들어 `NetworkManager`가 프로퍼티로 해당 타입을 가지고 있도록 하여 `URLSession`뿐만 아니라 `StubURLSession`을 주입할 수 있도록 하여 테스트했습니다.
**✅ 테스트**
```swift
// given
// 받아올 데이터를 임의로 생성
let dummy = DummyData(data: data, response: response, error: nil)
let stubUrlSession = StubURLSession(dummy: dummy)
sut = NetworkManager(urlSession: stubURLSession)
// when
sut?.requestData(from: url, completion: { result in
// then
// startLoad로 받아온 데이터는 DummyData의 데이터
XCTAssertEqual(data, dataAsset.data)
expectation.fulfill()
})
```
- `StubURLSession`는 `DummyData`를 가지고 `dataTask` 메서드를 실행시 `StubURLSessionDataTask`을 이용해서 `DummyData`만 던져주도록 구현했습니다.
<br>
### 3️⃣ Closure Capture
#### 🔥 문제점
`BoxOfficeManager`의 `fetchBoxOffice`에서 `requestData`로 네트워킹을하여 데이터를 받아오는 로직에 클로져 내에서 `BoxOfficeManager`의 프로퍼티인 `dailyBoxOffices`로 접근하여 프로퍼티를 변경하는 로직이 있었습니다. 이때 클로져가 `self`를 캡쳐하면서 RC가 올라가 메모리 해제가 안되는 강한참조순환이 발생할 수 있다는 문제가 있었습니다.
```swift
final class BoxOfficeManager {
private(set) var dailyBoxOffices: [DailyBoxOffice] = []
// ...
func fetchBoxOffice(completion: @escaping (Bool) -> Void) {
// ...
networkManager.requestData(from: url) { result in
switch result {
case .success(let data):
do {
let boxOffice = try JSONDecoder().decode(BoxOffice.self, from: data)
self.dailyBoxOffices = DataManager.boxOfficeTransferDailyBoxOfficeData(boxOffice: boxOffice)
completion(true)
} // ...
}
}
}
}
```
#### 🧯 해결방법
클로져의 캡쳐로 `self`의 RC가 올라가는 것을 막아주기 위해 `[weak self]`로 클로져를 캡쳐하여 강한순환참조를 예방하였습니다.
```swift
final class BoxOfficeManager {
private(set) var dailyBoxOffices: [DailyBoxOffice] = []
// ...
func fetchBoxOffice(completion: @escaping (Bool) -> Void) {
// ...
networkManager.requestData(from: url) { [weak self] result in
guard let self else {
return
}
switch result {
case .success(let data):
do {
let boxOffice = try JSONDecoder().decode(BoxOffice.self, from: data)
self.dailyBoxOffices = DataManager.boxOfficeTransferDailyBoxOfficeData(boxOffice: boxOffice)
completion(true)
} // ...
}
}
}
}
```
<br>
### 4️⃣ endRefreshing
#### 🔥 문제점
화면을 위로 드레그하여 데이터를 리로드할 때 데이터 로드에 실패하면 `alert`이 뜨는데 이때 `refreshControl?.endRefreshing()`이 안되는 문제가 있었습니다.
<Img src = "https://hackmd.io/_uploads/Hyq_Iz9o3.gif" width="300"/>
#### 🧯 해결방법
`endRefreshing`의 애니메이션 효과와 `present`의 애니메이션 효과를 동시에 처리하지 못하여 발생하는 에러였습니다.
`present`의 `animated`를 `false`로 설정하여 해결하였습니다.
```swift
present(alert, animated: false)
```
<Img src = "https://hackmd.io/_uploads/rkRtvMqi3.gif" width="300"/>
<br>
### 5️⃣ Hashable
#### 🔥 문제점
기존의 `CollectionView`에 `CompositionalLayout`와 `DiffableDataSource`를 적용하는 과정에서 `DailyBoxOffice` 객체가 `Hashable`을 채택해야 했습니다.
그리고 `Hashalbe`을 채택할 때 `struct`의 모든 프로퍼티는 `Hashable`을 준수해야 한다고 나와있었지만 `rankStateColor`는 `typealias` 타입이라 `Hashable`을 준수하지 않았습니다.
> For a struct, all its stored properties must conform to Hashable.
#### 🧯 해결방법
`rankStateColor`는 `rankState property`를 통해서 만들어지는 `property`이기 때문에
`hash value`를 비교할때 필요하지 않는 값이라고 생각해서 `hash`함수에서 빼줬습니다.
```swift
typealias RankStateColor = (targetString: String, color: UIColor)
struct DailyBoxOffice: Hashable {
let movieCode: String
let rank: String
let rankState: String
let movieTitle: String
let dailyAndTotalAudience: String
let rankStateColor: RankStateColor
static func == (lhs: DailyBoxOffice, rhs: DailyBoxOffice) -> Bool {
return lhs.movieCode == rhs.movieCode
&& lhs.rank == rhs.rank
&& lhs.rankState == rhs.rankState
&& lhs.movieTitle == rhs.movieTitle
&& lhs.dailyAndTotalAudience == rhs.dailyAndTotalAudience
}
func hash(into hasher: inout Hasher) {
hasher.combine(movieCode)
hasher.combine(rank)
hasher.combine(rankState)
hasher.combine(movieTitle)
hasher.combine(dailyAndTotalAudience)
}
}
```
<br>
### 6️⃣ Delegate패턴 순환참조
#### 🔥 문제점
`Delegate` 패턴을 사용할 때 순환참조가 일어나 메모리 해제가 되지 않을 가능성이 있었습니다.
**예시코드**
```swift
protocol ModelDelegate: AnyObject {}
class Model {
var delegate: ModelDelegate?
deinit {
print("deinit Model")
}
}
class ViewController: ModelDelegate {
var model: Model?
deinit {
print("deinit ViewController")
}
func start() {
model = Model()
model?.delegate = self
}
}
var viewController: ViewController? = ViewController()
viewController?.start()
viewController = nil
```
- `model?.delegate = self`로 `delegate` 프로퍼티가 `ViewController`를 참조하고, `ViewController`에서 `Model`을 프로퍼티로 가지고 있어 참조하게 됩니다.
- 따라서 `ViewController = nil`을 하여 `ViewController`를 해제 시키려고 해도 강한순환참조가 일어나 메모리 해제가 되지 않습니다.
#### 🧯 해결방법
순환참조가 일어나는 것을 방지해주기 위해서 `delegate` 프로퍼티를 `weak var`로 선언하도록 하였습니다.
**예시 코드**
```swift
protocol ModelDelegate: AnyObject {}
class Model {
weak var delegate: ModelDelegate?
deinit {
print("deinit Model")
}
}
class ViewController: ModelDelegate {
var model: Model?
deinit {
print("deinit ViewController")
}
func start() {
model = Model()
model?.delegate = self
}
}
var viewController: ViewController? = ViewController()
viewController?.start()
viewController = nil
// deinit ViewController
// deinit Model
```
- `delegate` 프로퍼티를 `weak var`로 약한 참조하여 `viewController = nil`을 하여도 강한 순환 참조가 일어나지 않고 메모리 해제가 됩니다.
**프로젝트 코드**
```swift
final class CalendarViewController: UIViewController {
weak var delegate: CalendarViewControllerDelegate?
// ...
}
```
<br>
### 7️⃣ 화면 모드 변경 애니메이션
#### 🔥 문제점
처음에는 모드 변경을 할 때 `collectionView.reloadData()`로 모든 셀을 다시 로드하였습니다.
이럴 경우 애니메이션 없이 화면이 바로 바껴 사용자 경험을 저하시킬 수 있다는 단점이 있었습니다.
<Img src = "https://hackmd.io/_uploads/rkA6tFnnn.gif" width="300"/>
#### 🧯 해결방법
`setCollectionViewLayout`과 `reloadSections`을 사용하여 `layout`이 바뀌는 순간과 `section`이 리로드 되는 순간에 애니메이션 효과를 주었습니다.
**layout이 업데이트 될 때 애니메이션**
```swift
switch collectionViewMode {
case .list:
collectionView.setCollectionViewLayout(listLayout(), animated: true)
case .grid:
collectionView.setCollectionViewLayout(gridLayout(), animated: true)
}
```
**reloadSections와 apply를 사용하여 section이 업데이트 될 때 애니메이션**
```swift
var snapshot = NSDiffableDataSourceSnapshot<Section, DailyBoxOffice>()
snapshot.appendSections([.main])
snapshot.appendItems(boxOfficeManager.dailyBoxOffices, toSection: .main)
snapshot.reloadSections([.main])
dailyBoxOfficeDataSource.apply(snapshot, animatingDifferences: true)
```
<br>
### 8️⃣ dynamic type과 label의 너비
#### 🔥 문제점
`DetailView`에 `dynamic type`을 적용했을때 글씨가 커질 경우 감독, 제작년도, 개봉일 등의 `titleLabel`의 크기가 동일한 비율을 유지하며 커지지 않는 문제가 있었습니다.
<Img src = "https://hackmd.io/_uploads/H1QcsYn33.png" width="300"/>
#### 🧯 해결방법
`titleLabel`의 `widthAnchor`를 `heightAnchor`의 3배로 설정하여 비율을 주고 `setContentCompressionResistancePriority`로 높이가 우선적으로 늘어날 수 있게 하여 글씨가 커질 경우에도 비율에 맞게 `label`의 너비가 늘어날 수 있도록 하였습니다.
```swift
let titleLabelConstraint = titleLabel.widthAnchor.constraint(equalTo: titleLabel.heightAnchor, multiplier: 3)
titleLabel.setContentCompressionResistancePriority(.required, for: .vertical)
```
<Img src = "https://hackmd.io/_uploads/Hy5JhYhn2.png" width="300"/>
<br>
<a id="7."></a>
## 7. 👬 팀 회고
### 😃 잘한 점
- 객체분리에 대해 많이 고민했습니다. 다른 객체와의 결합도를 낮추는데 집중했고, 이로인해 가독성과 유지보수성이 높은 코드를 작성했습니다.
- 이해되지 않는 부분은 시간이 걸리더라도 최대한 이해하려고 노력했습니다.
- 새로운 기술 스택을 사용할 때, 해당 기술의 공식 문서를 꼼꼼히 읽고 사용했습니다.
- 프로젝트 진행 중 문제가 발생하거나 버그를 해결할 때 공식문서를 잘 활용했습니다.
### 😢 아쉬웠던 점
- `Protocol`을 많이 활용하지 못한 부분이 아쉬웠습니다.
- `POP`에 대한 고민을 하지 못한 점이 아쉬웠습니다.
<br>
<a id="8."></a>
## 8. 🔗 참고 링크
- [🍎Apple: CodingKey](https://developer.apple.com/documentation/swift/codingkey)
- [🍎Apple: URLSession](https://developer.apple.com/documentation/foundation/urlsession)
- [🍎Apple: Fetching Website Data into Memory](https://developer.apple.com/documentation/foundation/url_loading_system/fetching_website_data_into_memory)
- [🍎Apple: Capturing Values](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures/#Capturing-Values)
- [🍎Apple: UICollectionViewCompositionalLayout](https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout)
- [🍎Apple: UICollectionViewDiffableDataSource](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource)
- [🍎Apple: NSDiffableDataSourceSnapshot](https://developer.apple.com/documentation/uikit/nsdiffabledatasourcesnapshot)
- [🍎Apple: Hashable](https://developer.apple.com/documentation/swift/hashable)
- [🍎Apple: UICalendarView](https://developer.apple.com/documentation/uikit/uicalendarview#3992448)
- [🍎Apple: estimated(_:)](https://developer.apple.com/documentation/uikit/nscollectionlayoutdimension/3199057-estimated)
- [🍎Apple: setCollectionViewLayout(_:animated:)](https://developer.apple.com/documentation/uikit/uicollectionview/1618086-setcollectionviewlayout)
- [🍎Apple: reloadSections(_:)](https://developer.apple.com/documentation/uikit/nsdiffabledatasourcesnapshot/3375784-reloadsections)
- [🐻야곰닷넷: Unit Test](https://yagom.net/courses/unit-test-작성하기/)
- [📗Velog: You don’t (always) need [weak self]](https://velog.io/@haanwave/Article-You-dont-always-need-weak-self)