본문 바로가기

Swift

MVVM+RxSwift

728x90

RxSwift를 MVVM 디자인 패턴과 함께 사용하면 보다 효과적으로 사용할 수 있습니다. MVVM 디자인 패턴에 대해서 간단하게 알아보고, RxSwift를 적용하는 방법을 예시를 통해서 알아보도록 하겠습니다. 

 

먼저 많이 사용하고 있는 MVC 패턴에 대해서 알아보겠습니다. 

MVC 패턴

MVC 패턴은 대부분의 앱에서 사용되고, 애플에서 기본적으로 권장하는 디자인 패턴입니다. 

Model + View + Controller의 구조로 되어 있는 형태를 말합니다.

- Controller가 View와 Model을 모두 업데이트 하고,

- View는 화면에 UI, Model의 정보 등을 보여주는 역할만 하고,

- Model은 앱의 정보를 업데이트하는 데이터를 읽고 씁니다.

 

View와 Model은 서로에 대해 몰라야 합니다. 그리고 이 둘을 Controller가 이어줍니다. MVC 패턴의 경우 각 역할에 맞게 잘 나누어 코드를 작성하면 간단한 디자인 패턴으로 사용할 수 있습니다. 

 

이러한 MVC는 앱의 규모가 커지거나 각 역할의 경계가 모호해지면 하나의 Controller에 더 이상의 코드를 추가할 수 없게 됩니다. 애플의 MVC 패턴은 View와 Controller가 강하게 연결되어 있어 View Controller가 많은 일을 하게 됩니다. (이를 두고 MVC 패턴을 Massive View Controller Pattern이라는 말을 하기도 했습니다.)

 

위에서 말한 MVC의 단점을 피하기 위해 MVVM 패턴이 나왔습니다.

MVVM 패턴

MVVM 패턴은 View Model이 새로 도입된 패턴입니다. View Model의 역할은 View를 표현하기 위해 만들어진 Model입니다. View Model은 View의 비즈니스 로직을 관리하고 Model과 View사이에서 둘을 이어주는 역할을 합니다.

 

MVVM 패턴의 규칙

- Model은 데이터 변경에 대한 알림을 보내지만, 직접적으로 통신하지 않습니다.

- View Model은 Model과 통신하며 변경된 데이터를 View(View Controller)로 내보냅니다.

- View Controller는 View Life Cycle을 처리하고 데이터를 UIComponet와 연결할 때, View Model 또는 View와 통신합니다.

 

MVC 패턴이 문제가 되었던 것은 View Controller가 View와 관련 없는 코드를 작성하여 그 역할이 많아진 것입니다. MVVM 패턴은 이 문제를 해결하기 위해 View Controller와 View를 묶어서 단독으로 View를 관리합니다. Model에서 Business Logic을 관리하고 View MDoel에서 Presentaion Logic을 관리합니다. 

 

MVVM 패턴의 장점

- 코드 테스트가 용이합니다. View의 생명주기에서 비즈니스 로직을 분리하기 때문에 View Controller와 View 모두에 대해 명확하게 테스트가 가능합니다.

- View Model은 UI와 분리되어 있고 필요시에 재사용이 가능하며, iOS/MacOS/tvOS 까지 마이그레이션이 가능합니다.

 

MVVM + RxSwift 

로그인 화면을 예시로 하여 흐름을 살펴보겠습니다. 

 

ViewModel

ViewModel은 Model로부터 받는 Input 값이 있고 이를 보여주는 Output 값이 있습니다. View로부터 받는 입력은 Input 구조체 안에 정의되고 로직을 통해서 나온 결과로 나오는 출력들은 Output 구조체에 정의됩니다. 

 

먼저 ViewModel을 작성해보겠습니다.

import RxSwift
import RxCocoa

class SignInViewModel: NSObject {

    let input = Input()
    let output = Output()
    
    struct Input {
    	...
    }
    
    struct Output {
    	...
    }
}

 

Input

입력은 화면을 통해서 사용자에게 입력받는 값을 의미합니다. 

