[iOS] MVVM에 대한 고찰
안녕하세요! 미니입니다.
프로젝트를 하면서 MVVM에 대해서 고민했던 부분들을 정리해보려고 합니다. 저도 학습을 하고, 생각을 정리하는 글이다 보니 틀린 부분이 있을 수 있습니다. ㅜㅜ (댓글 달아주시면 발전하겠습니다!!)
왜 MVVM 써야 하는뎅..?
전통적으로 Apple에서 정의한 MVC를 Massive View Controller라고 놀리곤합니다. 근데, 저는 Massive하다는 것이 어느정도 커져야지 그런 거지?라는 생각과 함께 좀 고민을 해야 할 표현이라고 생각했습니다. 실제 개발을 하면서 ViewController가 커지는 경우가 있기는 한데, MVVM으로 변경하는 적절한 이유라고는 생각하지 못했습니다.
이런 이유들보다는 View와 비즈니스 로직을 분리하고 객체들에게 명확한 책임을 주게 하기 위해서라고 생각하게 되었습니다. 즉, ViewController은 뷰를 보여주는 역할, ViewModel은 우리의 비즈니스 로직 으로 명확하게 구분 짓는 것이 더욱 큰 이유라고 생각했습니다.
또한, MVC 패턴에서 Unit - Test를 작성하는 것은 거의 불가능하다고 할 수 있습니다. 저희는 비즈니스 로직을 테스트하기 위해서는 스스로 책임을 가지는 객체로 구성하는 것이 이점이 되기 때문입니다.
MVVM 구현
그렇다면, MVVM을 구현하기 위해서는 어떻게 해야할까요? 저희는 Binding이라는 작업을 통해서 MVVM을 구현해야 한다고 합니다. Binding이라는 작업은 도대체 무엇일까요?
컴퓨터 프로그래밍에서 데이터 바인딩(data binding)은 제공자와 소비자로부터 데이터 원본을 결합시켜 이것들을 동기화하는 기법이다
code 레벨로 설명을 하자면, ViewModel에서 생성되는 데이터를 View에다가 동기화시키는 것을 이야기합니다. RxSwift와 같은 라이브러리를 쓰는 것만 데이터 바인딩을 구현할 수 있는 것은 아니면, 다음은 이에 대한 예시들입니다.
- Delegate 패턴
- Closure 활용
- Custom Observable 타입 구현
- NotificationCenter 사용하기
이런 방법을 지원하기 위해서 애플에서 제공하는 프레임워크가 Combine입니다. 프로젝트에서 Rx를 활용하였기 때문에 아래 예제들은 Rx를 활용해서 설명하겠습니다.
일반적인 구현
class MainTabBarController: UIViewController {
// 뷰 생성 함수 생략
private let imageView: UIImageView
private let loadButton: UIButton
private let cancelButton: UIButton
private let viewModel = MainViewModel()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
button.rx.tap
.asObservable()
.bind(to: viewModel.buttonTappedEvent)
.disposed(by: disposeBag)
cancelButton.rx.tap
.map { return nil }
.bind(to: imageView.rx.image)
.disposed(by: disposeBag)
viewModel.imageName
.map { UIImage(systemName: $0) }
.bind(to: imageView.rx.image)
.disposed(by: disposeBag)
}
// 레이아웃 메서드 중략
func configureUI() { }
}
class MainViewModel {
var buttonTappedEvent = PublishSubject<Void>()
var imageName = PublishSubject<String>()
private var disposeBag = DisposeBag()
init() {
buttonTappedEvent.debug()
.map { _ in "person" }
.bind(to: imageName)
.disposed(by: disposeBag)
}
}
보통 다음과 같이 ViewModel 바인딩 프로퍼티에 View에서 발생하는 이벤트를 직접 보내는 방법을 많이 활용하게 된다. 또한, ViewModel에서 발생하는 이벤트를 View에서 직접적으로 바로보는 형태를 가지게 된다. 이런 방식을 프로젝트 초기에 활용하였는데, 개발을 하면서 계속해서 변수를 생성하고, 이벤트의 흐름을 파악하기 힘들다는 문제가 발생하였습니다. 그래서 다른 방식으로 바인딩을 구현할 수 있는 방법에 대해서 고민하였고, 팀원들과 함께 학습하던 과정 중, Input / Output 패턴을 활용하는 방식으로 개선했습니다.
Input / Output 패턴
Kickstarter에서 사용하는 Input / Output 타입을 구현하여서 ViewModel과 View의 이벤트가 전달될 수 있는 통로를 하나로 제한하는 방법입니다.
class MainTabBarController: UIViewController {
private let imageView: UIImageView
private let button: UIButton
private let cancelButton: UIButton
private let viewModel = MainViewModel()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
let input = MainViewModel.Input(
buttonTappedEvent: button.rx.tap.asObservable()
)
let output = viewModel.transform(with: input)
output.imageNameEvent
.map { UIImage(systemName: $0) }
.bind(to: imageView.rx.image)
.disposed(by: disposeBag)
}
func configureUI() { }
}
protocol ViewModel: AnyObject {
associatedtype Input
associatedtype Output
func transform(with input: Input) -> Output
}
class MainViewModel: ViewModel {
struct Input {
var buttonTappedEvent: Observable<Void>
}
struct Output {
var imageNameEvent: Observable<String>
}
private var disposeBag = DisposeBag()
func transform(with input: Input) -> Output {
let imageName = input.buttonTappedEvent
.map { _ in return "person" }
.asObservable()
return Output(imageNameEvent: imageName)
}
}
다음과 같이 바인딩을 구현하게 되면, 뷰모델로 들어오는 이벤트를 모아서 Input 타입으로 받고, Output타입으로 묶어서 나갈 수 있게 됩니다. 이 방법을 활용하는 것은 ViewModel을 테스트할 때에도 편하게 느껴졌습니다. 절대로 코드를 구현하는 방법에 정답이 있는 것은 아니기 때문에 편한 방법으로 활용하시면 될 것 같습니다.
저는 이벤트를 묶어서 제한하는 방식이 코드를 읽거나 개발을 진행하는 생산성을 증가시킬 수 있다고 생각하였습니다. 이런 단방향 바인딩을 구현하는 다양한 라이브러리도 존재하고 있기 때문에 추후에는 이것들도 블로그 포스팅을 해보겠습니다.