MVVM과 Coordinator Pattern 함께 사용하기 (Feat. ReactorKIt)

사진: Unsplash 의 NEOM

 

iOS 어플리케이션을 개발할 때, 아직까지 가장 많이 선호되는 디자인 패턴은 MVVM이라고 할 수 있습니다. 이번에는 MVVMCoordinator를 혼합한 디자인 패턴을 사용하고 구체적인 예시를 보여주는 포스트를 작성하려고 합니다.

MVVM

MVVM은 Model - View - ViewModel의 약어입니다. 아래 이미지에서 보이는 것과 같이 ViewModel은 View와 Model을 이어주는 인터페이스 역할을 하고 있습니다. iOS에서는 통상적으로 View를 ViewController라고 이해해도 좋습니다. 주로 사용자의 이벤트를 감지하고 그에 맞는 비즈니스 로직을 수행하여 View에게 업데이트를 요청합니다.

 

일반적인 ViewModel과 ViewController간의 소통은 Delegates, Callbacks, Binding 등을 통해 구현할 수 있습니다. 우리는 이 포스트에서 RxSwift를 통한 Binding 방법을 사용할 예정입니다. 실제로 MVVM을 사용할 때 RxSwift나 Combine 같은 반응형 프로그래밍으로 간단하고 쉽게 소통 방식을 구현할 수 있습니다.

 

먼저 UI를 구성할 ViewController를 구현해야 합니다. 참고할 UI는 작업했던 프로젝트의 편의점을 선택하는 페이지입니다.

 

import UIKit

import ReactorKit

final class SelectStoreViewController: BaseViewController, View {

  // MARK: - UI COMPONENTS

  ...

  // MARK: - Setup

  override func setupLayouts() { ... }

  override func setupConstraints() { ... }

  override func setupStyles() { ... }

  func bind(reactor: SelectStoreViewReactor) { ... } 
}

 

이번 프로젝트에서는 ReactorKit을 이용하여 ViewModel을 구현했습니다. 일반적으로 ViewModel 구현은 프로젝트의 성격에 따라서 변화무쌍하지만, 일관되고 통일된 ViewModel을 사용하기 위해 ReactorKit을 도입했습니다. ReactorKit을 사용하기 위해서는 ViewController에 View라는 프로토콜을 채택하고 bind(reactor:)에서 ViewModel인 Reactor(이하 Reactor)를 명시해주면 ViewController의 준비는 끝납니다.

 

import UIKit

import ReactorKit

final class SelectStoreViewController: BaseViewController, View {

  func bind(reactor: SelectStoreViewReactor) { 
		self.selectStoreView.emartButton.rx.tap
      .map { [unowned self] in
        SelectStoreViewReactor.Action.selectStore(.eMart, self.selectStoreView.emartButton.isSelected, self.fromSettings)
      }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

		self.skipButton.rx.tap
      .map { [unowned self] in
        return self.fromSettings ? SelectStoreViewReactor.Action.save : SelectStoreViewReactor.Action.skip
      }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

		...
	} 
}

 

위 화면에서 사용자의 이벤트는 주로 버튼의 클릭 이벤트입니다. 이벤트가 들어올 때마다 화면이 전환되거나 데이터의 변경이 생겨 UI를 업데이트할 상황이 생길겁니다. 그런 관련된 작업들은 모두 Reactor에서 작업하게 됩니다.

 

RxCocoa를 이용하여 사용자의 이벤트를 감지하고 Reactor에게 이벤트를 전달해주는 모습을 볼 수 있습니다. 이제 Reactor에서 해당 이벤트를 감지하고 비즈니스 로직을 수행하면 됩니다.

 

final class SelectStoreViewReactor: Reactor {
  enum Action {
    case selectStore(CVSType, Bool, Bool)
    case skip
    case save
  }

  enum Mutation {
    case goToHomeVC
    case popSelectStoreVC
    case updateSelectButton
  }

