---
# System prepended metadata

title: 박스오피스
tags: [project]

---


# 🎬🍿박스오피스🍿🎬

## 📖 목차
1. [소개](#소개)
2. [팀원](#팀원)
3. [타임라인](#타임라인)
4. [프로젝트 구조](#프로젝트-구조)
5. [실행 화면](#실행-화면)
6. [트러블 슈팅](#트러블-슈팅)
7. [참고 링크](#참고-링크)


<br>

## 소개

영화진흥위원회의 박스오피스 `open API`를 사용해 박스오피스 정보를 가져와 `CollectionView`를 사용하여 사용자에게 영화정보를 보여줍니다. 캘린더를 이용해 특정 날짜의 박스오피스를 확인할 수 있습니다. 또한 박스오피스 정보를 리스트 형태로 보여줄 지 아이콘 형태로 보여줄 지 레이아웃을 선택할 수 있습니다. 메인 화면에서 영화를 선택하면 `Daum 이미지 API`를 사용하여 영화 포스터와 함께 상세 정보를 보여줍니다.

주요개념: `CollectionView`, `Indicator`, `URLSession`, `async/await`, `Compositional Layout`, `Dynamic Type`, `Device Orientation`


<br>

## 팀원

|  minsup | Etial Moon |
| :--------: | :--------: |
| <Img src = "https://avatars.githubusercontent.com/u/79740398?v=4" width="200"> |<Img src="https://avatars.githubusercontent.com/u/86751964?v=4" width="200" height="200"> |
|[Github](https://github.com/agilestarskim) |[Github](https://github.com/hojun-jo) |

<br>

## 타임라인

|날짜|내용|
|:--:|--|
|2023.07.24| BoxOffice DTO 생성 | 
|2023.07.25| NetworkManager 생성 |
|2023.07.27| URLSession을 async/await 방식으로 변경 |
|2023.07.27| Movie DTO 생성 |
|2023.07.31| NetworkManager와 Decoder의 역할 분리 |
|2023.07.31| CollectionView, Cell 생성 및 레이아웃 |
|2023.08.02| navi title, 악세사리, separator 생성 |
|2023.08.02| AttributedString을 통해 데이터별 String 다르게 생성 |
|2023.08.03| 데이터 로딩 간 Indicator, RefreshControl 추가, 에러 시 alert 생성 |
|2023.08.08| FetchImage구현, Detail 화면 구현 |
|2023.08.09| APIKey 숨기기, Detail 레이아웃, Indicator 생성 |
|2023.08.10| 영화 포스터 이미지 높이 조절|
|2023.08.15| CalendarViewController 구현 |
|2023.08.16| 아이콘 레이아웃 추가 및 Orientation, DynamicType 대응 |
|2023.08.18| 리팩토링 |



<br>

## 프로젝트 구조

### 폴더 구조
    ├── Appication
    │   ├── AppDelegate.swift
    │   └── SceneDelegate.swift
    ├── Controller
    │   ├── BoxOfficeCalendarViewController.swift
    │   ├── BoxOfficeMainViewController.swift
    │   ├── MovieDetailViewController.swift
    │   └── Protocol
    │       └── BoxOfficeCalendarViewControllerDelegate.swift
    ├── Model
    │   ├── DTO
    │   │   ├── BoxOffice
    │   │   │   ├── BoxOffice.swift
    │   │   │   ├── BoxOfficeItem.swift
    │   │   │   └── BoxOfficeResult.swift
    │   │   ├── Image
    │   │   │   ├── Image.swift
    │   │   │   └── ImageDocument.swift
    │   │   └── Movie
    │   │       ├── Movie.swift
    │   │       ├── MovieInformation.swift
    │   │       └── MovieResult.swift
    │   ├── Decoding
    │   │   └── JSONDecodingManager.swift
    │   ├── Error
    │   │   ├── DecodingError.swift
    │   │   └── NetworkError.swift
    │   ├── Extension
    │   │   ├── Array+.swift
    │   │   ├── Bundle+.swift
    │   │   ├── Date+.swift
    │   │   └── String+.swift
    │   ├── Network
    │   │   └── NetworkManager.swift
    │   └── RankChangeState.swift
    ├── Resource
    │   ├── APIKey.plist
    │   ├── Assets.xcassets
    │   │   ├── Contents.json
    │   │   └── boxOfficeTestSample.dataset
    │   │       ├── Contents.json
    │   │       └── boxOfficeTestSample.json
    │   ├── Base.lproj
    │   │   └── LaunchScreen.storyboard
    │   └── Info.plist
    └── View
        ├── BoxOfficeCollectionViewIconCell.swift
        ├── BoxOfficeCollectionViewListCell.swift
        ├── BoxOfficeMainView.swift
        ├── Extension
        │   ├── UICollectionView+.swift
        │   ├── UICollectionViewCell+.swift
        │   ├── UIFont+.swift
        │   └── UILabel+.swift
        ├── MovieDetailView.swift
        └── Protocol
            └── Reusable.swift

<br>

### 다이어그램
#### Controller
![](https://hackmd.io/_uploads/B1mJi8hhn.png)

#### Model
![](https://hackmd.io/_uploads/BkIk2Un33.png)


<br>

## 실행 화면

|당겨서 새로고침|네트워크 통신 중 로딩UI 표시|
|:---:|:---:|
|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede8145e6bf50b2f4344e.gif)|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede7d45e6bf50b2f4344c.gif)|

|영화 상세 정보 화면|날짜 선택|
|:---:|:---:|
|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede8445e6bf50b2f43450.gif)|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede6245e6bf50b2f4344a.gif)|

|화면 모드 변경|아이콘 화면 회전|
|:---:|:---:|
|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede9245e6bf50b2f43458.gif)|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede8f45e6bf50b2f43456.gif)|

|아이콘 화면(세로) 다이나믹 타입|아이콘 화면(가로) 다이나믹 타입|
|:---:|:---:|
|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede8b45e6bf50b2f43454.gif)|![](https://s3.ap-northeast-2.amazonaws.com/media.yagom-academy.kr/resources/usr/6434a442324ce85b6f359dee/20230818/64dede8845e6bf50b2f43452.gif)|


<br>

## 트러블슈팅
### 1️⃣ Completion Handler에서 async/await으로

#### 🔒 문제점

URLSession.shared.dataTask() 메소드를 사용하게 되면 결과를 completion Handler를 통해 결과를 받습니다. 하지만 저희는 결과로 받은 데이터를 리턴하고 싶었습니다.
```swift
func getBoxOffice() -> BoxOffice {
    let task = URLSession.shared.dataTask(from: url) { data, response, error in 
    //생략...
        return data //compile error
    }
    task.resume()
}
```

`return data`는 dataTask가 받는 클로저의 return으로 들어가기 때문에 컴파일에러가 생깁니다.
따라서 data를 리턴하고 싶으면 또 completion Handler를 받아 전달해야합니다.

```swift
func getBoxOffice(completion: (BoxOffice) -> Void) {
    let task = URLSession.shared.dataTask(from: url) { data, response, error in 
        completion(data)
    }
    task.resume()
}
```

그러면 사용하는 곳에서 또 completion 을 전달해야합니다.
```swift
getBoxOffice { boxOffice in 
    print(boxoffice)
}
```

하지만 저희가 원하는 방식은 다음과 같습니다.
```swift
let boxOffice = getBoxOffice()
```

#### 🔑 해결 방법
원하는 방식을 고민하던 중 async await을 알게 되었습니다.
async await은 이와 같은 문제 뿐만 아니라 URLSession의 쓰레드 문제, 버그발생 문제를 해결할 수 있습니다.

```swift
static func fetchData<T: Decodable>(fetchType: FetchType) async throws -> T {

    guard let url = fetchType.url else {
        throw NetworkError.invalidURL
    }

    guard let (data, response) = try? await URLSession.shared.data(from: url) else {
        throw NetworkError.requestFailed
    }

    guard let httpResponse = response as? HTTPURLResponse else {
        throw NetworkError.invalidHTTPResponse
    }

    guard (200..<300) ~= httpResponse.statusCode else {
        throw NetworkError.badStatusCode(statusCode: httpResponse.statusCode)
    }

    return try decode(from: data)
}
```

async await 덕분에 네트워크 통신 함수를 깔끔하게 정리할 수 있었고 원하는 방식으로 리턴값을 받을 수 있게 되었습니다.


### 2️⃣ separator

#### 🔒 문제점

요구사항 화면에 separator가 있는 것을 확인했습니다.
collectionView를 compositionalLayout으로 구현했더니 자동으로 separator가 만들어지지 않았습니다.
처음에는 셀에 border를 주었습니다. 하지만 border를 주는 방법 말고 다른 방법이 있어서 찾아보았습니다.

#### 🔑 해결 방법

compositionalLayout에 static method인 .list()를 사용하면 list configuration을 사용할 수 있게 됩니다. 이를 이용해 list 모양 형식을 그대로 collectionView에서도 사용할 수 있었습니다.
```swift
let config = UICollectionLayoutListConfiguration(appearance: .plain)
let layout = UICollectionViewCompositionalLayout.list(using: config)
        
collectionView.collectionViewLayout = layout
```

하지만 문제는 compositionalLayout으로 item과 section group을 설정했을 경우 listConfiguration을 사용하지 못한다는 한계가 있습니다. 이때는 cell에 border를 주는 것이 좋은 방법이라고 생각합니다.

### 3️⃣ separator 기본 공백

#### 🔒 문제점

![](https://hackmd.io/_uploads/H1g1qfYo3.png)

각 cell에 separator를 설정하였지만 위에 사진과 같이 약간의 공백이 생기는 문제가 생겼습니다.

#### 🔑 해결 방법

그 이유는 컨텐츠의 끝에 자동으로 줄 맞춤되기 때문입니다.

셀을 만들 때 UICollectionViewCell이 아닌 UICollectionViewListCell을 상속받으면 separator의 레이아웃을 잡을 수 있는 layoutGuide를 사용할 수 있게 됩니다. (disclosure accesary도 사용가능)

이 속성은 레이아웃을 .list()로 만든 레이아웃에서만 적용이 가능합니다.


```swift
separatorLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
```

### 4️⃣ API key 구분 및 은닉

#### 🔒 문제점
네트워크 매니저에서 박스오피스 정보만 불러올 때는 API key를 URL에 포함하여 URL만 사용했습니다. 하지만 영화 포스터 이미지를 불러오기 위해 필요한 URL 형식이 다르기 때문에 API별로 분리가 필요했습니다.
| 영화진흥위원회 | 다음 이미지 검색 |
|:-:|:-:|
|API key가 URL 쿼리에 포함|API key가 헤더에 포함|

#### 🔑 해결 방법
URLRequest를 만드는 메소드를 분리하며 URLQueryItem과 헤더를 만들게 되었습니다.
```swift
static private func createRequest(fetchType: FetchType) -> URLRequest? {
    guard var urlComponents = URLComponents(string: fetchType.url) else {
        return nil
    }
    
    switch fetchType {
    case .boxOffice(let date):
        urlComponents.queryItems = [
            URLQueryItem(name: "key", value: Bundle.main.kobisAPIKey),
            URLQueryItem(name: "targetDt", value: date)
        ]
        
        guard let url = urlComponents.url else {
            return nil
        }
        
        return URLRequest(url: url)
        ...
    }
}
```
아울러 API key를 숨기기 위해 plist에 키를 저장하고 gitignore를 활용하는 방법을 찾았습니다.
```swift
extension Bundle {
    var kakaoAPIKey: String {
        return fetchPropertyList(domain: "KAKAO")
    }
    
    var kobisAPIKey: String {
        return fetchPropertyList(domain: "KOBIS")
    }
    
    private func fetchPropertyList(domain: String) -> String {
        guard let file = self.path(forResource: "APIKey", ofType: "plist") else { return "" }
        
        guard let resource = NSDictionary(contentsOfFile: file) else { return "" }
        guard let key = resource[domain] as? String else { fatalError("APIKey.plist에 \(domain) API키를 등록하세요")}
        
        return key
    }
}
```

### 5️⃣ 이미지의 불필요한 공백

#### 🔒 문제점
이미지를 불러왔을 때 이미지 크기와 다르게 공백이 생기는 문제가 있었습니다.
<img src="https://hackmd.io/_uploads/ByRPbKgnn.png" width=300>

#### 🔑 해결 방법
이미지 뷰의 가로와 원본 이미지의 가로를 통해 비율을 구한 후, 이 비율을 통해 높이를 구하여 이미지 뷰의 높이를 고정했습니다.

```swift
func injectMovieInformation(_ movieInformation: MovieInformation?, image: UIImage?) {
    posterImageView.image = image
    updatePosterImageViewConstraints()
   ...
}

private func updatePosterImageViewConstraints() {
    guard let imageWidth = posterImageView.image?.size.width,
          let imageHeight = posterImageView.image?.size.height else { return }
    let ratio = posterImageView.frame.width / imageWidth
    let height = ratio * imageHeight
    posterImageView.heightAnchor.constraint(equalToConstant: height).isActive = true
}
```

### 6️⃣ async let을 이용한 비동기 작업

#### 🔒 문제점
현재 코드에선 데이터를 불러오는 방식으로 async/await을 사용중입니다.

```swift
let movie = await fetchMovie()
let poster = await fetchPoster()
detailView.injectMovieInformation(movie, poster)
```

해당 코드는 순차적으로 실행되기 때문에 movie의 값을 받을 때 까지 기다린 후 movie 데이터를 다 받으면 poster를 받기 시작합니다.

두 개는 서로 전혀 관계가 없고 오래 걸리는 작업이기 때문에 순차적으로 실행하며 기다리는 것은 손해라고 판단하였습니다.

#### 🔑 해결 방법
 async let을 이용하여 문제를 해결하였습니다.
```swift
async let movie = fetchMovie()
async let poster = fetchPoster()

detailView.injectMovieInformation(await movie, await poster)
```

async let을 사용해서 비동기적으로 movie와 poster를 fetch할 수 있다는 사실을 배웠습니다. 


<br>

## 참고 링크
[🍎Apple Docs: UICollectionView](https://developer.apple.com/documentation/uikit/uicollectionview)
[🍎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: UIRefreshControl](https://developer.apple.com/documentation/uikit/uirefreshcontrol)
[🍎Apple Docs: UIActivityIndicatorView](https://developer.apple.com/documentation/uikit/uiactivityindicatorview)
[🍎Apple Docs: UICalendarView](https://developer.apple.com/documentation/uikit/uicalendarview)
[📼Apple WWDC: Meet async/await in Swift](https://developer.apple.com/videos/play/wwdc2021/10132/)
[📼Apple WWDC: Use async/await with URLSession](https://developer.apple.com/videos/play/wwdc2021/10095/)
[📘blog: [Swift] Actor 뿌시기](https://sujinnaljin.medium.com/swift-actor-%EB%BF%8C%EC%8B%9C%EA%B8%B0-249aee2b732d)
[📗야곰닷넷: Swift Concurrency Programming](https://yagom.net/courses/swift-concurrency-programming/)
<!-- [🍏Apple Archive: ]() -->
<!-- [📙Swift forums: ]()
[📘stackOverflow: ]() -->

<br>


[step5]

![](https://hackmd.io/_uploads/B1gWJDun3.png)

![](https://hackmd.io/_uploads/B1UbHDu2h.png)


