Sign In
Swift | 학습 내용 정리

[UIKit + RxSwift] MVVM 구조에서 Input-Output 패턴으로 UI 컨트롤하기

한결
커스텀한 Observable 객체를 이용해서 MVVM 패턴으로 간단한 iOS 앱을 만들어왔다. View 객체에서 발생하는 이벤트를 인식하고 이벤트에 따른 비즈니스 로직을 ViewModel에서 처리하고, 그걸 View가 원하는 Output으로 돌려주는 형태로 코드를 작성했다. 자연스럽게 앱 전반에서 '이벤트 방출 - 구독 - 연산 - 결과 도출'의 매커니즘으로 모든 이벤트를 Asynchronous하게 처리하는 RxSwift의 여러 API를 학습하게 되었고, 이를 통해 간단하게 UI를 컨트롤 하는 방식을 기록한다.
MVVM(Input-Output 패턴) + RxSwift 로 코드를 구성한 화면은 아래의 이미지와 같다.
로그인 화면에서는 email, password를 입력받는 TextField의 텍스트 입력 이벤트를 구독하여 조건에 따라 UI를 컨트롤한다.
shopping list 라는 타이틀을 가진 화면에서는 역시 TextField의 텍스트 입력 이벤트를 구독해서 테이블뷰에 셀을 보여주고, 컬렉션 아이템 터치 이벤트를 통해 테이블뷰를 컨트롤한다.

Input - Output 패턴

