Skip to content

iOS-Yetti/ios-box-office

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

62 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

박스오피스🎬

소개

영화진흥위원회와 Daum 검색 API를 통해 날짜별 박스오피스, 영화 상세정보를 불러오는 앱입니다.

프로젝트 기간 : 23/07/24~23/08/18

목차

  1. 팀원 소개
  2. 타임 라인
  3. 시각화 구조
  4. 실행 화면
  5. 핵심 경험
  6. 트러블 슈팅
  7. 참고 자료
  8. 팀 회고


팀원 소개

Yetti maxhyunm


타임 라인

날짜 내용
2023.07.24. json 파일 파싱을 위한 BoxOfficeEntity 타입 생성, 디코딩 유닛테스트 진행
2023.07.25. DecodingManager에서 디코딩만 진행할 수 있도록 기능 분리
2023.07.26. NetworkingManager, BoxOfficeError, MoviewDetailEntity 타입 생성
2023.07.27. extension으로 중첩타입 분리, URLNamespace 타입 생성
2023.07.31. 프로젝트 진행을 위한 개인 공부시간
2023.08.01. 프로젝트 진행을 위한 개인 공부시간
2023.08.02. CollectionView세팅, BoxOfficeRankingCell 생성 및 셀 구성 세팅, DiffableDataSource 세팅 및 연결, 랭킹 증감분 AttributedString 처리, collectionView에 refreshControl 추가
2023.08.07. 리뷰에 따른 리팩토링 진행
2023.08.08. DaumImageEntity 파일 추가 및 DAUM_API_KEY 추가, MovieDetailViewController 파일 추가 및 view 전환 메서드 추가, MoviewDetailViewController에 MoviewDetail 네트워크 연결
2023.08.09. 프로젝트 진행을 위한 개인 공부시간
2023.08.10. 스택뷰의 text설정메서드 기능 분리 및 Namespace 생성, MovieDetailView 로딩화면 수정
2023.08.11. README 작성
2023.08.14. url 호출 실패시 completion으로 에러처리 추가, MovieDetailViewController에서 isDataLoading, isImageLoading 호출 위치 변경
2023.08.15. 프로젝트 진행을 위한 개인 공부시간
2023.08.16. 화면 모드 변경 버튼 추가 BoxOfficeRankingIconCell 타입 생성, 화면 모드 변경시 CollectionView의 레이아웃 설정을 바꾸는 기능 구현, 텍스트에 다이나믹 타입 적용 및 auto layout 수정
2023.08.17. APIKey 검증과정 및 에러처리 추가, 날짜 변경시 컬렉션뷰 레이아웃 틀어지는 오류 수정
2023.08.18. README 작성


시각화 구조

File Tree

├── BoxOffice
│   ├── Extension
│   │   ├── DateFormatter+.swift
│   │   └── String+.swift
│   ├── Model
│   │   ├── BoxOfficeEntity.swift
│   │   ├── MovieDetailEntity.swift
│   │   ├── DaumImageEntity.swift
│   │   ├── DecodingManager.swift
│   │   ├── Error.swift
│   │   ├── NetworkingManager.swift
│   │   └── URLSessionProtocol.swift
│   ├──View
│   │   ├── BoxOfficeRankingListCell.swift
│   │   ├── BoxOfficeRankingIconCell.swift
│   │   └── MovieDetailStackView.swift
│   ├── Controller
│   │   ├── BoxOfficeViewController.swift
│   │   ├── CalendarViewController.swift
│   │   └── MovieDetailViewController.swift
│   ├── Resource
│   │   ├── AppDelegate.swift
│   │   ├── SceneDelegate.swift
│   │   ├── NetworkConfiguration.swift
│   │   ├── Assets.xcassets
│   │   └── box_office_sample.json
│   └──Info.plist
├── BoxOffice.xcodeproj
├── BoxOfficeTests
│   ├── BoxOffice.xctestplan
│   ├── BoxOfficeDecodingTests.swift
│   ├── BoxOfficeNetworkingTest.swift
│   └── TestDouble.swift
└── README.md

UML

박스오피스 화면


