Skip to content

[기술 공유] MVVM 아키텍처 도입, 우리의 ViewModel 사용법

00me edited this page Nov 28, 2024 · 1 revision

문제 상황

MVC 패턴의 코드 길어짐과 수많은 의존성을 컨트롤러가 갖는 문제,

또한 View와 비즈니스 로직 분리 등을 위해 MVVM 도입했다.

우리 팀에서 MVVM의 ViewModel을 어떻게 사용하기로 정의했는지 설명하겠다.


문제 해결

MVVM 도입 결정

  • MVC에선 Controller가 View와 Model 일을, MVP에선 Presenter와 View가 서로 일 주고받음 MVVM에서 ViewModel은 Model하고만 소통함 즉, 관심사 분리를 잘 해낼 수 있음
  • 위 특징 때문에 테스트 가능한 구조가 되어 테스팅도 가능
  • MVC 패턴의 고질적인 컨트롤러에 많은 의존성이 쌓이는 문제를 덜어낼 수 있음

MVVM을 위한 Input-Output 패턴 도입

우리팀은 Combine을 사용하여 프로젝트를 진행하고 있다.

그리고 View와 ViewModel에 대한 단방향 데이터 플로우를 위해 Input-Output 패턴으로 적용하여 양방향 스트림을 진행하려 한다.

View가 갖고 있는 Subject로 Input을 넣으면, ViewModel은 View의 스트림을 구독을 하고 있다가, 데이터를 가공한 후에 자신의 Output 스트림으로 전달한다.

그러면 View가 구독중인 ViewModel의 output 스트림에 의해 화면이 다시 그려지게 되는 것이다.

ViewModelType 프로토콜

이를 위해 다음과 같은 프로토콜을 만들어주었다.

protocol ViewModelType {
    associatedtype Input
    associatedtype Output

    func transform(input: Input) -> Output
}

ViewModel 클래스

위 프로토콜은 모든 뷰모델이 채택하여 다음과 같이 사용된다.

public final class RegisterViewModel: ViewModelType {
    enum Input {
        case registerTextFieldEdited(text: String?)
        case registerButtonTapped(text: String)
    }
    
    enum Output {
        case registerButtonEnabled(isEnabled: Bool)
        case moveToHome(destination: String)
    }
    
    private let output = PassthroughSubject<Output, Never>()
    private var cancellables = Set<AnyCancellable>()
    
    public init() { }
    
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input.sink { [weak self] event in
            switch event {
            case .registerTextFieldEdited(let text):
                self?.validateTextField(text: text)
            case .registerButtonTapped(let text):
                self?.registerButtonTapped(text: text)
            }
        }.store(in: &cancellables)
        
        return output.eraseToAnyPublisher()
    }
    ...
}

View 클래스

이렇게 함으로써 뷰모델은 뷰로부터 오는 input 스트림을 구독하고, 자신의 output 스트림을 리턴해준다.

그러면 리턴 값을 아래 뷰가 다음과 같이 사용한다.

public final class RegisterViewController: UIViewController {
    // MARK: - Property
    private var viewModel = RegisterViewModel()
    private let input = PassthroughSubject<RegisterViewModel.Input, Never>()
    private var cancellables = Set<AnyCancellable>()
    ...
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        
        output.sink { [weak self] event in
            switch event {
            case .registerButtonEnabled(let isEnabled):
                self?.registerButton.isEnabled = isEnabled
            case .moveToHome(let houseName):
                do {
                    let homeViewModelFactory = try DIContainer.shared.resolve(HomeViewModelFactory.self)
                    let homeViewModel = homeViewModelFactory.make()
                    let homeViewController = HomeViewController(viewModel: homeViewModel)
                    self?.navigationController?.pushViewController(homeViewController, animated: false)
                    self?.navigationController?.viewControllers.removeFirst()
                } catch {
                    MHLogger.error(error.localizedDescription)
                }
            }
        }.store(in: &cancellables)
    }
}

그림으로 설명하면 아래와 같은 구조가 된다.


배운 점

  • MVVM 패턴을 적용하여 관심사 분리를 했다.
  • ViewModel에 Input-Output 패턴을 적용하여 플로우를 만들었다.

참조 링크

https://medium.com/myrealtrip-product/%EB%A7%88%EC%9D%B4%EB%A6%AC%EC%96%BC%ED%8A%B8%EB%A6%BD%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-ios-%EA%B0%9C%EB%B0%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-51048dca4626

https://medium.com/daily-monster/uikit-mvvm-with-combine-%EC%A0%81%EC%9A%A9%EA%B8%B0-ft-error-handling-a5f59389f8b7

https://youtu.be/KK6ryBmTKHg

https://medium.com/myrealtrip-product/%EB%8B%A8%EB%B0%A9%ED%96%A5-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%94%8C%EB%A1%9C%EC%9A%B0-unidirectial-data-flow-udf-ios-%EC%95%B1-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%A1%9C-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0-196a6c4f3b66

Clone this wiki locally