본문 바로가기

iOS

탕수육 찍먹해? 그럼 MVVM도 찍먹해.

728x90

드디어 왔다. 엠븨븨엠.

약간 나랑 전생에 콩쥐와 팥쥐였니? 혹시 내가 콩쥐였을까? 그래서 날 지금 이렇게 힘들게 하는것이야?! 

 

이제는 더 이상 물러날 곳이 없다.

무조건 이해한다. 안되면 외워.


그리고 디자인패턴의 경우, 어떤 하나의 패턴이 다른 패턴보다 우위(?)에 있다고 볼 수 없다.

즉, 아키텍처, 디자인 패턴 등에 있어서 상위호한이 되는 개념은 없고 어떤 프로젝트인지에 따라서 다르게 적용된다.

🔴 결국 각 패턴이 궁극적으로 목표하는 것은 

- 중복되는 코드를 줄이는 것

- 유지 보수가 원활한 코드를 작성하는 것

이므로 너무 틀에 박혀서 코드를 분리하거나 패턴에 의존할 필요는 없다. 


MVC

엠븨븨엠 설명하기 전에 엠븨싀 먼저하겟섬다. 

 

MVC 패턴은

Model - View - Controller 구조로, 앱의 한부분을 제어하기 위해서 MVC 패턴 하나가 사용된다. (보통 앱을 화면 단위로 나누기 때문에 하나의 화면에 MVC 구조가 하나가 있다고 생각할 수 있다.) 실제로 앱은 여러 개의 MVC 패턴으로 이루어져 있다.

 

🤔 탭바 컨트롤러도 하나의 MVC 구조를 가질까?

탭바 컨트롤러는 뷰의 일부로서 3~4개의, 별개의 MVC 구조를 갖게 된다. 즉, 각 탭을 누를 때마다 MVC가 역할을 하게 된다.

 

Model

화면과는 상관이 없으며 이 앱이 무엇인지 본질적인 데이터를 담당한다.

  • 모델은 화면과 상관이 없고 UI와 독립적인 구조로 이루어져 있다.
  • 모든 의사소통은 Controller를 통해서 전달이 된다.
  • 값이 변경되는 데이터를 모델이 갖고 있는 경우, Notification과 KVO 등을 통해서 Controller에 알린다. 

 

View

컨트롤러가 화면에 무엇을 보여주기 위해서 사용하게 되는 요소

  • 뷰는 본인이 어떤 뷰 컨트롤러에 속해 있는지 모르는 상태로 객체에 대해 알고 있는 것이 없기 때문에 미리 서술되어 있는 방식으로 컨트롤러와 소통 
    여기서 말하는 미리 서술되어 있는 방식이란?
    Target-Action / @IBAction 등을 의미 
  • 뷰는 모델에 영향을 줄 수도 있기 때문에 터치 이상으로 더 복잡한 소통 등을 해야 할 경우, 프로토콜(Delegate, DataSource)을 통해 컨트롤러에 책임을 위임한다. 

 

Controller

컨트롤러는 모델이 화면에 어떻게 표현이 될 지 이야기해주는 역할을 담당한다.

보통 UI로직/비즈니스 로직이 들어간다.

  • 항상 접근이 가능하며 모델에 대한 모든 것을 알고 있다.
  • 아울렛 변수나 인스턴스 변수를 통해 뷰에 항상 접근할 수 있다.

 

_

MVC 패턴의 경우, 위에서 역할을 통해서 알 수 있는 것처럼 결국 Controller가 하는 역할이 많아지기 때문에 Controller가 massive 해지는 경향이 있다. (컨트롤러에서 UI와 비즈니스 로직 처리를 모두 하게 될수록) 

이를 보완하기 위해서 나온 패턴이 MVVM 패턴이다.


MVVM이 뭔데.

let me introduce mvvm .. (내가 뭐라고 이 대단한 아이를 소개하겠어 .. 나? 김소깡. ㅇㅋㅇㅋ.)

 

Model - View- ViewModel 의 구조를 갖고 있는 패턴이다.

?? : Controller 어딨는데. 

소깡 : 내 마음 속 .. 은 아니고 View라고 보면 된다. 

 

Model - View- ViewModel

  • View(User-Interface와 관련된 코드)와 Model을 명확하게 분리하는 것이 목적이다.
  • Model의 모든 변화를 View가 파악하고 이를 바탕으로 UI를 업데이트 할 수 있어야 한다.
  • ViewModel을 통해서 UI와 비즈니스 로직을 분리한다.
  • MVC의 Controller 대신 ViewModel을 다루게 되며, MVC의 Controller는 View로 취급된다. 

 

