오늘은 진행하던 프로젝트의 ViewController에서 MVVM 패턴과 Combine을 적용해 리팩토링 과정을 진행했다.
MVVM (Model-View-ViewModel) 패턴을 적용하면 코드의 구조화와 유지보수가 쉬워지고, 테스트가 용이해진다.
또한, Combine을 사용하면 데이터 흐름을 반응형으로 관리할 수 있어, 데이터 변경 시 UI가 자동으로 업데이트된다는 장점이 있다.
✔️ 기존 구조 문제점
기존의 ViewController는 네트워크 호출과 UI 업데이트를 모두 담당했다.
- 네트워크 호출을 통해 날씨 데이터를 받아오고,
- 데이터를 받은 후, 바로 UI 요소(라벨, 이미지 등)을 업데이트
이렇게 모든 로직이 ViewController에 몰려 있으면 유지보수가 어렵고, 테스트도 힘들다.
✔️ MVVM 구조로 분리하기
- model: 데이터 구조체 및 API 관리
- viewModel: 비즈니스 로직 처리 및 데이터 처리
- view: UI 구성
Combine을 사용한 MVVM 패턴으로 리팩토링하려면,
데이터를 ViewModel로 분리하고 @Published와 Subscriber를 활용해 뷰와 데이터를 바인딩해야한다.
1. WeatherAPIManager : Combine + Alamofire
기존 API 를 호출할 때는 Completion Handler 방식을 사용했는데, 이를 Combine 방식으로 변경했다.
→ Combine을 통해 데이터를 Publisher로 반환하면 ViewModel이 쉽게 구독하고 데이터 흐름을 관리할 수 있다!
기존 방식 (Completion Handler)
func fetchHourlyWeather(lat: Double, lon: Double, completion: @escaping (Result<HourlyWeatherResult, Error>) -> Void) {
// URL 생성
guard let url = makeURL(endpoint: "forecast", queryItems: additionalQueryItems) else {
completion(.failure(NSError(domain: "InvalidURL", code: -1, userInfo: nil)))
return
}
// Alamofire 요청
AF.request(url, method: .get).responseDecodable(of: HourlyWeatherResult.self) { response in
switch response.result {
case .success(let hourlyData):
completion(.success(hourlyData))
case .failure(let error):
completion(.failure(error))
}
}
}
문제점
- ViewModel에서 매번 Completion Handler를 처리해야 해서 비동기 코드가 복잡해짐.
- 데이터 흐름을 추적하기 어려움.
수정된 방식 (Combine 사용)
Alamofire의 responseDecodable은 Combine과 함께 사용할 수 있는
AF.request(...).publishDecodable()을 제공하기에 이를 활용해서 수정했다.
// MARK: - Fetch Current Weather Data (Combine)
func fetchCurrentWeather(lat: Double, lon: Double) -> AnyPublisher<CurrentWeatherResult, Error> {
let additionalQueryItems = [
URLQueryItem(name: "lat", value: "\(lat)"),
URLQueryItem(name: "lon", value: "\(lon)")
]
guard let url = makeURL(endpoint: "weather", queryItems: additionalQueryItems) else {
return Fail(error: URLError(.badURL))
.eraseToAnyPublisher()
}
return AF.request(url)
.publishDecodable(type: CurrentWeatherResult.self)
.value() // 성공 시 데이터 반환
.mapError { $0 as Error } // Alamofire Error를 일반 Error로 변환
.eraseToAnyPublisher() // 타입 숨기기
}
→ completion 핸들러 대신 AnyPublisher를 반환하도록 변경
- AnyPublisher로 데이터를 반환해서 ViewModel이 쉽게 구독 가능.
- sink 또는 assign을 통해 반응형 데이터 처리.
- 에러 처리와 성공 데이터를 한 줄로 처리할 수 있음.
2. ViewModel: 로직 분리 및 Combine 적용
이제 ViewModel에서 @Published 프로퍼티와 sink를 사용해 데이터를 구독하고 UI에 반영할 수 있다.
WeatherViewModel을 생성해서 데이터를 처리하고 Combine을 통해 ViewController와 바인딩할 수 있도록 설정했다.
class WeatherViewModel {
@Published var currentWeather: CurrentWeatherResult?
@Published var hourlyWeather: HourlyWeatherResult?
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>() // 구독 관리
// API 호출
func fetchWeatherData(lat: Double, lon: Double) {
// 현재 날씨 데이터 가져오기
WeatherAPIManager.shared.fetchCurrentWeather(lat: lat, lon: lon)
.receive(on: DispatchQueue.main) // UI 업데이트를 위해 메인 스레드로 전환
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.errorMessage = "현재 날씨 불러오기 실패: \(error.localizedDescription)"
}
}, receiveValue: { [weak self] weatherData in
self?.currentWeather = weatherData
})
.store(in: &cancellables)
// 시간별 날씨 데이터 가져오기
WeatherAPIManager.shared.fetchHourlyWeather(lat: lat, lon: lon)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.errorMessage = "시간별 날씨 불러오기 실패: \(error.localizedDescription)"
}
}, receiveValue: { [weak self] hourlyData in
self?.hourlyWeather = hourlyData
})
.store(in: &cancellables)
}
}
- @Published 프로퍼티를 통해 날씨 데이터를 저장.
- Combine을 활용하여 네트워크 요청을 처리하고 데이터 스트림을 제공.
- private var cancellables: Combine의 구독을 관리하는 변수. 메모리 누수를 방지하기 위해 필요
🔗 Combine @Published, sink, assign
3. WeatherViewController: ViewModel 바인딩
이제 ViewController에서는 UI만 담당하고, 데이터 로직은 ViewModel이 처리한다.
combine을 활용해 ViewModel과 바인딩하면 데이터가 변경될 때마다 자동으로 UI가 업데이트된다.
setupBindings() 메서드를 통해 ViewModel의 데이터 스트림을 구독
private func setupBindings() {
// 현재 날씨 데이터 바인딩
viewModel.$currentWeather
.compactMap { $0 } // nil 값 필터링
.sink { [weak self] weatherData in
self?.updateCurrentWeatherUI(with: weatherData)
}
.store(in: &cancellables)
// 시간별 날씨 데이터 바인딩
viewModel.$hourlyWeather
.compactMap { $0 }
.sink { [weak self] hourlyData in
self?.updateHourlyWeatherUI(with: hourlyData)
}
.store(in: &cancellables)
// 에러 메시지 바인딩
viewModel.$errorMessage
.compactMap { $0 }
.sink { [weak self] errorMessage in
self?.showErrorAlert(message: errorMessage)
}
.store(in: &cancellables)
}
Combine 바인딩:
- sink를 통해 데이터를 구독하고 UI 업데이트.
- compactMap을 사용해 nil 값을 필터링.
- errorMessage가 발생할 경우 경고창 표시.
데이터가 변경되면 자동으로 UI 업데이트
private func updateCurrentWeatherUI(data: CurrentWeatherResult) {
tempLabel.text = "\(Int(data.main.temp))"
self.cityLabel.text = data.name
self.tempLabel.text = "\(Int(data.main.temp))"
self.tempMinLabel.text = "L: \(Int(data.main.tempMin))°"
self.tempMaxLabel.text = "H: \(Int(data.main.tempMax))°"
}
private func updateHourlyWeatherUI(data: HourlyWeatherResult) {
let currentDate = Date().toKST() // 현재 서울 시간
let calendar = Calendar.current
let futureDate = calendar.date(byAdding: .hour, value: 27, to: currentDate)!
self.houlyData = data.list.filter { weather in
if let date = weather.date {
// date 속성이 Date 타입이어야 함
return date > currentDate && date <= futureDate
}
return false
}
self.houlyData = Array(self.houlyData)
// 시간별 날씨 데이터를 collectionView에 적용 (reloadData 호출)
collectionView.reloadData()
}
private func showErrorAlert(message: String) {
let alert = UIAlertController(title: "오류", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "확인", style: .default))
present(alert, animated: true)
}
✏️ 핵심 흐름 정리
- APIManager는 Combine을 활용해 데이터를 Publisher로 반환.
- ViewModel은 APIManager에서 데이터를 받아 @Published 프로퍼티에 저장.
- ViewController는 ViewModel의 @Published 프로퍼티를 구독하고, 데이터가 변경될 때마다 자동으로 UI 업데이트.
- 에러가 발생하면 ViewModel의 errorMessage가 업데이트되고, ViewController에서 경고창 표시.
이렇게 MVVM 패턴을 적용해본 과정을 정리해봤다.
확실히 전보다 코드가 깔끔하고 유지보수도 쉬워지고, 데이터 흐름이 명확해진 것 같다.
'TIL✏️' 카테고리의 다른 글
[TIL] 아이콘과 KST 변환 관련 문제 해결 과정 정리 (0) | 2025.02.22 |
---|---|
[iOS] 라이프 사이클 관리 앱 (1) - 최종 프로젝트 시작, 프로젝트 기획 및 와이어프레임 (0) | 2024.06.04 |
[iOS] 알람 앱 (5) - 스톱워치 CoreData 적용하기 (1) | 2024.06.04 |
[iOS] 알람 앱 (4) - UIEditMenuInteraction과 UIPasteboard (3) | 2024.05.16 |
[iOS] 알람 앱 (3) - 스톱워치 테이블뷰에 랩 타임 추가하기 (3) | 2024.05.16 |