먹고 기도하고 코딩하라

[Architecture] MVC -> MVVM + RxSwift로 개조하기 본문

앱/Swift

[Architecture] MVC -> MVVM + RxSwift로 개조하기

사과먹는사람 2023. 9. 9. 15:55
728x90
728x90

 

MVC가 Massive View Controller라는 농담이 있다. 코코아 MVC에서는 뷰컨이 뷰의 역할도 하고 컨트롤러의 역할도 하기 때문에 뷰컨이 하는 일이 너무 많아지기 때문이다.

전통적인 MVC에서 모델, 뷰, 컨트롤러 3가지는 다음과 같은 일을 한다.

  • Model : 비즈니스 로직 수행에 필요한 데이터 구조를 정의하고 담는 역할
  • View : 유저에게 보일 뷰를 구성하고, 사용자 인풋을 받고 컨트롤러가 주는대로 아웃풋을 그리는 역할
  • Controller : 뷰와 모델의 중간 다리 역할로, 뷰에서 일어나는 사용자 인풋은 컨트롤러에 전달. 컨트롤러는 인풋으로부터 모델에 반영하고, 모델 변화에 따른 아웃풋을 뷰를 업데이트하는 역할.

MVVM은 뷰컨이 뷰의 역할만 할 수 있도록 하고, 뷰모델이 MVC에서 컨트롤러가 하던 일을 하게 한다.

  • Model : 위와 같음
  • View : UI를 구성하는데, 뷰와 뷰컨 모두 View로 구분됨. 뷰컨은 뷰로서의 의무만 가짐
  • ViewModel : 뷰에서 오는 사용자 인풋으로 모델을 업데이트하고, 모델 아웃풋으로 뷰 업데이트

장점은 크게 2가지인데, 비즈니스 로직이 뷰컨에서 뷰모델로 이동함으로써 뷰컨이 간결해졌다는 것 하나와 기존에 뷰가 같이 끼어들어있던 뷰컨은 순수히 기능만을 테스트하기 어려웠는데 뷰모델로 기능이 이동하면서 테스트하기가 더 쉬워졌다는 것이다.

단점으로는 뷰모델 역시 뷰컨이 하던 일을 하다보니, 뷰컨이 비대했다면 뷰모델 역시 비대해질 수 있다는 사실이다.

 

하지만 이론만 갖고는 어떻게 뷰컨이 하던 일을 뷰모델로 옮기고, 또 뷰와 뷰모델 간 바인딩을 어떻게 해야할지도 감을 잡기 어려웠다. 그리고 처음부터 MVVM으로 시작을 하는 프로젝트는 많지만 MVC에서 MVVM으로 마이그레이션하는 예제는 잘 없기도 하다.

그래서 처음 시작으로는 이것을 추천한다. 도시 이름을 입력받아 날씨를 보여주는 간단한 앱인데 여기에 통신도 있고 인풋도 받고 아웃풋도 있으니, 너무 복잡해지지 않는 선에서 느낌 정도는 알 수 있어서 좋다.

이 프로젝트에서 실패하는 테스트가 있을 수도 있는데, 맥 언어 설정이 한국어로 되어 있으면 "Richmond, VA"가 "리치먼드, VA"로 표시되기 때문이다. 이 부분을 확인하고 테스트 케이스의 if문에서 검사하는 문자열을 "리치먼드, VA"로 쓰면 테스트가 통과된다. 

 

 

뷰-뷰모델 바인딩

방법으로 크게 4가지가 있다고 소개하고 있다.

  • KVO : keyPaths를 이용해 프로퍼티 관찰, 프로퍼티 값이 변경된 경우 통지
  • Functional Reactive Programming or FRP : 이벤트, 데이터를 스트림으로 처리. RxSwift, Combine 등
  • Delegation : 값의 변화에 따라 notification 전달. 델리게이트 함수 이용
  • Boxing : 값이 변화할 때 프로퍼티 옵저버를 이용해 notification

연산 프로퍼티의 didSet을 사용하면 바인딩과 반응형 프로그래밍의 의도를 달성할 수는 있지만, 그걸 사용한다고 MVVM 구조가 되진 않는다. 바인딩과 MVVM은 별개이다.

이 프로젝트에서는 Boxing을 이용하고 있는데, 사실 이 Box를 뜯어보면 RxSwift의 ObservableType과 상당히 유사하다.

import Foundation

final class Box<T> {
  typealias Listener = (T) -> Void
  var listener: Listener?
  var value: T {
    didSet {
      listener?(value)
    }
  }

  init(_ value: T) {
    self.value = value
  }
  
