Notice
Recent Posts
Recent Comments
Link
«   2026/04   »
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
Archives
Today
Total
관리 메뉴

개발 회고록

[iOS 센츠노트] ViewModel의 Input Output패턴에 관한 생각 본문

iOS

[iOS 센츠노트] ViewModel의 Input Output패턴에 관한 생각

드드기 2023. 1. 17. 16:54

Unit Test 짜면서 드는 생각이 과연 기존 방식의 Input & Output 로직이 좋은 코드일까? 의문이 들었다.

이전 글에서도 Input & Output 의 비효율성에 대해 언급한 적이 있지만 구체적으로 담지는 않았다.

이번 글에서 그 이유를 적어보려고 한다.

final class LoginViewController {
  ...
  
  private func bindViewModel() {
    let input = LoginViewModel.Input(
      emailTextFieldDidEditEvent: self.emailTextField.rx.text.orEmpty.asObservable(),
      passwordTextFieldDidEditEvent: self.passwordTextField.rx.text.orEmpty.asObservable(),
      loginButtonDidTapEvent: self.loginButton.rx.tap.asObservable(),
      signupButtonDidTapEvent: self.signUpButton.rx.tap.asObservable()
    )
      
    let output = self.viewModel?.transform(from: input, disposeBag: self.disposeBag)
    self.bindLoginButton(output: output)
  }
}

final class LoginViewModel {
  
  struct Input {
    let emailTextFieldDidEditEvent: Observable<String>
    let passwordTextFieldDidEditEvent: Observable<String>
    let loginButtonDidTapEvent: Observable<Void>
    let signupButtonDidTapEvent: Observable<Void>
  }
  
  struct Output {
    let canDone = BehaviorRelay<Bool>(value: false)
    let notCorrect = PublishRelay<Void>()
  }
  
  func transform(input: Input) -> Output {
    let output = Output()
    ...
    return ouput
  }
  
}

 

위의 예시는 LoginViewController와 LoginViewModel 에서 UI를 통해 생기는 Input & Output을 처리하는 지를 보여준다.

이 방식의 핵심은 Input을 직접 ViewController의 Action에 대입시켜준 후 ViewModel의 Transform의 파라미터로 전달함에 있다. 덕분에 ViewModel에서 Input을 관측할 때 Subject와 같은 Observable과 Observer을 동시에 수행해야하는 변수가 추가적으로 필요하지 않다는 장점이 있다.

 

ViewModel의 입장에서 Input이 발생하는 곳은 LoginViewController 뿐이기 때문에 Transform 함수에 Input을 파라미터로 넣어주고 Output을 Return값으로 설정해 ViewController에서 대응하기 수월해보인다.

 

이처럼 장점이 많이 보이지만 다음과 같은 경우에는 어떻게 대응할 것인가..?

final class PerfumeDetailViewModel {

  let input = ScrollInput()
  let reviewInput = ReviewInput()
  let infoInput = InfoInput()
  let output = Output()
  
  struct Input {
    ...
  }
  
  // vc 안의 ScrollView(PageView)에서 Input
  struct ScrollInput {
    ...
  }
  
  // ScrollView 안의 InfoView에서 Input
  struct InfoInput {
    ..
  }
  
  // ScrollView 안의 ReviewView에서 Input
  struct ReviewInput {
    ...
  }
  
  struct Output {
    ...
  }
}

 

그림이 이상해도 양해부탁드려요..

무려 4 군 데에서 하나의 ViewModel을 공유하고 있고 각각의 Input이 존재한다. Transform 함수는 ViewController에서 한 번만 호출되어야 하기에 한계가 있다.

만약 하나의 뷰당 하나의 ViewModel을 만들어 준다면? 4개의 ViewModel에 각기 다른 Input이 들어가 서로 상호작용이 일어나야 할 것이므로 굉장히 번거로운 일이 생길 것이다.

 

게다가 Unit Test에서도 문제가 발생했다.

final class LoginViewModelTest: XCTestCase {

  func testTransform_inputEmailAndPasswordTextField_updateCanButton() {
    
    // Given
    ...
    
    // When
    let input = LoginViewModel.Input(emailTextFieldDidEditEvent: emailTextFieldObservable.asObservable(),
                                     passwordTextFieldDidEditEvent: passwordTextFieldObservable.asObservable(),
                                     loginButtonDidTapEvent: Observable.just(()),
                                     signupButtonDidTapEvent: Observable.just(()))
    
    ...
    
    // Then
    ...
  }
  
