호댕의 iOS 개발

[디자인 패턴] MVVM 알아보기 본문

Software Engineering/iOS

[디자인 패턴] MVVM 알아보기

호르댕댕댕 2022. 3. 18. 17:14

최근 프로젝트를 진행하며 RxSwift, RxCocoa를 사용하게 됐다. 또한 기존 MVC 패턴을 사용해 주로 개발을 진행했지만 MVVM 디자인 패턴을 새롭게 사용해봤다. 

 

물론 아직은 부족한 점이 많지만 그래도 프로젝트를 진행하면서 알게 된 MVVM은 무엇이고 어떻게 적용을 할 수 있는지 정리해보고자 한다. 

 

🔸 그 전에 MVC 패턴부터...

일단 기존에 사용하던 MVC 패턴의 경우 Model, View, Controller로 나뉘게 된다. 

애플에서도 CocoaMVC를 기본적으로 사용했다. 

CocoaMVC

 

  • View : 사용자가 직접 볼 수 있는 객체로, 스스로 어떻게 그려지는지를 알고 있으며, 사용자의 Action에 응답할 수 있다. 
  • ViewController : 하나 이상의 View와 하나 이상의 Model을 중재하는 역할을 한다. 따라서 Model이 변경이 있을 경우 View에게 이를 전달해주며, User Action을 통해 데이터가 변경이 되어야 한다면 이를 Model에 알려주는 역할을 한다.
  • Model : 앱의 특정 데이터를 캡슐화하고, 해당 데이터를 조작하고 처리하는 방법을 정의한다. 모델 객체에 데이터를 생성하거나, 수정하는 View 단의 작업은 Controller와의 통신을 통해 이뤄진다.

 

하지만 MVC 패턴을 사용해서 개발을 진행하다 보면 ViewController가 View에 대한 비즈니스 로직을 다루고, View에 띄울 UI 요소들까지 관리를 하다보니 ViewController 자체가 비대해지는 문제를 가지고 있다. 

 

또한 단순히 ViewController가 비대해지는 것 뿐만 아니라, UI 관련 코드 또한 혼재해서 가지고 있기 때문에 ViewController에 대한 테스트도 어렵다는 문제가 있었다. 

 

이런 문제점들을 해결하기 위해 새롭게 나온 디자인 패턴이 바로 MVVM이다.

 

🔸 그럼 MVVM은 뭔데?

일단 ViewController가 너무 비대해지는 것을 방지하고, ViewModel을 따로 두어 이를 Test하기 쉽도록 하며, 역할에 따라 명확히 코드를 분리할 수 있다는 점에서 MVVM이 사용된다.

사실 그림 자체는 위에 있던 CocoaMVC 그림과 거의 구조가 동일해 보인다.

 

🔹 View

하지만 MVVM의 View에는 비즈니스 관련 로직이 아예 존재하지 않는다

 

즉, 어떤 상황에서 어떤 화면을 띄워야하는지, 터치 이벤트가 들어왔을 때 이를 어떻게 처리하는지는 View가 관리하지 않는다.

정말 단순하게 그냥 어떤 View를 화면에 띄울지만 알고 있는 것이다. 또한 View는 어떤 UserAction이 들어왔는지만 받아서 ViewModel에게 어떻게 처리하면 좋을지 넘기기만 한다. 

 

쉽게 말하면 View는 Action이 들어왔을 때 이를 어떻게 처리할 지는 전혀 모르고 단순히 ViewModel이 알려준 대로 View만 띄우는 것이다. (View가 멍청해진다고 생각할 수 있다)

 

직접 개발을 할 때에는 ViewController와 스토리보드(만약 사용한다면)가 View라고 볼 수 있다. 

 

🔹 ViewModel

ViewModel도 이전 MVC에서 ViewController와는 다르게 UI관련 내용은 담당하지 않고, View가 받은 Action을 어떻게 처리할지에 대한 비즈니스 로직을 담고 있게 된다

따라서 ViewModel에선 import UIKit이나 import RxCocoa 같은 UI를 위한 프레임워크나 라이브러리를 import하지 않아야 한다.

 

모델의 경우 기존 MVC와 차이가 없기에 따로 작성은 하지 않는다. 

 

또한 ViewModel은 View가 받은 액션을 처리하며 View를 추상화한 객체로 볼 수 있다. 

 

따라서 물론 View가 단순히 Grid 형태에서 List 형태로 바뀌는 경우에는 View : ViewModel = n : 1의 관계를 맺을 수도 있겠으나, 만약 보여주는 View는 동일한데 데이터원이 CoreData와 Firebase와 같이 다르다면 오히려 View : ViewModel = 1 : n으로 설계할 수도 있다. 

 

ViewModel의 경우 View의 추상화를 했을 때 필요한 부분이 다르다면 따로 두는게 낫다.

만약 하나의 큰 ViewModel을 만들고 다양한 View에서 이를 사용한다면, 물론 이를 전부 사용해야 하는 View가 있을 수도 있지만 일부 View의 경우 몰라도 되는 ViewModel의 정보까지 알 수도 있기 때문이다. 또한 하나의 ViewModel만 사용할 경우 ViewModel도 불필요하게 비대해지는 문제도 있을 수 있다. 

 

