오늘의 날씨와 이후 5일의 날씨를 보기 쉽게 구현한 날씨 앱입니다🌞
Miro(@longlivdrgn) | Sunny(@SunnnySong) |
---|---|
메인 뷰 |
---|
CoreLocation
URLSession
Swift Concurrency
async
,await
CodingKeys
DateFormatter
NotificationCenter
Collection View 구현
CompositionalLayout
UICollectionLayoutListConfiguration
UICollectionViewListCell
Datasource
RefreshControl
- 🔨 범용성과 재사용성, 유연성을 고려한 API 네트워크
- 🔨 현재 위치를 사용하기 위한 CoreLocation
- 🔨 Collection View 구현하기
- 🔨 View Model의 업데이트를 UI에 반영하기
- 🔨 Swift Style Guideline
- 🔨 트러블 슈팅
- 🔨 Async, Await의 활용
�해당 프로젝트는 OpenWeatherAPI를 사용해요. 사용하는 API는 한 개이지만, 필요한 정보에 따라 URL주소가 변경되기도 하고 만약 여러 API를 사용하게 된다면 어떻게 URL을 관리해야 할까? 라는 생각이 들었어요.
저희가 생각한 방법은 URL extension과 API 타입 생성, 두 가지 입니다.
- URL extension으로 모든 API를 관리한다.
➡️ URL extension으로 각 API 마다 URL을 만들어 반환하는 메서드를 생각했어요.
- API의 타입 안에서 URL을 관리한다.
func makeWeatherURL(coordinate: Coordinate) -> URL {
let queryItems = "\(coordinate.description)&units=metric&appid="
let apiKey = APIKeyManager.openWeather.apiKey
return URL(string: WeatherAPI.baseURL + self.path + queryItems + apiKey)!
}
➡️ 각 API를 관리하는 타입을 만들어 내부에서 URL 요소를 관리하는 방법이에요. ➡️ 해당 타입 안에서 URL을 만들어 반환하는 메서드를 생성해요.
- 최종 구현한 방법
- 저희는
API 타입 생성
으로 구현했어요. - 첫 번째 방법은 사용하는 API 수가 적고, URL을 구성하는 요소들이 단순해 큰 변경이 없을 때 적합한 방법이에요.
- 두 번째 방법은 프로젝트에서 사용하는 API수가 많고 URL을 구성하는 요소들이 복잡할 때 URL 관리가 용이해지는 방법이에요.
- 이번 프로젝트에서 사용하는 API는 한 개이지만,
currentWeather
와fiveDaysForecast
별 사용하는 path가 다르고 이미지를 가져오는 URL까지 - 존재하기 때문에 두 번째 방법이 더 적합하다 생각했어요.
- URL은 크게
baseURL
,path
,query
로 나눠져 있어요.
enum WeatherAPI: String {
case currentWeather
case fiveDaysForecast
}
extension WeatherAPI {
static let baseURL = "https://api.openweathermap.org"
static let baseImageURL = "https://openweathermap.org"
var path: String {
switch self {
case .currentWeather:
return "/data/2.5/weather?"
case .fiveDaysForecast:
return "/data/2.5/forecast?"
}
}
}
WeatherAPI
를 enum 타입으로 구현해, path를 설정할 때switch문
으로 각 정보별 path를 설정해주었어요.- 날씨 정보를 가져오는 URL과 이미지를 가져오는 URL이 다르기 때문에 두 개의
baseURL
을 설정했어요.
-
CodingKeys의 활용
DTO(Data Transfer Object)
란, 서버에서 받아온 데이터를 앱에 적합한 형태로 변환해주는 객체에요.JSONDecoder
를 사용하면 JSON 데이터의 Key와 DTO(Decodable을 채택한)의 프로퍼티를 매핑할 수 있어요.- 이때 JSON 데이터의 Key와 DTO의 프로퍼티 이름이 같지 않으면, 데이터 처리에 실패해요.
- 저희는 이런 문제를 방지하고자 DTO 내부에
CodingKeys
를 정의해주었어요.
-
CLLocation
대신Coordinate
타입을 선언했어요.- 프로젝트 로직에서 longitude, latitude는 많이 사용되고 있어요.
- 만약
CLLocation
을 사용하면, 위도와 경도를 사용하는 모든 파일에import CoreLocation
를 선언해야 해요. CoreLocation
의CLLocation
만 사용하기 위해 import를 하는 것은 불필요한 빌드 시간을 발생시킬 수 있어요.- 저희는 프로젝트 파일이 필요한 모듈만 가져올 수 있도록 위도와 경도를 프로퍼티로 갖는
Coordinate
타입을 선언했어요.
-
재사용성을 높이기 위해
단일 책임 원칙
에 맞는 네트워킹 타입을 구현하려고 노력했어요. -
OpenWeatherAPI 뿐만 아니라 다른 API의 네트워킹 요청까지 처리할 수 있어 범용성과 재사용성을 만족시키는 타입을 만들었어요.
-
네트워크 코드를 설명하기 전, 더 쉬운 이해를 위해 네트워크 구조를 시각화 해봤어요.
final class NetworkSession {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func fetchData(from urlRequest: URLRequest) async throws -> NetworkResult {
let (data, response) = try await session.data(for: urlRequest)
guard response.checkResponse else {
return .failure(.outOfReponseCode)
}
return .success(data)
}
}
NetworkSession
타입은 네트워크 요청을 전송하는 책임을 갖고 있어요.fetchData(from:)
:URLRequest
를 인자로 받아 네트워크 요청을 전달하고, 그 결과를 반환해요.
typealias NetworkResult = Result<Data, NetworkError>
- 네트워크 결과는 Result 타입으로 구현된
NetworkResult
를 사용했어요.- 네트워크 결과가 성공일 경우 받아오는 데이터와 실패할 경우 반환되는
NetworkError
를 하나의 Result타입으로캡슐화
했어요.
- 네트워크 결과가 성공일 경우 받아오는 데이터와 실패할 경우 반환되는
func makeWeatherRequest(of weatherAPI: WeatherAPI, in coordinate: Coordinate) -> URLRequest {
let url = weatherAPI.makeWeatherURL(coordinate: coordinate)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
return urlRequest
}
makeWeatherRequest(of:in:)
: 날씨 정보를 받기 위한 네트워크 요청을 만드는 메서드에요.
func requestWeatherInformation(of weatherAPI: WeatherAPI,
in coordinate: Coordinate) async throws -> Decodable? {
let urlRequest = makeWeatherRequest(of: weatherAPI, in: coordinate)
let result = try await networkSession.fetchData(from: urlRequest)
switch result {
case .success(let data):
guard let decodeData = try self.deserializer.deserialize(data: data, to: weatherAPI.decodingType) else { throw NetworkError.failedDecoding}
return decodeData
case .failure(let error):
print(error.errorDescription)
return nil
}
}
requestWeatherInformation(of:in:)
:NetworkSession.fetchData(from:)
을 사용해 비동기 네트워크 요청을 보내고 그 결과를 case별로 처리해요.- Result 타입의 네트워크 결과를 switch문으로 처리해 손 쉬운 error Handling을 구현해보았어요.
private let deserializer = JSONNetworkDeserializer(decoder: JSONDecoder())
- 성공했을 경우, 전달된 데이터를
JSONNetworkDeserializer()
을 사용해 각 데이터에 알맞는 DTO 타입으로 변환해주었어요.
- Serialization : 데이터를 특정 형식으로 변환하는 타입을 가진 폴더에요.
- Serialization은 Deserializer, Serializer 을 모두 포함하지만, 이번 프로젝트에서는 Deserializer만 구현했어요.
- Error : 네트워크 에러를 정의해 놓은 폴더에요.
- DTO : Serialization에 사용하는 데이터 전송 객체를 모아 놓은 폴더에요.
- 해당 프로젝트에서는 API를 통해 두 가지의 데이터를 전송받기 때문에 두 개의 DTO를 생성했어요.
- Model : 네트워크에 필요한 모델을 정의해 놓은 폴더에요.
- API : API와 통신하는데 필요한 파일이 담긴 폴더에요.
- APIKeys : API의 Key 관리 폴더에요.
- 실제 API Key는 APIKeys 파일에 넣고 .gitignore에 추가했어요.
- APIKeyManager로 APIKey에 접근해 보안성을 높혔어요.
- APIKeys : API의 Key 관리 폴더에요.
CLLocationManagerDelegate
프로토콜을 채택한 CoreLocationManager 타입을 구현했어요.- 처음 구현에서는
CLLocationManager
을 직접 상속한 타입을 만들었어요. - 하지만
CLLocationManager
은 iOS의 내부적인 System Framework로, 일부 메서드를 오버라이드 하는 것은 권장되지 않는다는 사실을 알게 되었어요.
- 처음 구현에서는
CLLocationManagerDelegate
프로토콜을 채택해CLLocationManager
가 트리거한 이벤트에 대한 응답을 설정해주었어요.- CLLocationManager의 프로퍼티인 location이 업데이트 되면,
CoreLocationManagerDelegate
를 통해WeatherViewModel
에게 현재 위치를 전달해주었어요.
- Table View와 같은 list 형식의 Collection View를 구현하기 위하여 애플 공식문서 Implementing Modern Collection Views를 참고하여
Compositional Layout
를 활용해 Collection View를 구현했어요. 아래는 저희가 이와 같은 스택을 결정하게된 고민의 과정이에요.
- 저희는 두 가지 방식의 collection view의 layout 구현하는 방법을 고려해보았어요.
- DelegateFlowLayout
- CompositionalLayout
DelegateFlowLayout
- 해당 방법은 가장 기본적인 Collection View의 레이아웃을 구현하는 방법이에요. 이 방법은 아이템 간 간격, 행과 열의 갯수 등을 구성할 수 있는 메서드를 제공하므로 비교적 간단한 형태의 Collection View의 레이아웃을 구현할 때 활용할 수 있어요.
CompositionalLayout
DelegateFlowLayout보다
더 세밀하게 레이아웃을 지정할 수 있고, 각 섹션 별로 다른 형태의 그리드를 구성할 수 있도록 해요.
처음 Collection View를 구현할 때는 굳이
CompositionaLayout을
활용하는 게 아닌DelegateFlowLayout을
통해서 구현할 수 있다고 생각했지만, Implementing Modern Collection Views에서 소개된UICollectionLayoutListConfiguration를
활용하기 위해서는CompositionalLayout
을 활용해야되었어요. 따라서CompositionalLayout
으로 레이아웃을 구현하기로 결정했어요.
- 저희는 Collection View의 Datasource를 구현하기 위해서 아래와 같은 두 가지 방식을 고려했어요.
- DiffableDatasource
- Datasource
Datasource
- 가장 기본적으로 Collection View Cell의 Data를 구현하는 방식으로 Collection View의 섹션 수, 각 섹션의 항목 수, 셀 구성 및 구성된 셀에 대한 데이터 제공 등을 제공해요.
DiffableDatasouce
- Collection View의 데이터 업데이트를 더 손쉽게 하기 위해서 발전된 Datasource 구현 방법이에요.
NSDiffableDataSourceSnapshot
객체를 통하여 데이터 소스의 변경사항을 체크하고 스냅 샵을 통하여 변경된 데이터 소스를 통하여 Collection View를 업데이트해요.
저희는 매번 reloadData()를 통하여 collection view를 업데이트를 하는
DataSource
보다 스냅샷을 통하여 리소스 낭비 없이 Collection View 업데이트를 하는DiffableDatasource
를 활용하려고 했어요. 그러나, HeaderView에 다른 데이터 소스가 필요한 저희 프로젝트에서DiffableDatasource
를 활용할 경우 Datasource를 두 개를 만들어야하므로 비효율적이라는 생각이 들었어요. 또한, 지속적인 collection View의 업데이트가 일어나야되는 것이 아닌refreshControl
을 통해서만 업데이트되는 현재의 조건에서는 굳이DiffableDatasource
를 활용하지 않아도 된다는 판단이 들어 기본Datasource
를 활용하기로 결정했어요.
- 저희는 Table View와 같이 생긴 Collection View를 더욱 손쉽게 구현하기 위하여
UICollectionLayoutListConfiguration
를 활용하기로 했어요. 이를 통해서 굳이 Cell의 사이즈를 설정할 필요없이 원하는 모습의 Collection View를 구현할 수 있었어요.
private func createLayout() -> UICollectionViewLayout {
var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
configuration.headerMode = .supplementary
configuration.backgroundColor = .clear
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
return layout
}
- 또한, cell Configuration을 통하여 cell안의 layout을 따로 잡아주지 않고 이미 구현된 layout을 활용하여 cell의 layout을 구현할 수 있었어요.
override func updateConfiguration(using state: UICellConfigurationState) {
super.updateConfiguration(using: state)
var configuration = ...
contentConfiguration = configuration
}
- 반복되어 사용되는 UICollectionReusableView reuseIdentifier를 extension으로 빼주어 reuseIdentifier가 반복되어 하드 코딩되는 것을 피할 수 있었어요.
extension UICollectionReusableView {
static var reuseIdentifier: String {
return String(describing: Self.self)
}
}
- 또한, cell을 register할 때에 reuseIdentifier와 class를 따로 넣어주는 것이 아닌 동시에 넣어줄 수 있게 하여 훨씬 더 간결하게 view controller 코드를 구성할 수 있도록 했어요.
extension UICollectionView {
func register<T: UICollectionViewCell>(cell: T.Type) {
register(T.self, forCellWithReuseIdentifier: T.reuseIdentifier)
}
func register<T: UICollectionReusableView>(header: T.Type) {
}
- 이 뿐만아니라 dequeue 메소드 또한 아래와 같은 extension으로 구현하여 더 간결하게 코드를 구성할 수 있어용.
func dequeue<T: UICollectionViewCell>(cell: T.Type, for indexPath: IndexPath) -> T {
return dequeueReusableCell(withReuseIdentifier: cell.reuseIdentifier, for: indexPath) as! T
}
// 🔥 WeatherViewModel
NotificationCenter.default.post(name: Notification.Name.modelDidFinishSetUp, object: nil)
// 🔥 WeatherViewController
NotificationCenter.default.addObserver(self, selector: #selector(modelDidFinishSetUp(_:)), name: Notification.Name.modelDidFinishSetUp, object: nil)
저희는 동료와 수월한 협업 및 코드의 가독성을 위하여 Swift Style Guideline 및 kodecocodes의 sytle guide를 참고하여 코드를 짜려고 노력했어요.
CoreLocationManagerDelegate
WeatherViewModelDelegate
- UIKit의 대부분의 delegate 메소드가 그러하듯 delegate 메소드의 첫 번째 파라미터는 delegate 소스를 받도록 했어요.
// 🌈 CoreLocationManagerDelegate
protocol CoreLocationManagerDelegate: AnyObject {
func coreLocationManager(_ manager: CoreLocationManager, didUpdateLocation location: CLLocation)
}
// 🌈 WeatherViewModelDelegate
protocol WeatherViewModelDelegate: AnyObject {
func weatherViewModelDidFinishSetUp(_ viewModel: WeatherViewModel)
}
재사용성을 높인 네트워크 코드 구현
파트에서 설명드린 대로 저희는 각 네트워크 계층의 메소드 명과 타입 명을 확실히 분리하여 가독성을 높일 수 있었어요.
NetworkSession
fetch
메소드를 통하여 네트워크 요청을 전송하는 책임을 갖고 있어요.- Network 계층에서 가장 하단에 위치한 타입이에요.
WeatherNetworkDispatcher
request
메소드를 통하여NetworkSession
의fetch
메소드를 호출하여 최종적으로 Data를 받아오는 타입이에요.- Network 계층에서
NetworkSession
의 상위 단계의 타입이에요.
WeatherViewModel
execute
메소드를 통하여weatherNetworkDispatcher의
request
메소드를 호출하여 API 호출을 통하여 최종적으로 원하는 Model을 생성하는 타입이에요.
- 기존의 ViewController의 경우,
CoreLocation
을 통하여 위치를 받아오기 위해CLLocationManagerDelegate
을 채택했어요. - API 통신으로 받아온 데이터를 Collection View에서 보여주기 위한 로직을 담고있어야했어요.
// 기존 ViewController 코드
class WeatherViewController: UIViewController {
private let networkModel = NetworkModel()
private lazy var network = WeatherAPIManager(networkModel: networkModel)
private let locationDelegate = LocationManagerDelegate()
lazy var coreLocationManger: CLLocationManager = {
let manager = CLLocationManager()
manager.desiredAccuracy = kCLLocationAccuracyKilometer
manager.requestWhenInUseAuthorization()
return manager
}()
override func viewDidLoad() {
super.viewDidLoad()
coreLocationManger.delegate = locationDelegate
network.fetchWeatherInformation(
of: .currentWeather,
// 예시 위도 경도
in: Coordinate(longitude: 126.96368972, latitude: 37.53361968)
)
}
}
- 그러던 중, 뷰 컨트롤러는 죄가 없다 라는 아티클을 보게되었어요. 해당 아티클에서 쓰여진 말처럼, 저희의 코드는 네트워크 API콜을 하고 response를 받아 View에 띄우는 것 까지 모두 View Controller이 담당하고 있었어요. 저희는 이러한 코드는 View Controller에게 너무 큰 책임을 주는 것이라고 생각이 들었어요.
- 문제를 인식한 뒤, View Controller가 View에 데이터를 present해주는 로직만 담기로 결심했어요.
- View들을 관리하는 View Controller처럼, Data들을 관리하는
Data Controller
파일을 생성하기로 했어요. - 네트워크 API를 호출하고, 결과를 받아 View에 보여주기 위한 타입으로 변환하는 로직 등 데이터 관련 로직을
Data Controller
에서 구현했어요.
- View들을 관리하는 View Controller처럼, Data들을 관리하는
- Apple Documentation의 Implementing Modern Collection Views 예제 프로젝트를 참고했어요.
- 저희는 이 프로젝트의
MountainsController
파일이 Data Controller 역할을 한다고 생각했어요.MountainsController
는 Collection View에 데이터를 띄워주기 위해 Mountains 데이터 셋을 만들고 있어요.
- 저희는 이 프로젝트의
- 따라서, 저희는 ViewController와는 다른 Data Controller 파일을 생성하게 되었어요.
- Data Controller 파일에서는 CoreLocation의 알림을 받고, API 통신과 관련된 로직이 포함되어 있어요.
- 또한 Collection View에서 사용할 FiveDaysForecast 구조체와 CurrentWeather 구조체가 존재해요.
- 리팩토링을 통해 가벼운 View Controller를 얻었지만, 그에 반해 매우 많은 로직을 담은 Data Controller를 만들게 되었어요.
- 현재 Data Controller에는 CurrentWeather, FiveDaysForecast 두 개의 타입을 모두 다루고 있어요.
final class WeatherController {
struct CurrentWeather: Identifiable {
let id = UUID()
let image: UIImage?
let address: String
let temperatures: Temperature
}
struct FiveDaysForecast: Identifiable {
let id = UUID()
var image: UIImage?
let date: String
let temperature: Double
}
private var weatherAPIManager: WeatherAPIManager?
private let locationManager = LocationManager()
weak var currentWeatherDelegate: CurrentWeatherDelegate?
var currentWeather: CurrentWeather?
var forecaseWeather: [FiveDaysForecast] = []
init(networkModel: NetworkModel = NetworkModel(session: URLSession.shared)) {
weatherAPIManager = WeatherAPIManager(networkModel: networkModel)
locationManager.locationDelegate = self
}
// CLLocation -> Coordinate 변환 함수
func makeCoordinate(from location: CLLocation) -> Coordinate {
// ...
}
// API 통신 후 decoded 한 CurrentWeatherDTO를 CurrentWeather 타입으로 변환하는 함수
private func makeCurrentWeather(location: CLLocation) {
// ...
}
// API 통신 후 decoded 한 FiveDaysForecastDTO를 FiveDaysForecast 타입으로 변환하는 함수
private func makeForecastWeather(location: CLLocation) {
// ...
}
}
func makeWeatherData(location: CLLocation) {
makeCurrentWeather(location: location)
makeForecastWeather(location: location)
}
}
extension WeatherController: LocationDelegate {
func send(location: CLLocation) {
makeWeatherData(location: location)
}
}
- SRP란, 클래스는 하나의 기능만 가지며, 클래스가 제공하는 모든 서비스는 하나의 책임을 수행하는데 집중되어야 한다는 SOLID 원칙 중 하나에요.
- 기존 코드는
WeatherController
안에CurrentWeather
와FiveDaysForecast
가 존재하고, DTO 데이터를 각 타입으로 변환하는 두 개의 서비스를 갖고 있어요. 이러한WeatherController
는 SRP 원칙을 위배하는 코드라고 생각했어요. WeatherController
를CurrentWeatherViewModel
과FiveDaysForecastWeatherViewModel
로 분리했어요.- 저희는 DataController라는 네이밍 보다는 View Controller에서 사용하는 Model이라는 뜻에서 ViewModel이라는 네이밍 더 명시적이라고 생각했어요.
CurrentWeatherViewModel.swift
- CurrentWeatherViewModel 파일은 CurrentWeather 타입과 그에 관련된 서비스만을 다루고 있어요.
final class CurrentWeatherViewModel {
struct CurrentWeather: Identifiable {
let id = UUID()
let image: UIImage?
let address: String
let temperatures: Temperature
}
func makeCurrentAddress(locationManager: CoreLocationManager,
location: CLLocation,
completion: @escaping (String) -> Void
) {
// ...
}
func makeCurrentInformation(weatherAPIManager: WeatherAPIManager?,
coordinate: Coordinate,
location: CLLocation,
address: String,
completion: @escaping (String, CurrentWeatherDTO) -> Void
) {
// ...
}
func makeCurrentImage(weatherAPIManager: WeatherAPIManager?,
iconString: String,
address: String,
weatherData: CurrentWeatherDTO
) {
// ...
// 추후 WeatherViewModel로 전달하는 Delegate 구현
}
}
}
FiveDaysForecastWeatherViewModel.swift
- FiveDaysForecastWeatherViewModel 타입은 FiveDaysForecast 타입과 그에 관련된 서비스만을 다루고 있어요.
final class FiveDaysForecastWeatherViewModel {
struct FiveDaysForecast: Identifiable {
let id = UUID()
var image: UIImage?
let date: String
let temperature: Double
}
func makeForecastWeather(weatherAPIManager: WeatherAPIManager?,
coordinate: Coordinate,
location: CLLocation,
completion: @escaping (String, Day) -> Void
) {
// ...
}
func makeForecastImage(weatherAPIManager: WeatherAPIManager?,
icon: String,
eachData: Day
) {
// ...
// 추후 WeatherViewModel로 전달하는 Delegate 구현
}
}
}
WeatherViewModel.swift
final class WeatherViewModel {
private let fiveDaysForecastWeatherViewModel = FiveDaysForecastWeatherViewModel()
private let currentWeatherViewModel = CurrentWeatherViewModel()
private let locationManager = CoreLocationManager()
private let weatherAPIManager: WeatherAPIManager?
var fiveDaysForecastWeather: [FiveDaysForecastWeatherViewModel.FiveDaysForecast] = []
var currentWeather: CurrentWeatherViewModel.CurrentWeather?
init(networkModel: NetworkModel = NetworkModel(session: URLSession.shared)) {
weatherAPIManager = WeatherAPIManager(networkModel: networkModel)
locationManager.locationDelegate = self
}
func makeCoordinate(from location: CLLocation) -> Coordinate {
// ...
}
func makeWeatherData(locationManager: CoreLocationManager, weatherAPIManager: WeatherAPIManager?) {
// currentWeather 만드는 메서드 호출
currentWeatherViewModel.makeCurrentAddress(
locationManager: locationManager,
location: location
) { [weak self] address in
self?.currentWeatherViewModel.makeCurrentInformation(
weatherAPIManager: weatherAPIManager,
coordinate: coordinate,
location: location,
address: address
) { [weak self] iconString, weatherData in
self?.currentWeatherViewModel.makeCurrentImage(
weatherAPIManager: weatherAPIManager,
iconString: iconString,
address: address,
weatherData: weatherData
)
}
}
// fiveDaysForecast 만드는 메서드 호출
self.fiveDaysForecastWeatherViewModel.makeForecastWeather(
weatherAPIManager: weatherAPIManager,
coordinate: coordinate,
location: location
) { [weak self] iconString, eachData in
self?.fiveDaysForecastWeatherViewModel.makeForecastImage(
weatherAPIManager: weatherAPIManager,
icon: iconString,
eachData: eachData
)
}
}
}
extension WeatherViewModel: LocationDelegate {
func didUpdateLocation() {
makeWeatherData(
locationManager: locationManager,
weatherAPIManager: weatherAPIManager
)
}
}
- 또한 변환한 데이터 셋을 가지며 View Controller와 통신하는 WeatherViewModel 타입을 만들었어요.
- 데이터 모델을 만들기 위해서는 기본적으로 여러 번의 API 호출을 거쳐야됐어요. Current Weather의 경우, 아래의 순서를 거쳐서 객체가 생성되요.
- CLGeocoder().reverseGeocodeLocation 메서드를 사용해 현재의 주소를 받아와요.
- 경도, 위치 정보를 통하여 CurrentWeatherDTO를 생성해요.
- CurrentWeatherDTO의 icon 프로퍼티 값을 통하여 icon Image를 받아오는 API 통신을 해요.
- 최종적으로 CurrentWeather 객체를 생성해요.
- 이러한 로직을 통하여 코드를 구현하니 아래와 같은 코드가 생성되었어요.
func makeCurrentWeather(location: CLLocation) {
// 1. coordinate 주소 가져오기
let coordinate = self.makeCoordinate(from: location)
// 2. address 생성하기
locationManager.changeGeocoder(location: location) { [weak self] place in
guard let locality = place?.locality, let subLocality = place?.subLocality else { return }
let address = "\\(locality) \\(subLocality)"
// 3. currentWeather 가져오기
self?.weatherAPIManager?.fetchWeatherInformation(of: .currentWeather, in: coordinate) { [weak self] data in
let group = DispatchGroup()
guard let weatherData = data as? CurrentWeatherDTO else { return }
guard let icon = weatherData.weather.first?.icon else { return }
group.enter()
// 4. 이미지 가져오기
self?.weatherAPIManager?.fetchWeatherImage(icon: icon) { [weak self] weatherImage in
self?.currentWeather = CurrentWeather(image: weatherImage, address: address, temperatures: weatherData.temperature)
group.leave()
}
group.notify(queue: .main) {
self?.currentWeatherDelegate?.notifyToUpdateCurrentWeather()
}
}
}
}
😅 위와 같은 코드는 아래와 같은 단점을 가지고 있어요.
-
CompletionHandler로 인해 과도한 중첩 클로저가 유발되어 코드 복잡성이 증가되었어요.
-
연속적인 completionHandler로 인해 코드의 가독성이 저하되었어요.
-
오류 처리와 같은 예외 상황 처리가 어렵기 때문에 유지보수 저하가 우려되요.
-
self property
의 접근으로 인해strong retain cycle
발생 가능성이 있어요.- 위 코드에서
strong retain cycle
을 막기 위해 약한 참조를 사용했지만, 이는 런타임 오버헤드를 발생시킬 수 있어요.
- 위 코드에서
-
이러한 장풍 코드를 개선하기 위한 방법을 고민해봤어요.
- DispatchGroup의 활용해 return 값으로 데이터를 전달한다.
- async, await를 활용한다.
-
저희는 위 방법 중 async, await을 활용해보기로 하였고, 아래와 같은 코드를 완성할 수 있었어요!
// WeatherViewModel
private func execute(locationManager: CoreLocationManager,
location: CLLocation,
weatherNetworkDispatcher: WeatherNetworkDispatcher) {
let coordinate = self.makeCoordinate(from: location)
Task {
let address = try await currentWeatherViewModel.fetchCurrentAddress(
locationManager: coreLocationManager,
location: location)
let currentWeatherDTO = try await currentWeatherViewModel.fetchCurrentInformation(
weatherNetworkDispatcher: weatherNetworkDispatcher,
coordinate: coordinate
)
let currentWeatherImage = try await currentWeatherViewModel.fetchCurrentImage(
weatherNetworkDispatcher: weatherNetworkDispatcher,
currentWeatherDTO: currentWeatherDTO
)
let currentWeather = currentWeatherViewModel.makeCurrentWeather(
image: currentWeatherImage,
address: address,
currentWeatherDTO: currentWeatherDTO
)
self.currentWeather = currentWeather
let fiveDaysForecastWeatherDTO = try await fiveDaysForecastWeatherViewModel.fetchForecastWeather(
weatherNetworkDispatcher: weatherNetworkDispatcher,
coordinate: coordinate
)
let fiveDaysForecastImages = try await fiveDaysForecastWeatherViewModel.fetchForecastImages(
weatherNetworkDispatcher: weatherNetworkDispatcher,
fiveDaysForecastDTO: fiveDaysForecastWeatherDTO
)
let fiveDaysForecasts = fiveDaysForecastWeatherViewModel.makeFiveDaysForecast(
images: fiveDaysForecastImages,
fiveDaysForecastDTO: fiveDaysForecastWeatherDTO
)
self.fiveDaysForecastWeather = fiveDaysForecasts
DispatchQueue.main.async {
self.delegate?.weatherViewModelDidFinishSetUp(self)
}
}
}
func fetchCurrentAddress(locationManager: CoreLocationManager,
location: CLLocation) async throws -> String {
let location = try await locationManager.changeGeocoder(location: location)
guard let locality = location?.locality, let subLocality = location?.subLocality else {
throw NetworkError.failedTypeCasting
}
let address = "\\(locality) \\(subLocality)"
return address
}
func fetchCurrentInformation(weatherNetworkDispatcher: WeatherNetworkDispatcher,
coordinate: Coordinate) async throws -> CurrentWeatherDTO {
let decodedData = try await weatherNetworkDispatcher.requestWeatherInformation(of: .currentWeather, in: coordinate)
guard let currentWeatherDTO = decodedData as? CurrentWeatherDTO else {
throw NetworkError.failedTypeCasting
}
return currentWeatherDTO
}
func fetchCurrentImage(weatherNetworkDispatcher: WeatherNetworkDispatcher,
currentWeatherDTO: CurrentWeatherDTO) async throws -> UIImage {
guard let iconString = currentWeatherDTO.weather.first?.icon else {
throw NetworkError.failedTypeCasting
}
let image = try await weatherNetworkDispatcher.requestWeatherImage(icon: iconString)
guard let image = image else {
throw NetworkError.failedTypeCasting
}
return image
}
func fetchForecastWeather(weatherNetworkDispatcher: WeatherNetworkDispatcher,
coordinate: Coordinate) async throws -> FiveDaysForecastDTO {
let decodedData = try await weatherNetworkDispatcher.requestWeatherInformation(of: .fiveDaysForecast, in: coordinate)
guard let fiveDaysForecastDTO = decodedData as? FiveDaysForecastDTO else {
throw NetworkError.failedTypeCasting
}
return fiveDaysForecastDTO
}
func fetchForecastImages(weatherNetworkDispatcher: WeatherNetworkDispatcher,
fiveDaysForecastDTO: FiveDaysForecastDTO) async throws -> [UIImage] {
var images: [UIImage] = []
for day in fiveDaysForecastDTO.list {
guard let iconString = day.weather.first?.icon else {
throw NetworkError.failedTypeCasting
}
let image = try await weatherNetworkDispatcher.requestWeatherImage(icon: iconString)
guard let image = image else {
throw NetworkError.failedTypeCasting
}
images.append(image)
}
return images
}
- 콜백 지옥에서 벗어나 가독성을 향상시킬 수 있어요.
- 에러 핸들링이 쉬워져요.
- 어느 부분에서 에러가 발생했는지 확인이 용이하기 때문에 유지보수가 용이해요.
- 비동기 코드들의 작업 순서를 쉽게 제어할 수 있어요.