뤼이드에서 신규 서비스의 iOS 앱 개발을 담당하게 되어, 아키텍처에 대한 고민을 하게 되었습니다. 협업을 위해서는 어느 정도 대중적인 아키텍처가 필요했는데, 익숙함이 가장 큰 무기인 MVC(Massive View Controller)는 유지보수와 확장성을 생각했을 때, 미래에 큰 고통을 받을 것이 분명하여 제외했습니다.
팀원과의 긴 논의를 통해 MVVM 아키텍처를 선택하게 되었고, 다양한 구현 패턴을 보며 학습을 하던 중 iOS MVVM은 표준이 없고 구현하는 사람마다 패턴이 조금씩 다르다
는 것을 알게 되었습니다. 그중에, Kickstarter에서 사용하는 Input과 Output Protocol을 사용하는 방식에 영감을 받아 신규 프로젝트에 적용해보기로 했습니다.
그럼 간단한 예제를 통해 Input, Output Protocol을 사용한 MVVM 아키텍처의 구현을 알아보겠습니다.
Example
Protocol with Input&Output
ViewModel의 의존성인 Dependency, View에서 전달되는 이벤트인 Input과 Input의 결과를 출력하는 Output을 associatedtype으로 정의합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
protocol ViewModelType {
associatedtype Dependency
associatedtype Input
associatedtype Output
var dependency: Dependency { get }
var disposeBag: DisposeBag { get set }
var input: Input { get }
var output: Output { get }
init(dependency: Dependency)
}
그럼 이 Protocol을 사용하여 이름과 이메일 주소를 입력받고, 확인 버튼의 활성화 상태를 출력하는 ViewModel을 만들어보겠습니다.
ViewModel
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
import Foundation
import RxSwift
import RxCocoa
final class MyViewModel: ViewModelType {
struct Dependency {
var name: String?
var email: String?
}
struct Input {
var nameText: AnyObserver<String?>
var emailText: AnyObserver<String?>
}
struct Output {
var isConfirmEnabled: Driver<Bool>
}
let dependency: Dependency
var disposeBag: DisposeBag = DisposeBag()
let input: Input
let output: Output
private let nameText$: BehaviorSubject<String?>
private let emailText$: BehaviorSubject<String?>
init(dependency: Dependency = Dependency(name: nil, email: nil)) {
self.dependency = dependency
// Streams
let nameText$ = BehaviorSubject<String?>(value: nil)
let emailText$ = BehaviorSubject<String?>(value: nil)
let isConfirmEnabled$ = Observable.combineLatest(nameText$, emailText$)
.map(validation)
.asDriver(onErrorJustReturn: false)
// Input & Output
self.input = Input(nameText: nameText$.asObserver(),
emailText: emailText$.asObserver())
self.output = Output(isConfirmEnabled: isConfirmEnabled$)
// Binding
self.nameText$ = nameText$
self.emailText$ = emailText$
}
}
private func validation(name: String?, email: String?) -> Bool {
return name?.isEmpty == false && email?.isEmpty == false
}
이름과 이메일 입력이 Input에, 버튼 활성화 여부의 출력이 Output에 정의되어있는 것을 볼 수 있습니다. 스트림 생성과 관리는 init(dependency:)에서 담당하고 있습니다.
init에서 스트림을 관리하는 것이 복잡하거나 다소 부담스럽다면, 아래와 같은 방식으로 구현하는 것도 괜찮습니다.
1
2
3
4
5
6
7
8
9
10
final class MyViewModel: ViewModelType {
.
.
.
func bind(input: Input) -> Output {
// TODO: bind
// View에서 Output을 Bind하기 전에 호출합니다.
}
}
View (Controller)
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
import UIKit
import RxSwift
import RxCocoa
final class View: UIViewController {
private let nameTextField: UITextField = UITextField()
private let emailTextField: UITextField = UITextField()
private let confirmButton: UIButton = UIButton()
let disposeBag = DisposeBag()
var viewModel: MyViewModel = MyViewModel()
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
private func bind() {
// Input
nameTextField.rx.text
.bind(to: viewModel.input.nameText)
.disposed(by: disposeBag)
emailTextField.rx.text
.bind(to: viewModel.input.emailText)
.disposed(by: disposeBag)
// Output
viewModel.output.isConfirmEnabled
.drive(confirmButton.rx.isEnabled)
.disposed(by: disposeBag)
}
}
View는 textField의 text입력을 ViewModel의 input으로 전달하고, ViewModel의 output을 구독하여 화면에 반영합니다.
마무리
ViewModel의 Input과 Output을 통해 View와 ViewModel 간의 바인딩이 매우 간결한 것을 볼 수 있습니다. 기능의 수정 또는 추가 시, Input과 Output에 맞춰 적절한 변수를 선언해주면 됩니다.
물론 이 구조가 만능은 아닙니다. 화면 또는 기능이 복잡해질수록 늘어나는 스트림 관리에 신경을 많이 써야 하며, ViewModel이 비대해질 가능성이 매우 큽니다. 이러한 이유 때문에 MVVM이 다양한 형태의 구현을 가지고 있는게 아닐까 싶네요. 😥
아키텍처 후보에는 산타토익에서 사용하는 Geppetto 또는 ReactorKit 같은 단방향 아키텍처도 있었지만, 대중성을 고려하여 다음 기회로 미루기로 했습니다. 하지만, 단방향 아키텍처는 매우 효율적이니 한 번쯤 알아보시는 것을 추천합니다.
이번 글은 여기서 마치겠습니다.
읽어주셔서 감사합니다! 😆