포스트

MVVM 패턴에 RxSwift 묻혀보기

MVVM 패턴이란?

프로그램을 개발할 때 디자인 패턴(Design Pattern, 이하 ‘패턴’)을 올바르게 적용하는 건 개발 시간을 단축할 수 있을 뿐더러, 생산성을 향상시킬 수 있습니다. 디자인 패턴은 마치 약속과도 같습니다. 프로젝트 도중 새로운 개발자가 참여하더라도 빠르게 적응할 수 있도록 도와주며, 문제 해결 과정에서 원활한 커뮤니케이션(Communication)을 이끌도록 해줍니다. 프로그램 개발에 가장 대중적인 디자인 패턴은 MVC, MVVMVIPER가 있습니다.

2

MVVM(Model-View-ViewModel) 패턴은 뷰(View)의 개발을 비즈니스 로직(Business Logic)과 모델(Model)로부터 분리해서, 뷰가 어느 특정한 계층에 종속되지 않도록 합니다. 뷰는 뷰-모델(ViewModel)을 알고 있지만, 뷰-모델과 모델(Model)은 뷰를 알지 못합니다. 그리고 뷰-모델은 모델을 알지 못하죠. 이런 구조는 테스트를 용이하게 만들어줍니다. 단순히 테스트하고픈 뷰-모델을 갈아 끼우기만 해도 전체 코드는 문제없이 작동합니다.

MVVM의 구성 요소
뷰(View): UILabel, UITextField, UISwitch와 같은 사용자 인터페이스(UI)를 표시하고 사용자로부터 이벤트를 받습니다. 받은 이벤트는 모두 뷰-모델(ViewModel)에게 전달하고, 뷰-모델은 이를 처리해 다시 뷰에게 넘겨줍니다. 뷰는 UI를 업데이트하고 필요한 애니메이션 작업을 수행합니다.
뷰-모델(ViewModel): 뷰와 모델의 중간 계층에 위차한 뷰-모델은 뷰로부터 넘겨 받은 이벤트로 필요한 비즈니스 로직을 수행하고, 모델에 변화가 생기면 뷰가 사용하기 용이한 형태로 가공해 다시 넘겨줍니다.
모델(Model): 프로그램의 상태와 데이터를 표현합니다.

MVVM 패턴을 올바르게 적용하기 위해 꼭 기억해야 할 점은 로직이 뷰 → 뷰-모델 → 모델 → 뷰-모델 → 뷰로 흐르도록 해야 한다는 점입니다. 필요하다면 뷰-모델에서 네크워트 통신, DB 처리와 같은 사이드 이펙트(Side Effect)를 수행할 수 있습니다.

Rx-MVVM 패턴 알아보기

그럼 이제 우리의 관심사에 집중해봅시다. 어떻게 하면 MVVM 패턴에 RxSwift를 묻힐 수 있을까요? 이벤트-기반 프로그래밍(Event-Driven Programming) 코드에 어떻게 하면 MVVM 패턴을 적용할 수 있을까요?

Input-Output

3

가장 널리 알려진 방식은 바로 뷰-모델을 InputOutput으로 나누는 겁니다. Input은 사용자 인터페이스로부터 받는 이벤트를 나타내고, Output은 뷰-모델에서 넘겨 받은 이벤트로 필요한 비즈니스 로직을 수행한 결과를 의미합니다. 즉, 뷰에서 발생한 스트림이 Input을 거쳐 transform(_:) 메서드에서 필요한 사이드 이펙트를 수행하고 다시 Output으로 흘러가게 됩니다.

transform(_:)Input을 매개변수로 받아 Output을 반환하는 메서드입니다. 이 메서드에서 map, reduce, combineLatest 연산자를 활용해 전달받은 항목을 가공하거나, flatMap, flatMapLateset 연산자로 네트워크 통신, DB 처리와 같은 사이드 이펙트를 수행할 수 있습니다.

똑같은 MVVM 패턴이라 하더라도 그 방식은 차이가 날 수 있습니다. 가장 널리 알려진 방식으로 GitHub의 Repository를 검색한 결과를 출력하는 예제를 작성해보겠습니다. 사용한 라이브러리는 RxSwift, RxCocoa, SnapKit, Then입니다.

1
2
3
4
5
6
7
// ViewModelType.Swift
protocol ViewModelType {
    associatedtype Input
    associatedtype Output
    
    func transform(_ input: Input) -> Output
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// GitViewModel.swift

import Foundation

import RxSwift
import RxCocoa

final class GitViewModel: ViewModelType {
    
    // MARK: - Input
    struct Input {
        var inputText: Observable<String>
    }
    
    // MARK: - Output
    struct Output {
        var totalCount: Driver<Int>
        var repositories: Driver<[Item]>
    }
    
    // MARK: - Properties
    private let apiWorker = GitAPIWorker()
    
