TIL✏️

[TIL] MVVM 리팩토링: Combine 방식

yujjne 2025. 2. 11. 17:24

 

오늘은 진행하던 프로젝트의 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)
}

 


✏️ 핵심 흐름 정리

  1. APIManager는 Combine을 활용해 데이터를 Publisher로 반환.
  2. ViewModel은 APIManager에서 데이터를 받아 @Published 프로퍼티에 저장.
  3. ViewController는 ViewModel의 @Published 프로퍼티를 구독하고, 데이터가 변경될 때마다 자동으로 UI 업데이트.
  4. 에러가 발생하면 ViewModel의 errorMessage가 업데이트되고, ViewController에서 경고창 표시.

 

이렇게 MVVM 패턴을 적용해본 과정을 정리해봤다.

확실히 전보다 코드가 깔끔하고 유지보수도 쉬워지고, 데이터 흐름이 명확해진 것 같다.