[iOS][DesignPattern] MVVM 과 DataBinding에 대한 간략한 소개

Uno·2021년 12월 16일
8

iOS 디자인패턴

목록 보기
5/5

MVVM을 공부하게되는 여러 가지 이유들


iOS를 배우시고, 프로젝트를 구성하다보면 이런 생각을 하신 적이 있으실 겁니다.

“ViewController에 역할도 너무 많고 코드도 너무 많은데… 이거 어떻게좀 할 수 없을까?”

특정 객체에 너무 많은 역할이 있다면, 코드가독성이 떨어지고, 협업을 방해합니다.
여기서 협업은 다른 개발자와 함께 일하는 것을 넘어서,
과거의 자신과 협업이 안됩니다.(제가 그랬음..)
대충 아래와 같은 상황이지 않을까요?

과거의 나 : 이렇게 작성하면 되겠다. 완벽해!^_^
현재의 나 : 아니,,,, 이건 왜 여기에 썼더라..
		 이거 어디서 호출했지?? ,,, 이거 누가짠코드야 ㅡㅡ?

해외에서는 이걸 ‘Massive View Controller’ 라고 칭하곤 합니다. 여기서 “Massive” 는 역할이 너무 많고, 동시에 코드가 너무 많다 라는 의미입니다. 이 문제에 대한 대안으로 주로 언급되는 것들이 아래에 있는 아키텍처 패턴입니다.

  • VIPER
  • MVVM
  • VIP

그 중에서 MVVM에 대해서 알아보고자 이 글을 작성했습니다.

MVVM에 대하여


MVVM이 무엇인가 보면, 아래와 같습니다.

“Model - View - ViewModel” 로 구성된 아키텍쳐 패턴입니다.

처음 보신 분들이라면, 바로 질문이 생기겠죠.

Model, View 그리고 ViewModel은 뭔데?

  • View
    View는 말 그대로 “보여주는” 친구입니다. 화면을 보여주죠.

  • ViewModel
    ViewModel은 “보여주기 위한 data를 관리하는” 친구 입니다.

  • Model
    Model은 데이터들의 형상 그 자체 입니다.

    iOS 로 프로젝트를 구성하시면, (UIKit을 전제로 작성합니다.) ViewController 가 있죠?

MVVM의 역할을 현재 MVC 패턴으로 구성된 프로젝트에서의 떼어내보면 다음과 같습니다.

  • View -> ViewController ( ViewController 안에 있는 View)
  • ViewModel -> ViewController의 로직에 해당하는 부분
  • Model -> 데이터 구조체 (Struct)

View와 ViewModel이 겹치고 있죠?

ViewModel은 “연산” 만 해주면 됩니다.
View는 “보여” 주기만 하면 됩니다.

현재 MVC 패턴으로 구성된 프로젝트는 연산과 동시에 보여주죠.

이런 의문이 들 수도 있으실 것 같습니다.

아니, 그냥 한 곳에서 다하면 안되요? 뭐하러 나눠요? 귀찮게

귀찮은 것을 기준으로 하면 나눌 필요가 없을 수도 있겠지만, 가정을 하나 해봅시다.

화면에 10개의 데이터를 보여줘야하는데, 어디서 문제가 생겼는지, 아예 안보이거나, 다보이거나 일부만 보이는 상태입니다.

그러면 우리는 “디버깅” 을 통해서 문제를 진단하고 문제를 해결하겠죠.

만약 현재의 상태라고 하면 코드 읽는데 시간이 더 걸릴 가능성이 있습니다.
(상대적으로) 왜냐하면, 로직과 동시에 화면을 보여주고 있다보니, “보여주는” 곳의 코드와 “연산하는” 곳의 코드를 직접 매번 디버깅할 떄마다 확인해야하니까요.

만약 MVVM으로 구성되어 있어서 ViewModel에서 모든 연산처리를 하고 View에서 보여주기만 한다면? 벌써 신나죠? 연산하는 곳만 먼저 봅니다. ViewModel 이겠죠. ViewModel에서 연산이 잘못된건지 먼저 확인하고, 콘솔로 보니 ViewModel의 연산은 정상입니다. 그러면 이제 범인은 한 명이죠. 바로 View입니다. View만 보면 됩니다.

이런식으로, 로직이 구분됨에 따라서, 디버깅이 용이해질 겁니다.디버깅 뿐만아니라 많은 사람이 이러한 약속을 통해 코드를 구성한다면, 다른사람이 작성한 코드를 보더라도, 커뮤니케이션 비용이 줄어들겠죠.