  struct State {
    var isPushHomeVC: Bool = false
    var isPopSelectStoreVC: Bool = false
    var updateSelectButton: Bool = false
  }

  let initialState: State = State()

  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case .selectStore(let cvsType, let isSelected, let fromSettings):
      AudioServicesPlaySystemSound(1520)
      if isSelected {
        CVSStorage.shared.saveToFavorite(.all)
      } else {
        CVSStorage.shared.saveToFavorite(cvsType)
      }
      return fromSettings ? .just(.updateSelectButton) : .just(.goToHomeVC)
    case .skip:
      CVSStorage.shared.saveToFavorite(.all)
      return .just(.goToHomeVC)
    case .save:
      return .just(.popSelectStoreVC)
    }
  }

  func reduce(state: State, mutation: Mutation) -> State {
    var nextState = state

    switch mutation {
    case .goToHomeVC:
      nextState.isPushHomeVC = true
    case .popSelectStoreVC:
      nextState.isPopSelectStoreVC = true
    case .updateSelectButton:
      nextState.updateSelectButton = true
    }

    return nextState
  }
}

 

우리는 ViewController에서 특정 이벤트를 bind(to:)를 통해 이벤트를 전달했습니다. 이제 Reactor는 enum으로 정의한 Action에 따라서 **mutate(action:)**이라는 메서드가 자동으로 호출됩니다. 이 메서드에서는 Observable<Mutation>을 반환하게 되는데, 이것은 또 다시 **reduce(state:, mutation:)**에서 감지되어 최종적으로 Struct인 State값을 변경할 수 있습니다. 즉 정리하자면

  1. ViewController에서 이벤트를 감지한다.
  2. RxCocoa로 Reactor의 알맞은 Action으로 타입을 변경하고 Reactor에게 이벤트를 전달한다.
  3. Reactor에서 이벤트를 감지한다. mutate → reduce 순으로 과정이 진행된다.
  4. 마지막으로 새로운 State가 반환되면 ViewController는 새로운 State를 Wrapped한 Observable<State>값을 감지하여 UI를 변경할 수 있다.

이런 순차적인 과정 속에서 네트워킹을 진행할 수 있고 다른 데이터 값을 변경할 수도 있습니다. 그것은 여러분의 자유입니다.

 

import UIKit

import ReactorKit

final class SelectStoreViewController: BaseViewController, View {

  func bind(reactor: SelectStoreViewReactor) { 
    reactor.state
      .map { $0.updateSelectButton }
      .filter { $0 }
      .withUnretained(self)
      .bind { owner, _ in
				// UI 업데이트
        owner.updateButtonUI()
      }
      .disposed(by: disposeBag)
	} 
}

 

이제 다음과 같이 reactor.state를 구독하게 되면 자동으로 UI를 업데이트 할 수 있습니다! 이렇게 MVVM의 기본적인 동작 방식을 ReactorKit과 함께 살펴봤습니다. 이런 구현 방식은 다음과 같은 이점을 얻을 수 있습니다.

  1. ViewController의 작업량이 간단해집니다. 이제 ViewModel → ViewController의 단방향 소통을 하기 때문에, ViewController는 데이터나 비즈니스 로직을 알 필요 없이 UI관리에만 집중할 수 있습니다.
  2. 테스트를 하기 쉬워지는 조건은 객체간의 의존성을 최대한 없애는 것입니다. 우리는 화면과 비즈니스 로직간의 의존성을 없앴기 때문에 테스트하기 훨씬 용이합니다.
  3. 각 객체간의 역할이 명확하게 구분되어 있어서 유지보수를 하기 쉬워집니다.

 

MVVM + Coordinator Pattern

하지만 아직 ViewController에서 분리할 작업이 한 가지 남았습니다. 바로 화면 전환에 대한 로직입니다. 화면을 전환한다는 것은 앱의 규모가 커질수록 중복되는 코드도 많고 복잡해질 수 있습니다. 이것을 온전히 ViewController에게만 위임하는 것은 비효율적일 수도 있습니다. 그래서 우리는 Coordinator Pattern이라는 것을 적용해볼 수 있습니다.

 

