최근 iOS 프레임워크에서의 MVVM 아키텍처 구현은 RxSwift를 이용한 데이터 바인딩이 표준으로 자리잡는 추세로 보인다. RxSwift는 MVVM과 좋은 궁합으로 시너지를 창출하였지만 좋지 못한 ViewModel 패턴을 양산하고 있는데 대표적으로 Subject의 남용이다.

ObserverObservable 모두의 역할을 하는 Subject는 높은 자유도로 구현에 있어 많은 편의를 제공한다. 그러나 데이터를 방출하는 주체와 구독하는 주체가 ViewModel인지 View인지 모호해지기 쉽기 때문에 실수와 버그의 원인이 될 수 있다.

따라서 본 포스팅에서는 이 문제에 대한 하나의 솔루션으로 MVVM의 핵심이 되는 ViewModel을 단방향 데이터 흐름을 갖도록 엄격하게 정의하는 방법을 제안하고자 한다. 이를 이용한다면 전체 코드에 걸쳐 일관적인 MVVM 아키텍처를 구성하는데 효과적일 것으로 기대한다.

ViewModelType 정의

먼저 InputOutput 타입을 가지는 ViewModelType 프로토콜을 정의한다. 그리고 transform 함수는 View로부터 Input을 받아 변환하여 다시 ViewOutput을 제공하는 역할을 한다.

protocol ViewModelType {
    
    associatedtype Input
    associatedtype Output
    
    func transform(from input: Input) -> Output
}

모든 ViewModelViewModelType을 따라야 하는데 이 때 InputOutput 타입 내의 프로퍼티는 var 대신 let을, Subject(또는 Relay) 대신 Observable만 사용하도록 한정한다.

이를 통해 ViewModel에서 의도한 InputOutput의 데이터 방향성을 View에서 오용하는 것을 방지할 수 있다.

class ViewModel: ViewModelType {
    
    struct Input {
        let searchText: Observable<String>
        let selectItem: Observable<Int>
    }
    
    struct Output {
        let status: Observable<String>
        let itemList: Observable<[String]>
    }
    
    func transform(from input: Input) -> Output {
        
        let status =
            input.selectItem
                .map { ... }
                ...

        let itemList =
            input.searchText
                .map{ ... }
                ...

        return Output(
            status: status
            itemList: itemList
        )
    }
    
}

ViewController 바인딩

ViewController에서는 View가 로드 되는 시점에 UI요소에서 ViewModel로 전달할 이벤트를 Input으로 생성하고 transform 수행 후 만들어진 Output을 UI요소에 바인딩 한다.

이로써 ViewModelViewController간의 단일 데이터 흐름이 완성된다. 한편으로는 모든 데이터 바인딩 관련 코드가 bind() 함수 한 곳에서 수행되기 때문에 응집도 향상이라는 부수적인 효과도 가진다.

class ViewController: UIViewController {

    var viewModel: ViewModel!

    ...


    override func viewDidLoad() {
        super.viewDidLoad()

        bind()
    }

    func bind() {

        let input = ViewModel.Input(
            searchText: textView.rx.text,
            selectItem: tableView.rx.modelSelected(String.self)
        )

        let output = viewModel.transform(from: input)

        output.status
            .bind( ... )
            .disposed(by: disposeBag)

        output.itemList
            .bind( ... )
            .disposed(by: disposeBag)
        
    }

}

결론

본 포스팅에서 제시한 MVVM의 패턴은 View 초기화 시에 모든 데이터 바인딩을 수행해야 하므로 실무에서는 꽤나 큰 제약으로 느껴질 수도 있다. 하지만 구현 과정에서 희생된 약간의 자유도는 낮은 모듈간 결합도와 테스트를 용이성이라는 이점으로 크게 보상된다.

앞으로 여러 프로젝트를 진행하면서 이러한 단일 데이터 흐름의 MVVM 패턴이 어느 정도까지 활용 가능할지 계속 검증해 나갈 예정이다.

참고자료