여기까지 들으면, MVVM is the best 라고 생각하기 쉽습니다. 세상에 공짜는 없죠.^^
단점도 있습니다.

  • 화면이 상대적으로 단순하다면, MVC로 구성하는 것 보다 MVVM으로 구성하는 수고가 더 든다.
  • 데이터 바인딩에 대한 수고가 든다.
  • MVVM은 유명하지만, 사람마다 생각하는 MVVM이 다르다. 이로 인해 미스커뮤니케이션이 발생할 우려가 있다.

이러한 단점보다 프로젝트가 복잡하다면, 그때가 바로 “MVVM” 을 적용할 때라고 전 생각합니다.
(혹은 복잡해질 “우려” 가 있다면)

MVVM의 구성


MVVM의 구조에 대해서 먼저 알아볼게요.

먼저, 각 객체를 누가 소유하고 있는지만 보겠습니다.

View는 ViewModel(이하 VM)을 소유하고 있습니다. VM은 Model을 소유하고 있습니다.

class ViewController: UIViewController {
	private let viewModel = ViewModel()
}

-> View가 ViewModel을 소유하고 있는 모습입니다.

class ViewModel {
	private var storage: [Model]
}

-> ViewModel이 Model을 소유하고 있는 모습입니다.

struct Model {
	var name: String
	var postCode: Int
}

-> Model 입니다.

자, 그러면, 데이터가 변경되면 어떤 순서로 데이터가 이동하는지 살펴보겠습니다.
(made by uno)

간단하죠? Model이 바뀌고 ViewModel이 바뀌고, View가 바뀝니다.
현실사례를 생각해볼까요?

주식을 10만원에 샀습니다.(ㅅㅅㅈㅈ, 너무비싸게샀나..?)

struct 삼성전자주식 {
	var name: String
	var price: Int
}

Model은 위와 같습니다.

그런데 1 주당 가격이 5만원이 되었습니다. 한강 is my second home

그런데, 만약 UI가 업데이트 안된다면 어떨까요?
계속 10만원에 사겠죠? 혹은 손절매하시는 분들이 손절을 안하겠죠. 그러므로 데이터 변화에 맞게 UI를 변경해주어야 합니다.

하지만 View는 Model과 소통할 수 없습니다. 이유는 소유관계가 성립되지 않았죠. 중간에 ViewModel이 있습니다. ViewModel을 거쳐서 View로 데이터가 갈겁니다. 그냥 ViewModel이 전달만 하는 것이 아니라, 외국인 사용자 앱이라면 “달러”로 환산해서 보여줘야할 겁니다. 한국 사용자라면 당연히 “원” 이겠죠. 혹은, 서버에서 받아온 데이터들을 한번 가공해준 후 View에 보여줄겁니다. 당장 index 관련 연산을 해줘야겠죠. 프로그래밍에서는 0부터 시작이지만, 현실에서는 1 부터 시작하는게 보통이니까요. 이처럼 ViewModel의 연산된 결과를 View에서 보여주는 겁니다.

그래서 위 다이어그램(손그림) 에서
1. 데이터 변경 알림 == 주식가격떨어짐
2. UI 업데이트 해! == 가격 떨어진 것에 맞게 데이터를 보여줘

로 1:1 대응이 됩니다.

여기서 한 걸음 더 나아가보겠습니다.

데이터가 변경될때마다, 내가 저걸 다 연결해줘야해? 데이터 변경되면 알아서 UI가 딱딱 변경되도록 무언가 연결해놓으면(Binding) 편하지 않을까?

위와 같은 생각이 드실겁니다.그렇다고해주세요. 제발

그래서 MVVM을 구성하고 데이터를 바인딩을 통해서 MVVM을 만들어 보겠습니다.

“Observables” 클래스를 이용한 데이터 바인딩


앞으로 작성할 방법이 가장 대중적으로 사용되는 방법입니다.
라이브러리 중 “Bond” 라는 라이브러리에서도 아래처럼 구성되어 있죠.

("Bond" 라이브러리 보시면, 정말 편리하게 데이터바인딩을 할 수 있습니다. 코드한 번 해체해보시는걸 추천드려요~!)

자 그러면, 이제 본격적으로 시작해볼게요.

1. Observables 정의

“Observables” 이라는 클래스를 선언하고, 해당 클래스를 서브클래싱할겁니다. 그를 통해서 우리가 하고자하는 “데이터 바인딩” 을 할 예정입니다. Observable을 다음과 같이 정의합니다.

class Observable<T> {
    private var listener: ((T) -> Void)?
    
    var value: T {
        didSet {
            listener?(value)
        }
    }
    
    init(_ value: T) {
        self.value = value
    }
    
    func bind(_ closure: @escaping (T) -> Void) {
        closure(value)
        listener = closure
    }   
}

