Swift - MVVM: RxSwift와 Input/Output 패턴 적용

3 분 소요


기본적으로 애플에서는 MVC를 지원해준다. 하지만 실제로 사용하기에는… 좀 빡세다
MVC는 Model, View Controller의 조합인데 이렇게 되면 ViewController에서 비즈니스 로직까지 전부 담게 되므로, 코드가 방대해지고 읽기 힘들어지는 문제가 있다.

나도 처음에 혼자 토이 프로젝트로 개발할 때는 MVC를 썼었다. 이 때는 프로젝트 규모도 작은 편이고, 코드가 암만 길어도 내가 쓴 거니까 그냥 읽혀서 상관없었다.
그런데 다른 프로젝트로 협업을 하게 되면서, 프로젝트 규모도 커지고, 남들과 미래의 나(유지보수)도 내 코드를 빠르게 파악할 수 있어야 했다. 그래서 해당 프로젝트를 시작할 때는 처음부터 MVVM 패턴을 사용하고, 구조도 전체적으로 짜고, POP도 적용하며 시작했었다.

적다보니 왜 애플은 꾸진 MVC를 지원하나? 생각해 봤는데, 기존에는 스토리보드를 사용해서 뷰를 다 짜고, 세세한 뷰나 로직만 코드로 VC에다 작성하는 방식이 주였기 때문에 그런 것 같긴 하다.
요새는 스토리보드 없이 UIKit으로 코드로만 뷰를 짜기도 하고, 이젠 SwiftUI도 나왔으니 MVC는 사장되지 않을까요??


MVVM 아키텍처

암튼 MVVM은 Model, View, View Model의 조합이다. 따라서 모델은 모델만 관여하고, 뷰는 뷰만 관여하고, 비즈니스 로직은 View Model에서 다 담당한다. 즉 모델과 뷰의 중계 역할이라 보면 되겠다.

예시

간단하게, 이메일을 입력받는 emailTextField와, 입력받은 이메일의 validation 결과를 보여주는 emailValidationLabel이 있다고 보자.

1. 구조

class RegisterViewController: UIViewController {
    let disposeBag = DisposeBag()
    let viewModel = RegisterViewModel()
    
    lazy var emailTextField = UITextField()
    lazy var emailValidationLabel = UILabel()
}

그럼 뷰컨은 이렇게 가지고 있을 거고

class RegisterViewModel {
    let disposeBag = DisposeBag()
    let emailRelay = BehaviorRelay<String?>(value: "")
    let emailLabelRelay = BehaviorRelay<String?>(value: "")
}

뷰모델은 이런 식으로 될 거다.

2. 바인딩

class RegisterViewController: UIViewController {
    func setupBindings() {
        // Input
        emailTextField.rx.text
            .bind(to: viewModel.emailRelay)
            .disposed(by: disposeBag)
        
        // Output
        viewModel.emailLabelRelay
            .bind(to: emailLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

뷰컨에서는 뷰 모델의 릴레이와 바인딩을 해줘야 한다. emailTextField에 text 입력이 들어오면 viewModel의 emailRelay에 보내줘야 하므로, 이를 바인딩 한다.
또, 뷰 모델의 emailLabelRelay가 바뀌면 이 값은 emailLabel의 text에 적용되어야 하므로 이를 또 바인딩해준다.

class RegisterViewModel {
    init() {
        emailRelay
          .map { getEmailValidationString($0) }
          .bind(to: emailLabelRelay)
          .disposed(by: disposeBag)
    }
}

뷰 모델에서는 emailRelay에 입력이 들어오면 이에 validation을 수행하고, 결과를 emailLabelRelay에 보내면 된다.

ViewController Input/Output ViewModel
emailTextField의 text 입력 emailRelay
   
비즈니스 로직
emailValidationLabel의 결과 출력 emailLabelRelay

즉 위와 같은 구조.


Input/Output 패턴

그런데 이런 MVVM 패턴 자체도 구현 방식이 다양한 편이더라. 그래서 그 중에서 제일 마음에 들고, 지금 프로젝트에서 적용하고 있는 Input/Output 패턴을 정리한다.

MVVM 아키텍처에서 RxSwift를 사용할 때 비즈니스 로직을 좀 더 쉽게 구분해내기 위해 사용하는 패턴이다.

모든 ViewModel들은 각자가 정의한 Input과 Output이 있다.

  • Input
    • View에서 ViewModel로 전달되는 Input
  • Output
    • ViewModel에서 View로 전달할 Output

구현

protocol ViewModel {
    associatedtype Input
    associatedtype Output
    
    var disposeBag: DisposeBag { get set }
    
    func transform(input: Input) -> Output
}

POP(Protocol-Oriented Programming)에 따라 protocol ViewModel을 선언한다.
뷰에서의 입력은 모두 Input에 정의하고, 비즈니스 로직의 출력은 Output에 정의하면 된다. 또, 그러한 Input에서 Output으로의 변환 과정은 transform()에서 수행하면 된다.

class RegisterViewModel: ViewModel {
    struct Input {
        let emailTextField: Observable<String?>
    }
    
    struct Output {
        let emailLabel: Observable<String?>
    }
    
    var disposeBag = DisposeBag()
    
    let emailRelay = BehaviorRelay<String?>(value: "")
    let emailLabelRelay = BehaviorRelay<String?>(value: "")
}

위 예시에서와 같은 예를 보자.
프로토콜에 존재하던 제네릭 타입 Input과 Output을 필요에 따라 정의한다.

// In ViewModel.
    func transform(input: Input) -> Output {
        input.emailTextField
            .bind(to: emailRelay)
            .disposed(by: disposeBag)
        
        emailRelay
            .map { getEmailValidationString($0) }
            .bind(to: emailLabelRelay)
            .disposed(by: disposeBag)
        
        return Output(emailLabel: emailLabelRelay.asObservable())
    }

ViewModel에서 Input을 Output으로 처리해주는 함수를 정의해준다.

이제 protocol ViewModel을 따르게 된다.
ViewModel에서 비즈니스 로직에 따라 Input을 처리하고, Output을 제공할 수 있다.

사용

// In ViewController.
private func setupBindings() {
    let input = RegisterViewModel.Input(emailTextField: emailTextField.rx.text)

    let output = viewModel.transform(input: input)
    output.emailLabel
         .bind(to: emailLabel.rx.text)
         .disposed(by: disposeBag)
}

사용할 때는 인풋을 넣어주고, 아웃풋을 받아와 각각 바인딩을 해주면 된다.

💡❗ 장점

  • 비즈니스 로직의 Input이 emailTextField임을 한 눈에 볼 수 있다.
  • 비즈니스 로직의 Output이 어떻게 쓰이는 지 한 눈에 볼 수 있다.
    • 즉 이 뷰가 하는 일, 데이터의 흐름이 한 눈에 보여서 유지보수도 좋다.
  • POP에 기준하여 Input과 Output이 명시적으로 보이기 때문에, 뷰컨에서는 뷰모델이 내부에서 무슨 짓을 하는 지 전혀 알 필요가 없다.
    • 그냥 결과만 갖다 쓰면 됨~~
    • 이 점이 특히 편한 게 뷰모델 내부 프로퍼티나 함수는 private 다 붙이면 뷰컨에서는 transform을 통한 Output만 사용할 수 있어서 읽고 사용하기 좋다.



굿
RxSwift도 정리했던 거 올려야 겠다

댓글남기기