  func bind(listener: Listener?) {
    self.listener = listener
    listener?(value)
  }
}

Box는 값과 리스너를 갖는다. 생성될 때는 초기값을 갖고 bind를 통해 리스너를 받을 수 있다. 값은 변경될 때마다 리스너를 호출한다. (아래에서 살펴볼 BehaviorRelay와 비슷한 특징을 갖고 있다)

뷰모델에서 이 Box들을 갖고 있고 뷰컨트롤러는 그 뷰모델을 갖고 Box에 바인딩 리스너를 등록한다. 실제 연산이나 값 조작은 뷰모델에서 하기 때문에 뷰컨트롤러는 Box 값 변경에 따라 UI를 어떻게 조작할지만을 정하고, 단순히 보여주는 역할만을 할 수 있다. 

 

 

통신 부분에서 RxSwift 사용하기

사실 RxSwift를 처음 접했을 때는 왜 좋은지 모르는 상태에서 다소 맹목적인 학습을 했다.

지금은 비동기 통신 이후 클로저(completion handler)로 데이터를 담아와서 코드 여기저기를 옮겨다니며 확인해야 했다면(코드를 위에서 아래로 보는 것이 아니라 여기저기 옮겨다니며 봐야 하는 경우가 많다), Rx는 스트림으로 받아서 동기적인 코드로 처리할 수 있다는 데에 가장 큰 미덕이 있다고 생각하고 있다. (사실 async-await 등장으로 Rx의 필요성이 다소 떨어지기는 했지만 async-await를 지원하지 않는 구 버전에서는 여전히 유효하다고 생각한다)  

이 프로젝트에서 통신은 WeatherbitService의 weatherDataForLocation 메소드 내에 존재한다. 이 메소드는 URL을 생성하고 그 URL로 통신을 요청하는 2가지 작업을 하는데 통신에만 집중할 수 있도록 URL 생성은 다른 메소드를 만들어서 빼고 시작한다.

  static private func createURL(latitude: Double, longitude: Double) -> URL {
     var urlBuilder = URLComponents()
     urlBuilder.scheme = "https"
     urlBuilder.host = WeatherbitService.host
     urlBuilder.path = WeatherbitService.path
     urlBuilder.queryItems = [
       URLQueryItem(name: "key", value: WeatherbitService.apiKey),
       URLQueryItem(name: "units", value: WeatherbitService.fahrenheit),
       URLQueryItem(name: "lat", value: "\(latitude)"),
       URLQueryItem(name: "lon", value: "\(longitude)")
     ]

     return urlBuilder.url!
   }

그럼 weatherDataForLocation에서는 다음과 같이 쓸 수 있다.

  static func weatherDataForLocation(latitude: Double, longitude: Double, completion: @escaping WeatherDataCompletion) {
     let url = createURL(latitude: latitude, longitude: longitude)
   }

RxSwift를 사용하려면 SPM이나 코코아팟 등으로 설치가 필요하다. 만약 SPM을 사용한다면 RxCocoa, RxRelay, RxSwift를 체크하고 패키지를 받는다. (RxCocoa는 UI 컴포넌트의 이벤트를 바인딩하는 데 사용된다)

 

WeatherbitService에 RxSwift를 import한다.

이제 weatherDataForLocation 메소드를 변경하는데, 매개변수로 받던 @escaping 클로저를 받지 않는다. 이 클로저의 존재 의의는 통신이 완료된 후의 에러 혹은 데이터를 이 메소드를 호출하는 곳(여기서는 뷰모델)으로 빼서 처리하기 위함이다. 이제 Rx를 써서 클로저가 아니라 Observable로 통신 결과를 반환할 것이다.

 

헤더를 변경한다.

static func weatherDataForLocation(latitude: Double, longitude: Double) -> Observable<WeatherbitData>

2가지 문제가 생기는데 메소드 내의 completion 호출부에서 더이상 completion이 없다는 것과 Observable이 반환되어야 하는데 반환값이 없다는 에러이다.

URLSession.shared.dataTask의 컴플리션 핸들러에서 통신 완료 후의 데이터나 에러를 처리하게 되어 있는데, 이 클로저 바깥으로 데이터를 그냥 빼내는 것은 불가능하다. 그러면 어떻게 WeatherbitData 타입의 Observable을 반환할 수 있을까? Observable.create에 딸린 클로저 내부에서 dataTask를 만들고 resume시키면 된다.

여기서 기존에 completion을 쓰고 있던 부분은 onError, onNext, onCompleted 등으로 적절히 처리해주면 된다.