코드에 주석을 좀 달아서, 설명을 대체하겠습니다.

class Observable<T> {
    // 3) 호출되면, 2번에서 받은 값을 전달한다.
    private var listener: ((T) -> Void)?
    
    // 2) 값이 set되면, listener에 해당 값을 전달한다,
    var value: T {
        didSet {
            listener?(value)
        }
    }
    
    // 1) 초기화함수를 통해서 값을 입력받고, 그 값을 "value"에 저장한다.
    init(_ value: T) {
        self.value = value
    }
    
    // 4) 다른 곳에서 bind라는 메소드를 호출하게 되면, 
    // value에 저장했떤 값을 전달해주고,
    // 전달받은 "closure" 표현식을 listener에 할당한다.
    func bind(_ closure: @escaping (T) -> Void) {
        closure(value)
        listener = closure
    }
}

2. ViewModel Protocol 정의

프로토콜을 정의해서, 어떻게 ViewModel 들을 구성할 건지, 구상해볼게요.

  • ViewModel이니까 데이터를 가져와야겠죠? -> fetchData()
  • 에러가 발생하면 에러에 대한 세팅을 해둬야겠죠?-> setError(_ message: String)
  • 받아온 데이터를 저장하는 프로퍼티가 필요할겁니다. -> storage
  • 에러 메세지를 따로 저장해두겠습니다. -> errorMessage
  • 에러의 여부를 판단할 불리언 프로퍼티도 필요할겁니다. -> error

이렇게 구상한 걸 프로토콜로 작성하면 다음과 같습니다.

protocol ObservableVMProtocol {
    associatedtype T
    
    // 데이터를 가져옵니다.
    func fetchData()
    
    // 에러를 처리합니다.
    func setError(_ message: String)
    
    // 원본데이터
    var storage: Observable<[T]> { get set }
    
    // 에러 메세지
    var errorMessage: Observable<String?> { get set }
    
    // 에러
    var error: Observable<Bool> { get set }
}

3) 프로토콜을 채택한 ViewModel을 생성

프로토콜을 정의했으니 해당 프로토콜을 채택하여 VM을 구성해볼게요.

모델에 대한 타입 선언이 필요해서 간단하게 만들었습니다.

struct Stock {
    var name: String
    var price: Int
}

그리고 채택한 후, 자동완성하면 다음과 같은 코드가 될겁니다.

class ObservableViewModel: ObservableVMProtocol {
    typealias T = Stock
    
    func fetchData() {
        //
    }
    
    func setError(_ message: String) {
        //
    }
    
    var storage: Observable<[Stock]>
    
    var errorMessage: Observable<String?>
    
    var error: Observable<Bool>
    
}

이제 값을 적당히 초기화해둘게요.

class ObservableViewModel: ObservableVMProtocol {
    typealias T = Stock
    
    func fetchData() {
        //
    }
    
    func setError(_ message: String) {
        //
    }
    
    var storage: Observable<[Stock]> = Observable([])
    
    var errorMessage: Observable<String?> = Observable(nil)
    
    var error: Observable<Bool> = Observable(false)
}

제가 참고한 자료에서는 API 통신을 하고 있습니다만, 저는 전역변수로 dummydata를 통해서 진행하도록 하겠습니다.

let exampleStock1 = Stock(name: "삼성전자", price: 100000)
let exampleStock2 = Stock(name: "펄어비스", price: 130000)
let exampleStock3 = Stock(name: "NC소프트", price: 700000)
let exampleStock4 = Stock(name: "삼성화재", price: 200000)
let exampleStock5 = Stock(name: "진에어", price: 16000)
let exampleStock6 = Stock(name: "대한항공", price: 27000)

let allStocks = [exampleStock1,
                 exampleStock2,
                 exampleStock3,
                 exampleStock4,
                 exampleStock5,
                 exampleStock6]

(여기있는 데이터들이 서버에서 받아온 데이터들을 대체합니다.)

UI는 다음과 같이 구성했습니다.

  • ViewController.swift

  • TableViewCell.swift

이제 데이터를 받아오는 로직을 추가해볼게요.

Repository 라는 이름으로 class를 만들고 Dummy Data 를 가져올게요. 실제 프로젝트라면 이곳에서 Alamofire 라이브러리나 URLSession을 직접 구성해서 Http 통신을 해서 서버에서 데이터를 가져올 겁니다.
Alamofire GitHub Link : GitHub - Alamofire/Alamofire: Elegant HTTP Networking in Swift

전 더미데이터를 가져오도록 Repository를 구성해볼게요.

  • Repository.swift
