본문 바로가기

Swift

[MVVM] 인앤아웃버거? 인풋/아웃풋패턴!

728x90

인앤아웃 버거가 그렇게 짜다는데 .. 먹어봤어야 알지 .. 미국을 가봤어야 알지 !!!

 

MVC패턴에서 UI부분과 비즈니스 로직을 분리해서 MVVM 패턴으로 개선할 수 있다.

그리고 이 MVVM 패턴에서 좀 더 데이터의 흐름을 분리하면, (UI 로직과 비즈니스 로직을 좀 더 분리하면 = 뷰에서는 로직에 대한 것을 모두 가리는 것, 없애는 것) Input/Output 패턴으로 개선할 수 있다. 

 

개념은 간단(?)하다.

  • Input
    • 뷰로부터 전달된 데이터를 뷰모델에서 받게 되는 입력 데이터
    • VC -> VM
  • Output
    • 입력받은 데이터를 수정/변경해서 뷰에 표현하기 위한 출력 데이터
    • VM -> VC 

 

간단한 예시로 말하자면 아래와 같다.

ex) Input : 버튼의 탭, 텍스트 필드에 입력하는 텍스트 

ex) Output : 뷰의 상태, 텍스트, 화면 전환, 알림창 

 


 

예시 코드를 통해서 살펴보자.

아래와 같은 로그인 화면이 있다고 할 때, 

이 화면에서의 Input과 Output을 나누면 아래와 같이 나눌 수 있다.

  • Input
    • 이메일 텍스트 필드, 비밀번호 텍스트 필드에서의 입력값 
    • 로그인을 탭 할 때의 입력값
  • Output
    • 이메일과 비밀번호 String 값
    • 이메일과 비밀번호 유효성 판단
    • 로그인 성공 유무 

 

1. ViewModel - Input, Output 구조체 생성 

struct Input {
    
}

struct Output {
    
}

func transform(from: Input) -> Output {
    
}

 

그리고 ViewController에서 Input, Output을 나눠서 뷰모델 안에 생성한다.

 

Input 

사용자가 이메일, 비밀번호 텍스트필드에 값을 입력하는 것을 Input으로 볼 수 있다. 

각 텍스트 필드의 text까지를 VC -> VM로 전달되는 Input이라고 생각할 수 있다.

 

이 때의 타입을 확인하면 ControlProperty<String?> 인 것을 확인할 수 있다.

 

그러므로 이메일과 비밀번호 입력값에 대한 Input을 아래와 같이 구조체 안에 표현할 수 있다.

struct Input {
    let emailText: ControlEvent<String?>
    let passwordText: ControlEvent<String?>
}

 

또 다른 Input으로 로그인 버튼을 탭하는 인터랙션을 볼 수 있다.

탭하는 것까지 Input으로 볼 수 있고, 이것의 타입을 확인하면 

 

ControlEvent<Void> 타입이라는 것을 확인할 수 있다.

 

 

여기까지 보았을 때, VC -> VM로 전달되는 Input을 구조체로 작성하면 아래와 같다.

struct Input {
    let emailText: ControlProperty<String?>
    let passwordText: ControlProperty<String?>
    let loginTap: ControlEvent<Void>
}

 

Output

그리고 이제 VM -> VC로 전달되는 Output에 대해서 살펴보자.

 

뷰모델에서 참조하고 있는 모델이 ViewController로 전달된다면 Output이라고 할 수 있고,

Input에 대한 값을 바탕으로 어떠한 로직이 처리되어서 역시 ViewController에서 어떠한 작업을 한다며 이 역시 Output이라고 할 수 있다.

 

위의 로그인 화면에서는 

- 입력 받은 이메일, 비밀번호에 대해서 옵셔널 처리를 한 String값

- 버튼에 탭에 대한 반환 (버튼 탭의 경우는 Input과 동일한 형태로 반환된다고 할 수 있다.)

- 입력 받은 이메일, 비밀번호에 대한 유효성 판단 이후 Driver 형태로 반환된 값

- 이메일, 비밀번호를 바탕으로 로그인 과정 이후 성공 유무 