Observable은 값을 여러 번 방출(onNext)할 수 있지만 통신은 데이터를 한 번 보내면 끝이므로 onNext 뒤에 바로 onCompleted를 써서 Observable이 종료됨을 알린다.

마지막엔 Disposables.create를 return하는데, Observable이 disposed될 때 task를 취소하는 클로저를 추가로 붙여준다. 

static func weatherDataForLocation(latitude: Double, longitude: Double) -> Observable<WeatherbitData> {
     let url = createURL(latitude: latitude, longitude: longitude)

     return Observable<WeatherbitData>.create { emitter in
       let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
         //execute completion handler on main thread
         DispatchQueue.main.async {
           guard error == nil else {
             print("Failed request from Weatherbit: \(error!.localizedDescription)")
             emitter.onError(WeatherbitError.failedRequest)
             return
           }

           guard let data = data else {
             print("No data returned from Weatherbit")
             emitter.onError(WeatherbitError.noData)
             return
           }

           guard let response = response as? HTTPURLResponse else {
             print("Unable to process Weatherbit response")
             emitter.onError(WeatherbitError.invalidResponse)
             return
           }

           guard response.statusCode == 200 else {
             print("Failure response from Weatherbit: \(response.statusCode)")
             emitter.onError(WeatherbitError.failedRequest)
             return
           }

           do {
             let decoder = JSONDecoder()
             let weatherData: WeatherbitData = try decoder.decode(WeatherbitData.self, from: data)
             emitter.onNext(weatherData)
             emitter.onCompleted()
           } catch {
             print("Unable to decode Weatherbit response: \(error.localizedDescription)")
             emitter.onError(WeatherbitError.invalidData)
           }
         }
       }
       task.resume()
       return Disposables.create { task.cancel() }
     }
   }

이제 뷰모델 쪽을 변경해준다. 뷰모델에도 RxSwift를 import한다.

fetchWeatherForLocation에서는 weatherDataForLocation을 호출하면서 클로저를 함께 넘겨주고 있었다. 이제 weatherDataForLocation의 매개변수에 클로저가 빠졌고 대신 반환값으로 Observable을 넘겨주므로 클로저를 지우고 반환값을 받는다. 클로저 부분이 완전히 사라지는 것은 아니므로 잠시 주석 처리를 하고 이렇게 수정한다.

  private func fetchWeatherForLocation(_ location: Location) {
     let weatherObservable: Observable<WeatherbitData> = WeatherbitService.weatherDataForLocation(
       latitude: location.latitude,
       longitude: location.longitude)
       // code
   }

Observable을 받았으므로 거기에 subscribe 메소드를 붙여서 onNext일 때, 클로저 내용처럼 처리하도록 변경해주면 된다. Observable은 한 번에 .error(error), .next(data), .completed 3가지 중 1개의 이벤트를 방출하므로 switch문으로 처리해주는 것이 깔끔하다.

weatherObservable.subscribe { event in
       switch event {
       case .next(let weatherData):
         self.date.value = self.dateFormatter.string(from: weatherData.date)
         let temp = self.tempFormatter.string(from: weatherData.currentTemp as NSNumber) ?? ""
         self.currentSummary.value = "\(weatherData.description) - \(temp)℉"
         self.forecastSummary.value = "\nSummary: \(weatherData.description)"
         self.iconImage.value = UIImage(named: weatherData.iconName) ?? UIImage()
         break
       case .error(let error):
         print(error.localizedDescription)
         break
       case .completed:
         break
       }
     }

이렇게 Observable에 subscribe를 달면 Disposable을 반환하게 된다. 이 Disposable을 DisposeBag에 담아 Observable이 더 이상 아무것도 방출되지 않는 것이 확실해지면(error, completed 상태) 사라지게 해서 메모리 릭을 방지한다.

뷰모델에 disposeBag을 선언하고 subscribe 끝에 .disposed(by: disposeBag)을 붙여 DisposeBag에 넣어준다.

let disposeBag: DisposeBag = DisposeBag()
   private func fetchWeatherForLocation(_ location: Location) {
     let weatherObservable: Observable<WeatherbitData> = WeatherbitService.weatherDataForLocation(
       latitude: location.latitude,
       longitude: location.longitude)
     weatherObservable.subscribe { event in
       switch event {
       case .next(let weatherData):
         self.date.value = self.dateFormatter.string(from: weatherData.date)
         let temp = self.tempFormatter.string(from: weatherData.currentTemp as NSNumber) ?? ""
         self.currentSummary.value = "\(weatherData.description) - \(temp)℉"
         self.forecastSummary.value = "\nSummary: \(weatherData.description)"
         self.iconImage.value = UIImage(named: weatherData.iconName) ?? UIImage()
         break
       case .error(let error):
         print(error.localizedDescription)
         break
       case .completed:
         break
       }
     }.disposed(by: disposeBag)
   }