class Repository {
    /* GET 메소드를 통한 API 통신(가정) */
    func getData(onCompleted: @escaping ([Stock]) -> Void) {
		  /* 만약 통신을 한다면 통신을 모두 하고 난 이후 */
	      /* 이스케이핑 클로저를 통해 값을 전달한다. */
        onCompleted(allStocks)
    }
}

이렇게 되면, 통신을 모두 마친 뒤에 [Stock] 타입의 데이터를 전달받을 수 있겠죠.

다시 “ObservableViewModel” 의 “fetchData” 로 가서 Repository의 메소드를 호출해보겠습니다.

    /* 데이터를 받아옵니다. */
    func fetchData() {
        repository.getData { response in
            let observable = Observable(response)
            self.storage = observable
        }
    }

자, 이제 데이터 받아오는 건 끝났네요. 그리고 Storage까지 전달해줬습니다. 이제 Binding을 해줘야 겠습니다.
ViewController로 이동해볼게요.
전체코드중에서
“/ 여기서 바인딩하고 있습니다 /“ 라고 주석 적힌 부분을 집중적으로 보시면 됩니다.

import UIKit

// MARK: Cell ID
private let cellID = "TableViewCell"

class ViewController: UIViewController {
    // MARK: - Properties
    let viewModel = ObservableViewModel()
    @IBOutlet weak var tableView: UITableView!
    
    
    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupData()
        setupTableView()
    }

    
    // MARK: - Actions
    @IBAction func shuffleButtonDidTap(_ sender: UIButton) {
        viewModel.storage.value.shuffle()
    }
    
    
    // MARK: - Helpers
    private func setupData() {
        viewModel.fetchData()
    }
    
    private func setupTableView() {
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(UINib(nibName: cellID, bundle: nil),
                           forCellReuseIdentifier: cellID)
        setupBinding()
    }
    /* 여기서 바인딩하고 있습니다 */
    private func setupBinding() {
		  /* storage의 값이 변경되면 reloadData를 실행합니다 */
        viewModel.storage.bind { [weak self] _ in
            guard let self = self else { return }
            self.tableView.reloadData()
        }

        /* Error Handling */
        let message = "에러 발생"
        viewModel.errorMessage = Observable(message)
        
        viewModel.error.bind { isSuccess in
            if isSuccess {
                print("DEBUG: success")
            } else {
                print("DEBUG: \(self.viewModel.errorMessage)")
            }
        }
    }
}

extension ViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.storage.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) as? TableViewCell else { return UITableViewCell() }
        cell.updateUI(cellData: viewModel.storage.value[indexPath.row])
        return cell
    }   
}

추가로 shuffleButtonDidTap(_ sender:) 메소드를 보면, 데이터를 셔플하고 있죠. 그리고 저는 reloadData나 어떤 UI 업데이트 코드를 작성하지 않았습니다. 그럼에도 불구하고 UI가 업데이트 되고 있죠.

소스코드는 git에 올려두겠습니다~!
https://github.com/kipsong133/MVVM_Example

정리

  • Observable Class 를 생성한다.
  • (생략가능) 프로토콜을 통해 ViewModel을 어떻게 구성할지 선언한다.
  • 프로토콜을 채택한 ViewModel을 생성하고, 프로토콜의 메소드와 프로퍼티를 구성한다.
  • 구성한 ViewModel과 View를 Controller에서 Binding한다. (이 때, 특정 데이터가 변경되면, 원하는 로직을 처리하도록 Binding 해야한다.)

만약 MVVM과 DataBinding을 처음 접하시는 분이면,

이거 왜 하는거야?
혹은

이거 이해하는게 더어려운데?

라고 느끼실 수도 있습니다.

다만, 무엇이 어려운지는 한 번 고민해보시면 좋을 것 같아요.
1. protocol 사용이 어색하다.
2. didSet 과 같은 프로퍼티 옵저버가 생소하다.
3. UI 짜는 것이 어색하다.
4. 클로져에 대한 활용을 잘 못하겠다.

중 하나 이실 수 있습니다. 위 개념들을 보고난 뒤에는 정말 좋은 도구를 갖추는 것이니 DataBinding은 한번쯤 실습해보시는걸 추천드립니다~!

끝까지 읽어주셔서 감사합니다^^

참고자료


profile
iOS & Flutter

2개의 댓글

comment-user-thumbnail
2023년 1월 9일

정말 좋은 설명 잘 보았습니다. 이해가 잘 안가서 깃허브로 좀 뜯어보려고 했는데 깃허브 파일이 비어있네요 ㅠㅠ

답글 달기
comment-user-thumbnail
2023년 10월 20일

좋은 설명 감사합니다! 참고 자료도 너무 유익하네요!

답글 달기