본문 바로가기

iOS

📱RxSwift + MVVM 패턴 iOS에 적용해보기 (간단한 로그인 화면)

완성 화면

이메일칸과 비밀번호를 모두 입력하게되면 로그인 버튼이 활성화되는 화면입니다!
두 칸 중 하나라도 비어있으면 버튼이 비활성화됩니다.


MVVM패턴

MVVMPattern - 위키백과

MVVM은 Model, View, ViewModel을 말합니다.
GUI의 개발을 3가지 역할로 나누어 개발을 하는 것을 의미합니다. 

View는 우리가 흔히 알고있는 UIView 화면에 해당합니다. 
Model도 MVC패턴에 있는 Model과 동일합니다.
ViewModel은 View와 Model 사이에 연결다리 역할을 해줍니다. 뷰에서 보여지는 데이터들을 가지고 있으며 이와 관련된 비즈니스 로직들도 포함되어있습니다. 

장점

View와 ViewModel 사이, ViewModel과 Model 사이에 의존성이 없어서 ViewModel을 UnitTest하기 수월해집니다.
테스트를 하기 위해서 Mock Model을 만들어서 테스트를 할 수 있습니다.
실제로 MVVM 패턴을 적용한 뒤 테스트를 작성하기 쉬워졌습니다.


Input Output

ViewModel을 좀더 이해하기 쉽게 표현하기 위해 뷰모델에서 받는 입력과 출력을 나눠서 정의를 하겠습니다.
View로부터 받는 입력은 Input 구조체 안에 정의되고 로직을 통해서 나온 결과로 나오는 출력들은 Output 구조체에 정의됩니다. 출력들은 텍스트, 혹은 화면 이동 등이 있습니다. 아래와 같은 형태의 ViewModel로 작성을 하겠습니다.

import RxSwift
import RxCocoa

class TempViewModel: NSObject {

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

ViewModel 구현

Input

화면에서 받는 입력이 몇개인지 출력이 몇개인지 확인해야합니다.
입력은 화면을 통해서 사용자가 입력하는 것들을 정의하면 됩니다.
가장 먼저 보이는 입력은 아이디 UITextField, 비밀번호 UITextField가 보이네요.
입력을 하고 난 뒤에는 로그인 버튼을 누르는데 이 행위도 입력에 해당됩니다! (화면을 통해 입력을 받는 행위이니까요!)
그렇기 때문에 Input 구조체는 아래처럼 생성이 됩니다.

struct Input {
    let email = PublishSubject<String>()
    let password = PublishSubject<String>()
    let tapSignIn = PublishSubject<Void>()
}

Output

출력은 여기에서 상세하게 정의를 하지 않을것이지만..
해당 화면에서 어떤 출력이 있는지 생각해보면 아이디, 비밀번호 입력에따라 변하는 로그인 버튼의 활성화 상태, 로그인이 성공했을 때 메인 화면으로 이동하는 것, 로그인이 실패했을 경우 보여져야하는 오류메시지가 있겠네요!
3가지 내용을 정리하면 이렇게 정의할 수 있겠네요!

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

비즈니스 로직 구현

ViewModel의 입력과 출력을 구현하였으니 두개를 이용해서 입력에 따라 실행되어야 하는 로직을 만들어줍니다.
저는 생성자 안에서 구현하도록 하겠습니다.
먼저 필요한 로직은 아이디와 비밀번호 입력에따라 변화해야하는 로그인 버튼의 활성화 상태입니다.
아이디 혹은 비밀번호 중 하나라도 비어있으면 버튼이 비활성화되어있고 두개다 1글자 이상이 입력될 경우 버튼이 활성화되어야합니다.

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

Observable.combineLatest를 사용하면 이메일과 비밀번호가 입력될때마다 발생하게됩니다.
발생할때마다 map기능을 사용하여 두가지 문자가 모두 비어있는지 Boolean값으로 만들어줍니다.
만들어진 값을 enableSignInButton (로그인 버튼 활성화 상태)에 바인딩 시켜줍니다.
(바인딩을 시켜주면 enableSignInButton 값은 이메일과 비밀번호가 하나라도 비어있으면 false, 두개다 입력되어있으면 true값이 전달됩니다.)

다음으로 정의할 로직은 로그인 버튼이 눌러졌을 경우 입니다.
로그인 버튼터치가 발생하면 아이디와 비밀번호를 가지고 와서 검증과 API통신을 한 뒤에 오류가 있으면 오류 출력, 인증 성공을 하면 메인화면으로 보내주면 됩니다. 

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

ViewModel 전체

import RxSwift
import RxCocoa

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

View 작성

View는 Snapkit을 사용해서 코드로 그렸습니다.

import UIKit

class EmailSignInView: BaseView {

    let emailField = FormTextField()
        
    let passwordField = 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을 구현해서 로직을 구현하였고 뷰도 모두 그렸으니 바인딩만 시켜주면 알아서 동작할겁니다!

import RxSwift
import RxCocoa

class EmailSignInVC: BaseVC {

    private lazy var emailSignInView = EmailSignInView(frame: self.view.frame)
    private let viewModel = EmailSignInViewModel()
    
    
    static func instance() -> EmailSignInVC {
        return EmailSignInVC(nibName: nil, bundle: nil)        
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view = emailSignInView
        bindViewModel()
    }
    
    private func bindViewModel() {
        // Bind input
        emailSignInView.emailField.rx.text.orEmpty
            .bind(to: viewModel.input.email)
            .disposed(by: disposeBag)
        emailSignInView.passwordField.rx.text.orEmpty
            .bind(to: viewModel.input.password)
            .disposed(by: disposeBag)
        emailSignInView.signInBtn.rx.tap
            .bind(to: viewModel.input.tapSignIn)
            .disposed(by: disposeBag)
        
        // Bind output
        viewModel.output.enableSignInButton
            .observeOn(MainScheduler.instance)
            .bind(to: emailSignInView.signInBtn.rx.isEnabled)
            .disposed(by: disposeBag)
        viewModel.output.errorMessage
            .observeOn(MainScheduler.instance)
            .bind(onNext: emailSignInView.showError)
            .disposed(by: disposeBag)
        viewModel.output.goToMain
            .observeOn(MainScheduler.instance)
            .bind(onNext: goToMain)
            .disposed(by: disposeBag)
    }
        
    private func goToMain() {
        if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate {
            sceneDelegate.goToMain()
        }
    }
}

Input은 입력이기 때문에 Component -> ViewModel로 바인딩을 하고, Output은 출력이므로 ViewModel -> Component(혹은 새로 정의한 함수) 방향으로 바인딩 시켜주세요.
바인딩 시키는 것은 ViewModel 입/출력에 알맞은 조각을 딱딱 끼워주는 과정입니다.


결론

RxSwift + MVVM을 사용해서 ViewModel을 구현한 결과 ViewModel을 사용하면 입력과 출력을 뚜렷하게 정의할 수 있게됩니다.
또한 정확하게 비즈니스 로직에 해당하는 것들만 뷰모델에 구현해서 테스트를 하기 편해지는 것 같습니다.
앱에서 실행되어야하는 비즈니스 로직만 완벽하게 독립되게 테스트할 수 있습니다.
(입력을 원하는 형태로 변형해서 바인딩만 시켜주면 됩니다.)
완벽하게 적용시킨 상태는 아닌 것 같지만 계속 공부하면서 좀더 꺠끗하게 적용도 해보고싶네요!