이렇게 한 다음 실행해보면, 수정 전과 동일하게 잘 동작하는 것을 볼 수 있다.

 

 

통신부를 Single로 변경

사실 통신하는 부분은 굳이 Observable을 사용할 필요는 없다. 통신이 이뤄지면 데이터나 에러를 방출하고 바로 Observable은 아무것도 방출하지 않기 때문에 한 번 데이터를 방출하면 onComplete 없이 바로 종료될 수 있도록 하면 좋을 것이다. 이럴 때 쓰면 좋은 것이 Single이다. Single은 딱 한 번 데이터를 방출하면 바로 끝이다. 그래서 single의 이벤트 타입은 .failure, .success 중 하나이다.

다시 WeatherbitService의 weatherDataForLocation으로 돌아간다. 우선 헤더의 반환 타입을 Observable이 아닌 Single로 변경한다.

static func weatherDataForLocation(latitude: Double, longitude: Double) -> Single<WeatherbitData>

반환 타입이 바뀌었으니 Observable.create이 아닌 Single.create을 써야 한다. Observable.create의 클로저의 매개변수는 AnyObserver 타입이지만 Single.create의 클로저 매개변수는 (Result<T, Error>) -> Void 이다. 따라서 아까 emitter를 쓸 때와는 조금 다르게 써줘야 한다.

    return Single<WeatherbitData>.create { single in
       let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
         //execute completion handler on main thread
         DispatchQueue.main.async {
           guard error == nil else {
             print("Failed request from Weatherbit: \(error!.localizedDescription)")
             single(.failure(WeatherbitError.failedRequest))
             return
           }

           guard let data = data else {
             print("No data returned from Weatherbit")
             single(.failure(WeatherbitError.noData))
             return
           }

           guard let response = response as? HTTPURLResponse else {
             print("Unable to process Weatherbit response")
             single(.failure(WeatherbitError.invalidResponse))
             return
           }

           guard response.statusCode == 200 else {
             print("Failure response from Weatherbit: \(response.statusCode)")
             single(.failure(WeatherbitError.failedRequest))
             return
           }

           do {
             let decoder = JSONDecoder()
             let weatherData: WeatherbitData = try decoder.decode(WeatherbitData.self, from: data)
             single(.success(weatherData))
           } catch {
             print("Unable to decode Weatherbit response: \(error.localizedDescription)")
             single(.failure(WeatherbitError.invalidData))
           }
         }
       }
      task.resume()
      return Disposables.create { task.cancel() }
    }

원래 .onError(Error)를 쓰던 부분은 (.failure(Error))로 고쳐 써주고 .onNext(T), .onCompleted()를 쓰던 부분은 간단하게 (.success(T))로 써주면 끝이다. 

뷰모델의 메소드도 변경해준다. 바뀌는 건 타입과 switch문 내의 case 정도이다.

  private func fetchWeatherForLocation(_ location: Location) {
     let weatherSingle: Single<WeatherbitData> = WeatherbitService.weatherDataForLocation(
       latitude: location.latitude,
       longitude: location.longitude)
     weatherSingle.subscribe { event in
       switch event {
       case .success(let weatherData):
         self.date.value = self.dateFormatter.string(from: weatherData.date)
         let temp = self.tempFormatter.string(from: weatherData.currentTemp as NSNumber) ?? ""
         self.currentSummary.value = "\(weatherData.description) - \(temp)℉"
         self.forecastSummary.value = "\nSummary: \(weatherData.description)"
         self.iconImage.value = UIImage(named: weatherData.iconName) ?? UIImage()
       case .failure(let error):
         print(error.localizedDescription)
       }
     }.disposed(by: disposeBag)
   }

좀 눈여겨볼 만한 것이라면 switch문 내 case가 .next, .error, .completed에서 .success, .failure 2가지로 변했다는 것이다. 앞서 썼듯이 Single의 .success는 .next + .completed와 동일하다. 

여기까지 통신부를 Observable에서 Single로 변경했다.

 

 

Box를 Relay로 변경하기

여기까지 변경했으니 Box도 Observable로 바꾸면 좋을 것 같다. 하지만 여기 더 좋은 게 있으니... 바로 Relay를 사용하면 된다. Relay에는 초기값이 없는 PublishRelay, 초기값을 갖는 BehaviorRelay가 있어 용도에 따라 다르게 쓰면 된다.