영화 상세정보 화면



실행 화면

영화 상세정보 날짜 변경 화면 모드 변경


핵심 경험

🌟 xcconfig, info.plist를 활용한 api key 설정

환경 파일을 활용해 원격 저장소에 공유되지 않아야 하는 key 정보를 관리하였습니다.

🌟 CodingKeys와 Nested Type Enum을 활용한 중첩 json 파싱

Nested Type을 활용하여 여러 단계로 중첩된 형태의 json을 파싱할 수 있도록 하였고, CodingKeys를 활용해 이해하기 어려운 파라미터명을 변경하였습니다.

상세코드
extension BoxOfficeEntity {
    struct BoxOfficeResult: Decodable {
        let boxOfficeType, showRange: String
        let dailyBoxOfficeList: [DailyBoxOffice]

        enum CodingKeys: String, CodingKey {
            case boxOfficeType = "boxofficeType"
            case showRange, dailyBoxOfficeList
        }
    }
}

🌟 Generic을 활용한 범용 메서드 구현

다양한 타입의 Entity를 반환해야 하는 DecodingManager의 메서드를 Generic으로 구현하였습니다.

상세코드
func decode<T: Decodable>(_ data: Data?) throws -> T {
    guard let data = data,
          let decodedData = try? decoder.decode(T.self, from: data) else {
        throw DecodingError.decodingFailure
    }
    return decodedData
}

🌟 Modern Collection View 구현

Modern Collection View를 통해 박스오피스 랭킹 리스트를 구현하기 위하여 Diffable Data SourceCollection View List Cell를 활용하였습니다.

상세코드
private let collectionView: UICollectionView = {
    let configuration = UICollectionLayoutListConfiguration(appearance: .plain)
    let layout = UICollectionViewCompositionalLayout.list(using: configuration)
...
}
    
private var dataSource: UICollectionViewDiffableDataSource<NetworkNamespace, BoxOfficeEntity.BoxOfficeResult.DailyBoxOffice>?
...
    

🌟 UICalendarView 활용

UICalendarView를 활용해 Calendar를 구현하고 과거 날짜의 데이터를 불러올 수 있도록 활용하였습니다.

상세코드
private let calendarView: UICalendarView = {
        var calendarView = UICalendarView()
        calendarView.translatesAutoresizingMaskIntoConstraints = false
        calendarView.backgroundColor = .systemBackground
        
        let endDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
        let calendarViewDateRange = DateInterval(start: Date(timeIntervalSince1970: 0), end: endDate)
        calendarView.availableDateRange = calendarViewDateRange
        
        return calendarView
    }()

🌟 UIActivityIndicatorView를 활용한 로딩 구현

데이터 fetch 상태에 따라 UIActivityIndicatorView의 상태값을 변경하여 로딩 마크가 활성화/비활성화 되도록 구현하였습니다.

상세코드
private let indicatorView: UIActivityIndicatorView = {
    let indicatorView = UIActivityIndicatorView()
    indicatorView.style = .large
    indicatorView.translatesAutoresizingMaskIntoConstraints = false

    return indicatorView
}()

private var isLoading: Bool = true {
    willSet(newValue) {
        if newValue == true {
            indicatorView.isHidden = false
            indicatorView.startAnimating()
        } else {
            indicatorView.isHidden = true
            indicatorView.stopAnimating()
        }
    }
}

🌟 UIRefreshControl를 활용한 새로고침 구현

Collection ViewUIRefreshColtrol 객체를 추가하여, 아래로 당겼을 때 새로고침을 진행할 수 있도록 하였습니다.

상세코드
collectionView.refreshControl = refreshControl
    refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)

🌟 UIToolBar 활용

UIToolBarflexibleSpace를 활용하여 화면 하단 버튼을 구현하였습니다.

상세코드
let modeChangeButton = UIBarButtonItem(title: "화면 모드 변경", style: .plain, target: self, action: #selector(hitChangeModeButton))
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil)
...
self.toolbarItems = [flexibleSpace, modeChangeButton, flexibleSpace]

🌟 UIAlertController 활용

