안녕하세요!! 집주인들 중 가장 잘생긴 박효준입니다!
이번에는 MVVM의 도입여부 논의를 하면서 결정한 ViewModel등의 사용법을 공유하고자 합니다.
거두절미하고 바로 들어가겠습니다~~
문제 상황
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/마이리얼트립에서-사용하는-ios-개발-아키텍처-51048dca4626
https://medium.com/daily-monster/uikit-mvvm-with-combine-적용기-ft-error-handling-a5f59389f8b7
'Architecture, Design Pattern' 카테고리의 다른 글
DIContainer 도입 논의 과정 (0) | 2024.12.04 |
---|---|
[Architecture] 기록소의 아키텍처 논의를 기록하다... (2) | 2024.11.10 |