🔸 ViewModel을 어떻게 설계하면 좋을까?

이렇게 개념을 정리해보고 난 생각은 '그럼 어떻게 ViewModel을 설계해서 View와 상호작용할 수 있도록 할 수 있을까?' 였다.

그래서 이번 프로젝트에서 참고한 architecture는 이것이다. 

 

물론 ViewModel을 설계하는데 다양한 방법이 존재할 수 있고, 더 좋은 방법이 있을 수 있겠으나 일단 현재 해당 아키텍처를 기반으로 설계를 해서 해당 아키텍처에 대해 소개하고자 한다.

 

일단 해당 Architecture에선 Input과 Output으로 나누고 transform 메서드를 통해 Input을 등록하고 만약 Output이 필요하다면 Output을 통해 어떤 행동을 해야할지 미리 선언할 수 있도록 하는 것이다. 

 

이렇게 해서 input으로 들어올 객체에 데이터가 들어오게 되면 이에 반응해서 View에 특정 Action을 하도록 알려주는 것이다. 

이렇게 설계를 할 경우 내가 느낀 장점은 다음과 같다. 

  • View에서 직접 ViewModel의 메서드를 호출하지 않아도 된다.
  • View가 직접 모델 객체를 가지고 있지 않아도 된다. 
  • View에 비즈니스 로직을 남기지 않도록 설계할 수 있다.
  • ViewModel의 프로퍼티들을 최대한 private하게 관리할 수 있다.

 

🔹 ViewModel의 설계

일단 ViewModel을 프로토콜을 통해 추상화를 해보자면 다음과 같다. 

protocol ViewModelDescribing {
    
    associatedtype Input
    associatedtype Output
    
    func transform(_ input: Input) -> Output
    
}

 

이를 통해 일단 ViewModel을 설계해보자. 

일단 Input과 Output을 ViewModel 클래스 내부에 정의를 해주어야 한다. 

 

  • Input의 경우 어떤 Action이 발생했는지 인식하는 역할을 수행하며 만약 ViewModel에서 처리할 때 필요한 데이터가 있다면 Input을 통해 받게 된다. 
  • Output의 경우 추후 View에서 띄워야 하는 UI가 있거나 하는 경우 사용을 한다. 

 

이렇게 하면 View가 직접 비즈니스 로직을 갖고 있지 않더라도, ViewModel이 구조화되어 액션에 대한 처리를 할 수 있게 된다. 

 

하지만 여기서 MVVM의 단점도 찾을 수 있는데 모든 Action에 대한 비즈니스 로직을 ViewModel이 처리를 해야하다 보니 ViewModel이 비대해질 수 있고, 너무 많은 Input과 Output이 생길 수 있다는 점이다. 

 

private enum Content {
    
    static let editTitle = "Edit"
    static let doneTitle = "Done"
    
}

class WorkFormViewModel: ViewModelDescribing {
    
    final class Input {
        
        let viewDidLoadObserver: Observable<Void>
        
        init(viewDidLoadObserver: Observable<Void>) {
            self.viewDidLoadObserver = viewDidLoadObserver
        }
        
    }
    
    final class Output {
    
        let showRightBarButtonItemObserver: Observable<String>
        
        init(showRightBarButtonItemObserver: Observable<String>) {
            self.showRightBarButtonItemObserver = showRightBarButtonItemObserver
        }
        
    }
    
    private(set) var selectedWork: Work?
    private(set) var workMemoryManager: WorkMemoryManager!
    private var list = BehaviorSubject<[Work]>(value: [])
    private let disposeBag = DisposeBag()
    
    func setup(selectedWork: Work?, list: BehaviorSubject<[Work]>, workMemoryManager: WorkMemoryManager) {
        self.selectedWork = selectedWork
        self.list = list
        self.workMemoryManager = workMemoryManager
    }
    
    func transform(_ input: Input) -> Output {
        let showRightBarButtonItemObserver = PublishSubject<String>()
        
        configureViewDidLoadObserver(by: input, observer: showRightBarButtonItemObserver)
        
        let output = Output(
            showRightBarButtonItemObserver: showRightBarButtonItemObserver
        )
        
        return output
    }
    
    private func configureViewDidLoadObserver(by input: Input, observer: PublishSubject<String>) {
        input
            .viewDidLoadObserver
            .bind(onNext: { [weak self] in
                if self?.selectedWork == nil {
                    observer.onNext(Content.doneTitle)
                } else {
                    observer.onNext(Content.editTitle)
                }
            })
            .disposed(by: disposeBag)
    }
    
}

만약 ViewDidLoad 시점에서 선택된 셀(작업)이 없을 경우 RightBarButton을 Done으로 두고, 선택된 셀이 있을 경우 Edit으로 바꿔야 하는 로직이 있다고 생각을 해보자. 

 

이 또한 비즈니스 로직이기 때문에 이를 View가 직접 처리하는 것은 적합하지 않다.  따라서 일단 ViewDidLoad가 되었는지를 ViewModel이 알아야 하기 때문에, Input으로 viewDidLoadObserver를 두었다.

 