화면 모드 변경시 UIAlertControlleractionSheet스타일로 아이콘 모드와 리스트 모드 화면을 선택적으로 적용할 수 있도록 구현하였습니다.

상세코드
@objc func hitChangeModeButton() {
    let mode: String = isListMode == true ? "아이콘" : "리스트"
    let alert = UIAlertController(title: "화면 모드 변경", message: nil, preferredStyle: .actionSheet)
    let modeChangeAction = UIAlertAction(title: mode, style: .default) { _ in
        self.isListMode.toggle()
    }
    let cancelAction = UIAlertAction(title: "취소", style: .cancel)
        
    alert.addAction(modeChangeAction)
    alert.addAction(cancelAction)
    present(alert, animated: true)
}

🌟 Attributed String 활용

하나의 레이블 안에서 여러 가지 색상을 표시하기 위하여 Attributed String을 활용하였습니다.

상세코드
attributedString.addAttribute(.foregroundColor, value: UIColor.systemRed, range: (fixedIntensity as NSString).range(of: ""))

🌟 DynamicType 적용

preferredFont를 활용하여 폰트에 DynamicType을 적용하였고, adjustsFontSizeToFitWidth 설정을 통해 가로 너비에 맞춰 텍스트 크기를 자동 조절할 수 있도록 구현하였습니다.

상세코드
private let audienceLabel: UILabel = {
    let label = UILabel()
    label.font = .preferredFont(forTextStyle: .body)
    label.translatesAutoresizingMaskIntoConstraints = false
    label.adjustsFontForContentSizeCategory = true
    label.adjustsFontSizeToFitWidth = true

    return label
}()


트러블 슈팅

1️⃣ 네트워크 연결시 URL 전달 방식

🚨 문제점
기존에는 dataTask 메서드에 URL 타입을 전달하여 네트워크 연결을 진행하도록 구현했었습니다. 하지만 이번에 Daum API가 추가되면서 Authorization 헤더를 추가해야 했기 때문에 해당 정보를 어떻게 전달할지 고민하였습니다.

💡 해결 방법
URLRequest를 활용하면 setValue를 통해 헤더 정보를 추가할 수 있음을 확인하였습니다. 이에 따라 기존 테스트더블 및 테스트코드 모두 URLRequest에 맞게 수정을 진행하였습니다.

var request = URLRequest(url: url)
request.setValue("KakaoAK \(NetworkNamespace.daumApiKey)", forHTTPHeaderField: "Authorization")

또한 Daum API에 전달할 쿼리 내용에는 띄어쓰기가 추가되어 있어, URL에 붙여서 보내기보다는 URLComponents를 사용해 필요한 쿼리 아이템을 추가하는 게 좋겠다는 생각이 들었습니다. 해당 내용은 아래와 같이 구현하였습니다.

var urlComponents = URLComponents(string: NetworkNamespace.daumImage.url)
urlComponents?.queryItems = [URLQueryItem(name: "query", value: "\(movieName) 영화 포스터")]

2️⃣ dataTask 메서드로 받아온 데이터 처리

🚨 문제점
NetworkingManagerload() 메서드를 호출한 위치에서 dataTask를 통해 받아 온 데이터를 활용할 수 있는 방법에 대해 고민이 있었습니다. 처음에는 두 타입을 델리게이트로 연결하여 전달하는 등의 방법을 생각했습니다. 하지만 데이터 처리를 위해 네트워킹을 사용하는 모든 타입을 델리게이트로 연결하는 것은 권장되는 방식도 아니고, 효율적이지 못한 것 같았습니다.

💡 해결 방법
@escaping 클로저와 Result타입을 활용하여 해결하였습니다. Result는 성공/실패 두 가지 가능성에 대한 데이터타입을 따로 지정해줄 수 있어 CompletionHandler에 활용하기에 적절하다고 판단했습니다.

