iOS/Swift

Design Patterns: MVVM, MVVM-C, MVP, Clean Architecture

hyunjicraft 2025. 1. 7. 23:55

디자인 패턴은 앱의 규모와 요구사항에 따라 선택할  있으며단순한 앱에서는 MVP MVVM, 복잡한 네비게이션이 포함된 앱에서는 MVVM-C 선호할  있다. 유지 보수를 위해 가독성이 좋고 의존성을 줄이고자 사용하는 것이기 때문에 누구나 알기 쉽게 작성하는 데 중점을 두어야 하고, 디자인 패턴을 위해 코드가 복잡해지는 것은 바람직하지 않다.

참고로 Apple에서 권장하는 디자인패턴은 Swift는 MVC, SwiftUI는 MVVM이라고 한다.

 

아주 간단한 카운터 앱

 

1. MVP (Model-View-Presenter)

ModelViewPresenter로 구성된 디자인 패턴으로, View와 Model을 분리하여 코드의 유지보수성을 높이는 구조이다.

  • Model: 애플리케이션의 비즈니스 로직과 데이터 관리 부분을 담당한다. 데이터베이스와의 상호작용 또는 외부 API 호출 등을 처리한다.
  • View: 사용자 인터페이스(UI)와 관련된 부분이다. 사용자가 볼 수 있는 화면 요소들을 나타내며, 사용자와의 상호작용을 처리한다.
  • PresenterView와 Model을 연결하는 역할을 한다. Presenter는 View에 데이터를 전달하고, 사용자의 액션을 Model에 전달하여 결과를 처리한 후 다시 View에 반영한다.

장점:

  • View와 Model의 의존성이 줄어든다. 즉, UI와 비즈니스 로직을 명확하게 분리할 수 있다.
  • Presenter는 UI와 독립적이기 때문에 유닛 테스트가 용이하다.

단점:

  • 코드가 다소 복잡해질 수 있으며, Presenter가 너무 많은 책임을 지게 되어 관리하기 어려울 수 있다.

Counter.swift

/// Counter는 간단한 변수 하나만을 가지고 있지만 일반적으로
/// MVP의 Model은 데이터베이스와의 상호작용 또는 외부 API 호출을 맡는다.
struct Counter {
    var count: Int = 0
}

 

CounterView.swift

/// View update Protocol
protocol CounterView: AnyObject {
    func updateCounterLabel(with text: String)
}

 

CounterPresenterMVP.swift

import UIKit

/// View와 Model간 중개 역할을 하는 Presenter.
class CounterPresenterMVP {
    private var model: Counter
    weak var view: CounterView?

    init(model: Counter, view: CounterView) {
        self.model = model
        self.view = view
    }

    func incrementCounter() {
        model.count += 1
        updateView()
    }

    func getCurrentCounterValue() {
        updateView()
    }

    private func updateView() {
        view?.updateCounterLabel(with: "Count: \(model.count)")
    }
}

 

CounterViewControllerMVP.swift

import UIKit

class CounterViewControllerMVP: UIViewController {
	...
    private var presenter: CounterPresenterMVP?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        
        // ViewController는 Model을 알지 못한다.
        presenter = CounterPresenterMVP(model: Counter(), view: self)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        presenter?.getCurrentCounterValue()
    }
    ...
}

extension CounterViewControllerMVP : CounterView {
    func updateCounterLabel(with text: String) {
        label.text = text
    }
}

2. MVVM (Model-View-ViewModel)

MVVM은 ModelViewViewModel을 사용하여 UI와 비즈니스 로직을 분리하는 아키텍처이다. 특히, 데이터 바인딩을 활용하여 View와 ViewModel 간의 연결을 자동화할 수 있다.

  • Model: 데이터와 비즈니스 로직을 담당한다. API 호출, 데이터베이스 연동 등과 같은 작업을 처리한다.
  • View: 사용자 인터페이스(UI)로, 사용자가 보고 상호작용하는 화면이다.
  • ViewModelView와 Model을 연결하는 중간 역할을 한다. ViewModel은 View에 필요한 데이터를 Model에서 가져와 가공하여 View에 바인딩할 수 있게 전달한다. View는 ViewModel에 직접적으로 의존하지 않고, 바인딩을 통해 ViewModel을 참조한다.

장점:

  • View와 Model 간의 의존성이 완전히 분리된다.
  • 데이터 바인딩을 통해 UI 업데이트가 자동으로 이루어지기 때문에 코드가 깔끔해진다.
  • 테스트가 용이하고, 코드가 재사용 가능성이 높다.

단점:

  • 데이터 바인딩 구현이 초기에는 복잡할 수 있으며, 모든 플랫폼에서 동일하게 동작하지 않을 수 있다.
  • ViewModel이 커지면 관리가 어려워질 수 있다.

CounterModel.swift

struct CounterModel {
    var count: Int = 0
}

 

CounterViewModel.swift

class CounterViewModel {
    private var model = CounterModel()
    var countText: String {
        "Count: \(model.count)"
    }
    
    var countUpdated: (() -> Void)?
    
    func increment() {
        model.count += 1
        countUpdated?()
    }
}

 

CounterViewControllerMVVM.swift

class CounterViewControllerMVVM: UIViewController {
    private let viewModel = CounterViewModel()
    ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        bindViewModel()
        label.text = viewModel.countText
    }

    private func bindViewModel() {
        viewModel.countUpdated = { [weak self] in
            DispatchQueue.main.async {
                self?.label.text = self?.viewModel.countText
            }
        }
    }
    ...
}

 