    // MARK: - Transform
    func transform(_ input: Input) -> Output {
        let repositories = input.inputText
            .throttle(.seconds(3), scheduler: MainScheduler.instance)
            .filter { !$0.isEmpty }
            .flatMapLatest {
                self.apiWorker.fetchRepositorySearch($0)
            }
            .debug()
            .asDriver(onErrorJustReturn: [])
        
        let totalCount = repositories
            .map { $0.count }
            .asDriver(onErrorJustReturn: 0)
        
        return Output(
            totalCount: totalCount,
            repositories: repositories
        )
    }
    
}

InputOutput을 통해 사용자로부터 받을 이벤트와 UI 상태를 정의하였습니다. 우리 앱은 뷰로부터 텍스트 필드 입력을 이벤트로 받으므로, Observable<String> 타입의 inputText 프로퍼티 하나를 정의해주었습니다. 그리고 transform(_:) 메서드를 통해 리포지토리를 검색한 결과와 검색 개수를 Output에 담아 다시 뷰로 항목을 방출(emit)하고 있습니다.

한 가지 주의할 점은 스트림은 끊겨서는 아니돤다입니다. 뷰-모델이나 사이드 이펙트에서 Input을 구독하고, 다시 새로운 스트림을 생성하는 건 이벤트-기반 프로그래밍이 가지는 장점을 퇴색시킵니다. (가능하면) 뷰에서 발생한 스트림은 끊기지 않고, 그대로 다시 되돌아와 뷰에서 구독해 필요한 UI 업데이트를 수행해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// ViewControllerType.swift

import Foundation

import RxSwift

private struct AssociatedKeys {
    static var viewModel = "viewModel"
}

protocol ViewControllerType: AnyObject {
    associatedtype ViewModel: ViewModelType
    
    var disposeBag: DisposeBag { get set }
    
    var viewModel: ViewModel? { get set }
    
    func bind(viewModel: ViewModel)
}

extension ViewControllerType {
    private var underlyingViewModel: ViewModel? {
        get {
            return objc_getAssociatedObject(
                self,
                &AssociatedKeys.viewModel
            ) as? ViewModel
        }
        set {
            objc_setAssociatedObject(
                self,
                &AssociatedKeys.viewModel,
                newValue,
                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
            )
        }
    }
    
    var viewModel: ViewModel? {
        get {
            guard let viewModel = underlyingViewModel else {
                fatalError("ViewModel has not been set")
            }
            return viewModel
        }
        set {
            disposeBag = DisposeBag()
            if let vm = newValue {
                bind(viewModel: vm)
            }
            underlyingViewModel = newValue
        }
    }
}

ViewControllerType 프로토콜에서는 disposeBag, 뷰 컨트롤러가 가질 viewModel 프로퍼티와 바인딩에 필요한 bind(viewModel:) 메서드를 정의합니다. viewModel 프로퍼티를 프로토콜 확장을 통해서 기본 구현을 제공하고 있습니다. ViewControllerType 프로토콜을 준수하는 뷰 컨트롤러의 viewModel 프로퍼티에 ViewModelType 프로토콜을 준수하는 객체(Object)를 할당하면 자동으로 bind(viewModel:) 메서드가 호출됩니다. 뷰 컨트롤러에서 bind(viewModel:) 메서드를 호출할 수고를 덜 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SceneDelegate.swift

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        window = UIWindow(windowScene: windowScene)
        
        let vm = GitViewModel()
        let vc = GitViewController(viewModel: vm) // 자동으로 bind(viewModel:) 메서드가 호출됨
        window?.rootViewController = UINavigationController(rootViewController: vc)
        window?.makeKeyAndVisible()
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// ViewController.swift

import UIKit

import RxCocoa
import RxSwift
import Then
import SnapKit

final class GitViewController: UIViewController, ViewControllerType {

    // MARK: - Typealias
    typealias ViewModel = GitViewModel

    // MARK: - Views
    private var searchController = UISearchController()
    private var tableView = UITableView()
    private var totalCountLabel = UILabel()
    
    // MARK: - Properties
    var disposeBag: DisposeBag = DisposeBag()
    
    // MARK: - Intializer
    convenience init(viewModel: GitViewModel) {
        self.init()
        self.viewModel = viewModel
    }
    
    // MARK: - LifeCycles
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        setupConstraints()
        setupAttributes()
    }

    // MARK: - Bind
    func bind(viewModel: GitViewModel) {        
        let input = GitViewModel.Input(
            inputText: searchController.searchBar.rx.text.orEmpty.asObservable()
        )
        
        let output = viewModel.transform(input)
        
        output.repositories
            .drive(tableView.rx.items(cellIdentifier: TableViewCell.reuseId)) { index, item, cell in
                var config = cell.defaultContentConfiguration()
                config.text = item.fullName
                cell.contentConfiguration = config
            }
            .disposed(by: disposeBag)
        
        output.totalCount
            .map { "검색 리포지토리 수: \($0)" }
            .drive(totalCountLabel.rx.text)
            .disposed(by: disposeBag)
    }

    // <...하략...>
}

4

짜잔🎉 이렇게 앱 하나가 완성되었습니다.

bind(viewModel:) 메서드에서는 Input 객체를 통해 사용자로부터 받을 이벤트를 정의하고, 이후 뷰 모델의 transform(_:) 메서드를 호출해 Output 객체을 반환받게 됩니다. 이렇게 반환된 Output 객체의 프로퍼티를 구독해 UI 업데이트에 필요한 작업을 수행할 수 있습니다.

자세한 코드는 여기를 참조하세요.

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.