Model

MVC 패턴에서의 모델과 크게 달라지지 않는다.

  • 뷰와 독립적이다.
  • 모델이 변경되었을 때 뷰 모델에게 변경된 사실을 말한다. 

 

여기서 뷰와 독립적이다 라는 말을 보다 쉽게 말하자면, 모델 파일에서 (ex. Login.swift) UIKit와 SwiftUI 등을 import 하지 않는다.

UI와 관련된 코드를 일절 작성하지 않기 때문.

 

View

MVC 패턴에서의 Controller를 View로 취급한다.

  • 모든 비즈니스 로직이 ViewModel로 들어갔기 때문에 (서로 분리했으므로) View와 ViewController에 관련된 코드가 줄어든다.
  • ViewModel을 참조하고 있다. (인스턴스 생성해서 사용한다.)
  • View는 ViewModel이 발표(Publish)한 내용을 구독(Subscribe)하고 관찰(Observe)한다. 

 

ViewModel

  • UI와 비즈니스 로직을 분리하기 위해서 사용된다. 
  • Model을 참조하고 있다.
  • View로부터 변경 상항을 받아 Model을 업데이트 하고 Model이 변경되면 View에 반영한다. (양방향으로 소통, 실시간으로 소통)
  • Interpreter의 역할을 하고 있다.
  • View에 변경 사항을 직접 알리지 않고, 무언가 변경되었다고 Publish를 한다. (-> 이를 구독하는 것이 View의 몫) 

 


여기까지가 개념인데, 프로젝트에 적용을 하면서 좀 더 알아보자.

 

MVVM에서 중요한 것은 ViewModel이다. 뷰 모델을 중간 매개체로 해서 모델과 뷰가 소통을 하기 때문인데, 즉 사용자에게서 인터랙션을 받아서 그것을 기반으로 데이터를 가공하고 이를 다시 뷰에 반영하는 과정이 뷰 모델을 통해서 이루어지기 때문이다. 

 

양방향으로 소통하기(바인딩하기) 위해서 

각 뷰 모델마다 제너릭하게 사용하기 위해서 

뷰모델을 만들기 전에 해야할 것은 Observable 클래스를 만드는 것이다. 

 

모델을 Observable로 한번 더 감싸서 은닉화/추상화하는 과정이다. 

 

import Foundation

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

 

코드를 하나씩 뜯어보자.

 

먼저 Observable로 선언할 수 있는 데이터는 제너릭이다.

어떠한 String/Int/Double 등의 값이 들어올 수도 있고, 배열, 클래스 등이 들어올 수 있다.

 

그리고 그 값이 들어오면 초기화 구문으로 값이 정해지는데 

프로퍼티 옵저버를 통해 값이 변경될 때마다 listener 클로저 구문을 실행한다. 

 

내부에 bind 메서드가 있는데 탈출 클로저를 매개변수로 받고 있고,

매개변수로 받은 클로저를 최초 한번 실행하고,

listener에 넣는다. 즉, value가 변경될 때마다 매개변수로 받은 클로저 구문이 실행된다. 

 


실습으로 로그인 화면을 만들어보자.

 

UI

UI는 크게 UITextField가 3개 존재하고, 그 아래로 UIButton이 있다고 해보자.

UI 관련 코드는 [더보기] 참고 .. (스토리보드 기반으로 했기 때문에 .. 대단한 코드는 없음 ..)

더보기
import UIKit

final class LoginViewController: UIViewController {

    // MARK: - UI Property
    
    @IBOutlet weak var nameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var emailTextField: UITextField!

    @IBOutlet weak var button: UIButton!
    
    ...
    
}

이렇게 UI 관련 코드가 있고 그 아래로 IBAction 코드가 있다.

    @IBAction func touchUpNameTextField(_ sender: UITextField) {
        guard let text = nameTextField.text else { return }
    }
    
    @IBAction func touchUpPasswordTextField(_ sender: Any) {
        guard let text = passwordTextField.text else { return }
    }
    
    @IBAction func touchUpEmailTextField(_ sender: UITextField) {
        guard let text = emailTextField.text else { return }
    }
    
    @IBAction func touchUpButton(_ sender: UIButton) {
    }

 

Model

그리고 로그인 데이터 모델은 간단하게 아래와 같다.