Relay는 Subject(Observable + Observer의 역할을 동시에 함)의 조금 더 팬시한(?) 버전으로, .onError, .onCompleted 등으로 종료되지 않고 프로그램이 살아있는 동안 계속 살아있다. 특성상 UI에 바인딩할 값으로 안성맞춤이다.

 

뷰모델에서 RxRelay를 import한다. 그 다음 뷰모델에서 Box로 만들어진 프로퍼티들을 BehaviorRelay, PublishRelay 등으로 변경하고 Box 선언도 삭제한다. 원래도 빈 값이 주어졌던 것은 굳이 BehaviorRelay를 사용할 필요는 없을 것 같지만 locationName 정도는 "Loading..."을 위해 BehaviorRelay로 만드는 게 좋겠다.

여기서 temp를 추가하고 기존에 currentSummary로 되어 있던 것을 description으로 변경해보자. 추가로 해야 할 일이 있다.

  let locationName: BehaviorRelay<String> = BehaviorRelay(value: "Loading...")
  let date: PublishRelay<String> = PublishRelay()
  let temp: BehaviorRelay<String> = BehaviorRelay(value: "0 ℉")
  let description: PublishRelay<String> = PublishRelay()
  let forecastSummary: PublishRelay<String> = PublishRelay()
  let iconImage: BehaviorRelay<UIImage> = BehaviorRelay(value: UIImage())

이렇게 하면 여기저기서 경고가 뜰 것이다. 하나씩 차근차근 고쳐본다.

이렇게 Relay로 바뀐 변수에는 = 연산자로 직접 값을 넣지 않는다. 대신 accept를 통해 값 이벤트를 발생시킬 수 있다. changeLocation에서 locationName을 바꾸는 작업은 이렇게 할 수 있다.

  func changeLocation(to newLocation: String) {
    locationName.accept("Loading...")
    geocoder.geocode(addressString: newLocation) { [weak self] locations in
      guard let self = self,
            let location = locations.first else { return }
      self.locationName.accept(location.name)
      self.fetchWeatherForLocation(location)
    }
  }

다음은 fetchWeatherForLocation 안을 수정한다. temp가 추가되고 기존의 currentSummary가 description으로 축소된 것을 염두에 두고 다음과 같이 accept를 사용해 값을 발생시킨다.

    weatherSingle.subscribe { event in
      switch event {
      case .success(let weatherData):
        self.date.accept(self.dateFormatter.string(from: weatherData.date))
        self.temp.accept("\(self.tempFormatter.string(from: weatherData.currentTemp as NSNumber) ?? "")℉")
        self.description.accept(weatherData.description)
        self.forecastSummary.accept("Summary: \(weatherData.description)")
        self.iconImage.accept(UIImage(named: weatherData.iconName) ?? UIImage())
      case .failure(let error):
        print(error.localizedDescription)
      }
    }.disposed(by: disposeBag)

뷰컨트롤러에서는 어떻게 써야 할까? 간단하게도 RxSwift와 RxCocoa를 import하면 원래 썼던대로 bind 메소드로 값과 UI 컴포넌트를 바인딩할 수 있다. bind 메소드 역시 Disposable을 반환하므로 이를 담을 DisposeBag이 필요하다. 뷰컨트롤러에도 하나 만들어준다.

import RxSwift
import RxCocoa

class WeatherViewController: UIViewController {  
  private let viewModel = WeatherViewModel()
  let disposeBag: DisposeBag = DisposeBag()
}

bind를 그냥 쓰면 되지만 뷰모델에 더이상 currentSummary가 없으므로 수정이 필요하다. 그런데 temp와 description 2가지 정보를 함께 써야 한다. 어떻게 해야 할까? 이럴 때는 Observable.combineLatest를 사용하면 좋다. combineLatest는 여러 Observable들의 값 이벤트를 캐치해서 서브 Observable의 값이 변경되면 새로운 값을 발생시키는 역할을 한다. combineLatest 안에는 ObservableType을 상속받는 어떤 시퀀스라도 들어갈 수 있다. Relay도 ObservableType을 상속받고 있으므로 서브 시퀀스가 될 수 있다.

    Observable.combineLatest(viewModel.temp, viewModel.description).bind { [weak self] (temp, desc) in
      self?.currentSummaryLabel.text = "\(desc) - \(temp)"
    }.disposed(by: disposeBag)

다른 bind 부분에도 .disposed(by: disposeBag)으로 DisposeBag에 집어넣는 작업을 해줘야 한다.

이렇게 하면 MVVM 구조의 프로젝트에 RxSwift 입히기 완성이다.

 

 

 

References

 

 

728x90
반응형
Comments