로그인 화면의 경우, 사용자에게 아이디와 비밀번호 입력 값을 받습니다. 그리고 입력 이후 로그인 버튼을 누르게 되는데, 이런 이벤트 입력 값도 입력 값에 포함됩니다. 

-> 화면을 통해서 사용자에게 받는 모든 이벤트를 의미합니다.

 

이렇게 3가지 입력을 받는다고 했을 때, Input 구조체는 아래와 같습니다. ⬇️

struct Input {
    let id = PublishSubject<String>()
    let password = PublishSubject<String>()
    let touchUpSignInButton = PublishSubject<Void>()
}

 

Output

출력은 해당 화면에서 받은 입력 값을 바탕으로 어떤 결괏값을 갖게 되는지를 말합니다.

- 아이디, 비밀번호의 입력 여부에 따른 로그인 버튼의 활성화 상태

- 로그인 성공 이후 메인화면으로 이동 

- 로그인 실패 시 보이는 오류 메시지 

등을 해당 화면의 입력 값에 따른 결과 값으로 생각할 수 있습니다.

 

이렇게 3가지 결과 값에 대한 Output 구조체는 아래와 같습니다. ⬇️

struct Output {
    let enableSignInButton = PublishRelay<Bool>()
    let errorMessage = PublishRelay<String>()
    let pushToMain = PublishRelay<Void>()
}

 

비즈니스 로직 구현

View Model의 입력과 출력을 모두 구현했으니 이 두 가지를 이용해서 입력에 따라 실행되어야 하는 로직을 구현하겠습니다. 

 

- 아이디, 비밀번호 입력에 따른 로그인 버튼의 활성화 상태 

아이디 or 비밀번호가 모두 채워져 있어야(두 가지 모두 1글자 이상 입력이 될 경우) 활성화되어야 합니다. 둘 중 하나라도 비어 있으면 버튼이 비활성화되어 있어야 합니다. 

init() {
    super.init()
    
    Observable.combineLatest(input.id, input.password)
        .map{ !$0.0.isEmpty && !$0.1.isEmpty }
        .bind(to: output.enableSignInButton)
        .disposed(by: disposeBag)
    }
}

Observable.combindLatest를 사용하면 아이디, 비밀번호가 입력될 때마다 이벤트 처리를 구현합니다. 

입력 시마다. map을 통해 두 가지 입력 값이 비어있는지 Bool형으로 반환합니다. 그리고 이때의 상태를 enableSignInButton에 바인딩합니다. 

-> 바인딩 시, enableSignInButton 값은 아이디, 비밀번호가 하나라도 비어있다면 false를, 모두를 입력했다면 true 값을 전달합니다.

 

- 로그인 버튼이 눌러졌을 경우

로그인 버튼을 탭 하면 이벤트가 발생해서 아이디, 비밀번호를 갖고 서버와 통신을 하여 결과를 보여줍니다. 

>> 오류가 있다면 오류 메시지를 출력하고

>> 인증에 성공하면 메인 화면으로 이동합니다.

 

init() {
    super.init()
    
    Observable.combineLatest(input.id, input.password)
        .map{ !$0.0.isEmpty && !$0.1.isEmpty }
        .bind(to: output.enableSignInButton)
        .disposed(by: disposeBag)
        
    input.touchUpSignInButton.withLatestFrom(Observable.combineLatest(input.id, input.password)).bind { [weak self] (id, password) in
        guard let self = self else { return }
        if password.count < 6 {
            self.output.errorMessage.accept("6자리 이상 비밀번호를 입력해주세요.")
        } else {
            // API로직을 태워야합니다.
            self.output.pushToMain.accept(())
        }
    }.disposed(by: disposeBag)
}

 

더보기

ViewModel 코드 전체 

 

import RxSwift
import RxCocoa

class SignInViewModel: BaseViewModel {
    
    var input = Input()
    var output = Output()
    
    struct Input {
        let id = PublishSubject<String>()
        let password = PublishSubject<String>()
        let touchUpSignInButton = PublishSubject<Void>()
    }
    
