Skip to content

Latest commit

ย 

History

History
473 lines (398 loc) ยท 20.1 KB

README.md

File metadata and controls

473 lines (398 loc) ยท 20.1 KB

๋ฐ•์Šค์˜คํ”ผ์Šค๐ŸŽฌ

์†Œ๊ฐœ

์˜ํ™”์ง„ํฅ์œ„์›ํšŒ์™€ 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 Source์™€ Collection 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 View์— UIRefreshColtrol ๊ฐ์ฒด๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ, ์•„๋ž˜๋กœ ๋‹น๊ฒผ์„ ๋•Œ ์ƒˆ๋กœ๊ณ ์นจ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

์ƒ์„ธ์ฝ”๋“œ
collectionView.refreshControl = refreshControl
    refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)

๐ŸŒŸ UIToolBar ํ™œ์šฉ

UIToolBar์™€ flexibleSpace๋ฅผ ํ™œ์šฉํ•˜์—ฌ ํ™”๋ฉด ํ•˜๋‹จ ๋ฒ„ํŠผ์„ ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

์ƒ์„ธ์ฝ”๋“œ
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 ํ™œ์šฉ

ํ™”๋ฉด ๋ชจ๋“œ ๋ณ€๊ฒฝ์‹œ UIAlertController์˜ actionSheet์Šคํƒ€์ผ๋กœ ์•„์ด์ฝ˜ ๋ชจ๋“œ์™€ ๋ฆฌ์ŠคํŠธ ๋ชจ๋“œ ํ™”๋ฉด์„ ์„ ํƒ์ ์œผ๋กœ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

์ƒ์„ธ์ฝ”๋“œ
@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 ๋ฉ”์„œ๋“œ๋กœ ๋ฐ›์•„์˜จ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ

๐Ÿšจ ๋ฌธ์ œ์ 
NetworkingManager์˜ load() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•œ ์œ„์น˜์—์„œ 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)
        ...
    }
}


์ฐธ๊ณ  ์ž๋ฃŒ



ํŒ€ ํšŒ๊ณ 

์ผ์ผ ์Šคํฌ๋Ÿผ