  func testTransform_updateEmailTextField_updateEmail() {
    
    // Given
    let loginButtonObservable = self.scheduler.createHotObservable([
      .next(20, ())
    ])
    
    // When
    let input = LoginViewModel.Input(emailTextFieldDidEditEvent: Observable.just("dyh0624@naver.com"),
                                     passwordTextFieldDidEditEvent: Observable.just("test"),
                                     loginButtonDidTapEvent: loginButtonObservable.asObservable(),
                                     signupButtonDidTapEvent: Observable.just(()))
    
    
    let _ = self.viewModel.transform(from: input, disposeBag: self.disposeBag)
    
    ...
    
    // Then
    ...
  }

}

무엇이 문제인지 보이는가?

테스트에 따라 필요한 Input 변수가 다를 것이다. 위에서는 1~2개의 Input 변수가 쓰이고 있다.

하지만 매번 쓰이지도 않을 나머지 Input 변수도 다 선언을 해줘야한다는 번거로움이 있다.

논리적으로도 맞지 않아 보인다.
만약 Input 변수가 20개가 있다면? 매 테스트마다 불필요한 코드가 20줄정도가 증가할 것이다.

 

Input 과 Output을 Struct화 해주는 방식과 Transform 함수를 통해 상호작용하는 부분은 나에게 있어 굉장히 매력적인 부분이라 이 부분을 가져가면서 리팩토링을 하였다.

final class LoginViewController: UIViewController {
  ...
  private func bindViewModel() {
    self.bindInput()
    self.bindOutput()
  }
  
  private func bindInput() {
    let input = self.viewModel.input
    
    self.emailTextField.rx.text.orEmpty.asObservable()
      .bind(to: input.emailTextFieldDidEditEvent)
      .disposed(by: self.disposeBag)
    
    ...
  }
  
  private func bindOutput() {
    let output = self.viewModel.output
    self.bindLoginButton(output: output)
  }
}

final class LoginViewModel {
  
  struct Input {
    let emailTextFieldDidEditEvent = PublishRelay<String>()
    let passwordTextFieldDidEditEvent = PublishRelay<String>()
    let loginButtonDidTapEvent = PublishRelay<Void>()
    let signupButtonDidTapEvent = PublishRelay<Void>()
  }
  
  struct Output {
    let canDone = BehaviorRelay<Bool>(value: false)
    let notCorrect = PublishRelay<Void>()
  }
  
  let input = Input()
  let output = Output()

  
  init(coordinator: LoginCoordinator?,
       loginUseCase: LoginUseCase,
       saveLoginInfoUseCase: SaveLoginInfoUseCase) {
    ...
    
    self.transform(input: self.input, output: self.output)
  }
  
    func transform(input: Input, output: Output) {
      let canDone = PublishRelay<Bool>()
      let notCorrect = PublishRelay<Void>()
    
      self.bindInput(input: input, canDone: canDone, notCorrect: notCorrect)
      self.bindOutput(output: output, canDone: canDone, notCorrect: notCorrect)
      self.bindNetwork(output: output, canDone: canDone, notCorrect: notCorrect)
    }
  }

이제는 ViewController에서 Transform을 생성할 수 없기 때문에 ViewModel의 init 에서 직접 선언해주고 있다. 

Input과 ouput이 불변임에도 파라미터로 넣어준 이유는 코드의 가독성 때문이다

필드에서 가져오는 경우 오른쪽과 같이 색상이 동일하기 때문에 가독성이 조금 떨어진다고 판단을 하였다.

 

Transform 함수에 모든 상호작용을 담기에는 굉장한 부담이다.

그래서 코드의 길이를 줄이고 역할에 맞게 함수를 나누어 주기로 했다. 크게 Input, Output, Network 세 가지로 나뉜다.

그때 생기는 함수들을 연결해줄, Observable & Observer의 역할을 하는 지역 변수가 필요했다.

 

이렇게 만들어진 코드를 Unit Test하기에도 굉장히 만족스러웠다.

final class LoginViewModelTest: XCTestCase {

  ...
  
  func testTransform_clickLogin_success() {
    
    // Given
    let loginButtonObservable = self.scheduler.createHotObservable([
      .next(20, ())
    ])
    
    // When
    loginButtonObservable.asObservable()
      .bind(to: self.input.loginButtonDidTapEvent)
      .disposed(by: self.disposeBag)
    
    self.scheduler.start()
    
    // Then
    ...
  }
}

필요한 Input만 가지고 테스트를 할 수 있게 되어 코드가 더 간력해졌다. 그리고 Transform은 ViewModel 생성과 함께 실행이 되기 때문에 각 테스트에서도 따로 넣어줄 필요가 없게 되었다.

 

이처럼 기존에 융통성이 없던 로직을 융통성있고 간결한 코드로 리팩토링해주었다.

모든 ViewModel을 바꾸기에는 시간이 오래걸리지만 더 나은 방식이 보이면 개발 부채가 생기지 않게 부지런하게 해결해야 할 것이다.

Comments