    struct Output {
        let enableSignInButton = PublishRelay<Bool>()
        let errorMessage = PublishRelay<String>()
        let pushToMain = PublishRelay<Void>()
    }
    
    
    init() {
        super.init()
        
        Observable.combineLatest(input.id, input.password)
            .map{ !$0.0.isEmpty && !$0.1.isEmpty }
            .bind(to: output.enableSignInButton)
            .disposed(by: disposeBag)
        
        input.touchUpSignInButton.withLatestFrom(Observable.combineLatest(input.id, input.password)).bind { [weak self] (id, password) in
            guard let self = self else { return }
            if password.count < 6 {
                self.output.errorMessage.accept("6자리 이상 비밀번호를 입력해주세요.")
            } else {
                // API 태우기
                self.output.pushToMain.accept(())
            }
        }.disposed(by: disposeBag)
    }
    
}

 

View

코드 베이스로 화면을 구현하겠습니다.

 

import UIKit

class SignInView: BaseView {

    let idField = FormTextField()
        
    let pwField = FormTextField()
    
    let errorLabel = UILabel().then {
        $0.isHidden = true
    }
        
    let signInButton = FormButton().then {
        $0.isEnabled = false
    }
    
    // ...
    
    // 로그인 결과 에러메시지가 발생할경우 호출되는 함수입니다.
    func showError(message: String) { 
        errorLabel.isHidden = false
        errorLabel.text = message
    }
}

 

ViewController

ViewModel을 통해 로직을 구현했고 View로 화면을 모두 그렸기 때문에 이 둘을 서로 바인딩하면 알아서 동작합니다.

 

import RxSwift
import RxCocoa

final class SignInViewController: BaseViewController {

    private lazy var signInView = SignInView(frame: self.view.frame)
    private let viewModel = SignInViewModel()
    
    
    static func instance() -> SignInViewController {
        return SignInViewController(nibName: nil, bundle: nil)        
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view = SignInView
        bindViewModel()
    }
    
    private func bindViewModel() {
        // Bind input
        signInView.idField.rx.text.orEmpty
            .bind(to: viewModel.input.id)
            .disposed(by: disposeBag)
            
        signInView.passwordField.rx.text.orEmpty
            .bind(to: viewModel.input.password)
            .disposed(by: disposeBag)
            
        signInView.signInBotton.rx.tap
            .bind(to: viewModel.input.touchUpSignInButton)
            .disposed(by: disposeBag)
        
        // Bind output
        viewModel.output.enableSignInButton
            .observeOn(MainScheduler.instance)
            .bind(to: signInView.signInButton.rx.isEnabled)
            .disposed(by: disposeBag)
            
        viewModel.output.errorMessage
            .observeOn(MainScheduler.instance)
            .bind(onNext: signInView.showError)
            .disposed(by: disposeBag)
            
        viewModel.output.pushToMain
            .observeOn(MainScheduler.instance)
            .bind(onNext: pushToMain)
            .disposed(by: disposeBag)
    }
        
    private func pushToMain() {
        if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate {
            sceneDelegate.pushToMain()
        }
    }
}

 

📌 MVVM+Rxswift를 사용하면, 뷰에 대한 입력과 출력을 뚜렷하게 정의하여 관리할 수 있고 이에 따라 데이터의 변화도 다르게 관리할 수 있습니다. 또한, 비즈니스 로직에 해당하는 부분을 ViewModel에서 관리하여 테스트가 편해집니다. (앱에서 실행되어야 하는 비즈니스 로직을 완벽하게 독립하여 테스트할 수 있습니다. 입력을 원하는 형태로 바꾸어서 바인딩하면 됩니다.)

'Swift' 카테고리의 다른 글

Initialization - 상속과 초기화  (0) 2022.04.20
Initialization - 무엇인가  (0) 2022.04.20
Hashable  (0) 2022.04.14
Type Casting  (0) 2022.04.01
Concurrency Programming - Intro  (0) 2022.03.28