이전가 매우 흡사한 다이어그램이지만, ViewModel에 또 하나의 구성 요소가 붙었습니다. 바로 Coordinator입니다. Coodinator는 화면 전환에 관한 모든 로직들을 처리하는 객체라고 생각하시면 됩니다. 대표적으로 UINavigationControllerUITabBarController같이 화면을 Stack처럼 쌓는 구조를 트리 구조로서 관리하게 됩니다.일반적으로 하나의 화면은 하나의 Coordinator를 소유하게 됩니다. Coordinator에 대한 자세한 포스트는 여기에서 보실 수 있습니다. :)

 

final class SelectStoreCoordinator: BaseCoordinator {
  
  init(_ navigationController: UINavigationController, fromSettings: Bool) {
    self.fromSettings = fromSettings
    super.init(navigationController: navigationController)
  }

  override func start() {
    let reactor = SelectStoreViewReactor()
    let selectStoreVC = SelectStoreViewController(fromSettings: self.fromSettings)
    selectStoreVC.coordinator = self
    selectStoreVC.reactor = reactor
    self.navigationController.pushViewController(selectStoreVC, animated: true)

    self.bind(reactor)
  }

  func bind(_ reactor: SelectStoreViewReactor) { ... }
}

 

먼저 해당 화면의 Coordinator 구현을 보시는 것이 먼저입니다. **start()**메서드를 보면 이 코드는 다른 화면에서 SelectStoreViewController로 전환되어야 할 때 불려지는 메서드입니다. 여기서 self.bind(reactor)라는 것을 통해 ViewModel에서 전달되는 Observable<State>를 구독할 수 있도록 하는 것이 포인트입니다. 이렇게 되면 ViewModel이 ViewController에게 받은 이벤트를 Coordinator에게도 잘 전달할 수 있게 됩니다. 위 다이어그램 처럼 똑같이 구현이 된거죠!

 

final class SelectStoreViewReactor: Reactor {

	...
	
  func reduce(state: State, mutation: Mutation) -> State {
    var nextState = state

    switch mutation {
    case .goToHomeVC:
      nextState.isPushHomeVC = true // Coordinator에게 전달!
    case .popSelectStoreVC:
      nextState.isPopSelectStoreVC = true  // Coordinator에게 전달!
    case .updateSelectButton:
      nextState.updateSelectButton = true
    }

    return nextState
  }
}
final class SelectStoreCoordinator: BaseCoordinator {

	...

	func bind(_ reactor: SelectStoreViewReactor) {
		// State를 받아서 다음 Coordinator의 로직을 수행할 수 있다.
    reactor.state
      .map { $0.isPushHomeVC }
      .filter { $0 }
      .distinctUntilChanged()
      .withUnretained(self)
      .bind { owner, _ in
        let coordinator = HomeCoordinator(navigationController: owner.navigationController)
        owner.start(childCoordinator: coordinator)
      }
      .disposed(by: disposeBag)
  }
}

 

즉 reduce에서 isPushHomeVC의 값이 변경된다면 Coordinator에서도 그 값을 방지하고 HomeCoordinator의 start()메서드를 호출할 수 있게 됩니다. RxSwift를 사용하게 되면 기존 Delegate나 CallBack에 비해서 훨씬 간단하게 MVVM - C를 구현할 수 있게 됩니다.

 

이번 포스트에서는 ReactorKit을 사용한 것을 보여드렸지만, 꼭 ReactorKit이 아니더라도 이것을 쉽게 구현할 수 있습니다. 비동기 프로그래밍(RxSwift or Combine)을 같이 사용하게 되면 기존 보다 더 적은 코드로 구현할 수 있고 많은 장점이 있습니다.

지금까지 ReactorKit을 이용하여 MVVM + Coordinator 을 이용한 예제를 보여드렸습니다.


References