func load(_ urlString: String, completion: @escaping (Result<Data, BoxOfficeError>) -> Void) {
    guard let url = URL(string: urlString) else {
        return
    }

    let task = session.dataTask(with: url) { data, response, error in
        if error != nil {
            completion(.failure(BoxOfficeError.connectionFailure))
            return
        }
        ...

3️⃣ ATS를 통한 네트워크 설정

🚨 문제점
API를 받아와야 하는 도메인이 https가 아닌 http를 활용하고 있어 네트워크 연결시에 오류가 발생하였습니다.

💡 해결 방법
해당 도메인 및 하위 도메인 정보를 ATS에 Exception Domains로 추가하여 정상적으로 네트워킹이 가능하도록 구현하였습니다.

4️⃣ 로딩과 새로고침 종료 위치 설정

🚨 문제점
네트워킹을 통해 받아 온 데이터를 처리하는 과정에서 성공한 경우에만 로딩을 끝내고 빠져나갈 수 있도록 처리하였더니, 에러가 났을 때는 아래와 같이 계속 로딩이 돌아가고 있는 것처럼 보이는 것을 확인했습니다.

또한 RefreshControl의 종료 처리를 아래와 같이 진행하니, 데이터 로딩이 완료되기 전에 비동기로 다음 호출이 진행되어 새로고침이 바로 끝나버리는 오류가 발생했습니다.

@objc private func refresh() {
    passFetchedData()
    refreshControl.endRefreshing()
}

💡 해결 방법
네트워킹과 디코딩 여부에 상관없이 로딩과 새로고침을 완료할 수 있도록 해당 호출부를 switch문 밖으로 이동하였으며, isLoading 변수가 false일 때 endRefreshing도 호출할 수 있도록 변경해주었습니다.

networkingManager?.load(url) { [weak self] (result: Result<Data, NetworkingError>) in
    switch result {
    case .success(let data):
        ...
    case .failure(let error):
        ...
}

DispatchQueue.main.async {
    self?.isLoading = false
    self?.refreshControl.endRefreshing()
}

5️⃣ 셀 재활용으로 인한 문제 해결

🚨 문제점
Collection View에서 셀을 재사용하면서, 검은 색으로 들어가야 하는 순위 텍스트가 빨간색으로 잘못 들어가는 문제가 있었습니다.
💡 해결 방법
PrepareForReuse()를 통해 아래와 같이 폰트 색상을 초기화해 주었습니다.

override func prepareForReuse() {
    rankIntensityLabel.textColor = .black
}

6️⃣ Test Double 생성

🚨 문제점
인터넷 연결이 없는 상태에서 네트워크 통신을 테스트하기 위해 Test Double을 생성하였습니다. 이 과정에서 테스트용 Stub Session과 실제 Session 사이에 호환이 가능하도록 하기 위해 URLSessionProtocol을 구현하였는데, URLSession에서 이를 상속하려 하니 아래와 같은 경고가 발생하였습니다.

💡 해결 방법
CompletionHandler typealias@Sendable을 채택하여 해결하였습니다.

typealias CompletionHandler = @Sendable (Data?, URLResponse?, Error?) -> Void

7️⃣ 다양한 네트워크에 대한 Test 환경 구성

🚨 문제점
세 가지 다른 API를 호출하는 프로그램이다 보니, 각 API 내용에 따라 매번 새로운 테스트 환경을 만들어주어야 해서 번거로웠습니다.

💡 해결 방법
아래와 같이 테스트 타입을 파라미터로 전달받아 각 타입에 맞는 테스트환경을 구성할 수 있도록 구현하였습니다.

func setUpSUT(isSuccess: Bool, apiType: NetworkNamespace) {
    ...
        switch apiType {
    case .boxOffice:
        urlString = String(format: NetworkNamespace.boxOffice.url, NetworkNamespace.apiKey, "20230801")
        asset = "box_office_sample"
    case .movieDetail:
        urlString = String(format: NetworkNamespace.movieDetail.url, NetworkNamespace.apiKey, "20230801")
        asset = "movie_detail_sample"
    case .daumImage:
        urlString = String(format: NetworkNamespace.daumImage.url)
        asset = "daum_image_sample"
    }
    ...

    if isSuccess {
        ...
        let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
        ...
    } else {
        let response = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)
        ...
    }
}


참고 자료



팀 회고

일일 스크럼


About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Swift 100.0%