MVP & MVVM, 왜 비슷해 보일까?

  1. iOS에서는 데이터 바인딩을 별도의 라이브러리 없이 구현하면, MVVM에서도 데이터 전달을 위해 클로저나 프로토콜을 사용할 수 있다. 이런 경우, MVP와 MVVM의 코드 구조가 매우 유사해질 수 있다.
  2. MVVM과 MVP 모두 View와 비즈니스 로직을 분리하려는 목적이 있어, 큰 그림에서 유사한 컴포넌트들이 생긴다.

MVVM과 MVP를 구분하는 방법

  1. MVVM:
    • ViewModel은 View와 독립적이어야 하며, 데이터 바인딩을 통해 간접적으로 통신.
    • Presenter와 달리 View를 전혀 참조하지 않음.
    • 클로저 또는 Reactive Programming(RxSwift, Combine)을 적극 활용.
  2. MVP:
    • Presenter는 View의 참조를 필요로 하며, View를 적극적으로 업데이트.
    • 명령형 스타일로 Presenter가 View에 데이터를 전달.

3. MVVM-C (Model-View-ViewModel-Coordinator)

MVVM-C는 MVVM 패턴을 확장한 아키텍처로, Coordinator를 추가하여 네비게이션(화면 전환) 로직을 관리한다.

  • Model: 비즈니스 로직과 데이터를 처리한다.
  • View: 사용자와의 상호작용을 처리하는 UI를 담당한다.
  • ViewModel: UI와 비즈니스 로직 간의 데이터 전송을 담당한다. View에서 필요한 데이터를 Model에서 가져와 가공하여 전달한다.
  • Coordinator: 화면 간의 네비게이션 로직을 처리한다. Coordinator는 ViewController들 간의 전환을 담당하며, ViewModel에 의존하지 않고 독립적으로 동작한다. 각 Coordinator는 하나의 화면 흐름을 책임진다.

장점:

  • 네비게이션 로직을 Coordinator로 분리하여 코드의 유지보수성이 향상된다.
  • ViewController의 책임이 줄어들고, UI 로직만 처리하게 된다.
  • 화면 전환과 같은 복잡한 UI 흐름을 명확하게 관리할 수 있다.

단점:

  • Coordinator 패턴이 복잡해질 수 있으며, 많은 화면이 포함된 앱에서는 관리가 어려워질 수 있다.
  • 추가적인 구조가 필요하므로 초기 구성시에는 더 복잡할 수 있다.

4. Clean Architecture

Clean Architecture는 Robert C. Martin(Uncle Bob)이 제시한 소프트웨어 아키텍처로, 코드의 의존성을 명확하게 구분하고 테스트 가능성, 유지보수성, 확장성을 극대화하려는 구조이다.

Clean Architecture는 4개의 계층으로 나누어진다. 각 계층은 안쪽에서 바깥쪽으로 구분하며 바깥 계층은 안쪽 계층을 알고 있지만, 반대의 경우에는 알지 못한다.

  • Entities: 비즈니스 로직과 규칙을 정의한 계층이다. 앱의 핵심적인 도메인 객체들이 이곳에 위치한다.
  • Use Cases: 앱 내 기능을 정의하는 계층이다. 각 기능은 Use Case로 모델링되며, 실제 애플리케이션의 동작을 구현한다.
  • Interface Adapters: 외부 시스템(예: 데이터베이스, API 등)과 애플리케이션 간의 데이터를 변환하는 계층이다. View와의 연결을 담당하며, 외부와의 인터페이스를 처리한다.
  • Frameworks and Drivers: 외부 라이브러리나 프레임워크, 그리고 사용자 인터페이스를 포함한 외부 시스템과의 연결을 담당하는 계층이다.

장점:

  • 각 계층이 독립적이고, 의존성이 안쪽에서 바깥쪽으로만 흐르므로 코드의 확장성이 뛰어나고 유닛테스트에 용이하다.
  • 비즈니스 로직과 UI, 외부 시스템이 분리되어 있어서 테스트가 용이하고 유지보수성이 높다.
  • 의존성 역전 원칙(DIP)을 지키므로, 외부 기술의 변화가 애플리케이션의 핵심 로직에 영향을 미치지 않는다.

단점:

  • 초기 설계가 복잡할 수 있으며, 작은 프로젝트에서는 과도한 아키텍처로 느껴질 수 있다.
  • 구현하는 데 시간이 더 걸릴 수 있으며, 계층 간의 상호작용을 관리하는 코드가 많아질 수 있다.

CounterEntity.swift

struct CounterEntity {
    var count: Int = 0
}

 

CounterUseCase.swift (Interactor)

/// 비즈니스 로직
protocol CounterUseCaseProtocol {
    func increment() -> Int
}

class CounterUseCase: CounterUseCaseProtocol {
    private var entity = CounterEntity()

    func increment() -> Int {
        entity.count += 1
        return entity.count
    }
}

 

CounterPresenter.swift

protocol CounterPresenterProtocol {
    var countText: String { get }
    func incrementCount()
}
/// UseCase를 유저 친화적인 형태로 바꾸어주는 클래스.
class CounterPresenter: CounterPresenterProtocol {
    private let useCase: CounterUseCaseProtocol
    private weak var view: CounterViewProtocol?

    private var count: Int = 0 {
        didSet {
            view?.updateCountText("Count: \(count)")
        }
    }

    init(useCase: CounterUseCaseProtocol, view: CounterViewProtocol) {
        self.useCase = useCase
        self.view = view
    }

    var countText: String {
        "Count: \(count)"
    }

    func incrementCount() {
        count = useCase.increment()
    }
}

 

블로그에 정리하려고 만든 아주 간단한 프로젝트 예제라, MVC나 MVP 정도가 적합한 것 같다. 모든 내용을 담지는 못하고 있어서 아쉽지만 아키텍쳐를 사용하는 이유는 어디까지나 유지보수를 위해서라는 점을 명심해야 한다.

프로젝트 구조