... 를 Output이라고 할 수 있다.

 

 

이를 코드로 나타내면 아래와 같다. 

struct Output {
    // 텍스트필드 입력값
    let emailText: ControlProperty<String>
    let passwordText: ControlProperty<String>
    
    // 버튼 탭
    let loginTap: ControlEvent<Void>
    
    // 로그인 성공 유무 > Output으로 해야 하는가?
    let isLoginSucceed: PublishSubject<LoginResponse>
    
    // 유효성 판단 
    let isValid: Driver<Bool>
}

 

 

2. ViewModel - Input -> Output 변경 메서드 생성

그리고 뷰모델에서는 Input을 Output의 형태로 변환해야 한다.

그래야, 이 Output을 View(= Controller)에서 사용할 수 있기 때문이다.

 

Output은 기존의 Controller에서 뷰모델의 프로퍼티로 접근해서 사용한 결과값을 operator로 가공을 했을, 그 때의 값으로 반환해주면 된다. 

 

말이 조금 어려운데 .. 하나의 예시만 살펴보자면,

이메일 텍스트 필드에 대한 값을 살펴보면, Input으로 emailTextField.rx.text까지 받았고,

이렇게 받았을 때의 타입은 String?이다. 이를 .orEmpty  operator를 사용해서 String으로 가공하고 있다.

여기서 !! 가공하는 부분을 비즈니스 로직으로 볼 수 있다.

 

그러므로 transform 메서드 안에서 이를 변환해주는 코드를 작성하면 된다.

func transform(from input: Input) -> Output {
    let emailText = input.emailText.orEmpty
    let passwordText = input.passwordText.orEmpty
    
    let isValid = isValid.asDriver(onErrorJustReturn: false)
    
    return Output(emailText: emailText,
                  passwordText: passwordText,
                  loginTap: input.loginTap,
                  isLoginSucceed: isLoginSucceed,
                  isValid: isValid)
}

 

 

그리고 이제 !! 다시 Controller로 가서 ViewModel에서 만들어준 Input과 Output 을 사용하면 된다.

 

 

3. Controller에서 Input, Outpt 사용

뷰에서 받을 입력값을 Input 구조체에 한번에 넣어주고

이를 가공해서 뷰에서 사용할 Output을 변수에 담아서 필요할 때마다 사용하면 된다.

 

    func bind() {
        let input = LoginViewModel.Input(emailText: emailTextField.rx.text,
                                         passwordText: passwordTextField.rx.text,
                                         loginTap: loginButton.rx.tap)
        
        let output = viewModel.transform(from: input)
        
        viewModel.emailRelay
            .bind(to: emailTextField.rx.text)
            .disposed(by: disposeBag)
        viewModel.passwordRelay
            .bind(to: passwordTextField.rx.text)
            .disposed(by: disposeBag)
        
        output.emailText
            .bind(to: viewModel.emailRelay)
            .disposed(by: disposeBag)
        
        output.passwordText
            .bind(to: viewModel.passwordRelay)
            .disposed(by: disposeBag)
        
        output.isValid
            .drive(loginButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        output.isValid
            .map { $0 == true ? UIColor.systemPink : UIColor.systemGray4 }
            .drive(loginButton.rx.backgroundColor)
            .disposed(by: disposeBag)
        
        output.loginTap
            .withUnretained(self)
            .bind { vc, _ in
                let request = LoginRequest(email: vc.viewModel.emailRelay.value, password: vc.viewModel.passwordRelay.value)
                vc.viewModel.requestLogin(request)
            }
            .disposed(by: disposeBag)
        
        output.isLoginSucceed
            .withUnretained(self)
            .subscribe { vc, response in
                vc.presentAlert("로그인 성공", "\(response.name)님 환영합니다.")
            } onError: { [weak self] error in
                guard let self = self else { return }
                self.presentAlert("로그인 실패", "\(error)")
            } onCompleted: {
                print("완료")
            } onDisposed: {
                print("버려")
            }
            .disposed(by: disposeBag)
    }