import Foundation

struct Login: Codable {
    var name: String
    var password: String
    var email: String
}

 

ViewModel

✅ 모델의 변경사항을 뷰모델을 통해서 뷰에게 전달해야 하고

✅ 뷰의 이벤트/인터랙션에 대해서 뷰모델을 통해서 데이터를 변경하도록

뷰모델 코드를 작성하면 된다. 

 

import Foundation
// -> UI와 별개이므로 UIKit는 들어가지 않는다.

class LoginViewModel {
    
    // MARK: - Property
    
    
    // MARK: - Method
    
    
}

뷰모델에서 작성할 부분을 크게 나누자면 모델의 데이터를 전달해야 하므로 모델을 참조할 프로퍼티가 필요할 것이고,

사용자의 이벤트/인터랙션에 대해서 뷰에서 뷰모델을 통해 데이터를 변경해야 하므로 이를 관리할 메서드들이 필요할 것이다.

 

class LoginViewModel {
    
    // MARK: - Property
    
    var loginData: Observable<Login> = Observable(Login(name: "", password: "", email: ""))
    var isValid: Observable<Bool> = Observable(false)
    
    ...
}

로그인에 사용될 모델과 유효성을 검사하기 위한 프로퍼티가 필요하다.

 

class LoginViewModel {
    
    ...
    
    // MARK: - Method
    
    func checkValidation() {
        if loginData.value.email.count >= 6 && loginData.value.password.count > 4 {
            isValid.value = true
        } else {
            isValid.value = false
        }
    }
    
    func signIn(completion: @escaping () -> ()) {
        // 조건 처리 .. 서버 통신 ..
        UserDefaults.standard.set(name.value, forKey: "name")
        
        // 화면 전환 코드 
        completion()
    }
}

유효성 검사, 조건처리, 서버 통신, 화면 전환 코드 등의 비즈니스 로직을 담을 부분들도 필요하다.

 

View Controller (= View)

그리고 위에서 UI만 만들어놓은 곳에서 뷰 모델 인스턴스를 만들어서 코드를 작성하면 되는데

 

import UIKit

final class LoginViewController: UIViewController {

    // MARK: - UI Property
    
    @IBOutlet weak var nameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var emailTextField: UITextField!

    @IBOutlet weak var button: UIButton!
    
    // MARK: - Property
    
    private var viewModel = LoginViewModel()
    
    // MARK: - Life Cycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel.isValid.bind { isValid in
            self.button.isEnabled = isValid
            self.button.backgroundColor = isValid ? .systemPink : .lightGray
        }
    }
    
    @IBAction func touchUpNameTextField(_ sender: UITextField) {
        guard let text = nameTextField.text else { return }
        viewModel.loginData.value.name = text
        
        viewModel.checkValidation()
    }
    
    @IBAction func touchUpPasswordTextField(_ sender: Any) {
        guard let text = passwordTextField.text else { return }
        viewModel.loginData.value.password = text
        
        viewModel.checkValidation()
    }
    
    @IBAction func touchUpEmailTextField(_ sender: UITextField) {
        guard let text = emailTextField.text else { return }
        viewModel.loginData.value.email = text
        
        viewModel.checkValidation()
    }
    
    @IBAction func touchUpButton(_ sender: UIButton) {
        viewModel.signIn {
            // 화면 전환
        }
    }
}

> 각 텍스트필드에서 입력값을 받은 다음에 입력값에 대해서 데이터를 갱신하고 

> 뷰모델에서 작성한 유효성 검사 비즈니스 로직을 실행해서 유효한지 확인한다.

> 만약 조건에 만족해서 버튼이 활성화 되면 뷰 모델에서 유저 디폴츠 저장, 서버 통신 등의 작업을 한 뒤에 탈출 클로저로

> 뷰 컨트롤러에서 화면 전환을 할 수 있도록 한다.

 

로그인에서 뷰 모델이 할 수 있는 비즈니스 로직에는

- 이메일/비밀번호 정규식 검사

- 유효성 검사

- 닉네임 중복 이슈

- id 제한 

등이 있다. 

 

모델과 뷰는 뷰 모델을 통해서 소통하고 뷰 모델은 UI는 관리하지 않고, 비즈니스 로직만 관리한다.
모델과 뷰가 각자의 변경 사항을 뷰 모델에게 전달하면 뷰 모델이 로직을 처리해서 변경 사항을 반영한다.