Today I Learn

Today I Learn(2) : MVVM 정리하기

Goniii 2025. 3. 4. 21:18

기간이 짧은 프로젝트의 초기인 만큼 디자인 패턴과 기술 스택에 대한 논의가 있었다.

MVVM 디자인 패턴에 RxSwift를 쓰기로 했었지만, 프로젝트의 규모가 크지 않고 짧은 시간 동안 Rx를 배우고 적용하기엔 시간이 부족하다고 판단했다.

결론적으로, 바인딩이 필수가 아닌 MVC 패턴으로 구현 후, 리팩토링하는 방식을 선택했다.

오늘은 MVVM 디자인 패턴에 대해 복습할 겸 정리해보려고 한다.

 

MVVM이란

MVVM(Model-View-ViewModel)은 UI와 비즈니스 로직을 분리하여 유지보수성과 테스트 용이성을 높이는 디자인 패턴이다

기존 MVC(Model-View-Controller) 패턴에서는 ViewController가 많은 일을 한다. Delegate, UI 업데이트, 네트워크 통신 등 ViewController 내에서 많은 작업을 하다보니, 코드가 길어지고 복잡해지는 문제가 발생한다

따라서, View와 관련된 로직을 제외한 네트워크 통신 및 바인딩 과정을 ViewModel로 분리하여 책임을 나누는 디자인 패턴인 것이다.

 

1. Model (모델)

  • 데이터와 관련된 로직을 담당하는 계층
  • 네트워크, 데이터베이스, API 등에서 데이터를 가져오거나 저장하는 역할
  • ViewModel에서 데이터를 가공하여 UI에 전달할 수 있도록 제공함
import Foundation

struct User: Decodable {
    let id: Int
    let name: String
    let age: Int
}

 

2. NetworkManager

import Foundation
import RxSwift

class NetworkManager {
    static let shared = NetworkManager()
    
    func fetchUserData() -> Observable<User> {
        return Observable.create { observer in
            // 네트워크 요청을 시뮬레이션 (비동기 작업)
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                let jsonString = """
                {
                    "id": 1,
                    "name": "iOS 개발자",
                    "age": 26
                }
                """
                let jsonData = jsonString.data(using: .utf8)!

                do {
                    let user = try JSONDecoder().decode(User.self, from: jsonData)
                    observer.onNext(user)
                    observer.onCompleted()
                } catch {
                    observer.onError(error)
                }
            }
            
            return Disposables.create()
        }
    }
}

 

3. ViewModel (뷰모델)

  • 비즈니스 로직을 처리하고 View에 필요한 데이터를 가공하여 제공
  • View와 Model 사이의 중간 역할을 하며, UI와 직접적인 상호작용 없이 View로 데이터를 반환함
  • View에 데이터를 전달하기 위해 바인딩(Binding) 기법을 사용
    • 바인딩: 데이터의 변경을 UI에 자동으로 반영하거나 UI의 변경을 데이터에 자동으로 반영하는 기법
      • RxSwift, Combine, Closure, Delegate, KVO 등을 활용하여 View와 데이터를 동기화
import RxSwift
import RxCocoa

class UserViewModel {
    private let disposeBag = DisposeBag()
    
    // Output: UI에서 구독할 데이터
    let userData = PublishSubject<String>()

    func fetchUserData() {
        NetworkManager.shared.fetchUserData()
            .map { user in
                // 비즈니스 로직 적용 (예: 나이에 따른 메시지 추가)
                return "👨‍💻 이름: \\(user.name), 나이: \\(user.age)세"
            }
            .observe(on: MainScheduler.instance) // UI 업데이트를 위해 메인 스레드에서 실행
            .subscribe(onNext: { [weak self] userInfo in
                self?.userData.onNext(userInfo) // View로 데이터 전달
            }, onError: { error in
                print("❌ 데이터 로드 실패:", error.localizedDescription)
            })
            .disposed(by: disposeBag)
    }
}
  • ViewModel이 NetworkManager를 통해 JSON 데이터를 가져오고 디코딩 후 비즈니스 로직을 적용함
  • ViewController에서 구독 중인, ViewModel의 userData에 데이터를 전달하여 ViewController에 알림

 

4. View(ViewController)

  • 사용자에게 UI를 보여주고 사용자 입력을 받는 계층
  • ViewModel의 데이터를 관찰하고 데이터를 사용하여 화면을 업데이트 함
  • ViewModel과 직접적으로 데이터를 주고 받음 (Model과는 직접 연결되지 않음)
import UIKit
import RxSwift
import RxCocoa

class UserViewController: UIViewController {
    private let disposeBag = DisposeBag()
    private let viewModel = UserViewModel()

    private let button: UIButton = {
        let btn = UIButton(type: .system)
        btn.setTitle("데이터 불러오기", for: .normal)
        btn.backgroundColor = .systemBlue
        btn.setTitleColor(.white, for: .normal)
        btn.layer.cornerRadius = 8
        return btn
    }()

    private let label: UILabel = {
        let lbl = UILabel()
        lbl.textAlignment = .center
        lbl.numberOfLines = 0
        return lbl
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        setupUI()
        bindViewModel()
    }
    
    private func setupUI() {
        view.addSubview(button)
        view.addSubview(label)
        
        button.translatesAutoresizingMaskIntoConstraints = false
        label.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            button.widthAnchor.constraint(equalToConstant: 150),
            button.heightAnchor.constraint(equalToConstant: 50),
            
            label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 20),
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
    }
    
    private func bindViewModel() {
        // 버튼 클릭 → ViewModel의 fetchUserData 호출
        button.rx.tap
            .bind { [weak self] in
                self?.viewModel.fetchUserData()
            }
            .disposed(by: disposeBag)
        
        // ViewModel에서 가공된 데이터를 UI에 표시
        viewModel.userData
            .bind(to: label.rx.text)
            .disposed(by: disposeBag)
    }
}
  • button의 탭을 바인딩하여 viewModel의 fetchUserData 호출
  • viewModel의 userData를 바인딩하여 label의 text를 업데이트

데이터 흐름

  1. 사용자가 ViewController에서 버튼을 탭하면 ViewModel의 fetchUserData() 실행
  2. ViewModel의 NetworkManager.fetchUserData()는 를 이용하여 네트워크 통신 후 JSON 데이터를 받아옴
  3. NetworkManager는 JSON 데이터를 디코딩 후, Model로 변환 후 fetchUserData로 리턴
  4. ViewModel.fetchUserData는 비즈니스 로직 처리 후, 변환된 데이터를 View에서 바인딩 중인 userInfo를 통해 View로 전달
  5. ViewController에서 viewModel.userData.bind(to: label.rx.text)를 통해 UI 업데이트
728x90