# 박스오피스 step4
@July911
줄라이 안녕하세요! 코낄이, 리지 입니다.
step4 PR 완성하여 올립니다!
2주동안 많은 도움 주셔서 감사합니다.
이번에도 잘 부탁드립니다 ☺️
# 고민했던 점
## 1️⃣ JSON 타입과 DiffableDataSource가 받을 데이터 타입 분리
웹에서 받은 데이터를 JSON Decoder로 파싱하여 UICollectionViewCell에 전달하고자 UICollectionViewDiffableDataSource를 사용하였습니다.
DiffableDataSource는 데이터를 제공하기 위해 snapshot을 사용하는데, 이 snapshot은 section과 item의 key, value로 구성되어 있고 이 둘은 Hashable 프로토콜을 준수해야하는 조건이 필요했습니다.
### 🔍 문제점
처음 구현한 방법은 value에 JSON`DailyBoxOffice` 타입에서 필요한 데이터인 Movie에 `Hashable`을 채택하였습니다.
```swift
struct DailyBoxOffice: Decodable {
...
struct BoxOfficeResult: Decodable {
...
let boxOfficeList: [Movie]
...
struct Movie: Decodable, Hashable {
// Movie의 모든 프로퍼티
```
```swift
final class DailyBoxOfficeViewController: UIViewController {
private typealias DataSource = UICollectionViewDiffableDataSource<Section, DailyBoxOffice.BoxOfficeResult.Movie>
```
이 코드에서`DiffableDataSource`의 관심사과 JSON 타입의 관심사를 분리하는게 좋을 것 같다는 줄라이의 의견을 받고 많은 고민을 하였습니다.
### ⚒️ 해결방안
둘의 관심사를 분리해야 하는 이유에 대해 줄라이가 말씀해주신 내용과 저희가 생각한 내용을 정리해보자면, `DiffableDataSource`의 역할은 UI 요소인 collectionViewCell에 데이터를 전달해서 띄워주는 것이기 때문에 네트워킹과의 의존성을 줄이고 필요한 데이터만 알도록 해주는 것이 맞다는 결론을 내렸습니다.
따라서 `DailyBoxOfficeItem` 타입을 만들고 그 타입이 Hashable을 채택하도록 하여 JSON decoder의 관심사와 `DiffableDataSource`의 관심사를 분리하였습니다.
```swift
struct DailyBoxOfficeItem: Hashable {
init(from movie: DailyBoxOffice.BoxOfficeResult.Movie) {
self.rank = movie.rank
self.rankVariance = movie.rankVariance
self.rankOldAndNew = movie.rankOldAndNew
self.code = movie.code
self.name = movie.name
self.audienceCount = movie.audienceCount
self.audienceAccumulation = movie.audienceAccumulation
}
let identifier = UUID() // uniqueIdentifier를 주기 위해 구현
let rank: String
let rankVariance: String
let rankOldAndNew: String
let code: String
let name: String
let audienceCount: String
let audienceAccumulation: String
}
```
## 2️⃣ URL로 Image 받아오기, 이미지 캐싱하기
영화 포스터 이미지를 받아오기 위해 API를 설계하여 통신을 통해 Image의 URL을 받아왔습니다.
``` swift
enum BoxOfficeEndPoint {
...
// 영화 포스터 이미지를 받아오기 위한 API
case MoviePosterImage(query: String, httpMethod: HttpMethod)
}
```
통신에 성공해서 받아온 URL로 실제 이미지 데이터를 받아오는 과정이 필요했는데, `Data(contentsOf: )`를 사용하여 이미지를 가져오도록 다음과 같이 구현하였습니다.
``` swift
func load(url: URL, completion: @escaping () -> Void) {
DispatchQueue.global().async { [weak self] in
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return }
...
}
```
### 🔍 문제점
하지만 줄라이의 코멘트를 받고 확인해보니, [공식 문서](https://developer.apple.com/documentation/foundation/nsdata/1413892-init)에서는 `Data(contentsOf: )`에 대하여 네트워크 통신에 사용하지 않을 것을 강조하고 있었습니다.
`Data(contentsOf: )` 는 현재 스레드에서 작업을 수행하기 때문에 위험할 수 있다고 합니다.
### ⚒️ 해결방안
공식 문서의 가이드에 따라 `Data(contentsOf: )`를 사용하는 대신 `dataTask(with:completionHandler:)` 메서드를 사용하였습니다.
``` swift
func load(url: URL, completion: @escaping ((Result<UIImage, NetworkError>) -> Void)) {
let urlRequest = URLRequest(url: url)
let task = URLSession.shared.dataTask { ... }
...
}
```
또한 추가적인 문제로 Image가 load 되는 속도가 느린 몇몇의 영화가 있어 속도 문제를 해결하고자 이번주 학습활동에 배운 캐싱을 적용해보았습니다.
- NSCache의 싱글톤 구현
```swift
final class ImageCacheManager {
static let shared = NSCache<NSString, UIImage>()
private init() {}
}
```
- 적용한 코드
```swift
private func fetchMoviePosterImage(from imageURL: URL) {
// 캐시된 이미지가 있다면 여기서 바로 이미지를 띄워주게 됩니다.
let cachedKey = NSString(string: imageURL.absoluteString)
if let cachedImage = ImageCacheManager.shared.object(forKey: cachedKey) {
DispatchQueue.main.async {
self.movieInformationScrollView.moviePosterImageView.image = cachedImage
self.loadingView.stopAnimating()
return
}
}
// 캐시된 이미지가 없다면 loadImage 메서드를 통해서 네트워킹으로 이미지를 받아와 캐싱을 합니다.
loadImage(from: imageURL) { [weak self] result in
switch result {
case .failure(let error):
print(error)
case .success(let image):
DispatchQueue.main.async {
ImageCacheManager.shared.setObject(image, forKey: cachedKey)
self?.movieInformationScrollView.moviePosterImageView.image = image
self?.loadingView.stopAnimating()
}
}
}
}
```
캐싱을 직접 사용해보니 확실히 이미지 로딩 속도가 빨라진 것을 확인할 수 있었습니다!
## 3️⃣ View와 Controller의 기능 분리
View는 판단하는 로직을 포함하거나 데이터를 정제하는 작업은 하지 않도록 하는것이 좋다는 의견을 주셔서 다음부터는 고려하도록 하겠습니다!
예시로 이번 프로젝트에서 반영한다면 다음과 같이 해볼 수 있을 것 같습니다.
``` swift
// 현재 구조 : View가 데이터를 정제하는 작업을 수행
final class DailyBoxOfficeCollectionViewCell: UICollectionViewCell {
...
private func setupMovieListLabels(with movie: DailyBoxOfficeItem) {
...
audienceInformationLabel.text = "오늘 \(todayAudience) / 총 \(totalAudience)"
}
```
``` swift
// 수정된 구조 : View는 정제된 데이터를 받아서 UI요소에 반영
final class DailyBoxOfficeCollectionViewCell: UICollectionViewCell {
...
private func setupMovieListLabels(with audience: String) {
...
audienceInformationLabel.text = audience
}
```
# 조언을 구하고 싶은 점
### 1️⃣ API 설계
저희는 `BoxOfficeEndPoint`로 네트워크 통신 요청을 보내도록 설계하였습니다.
``` swift
enum BoxOfficeEndPoint {
case DailyBoxOffice(tagetDate: String, httpMethod: HttpMethod)
case MovieInformation(movieCode: String, httpMethod: HttpMethod)
case MoviePosterImage(query: String, httpMethod: HttpMethod)
}
extension BoxOfficeEndPoint {
// 프로퍼티 구현
...
```
이번 스텝에서 API가 추가되어 `case MoviePosterImage`를 추가 구현하였는데요, 이 API는 기존의 두 API에는 없던 헤더가 추가된 형태였습니다.
다음과 같이 `createURLRequest` 메서드 내에서 분기하여 처리했는데요,
``` swift
func createURLRequest() -> URLRequest? {
guard let url = createURL() else { return nil }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = httpMethod
switch self {
case .MoviePosterImage:
urlRequest.setValue("KakaoAK d470dcea6bc2ede97003aac7b84e2533", forHTTPHeaderField: "Authorization")
return urlRequest
case .DailyBoxOffice, .MovieInformation:
return urlRequest
}
}
```
`BoxOfficeEndPoint`를 따로 만들까 생각이 들기도 했는데요, 아직 모델을 구분하는 기준을 가지고 있지 않아서 모델 설계에 어려움이 있는 것 같습니다. 줄라이의 의견을 여쭙고 싶습니다 :smile: