iOS MVVM 패턴에 대해서
App 개발시 간단한 앱의 경우 MVC를 사용하는 경우가 많다.
간단한 기능의 경우 로직등을 처리하는 Model이 크지 않기 때문인데 만약 기능이 추가되어 Model이 커진다면
이를 제어할 Controller로 같이 커져서 여러모로 유지/보수에 문제가 생긴다.
그래서 예전부터 조금씩 개인앱에 MVVM 패턴을 적용하고 있는데 이를 한번 정리하기위해 포스팅을 쓴다.
우선 전체코드는 아래의 Github에 올려놓았다.
https://github.com/manunite/iOS_MVVM_Example
1. MVVM
MVVM을 사진 한장으로 요약하면 아래와 같다.
기존 MVC의 경우 View를 업데이트하려면 Controller에 요청하고, Controller는 View와 Model을 모두다 보고있어서 적절한 로직으로 View를 업데이트 하였다.
사진에서도 알수있듯 Controller와 Model, View간의 의존성이 생기게 되는것을 볼수있다.
이렇게되면 각 컴포넌트들을 유닛 테스트등을 하려고 할때 저 의존성을 끊어주고 테스트를 해야하는 등의 불편한 사항이 생기게 된다.
이를 해소시키기 위해 MVVM을 사용하게 되는데
MVVM도 개인적으로 느끼기엔 개념을 정의하여도 구현 방법이 조금씩 다르기 때문에 어떤 방식이 맞다.. 라고 하기는 어려울것같다.
단적인 예로, A라는 처리 로직을 MVVM중 Model에 붙여야할지 ViewModel에 붙여야할지 난감한 경우도 종종 있는것같다.
일반적으로 데이터처리는 Model, 데이터가공은 ViewModel에 한다고 하는데 상황에 따라 달라지는 경우도 있기에 전반적인 개념만 알고
자신 상황에 맞는 ViewModel, Model을 구현해주면 될것같다.
MVVM의 핵심은 View(MVC에선 Controller정도?)와 ViewModel를 분리 시킬수있다는 점이다.
사진을보면 View(MainActivity)는 ViewModel에게 View의 변경을 요청하고 아무것도 하지않는다.
MVC의 사진을 보면 Controller는 1)Model에 데이터 갱신 요청을 하고 2)View에 그 데이터를 토대로 화면을 업데이트하라고 다시 요청을 한다.
그에 반해 MVVM에서 View는 ViewModel에게 업데이트 요청만 하고 끝나는데
이것이 가능한이유가 바로 Observer를 사용하기 때문이고, 이는 보통 iOS에서 Rx를 통해 구현한다.
MVVM의 View가 요청할때 문구를 잘보면 '이거 바꿔달래, 내가 바뀌는거 보고있을게' 라고 하는것을 볼수있는데
이를 다시 해석해보면 View는 업데이트 요청함과 동시에 자신이 요청한 값에 대해 지속적으로 보고있고,
이 덕분에 ViewModel이 굳이 View에게 업데이트 되었다고 알려주지않아도 View스스로 업데이트가 가능하다는 얘기이다.
또한 View와 ViewModel, View와 Model이 분리되었기 때문에 View와 비즈니스로직 관련(ViewModel, Model)을 독립적으로 테스트가 가능하다는 장점도 발생한다.
그렇다면 코드를 통해 간단하게 MVVM을 구현해보도록 한다.
아래 코드는 Kickstarter의 Github코드를 토대로 실제 적용중인 로직이다.
(https://github.com/kickstarter/ios-oss/tree/main/Kickstarter-iOS)
2. Model 구현
MVVM중 Model에 대해 구현해보도록 한다.
우선 Model의 경우 데이터 처리를 담당하는데 이는 일반적으로 DB값 조회, 서버에게 요청 등 View에서 요청한 데이터 자체를 갱신하는 역할을 한다.
이 예제에서는 Alamofire를 이용해 서버로부터 요청 및 응답을 받아 처리하도록 한다.
코드에 대한 설명은 주석으로 추가 해놓음.
// 서버로부터 응답받을 json의 자료구조를 Codable로 구현.
public class ReceiveData: Codable {
var current_user_url: String?
var current_user_authorizations_html_url: String?
var authorizations_url: String?
var code_search_url: String?
}
// ViewModel이 Model에게 자료 갱신을 요청할 메서드.
protocol ModelMethodProtocol {
func requestData(completion: @escaping ((ReceiveData) -> Void))
}
class Model: NSObject {
// Model자체는 싱글턴으로 구현하여 ViewModel에서 사용하도록 함.
static let shared = Model()
private override init() {
super.init()
}
}
extension Model: ModelMethodProtocol {
// 상단의 ModelMethodProtocol을 상속받아 구현한 내용으로
// api.github.com에 Http Get요청후 값을 ReceiveData의 자료구조 형태로 Decode해서 받음.
// 만약 요청이 성공했다면 해당 메서드의 파라미터로 받은 탈출클로저를 통해 ViewModel로 전달.
func requestData(completion: @escaping ((ReceiveData) -> Void)) {
let requestPath: String = "https://api.github.com"
AF.request(requestPath, method: .get).responseDecodable(of: ReceiveData.self) { (response) in
switch response.result {
case .success:
guard let info = response.value else { break }
completion(info)
case .failure:
break
}
}
}
}
3. ViewModel 구현
ViewModel은 Model이 갱신해준 값을 토대로 View에서 사용하기 적절한 값으로 가공해주는 역할을 한다.
이 예제에서는 서버로부터 받은값을 적절한 String Format으로 변경하여 View에 알려주는 역할을 하도록 한다.
// View가 호출할 메서드들을 정의한 Protocol
public protocol ViewModelInputs {
func requestData()
}
// ViewModel에 View에게 결과를 전달해줄 Binding 자료형
public protocol ViewModelOutputs {
var onReceiveData: PublishRelay<String> { get }
}
// View가 ViewModel을 호출하는건 inputs에 정의
// ViewModel이 View에게 전달하는건 outputs에 정의
public protocol ViewModelType {
var inputs: ViewModelInputs { get }
var outputs: ViewModelOutputs { get }
}
public final class ViewModel: ViewModelType {
public var inputs: ViewModelInputs { return self }
public var outputs: ViewModelOutputs { return self }
public let onReceiveData = PublishRelay<String>()
private let dataModel = Model.shared
public init() {
}
}
// View가 ViewModel을 호출하는 메서드 정의.
extension ViewModel: ViewModelInputs {
public func requestData() {
dataModel.requestData { [weak self] info in
guard let self = self else { return }
// Model이 갱신해준 데이터를 String format을 이용하여 적절한 String으로 변환후 View에게 전달.
let paramString: String = String.init(format: "현재 유저의 URL은 %@입니다.", info.current_user_url!)
self.outputs.onReceiveData.accept(paramString)
}
}
}
extension ViewModel: ViewModelOutputs {
}
4. View 구현
View에서는 ViewModel을 Binding하고, ViewModel에게 뷰를 업데이트 시켜달라고 호출하는 역할만 하면된다.
실제 View는 Binding을 하고나서 ViewModel을 일방적으로 호출만하고 별다른 Delegate등을 통해 Response를 받는동작은 없다.
이를 통하여 View와 ViewModel이 분리됨을 볼수있다.
class ViewController: UIViewController {
let disposeBag = DisposeBag()
// ViewModel 선언
let viewModel = ViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// ViewModel을 View에 바인딩
self.bindViewModel()
// ViewModel에게 View 갱신 요청 호출.
viewModel.requestData()
}
func bindViewModel() {
// ViewModel에게 View가 바인딩되어, 값 갱신이 일어나면 아래의 클로저가 호출된다.
self.viewModel.outputs.onReceiveData.asSignal().emit(onNext: {[weak self] data in
NSLog("\(data)")
}).disposed(by: disposeBag)
}
}