
MVI 패턴은 함수형 및 반응형 프로그래밍 원칙을 기반으로 하는 아키텍처로, 단방향 데이터 흐름(Unidirectional Data Flow)을 극단적으로 사용하는 패턴이다. 복잡한 비동기 상태 관리를 예측 가능하고 디버깅하기 쉽게 만드는 것이 핵심이다.
MVI 패턴은 Model - View - Intent의 3가지 요소로 구성되어 있다.
| 요소 | 역할 | 데이터 흐름 |
|---|---|---|
| Intent(의도) | 사용자의 모든 행동을 표현한다. (ex 버튼 클릭, 텍스트 입력, 스와이프 액션 등) | View → Model |
| Model(상태) | 앱의 현재 상태(State)를 나타내는 불변의 데이터 객체, Intent를 받아 새로운 상태를 생성한다. | Model → View |
| View | Model(상태)을 구독하여 UI를 렌더링하고, 사용자 상호작용을 Intent로 변환하여 Model에 전달한다. | Model의 변화를 View가 수신 |
Intent → Model → View → Intent의 순환 구조를 따른다.MVI 패턴은 RxSwift나 Combine과 같은 반응형 라이브러리를 사용해본 경험이 있다면 비교적 쉽게 흐름을 이해할 수 있다.
MVI 패턴의 핵심은 View에서 발생한 모든 행동을 Intent로 캡슐화하여 Model에 전달하고, Model이 새로운 State를 방출하면 View가 이 State를 받아 화면을 업데이트하는 순환 구조인 것이다.
앱의 현재 상태(데이터)를 정의하는 구조체로, 반드시 불변이어야 한다.
// MARK: - 1. Model (State)
struct CounterState {
let count: Int
let isLoading: Bool
let errorMessage: String?
// 초기 상태 정의
static var initial: CounterState {
return CounterState(count: 0, isLoading: false, errorMessage: nil)
}
}
사용자가 화면에서 수행하는 모든 상호작용(버튼 탭, 입력, 스와이프 등)을 열거형으로 정의한다.
// MARK: - 2. Intent
enum CounterIntent {
case incrementButtonTapped // 증가 버튼 탭
case decrementButtonTapped // 감소 버튼 탭
case loadInitialData // 초기 데이터 로드 시도
}
Presenter는 MVI 루프의 핵심이다. Intent를 입력받아 비즈니스 로직을 처리하고 새로운 State를 출력한다.
이는 MVVM 패턴에서 ViewModel의 역할과 유사하며, ReactorKit의 Reactor와 동일한 개념이다.
// MARK: - 3. Presenter (Logic)
final class CounterPresenter {
// ⭐️ State를 외부에 노출하는 Observable (출력)
let state: Observable<CounterState>
private let intentSubject = PublishSubject<CounterIntent>()
private let disposeBag = DisposeBag()
init() {
// ⭐️ Intent의 스트림을 State의 스트림으로 변환 (Scan 연산자 사용)
self.state = intentSubject
.scan(CounterState.initial) { currentState, intent in
// 이전 상태(currentState)와 새로운 의도(intent)를 기반으로
// 새로운 상태(nextState)를 계산하여 반환
switch intent {
case .incrementButtonTapped:
return CounterState(count: currentState.count + 1,
isLoading: false,
errorMessage: nil)
case .decrementButtonTapped:
return CounterState(count: currentState.count - 1,
isLoading: false,
errorMessage: nil)
case .loadInitialData:
// 비동기 로직 처리가 필요할 경우, 이 곳에서 Side Effect를 관리함
return CounterState(count: 0,
isLoading: true,
errorMessage: nil) // 상태만 변경
}
}
.startWith(.initial) // 초기 상태로 시작
.share(replay: 1, scope: .forever) // 여러 View가 구독 가능하도록 공유
}
// ⭐️ View가 Intent를 전달하는 함수 (입력)
func process(intent: CounterIntent) {
intentSubject.onNext(intent)
}
}
View는 Presenter의 state를 구독하여 UI를 업데이트하고, UI 이벤트를 Intent로 변환하여 Presenter에 전달하는 역할만 담당한다.
// MARK: - 4. View (ViewController)
class CounterViewController: UIViewController {
let presenter = CounterPresenter()
let disposeBag = DisposeBag()
let countLabel = UILabel()
let incrementButton = UIButton()
let decrementButton = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindIntent() // View 이벤트를 Intent로 변환
bindState() // State 변화를 View에 반영
}
// 1. View Events -> Intent (입력)
private func bindIntent() {
// 증가 버튼 탭 -> .incrementButtonTapped Intent 생성
incrementButton.rx.tap
.map { CounterIntent.incrementButtonTapped }
.bind(to: presenter.intentSubject) // Presenter의 Subject로 전달
.disposed(by: disposeBag)
// 감소 버튼 탭 -> .decrementButtonTapped Intent 생성
decrementButton.rx.tap
.map { CounterIntent.decrementButtonTapped }
.bind(to: presenter.intentSubject)
.disposed(by: disposeBag)
}
// 2. State -> View Update (출력)
private func bindState() {
presenter.state
.map { String($0.count) }
.distinctUntilChanged()
.observe(on: MainScheduler.instance)
.bind(to: countLabel.rx.text) // State의 count 값을 Label에 바인딩
.disposed(by: disposeBag)
presenter.state
.map { $0.isLoading }
// ... 로딩 인디케이터 관리 로직
.disposed(by: disposeBag)
}
}
ViewView는 이를 .incrementButtonTapped라는 Intent로 변환하여 Presenter에 전달Presenter는 현재 State(count: 0)와 Intent를 받아 count를 1 증가시킨 새로운 State(count: 1)를 생성하고 방출View는 새로운 State를 구독하고 있다가 count: 1을 받아 countLabel을 1로 업데이트MVI 패턴은 특히 복잡한 상태 변화와 비동기 이벤트 처리가 많은 앱에서 강력한 이점을 제공한다.
1. 예측 가능한 상태 관리 (Predictable State)
Intent → Model → View라는 명확한 단방향 흐름을 강제한다. 이는 상태가 변경되는 경로를 하나로 고정하여, 어디서 어떻게 상태가 바뀌었는지 쉽게 추적할 수 있게 한다.2. 불변성으로 인한 안정성
3. 높은 테스트 용이성 (Testability)
MVI 패턴은 강력한 구조를 제공하지만, 그 대가로 코드가 더 길어지고 오버헤드가 발생할 수 있다는 문제가 있다.
1. 상태 객체의 불필요한 재생성 (Boilerplate & Overhead)
2. 높은 학습 곡선 및 러닝 커브
Intent, State, Reducer 등 새로운 개념과 단방향 루프 구조 자체를 이해하는 데 시간이 필요하다.3. 단순한 작업에 대한 비효율성