이땐 단순히 viewDidload가 되었다는 것만 알면 되기 때문에 Observable<Void>로 어떤 값도 받아오지 않았지만, 만약 Input에 데이터를 받아와서 해당 데이터를 처리해야 한다면 Observable<받아야하는 데이터의 타입>이렇게 작성해주고 View에서 데이터를 받을 수도 있다.

 

 

또한 이에 대한 output으로 화면에 바뀐 Title의 RightBarButton을 보여줘야 하기 때문에 showRightBarButtonObserver를 두었다. 

 

이땐 String을 데이터로 내보내야 하기 때문에 Observable<String>으로 타입을 작성해주었다. 

그럼 View에서 Input으로 값을 넣어주고, Output을 subscribe하여 해당 로직에 대한 처리를 해주면 된다. 

 

 

❓왜 Input과 Output을 Observable로 선언했을까?

그 전에 Input과 Output을 굳이 Observable로 선언한 이유에 대해 살펴보자. 

 

PublishSubjectBehaviorSubject같은 Subject 타입을 사용하면 값을 외부에서 넣을 수 있기 때문이다. 따라서 외부에서 ViewModel의 값을 마음대로 변경할 수 없도록 하기 위해, Notification을 받을 순 있지만 외부에서 값을 주입받을 수는 없는 Observable 타입을 사용했다. 

 

 

그럼 이렇게 설계한 ViewModel과 View를 상호작용할 수 있도록 하는 메서드가 바로 transform(input:) -> Output이다.

    func transform(_ input: Input) -> Output {
        let showRightBarButtonItemObserver = PublishSubject<String>()
        
        configureViewDidLoadObserver(by: input, observer: showRightBarButtonItemObserver)
        
        let output = Output(
            showRightBarButtonItemObserver: showRightBarButtonItemObserver
        )
        
        return output
    }

 

외부에선 값을 주입받을 수 없지만, ViewModel에서는 값을 전달받을 수 있어야 하기 때문에 showRightBarButtonItemObserverPublishSubject로 선언했다.

 

여기서 configureViewDidLoadObserver 메서드에서 외부에서 input을 받아 처리를 해주고, output이 있다면 output에 해당하는 observer에 데이터를 넣어서 output을 정의하고 이 output을 return하는 구조이다. 

 

🔹 그럼 View에서 어떻게 input에 값을 넣어주고 output을 사용할까?

final class WorkFormViewController: UIViewController {
    
    @IBOutlet weak private var rightBarButtonItem: UIBarButtonItem!
    
    private let viewDidLoadObserver: PublishSubject<Void> = .init()
    private var disposeBag = DisposeBag()
    private var viewModel = WorkFormViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        viewDidLoadObserver.onNext(())
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        bind()
    }
    
    private func bind() {
        let input = WorkFormViewModel.Input(
            viewDidLoadObserver: viewDidLoadObserver.asObservable()
        )
        
        let output = viewModel.transform(input)
        
        configureShowRightBarButtonItemObserver(output)
    }
    
    private func configureShowRightBarButtonItemObserver(_ output: WorkFormViewModel.Output) {
        output
            .showRightBarButtonItemObserver
            .subscribe(onNext: { [weak self] in
                self?.rightBarButtonItem.title = $0
            })
            .disposed(by: disposeBag)
    }
}

bind

이는 viewModel로 넘겨줄 input의 인스턴스를 생성하고, viewModel의 transform 메서드를 사용해 input에 대한 처리를 ViewModel에 맡기고 output을 통해 View에 보여줄 데이터를 받기 위한 메서드이다. 

 

일단 View가 init되는 시점에서 bind를 해주어야 한다.

(현재는 스토리보드를 사용해서 required init(coder:)에서 bind 메서드를 사용했다)

 

이렇게 bind를 해주고 추후 ViewModel에 작성한 비즈니스로직이 필요한 Action을 했을 때 정의해놓은 input 프로퍼티에 값을 전달하여 ViewModel에서 이를 캐치하고 동작할 수 있도록 하는 것이다. 

 

초기화를 할 때 바인드를 해주어 추후 특정 액션이 있을 경우 동작을 할 수 있도록 미리 기반을 마련해두는 것이다.

 

이를 도식화해서 살펴보면 다음과 같다. 

 

이렇게 구현을 할 경우 ViewModel에는 UI 관련 코드가 없기 때문에 테스트가 용이해지며, ViewModel은 비즈니스 로직 / View는 UI만 다루게 되어 역할도 명확하게 분리가 된다는 장점이 생기게 된다. 

 


MVVM을 처음 프로젝트에 적용시켜보며 공부한 내용을 정리해보았다. 

예제 코드 대부분에서 RxCocoa에 있는 Driver 타입을 사용했던데 최대한 기본적인 Rx Operator와 타입들을 사용해서 적용시켜봤다.

 

나처럼 MVVM을 처음 다루며 막막한 사람들에게 도움이 되면 좋겠다 🙏

Comments