MVVM 아키텍쳐의 주요 골자는 View를 컨트롤 하는 View 객체, 앱에서 활용될 데이터에 대한 명세~메서드를 구축하는 Model 객체, 그리고 그 두 객체 사이를 연결하여 이벤트에 대한 로직을 처리하는 ViewModel 객체의 조합이다.
Input - Output 패턴도 이런 골자를 지키면서, 이벤트가 들어오는 Input 객체와 로직의 처리 결과로 View에게 반환되는 Output 객체를 각각 구분하는 컨셉을 가진다. ViewModel에서 활용되는 패턴으로, View에서는 Input 객체에 이벤트를 실어 ViewModel이 이벤트를 구독할 수 있게 한다. Input과 Output 객체를 연결하는 통로는 transform(Input) → Output 이라고 하는 메서드를 보통 활용한다.
프로토콜로 Input - Output 패턴으로 ViewModel 구현의 제약을 정해줄 수도 있겠다.
protocol InputOutputType { associatedtype Input associatedtype Output var disposeBag: DisposeBag { get set } func transform(input: Input) -> Output }
Input - Output 패턴이 적용된 ViewModel의 전형적인 모습은 아래와 같다.
import RxSwift import RxCocoa final class ViewModel { private let disposeBag: DisposeBag() struct Input { // 구독할 이벤트별로 프로퍼티를 지정해준다. } struct Output { // 이벤트 처리 결과에 따라 만들어진 Observable을 보통 반환한다. } func transform(input: Input) -> Output { .. return Output } }
내가 파악한 Input - Output 패턴의 핵심은 요정도이다.
View단에서 전달하려고 하는 Event Input과 View단에서 필요로 하는 Data(Disposable한) Output을 각각의 구조체에 묶어 보다 명확하게 인지할 수 있다.
결국 Output 객체의 프로퍼티로 전달하는 값도 Disposable을 반환하는 Observable 객체이다.
이벤트에 따라서 변화하는 Output 값을 View 단에서도 Observer에 구독시켜서 UI를 업데이트 한다.
RxSwift, RxCocoa 라이브러리가 가지는 API를 잘 활용하면 보다 편하게 Event Emit을 컨트롤 할 수 있다.

Login 정보 검증 화면

로그인 화면에서 이메일 주소 정보가 xxxx@xx.xxx 의 형태와 같이 입력이 되는지 / 비밀번호가 8자 이상인지와 같은 조건을 검증하고 싶다. ViewController에서 TextField의 Delegate 패턴을 적용하여 처리할 수 있겠다.
커스텀한 Observable 객체로는 Observable<String?>(fieldInput) 형태로 값의 변화를 ViewModel이 인지할 수 있게 property로 구축해둘 수 있을 것 같다.
RxCocoa 기반으로는 TextField.rx.text 와 같은 형태로 Rx에서 제공하는 Wrapper로 이벤트를 감싸서 텍스트 필드의 입력 이벤트(정확하게는 입력된 String? 값에 대한 이벤트)를 Observable 형태로 만들 수 있다.
import RxSwift import RxCocoa final class SomeViewController: UIViewController { private let textField = UITextField() .. private func bindView() { .. let input = SomeViewModel.Input( // text 입력에 대한 이벤트를 ViewModel의 Input 인스턴스에 반영 textFieldChange: textField.rx.text ) } }
이렇게 형성한 이벤트 Emit을 Input 객체에 전달해주면, ViewModel의 transform 메서드가 인자로 받은 Input 객체의 해당 이벤트 프로터티를 활용하여 내부 연산을 돌린다.
final class SomeViewModel { .. struct Input { let textFieldChange: ControlProperty<String?> } struct Output { let emailValidationText: PublishRelay<String> } func transform(for input: Input) -> Output { .. let emailValidationText = PublishRelay<String>() // 전달받은 textfield의 이벤트를 여기서 구독하여 특정 값 처리를 한다. input.textFieldChange.orEmpty .map { self.emailValidator(for: $0) && $0.count >= 3 } .subsribe(self) { owner, valid in emailValidationText.accept(valid ? "검증 성공" : "검증 실패") } .disposed(by: disposeBag) .. return Output( emailValidationText: emailValidationText ) } }
ControlProperty<Type> 은 ViewController 코드에서 textField.rx.text 로 넘겨준 Observable의 타입이다.
RxCocoa에서 지원해주는 Event Emit Wrapper에 대한 타입이다.
UIControl interface에 적용을 받는 View 요소들을 감싸서, Control 이벤트를 Rx가 이해할 수 있게 맵핑해주는 것으로 보인다.
UITextField 역시 addTarget 메서드로 이벤트를 제어할 수 있는 객체이기 때문에 해당 wrapper로 감쌀 수 있겠다.
transform 메서드에서는 전달받은 Observable에 대한 구독을 진행하기 전에 필요로 하는 형태로 변형 / 연산 / 결합 등을 하는 Stream 과정을 거친다.
Rx에서는 UI 이벤트뿐만 아니라 내부 비즈니스 로직에서 활용될 수 있는 상태값에 대한 Observable을 여러 형태로 가공하기 위해서 Operator를 제공한다.
입력받은 TextField의 text가 이메일 주소 형태와 동일한지 검증하는 절차도 이 Stream 과정에 반영된다.
이 과정까지도 Observer에 대한 구독이 발생하지 않았기 때문에 어떤 처리도 되지 않는다.
.subscribe .bind .driver 와 같은 Subscribe Operator가 체이닝 되는 시점에 비로소 Observer에 필요로 하는 이벤트가 전달된다. (이래서 비동기성 API라고 하는 것 같다.)
subscribe 함수가 체이닝 되면서 email 검증 결과에 대한 Bool 값에 따라 Output에 필요한 새로운 Observable을 생성한다.
dispose로 직접 구독을 취소하지 않고 있기 때문에, textfield의 입력이 발생할 때마다 해당 로직이 반복된다.
ViewModel의 transform 메서드에서 맵핑된 Output 객체는 다시 ViewController에서 Observable로 활용된다. ViewController에서 이벤트에 대한 Observable을 전달했다면, ViewModel에서는 어떤 값을 Emit 하는 것이라고 판단하면 편하다.
// SomeViewController private func bindView() { .. let input = .. // 위에 작성한 코드 참고 let output = viewModel.transform(for: input) // email 검증에 따른 결과 값(여기서는 PublishRelay<String>한 Observable)을 // emailValidationLabel 이라고 하는 Observer에 구독한다. output.emailValidationText .bind(to: emailValidationLabel.rx.text) // view에 바인딩 .disposed(by: disposedBag) .. }
Output으로 전달받은 PublishRelay Observable을 emailValidationLabel 이라고 하는 View 객체에 바인딩하면서 새로운 구독을 형성했다.
PublishRelay, BehaviorRelay 는 RxCocoa 라이브러리가 각각의 Subject를 더욱 UI 컨셉에 맞게 한 번 더 감싼 Observable 객체라고 생각하면 된다.
Subject와의 큰 차이는 .onComplete .onError 에 대한 처리를 신경쓰지 않는다는 것이다.
이렇게 Input-Output 구조로 형성된 Event → Emit → Subscribe → transform → New Emit → UI Bind 형태의 고리는 ViewController가 deinit 되기 전까지 유지된다. (disposedBag을 사용했기 때문에.)
위의 과정을 내가 이해한 바에 따라 간단하게 도식화 해보면 아래의 그림과 같이 나올 수 있다. 그렇지만 역시 중요한건 이것 또한 MVVM의 어느 한 방법에 불과하다는 것이다. MVVM에 정답은 없다. LogInView에 대한 코드는 여기서 확인 가능하다.

TableView, CollectionView가 함께 있는 뷰에서의 Rx + MVVM Input-Output 패턴

테이블뷰, 컬렉션뷰가 함께 있는 위와 같은 뷰도 Input-Output 패턴을 활용하는 방식은 동일하다. 다만, 테이블뷰나 컬렉션뷰는 리스트 형태의 데이터를 기반으로 스크롤이 되는 UI를 그려야 하기 때문에 Cell을 바인딩하고, 그 Cell에 각 indexPath에 맞는 데이터를 바인딩해줘야 한다. delegate, datasource 프로토콜에 구현된 메서드를 활용하는 것과 그 방식이 크게 차이나지는 않는다.
우선, 리스트 형태의 데이터 Observable을 테이블뷰, 컬렉션뷰에 UI로 바인딩 해보자. 준비되어야 할 것은 결국 Disposable한 데이터 Observable이다. ViewModel의 Output 객체에서 반환해주는 Relay, Subject를 잘 활용하자.
// SomeViewController private func bindView() { .. let cellButtonTouch = PublishRelay<Int>() let input = SomeViewModel.Input( .. cellButtonIndex: cellButtonTouch ) let output = .. // 위에 작성한 코드 참고 output.collectionViewDatalist .bind(to: collectionView.rx.items( cellIdentifier: Cell.id, cellType: Cell.self )) { row, item, cell in // 이 클로저 안에서 cell instance에 대한 ui 작업 호출 cell.bindView(for: item) cell.someButton.rx.tap .bind(with: self) { _, _ in cellButtonTouch.accept(row) } .disposed(by: cell.disposeBag) // ⭐️ } .disposed(by: disposeBag) .. }
cellIdentifier, cellType 과 같은 인자를 보면 UITableViewDatasource 와 같은 프로토콜이 강제해주는 cellForItem 과 활용법이 거의 비슷함을 알 수 있다.
cell 안에 구성되어 있는 View 요소의 이벤트를 감지해서 ViewModel이 처리해줄 수 있어야 하는 경우가 많다.
예를들어, 어떤 아이템에 대해 좋아요를 처리한다던지, 장바구니에 담기를 한다던지..
그런 경우에는 해당 요소를 인지할 수 있는 특정 Observable을 만들어 ViewModel의 Input 객체에 넣어줄 수 있는 형태로 맵핑해주는 과정이 필요하다.
위에서는 핸들링 하고자 하는 Cell의 row 값을 PublishRelay<Int> 형태의 Observable로 맵핑하여 Input에 넣어주고 있다.
결국, Rx로 테이블뷰나 컬렉션뷰를 다루기 위해서도 각 이벤트, 각 데이터에 대한 Disposable 한 Observable을 만들어 Input - Output에서 각각 구독할 수 있게 만들어야 하는 것이 핵심이다.
UITableViewDelegate나 UICollectionViewDelegate와 같은 프로토콜에서는 didSelectItemAt 라는 형태로 개별 Cell 선택에 대한 이벤트를 처리해주는 메서드가 있다.
RxCocoa에서도 Cell 내부 요소 말고, Cell 자체를 선택하는 이벤트에 대한 Observable을 처리해주는 방식이 있다.
// SomeViewController private func bindView() { .. let cellSelected = PublishRelay<(Int, SomeData)>() Observable.zip( collectionView.rx.itemSelected, collectionView.rx.modelSelected(SomeData.self) ) .bind(with: self) { vc, cellTuple in // itemSelected에서 확인되는 IndexPath 값과 // modelSelected에서 확인되는 cell의 Data 값을 // Tuple 형태로 반환해준다. cellSelected.accept((cellTuple.0.row, cellTuple.1)) } .disposed(by: disposeBag) .. }
.itemSelected 는 선택된 cell의 indexPath를 Observable Stream에 넘겨준다.
collection.rx.itemSelected .bind(with: Object, onNext: (Object, IndexPath) -> Void)
.modelSelected(model.Type) 은 선택된 cell에 바인딩 된 data 값을 지정해서 Observable Stream에 넘겨줄 수 있다.
collection.rx.modelSelected(SomeData.self)
두 Operator를 모두 잘 활용하고, 보통 합쳐서 활용하는 경우가 많아서 .zip Operator를 활용해, Tuple 형태로 Observable Stream에서 활용한다.
TableView, CollectionView를 함께 Rx + Input-Output 패턴으로 다룬 코드는 여기서 확인할 수 있다.
결국, 내가 생각하기에 RxSwift, RxCocoa를 활용해서 앱을 그리고 이벤트를 처리하는 것은
이벤트 수집과 방출을 위한 Observable을 어떻게 만들고
만들어진 Observable의 구독을 위해 Disposable한 형태로 전달해서
다시 View에서 구독할 수 있는 또 다른 Observable로 만들어내는 구독 과정을
잘 연결하는 과정이라고 생각한다. 확실히 커스텀한 Observable 객체를 만들어서 MVVM 패턴으로 활용해본 경험이 RxSwift에 접근하는데 많이 도움이 된 것 같다. RxSwift의 더욱 다양한 Operator, Trait 들을 활용해보면서 Rx가 가지는 기본 컨셉, Asynchronos 한 앱 구성에 익숙해지도록 해봐야 할 것 같다.
Ha
Subscribe to 'hankyeol'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to 'hankyeol'!
Subscribe
👍