Swift / MVI 패턴

iOS 앱개발 공부

목록 보기
13/30

🧠 핵심 요약

MVI 패턴은 함수형 및 반응형 프로그래밍 원칙을 기반으로 하는 아키텍처로, 단방향 데이터 흐름(Unidirectional Data Flow)을 극단적으로 사용하는 패턴이다. 복잡한 비동기 상태 관리를 예측 가능하고 디버깅하기 쉽게 만드는 것이 핵심이다.

MVI 패턴은 Model - View - Intent의 3가지 요소로 구성되어 있다.

각 요소별 특징

요소역할데이터 흐름
Intent(의도)사용자의 모든 행동을 표현한다. (ex 버튼 클릭, 텍스트 입력, 스와이프 액션 등)View → Model
Model(상태)앱의 현재 상태(State)를 나타내는 불변의 데이터 객체, Intent를 받아 새로운 상태를 생성한다.Model → View
ViewModel(상태)을 구독하여 UI를 렌더링하고, 사용자 상호작용을 Intent로 변환하여 Model에 전달한다.Model의 변화를 View가 수신

MVI의 특징

  • 단방향 루프: 데이터가 Intent → Model → View → Intent의 순환 구조를 따른다.
  • 불변성(Immutability): Model은 불변 객체이며, 상태가 변경될 때마다 새로운 Model 객체가 방출된다. 이는 상태 관리를 예측 가능하게 한다.
  • 예측 가능성: 주어진 Intent에 대해 항상 동일한 State를 생성하므로 버그를 줄이고 테스트를 용이하게 한다.

🎨 MVI 패턴 예시

MVI 패턴은 RxSwift나 Combine과 같은 반응형 라이브러리를 사용해본 경험이 있다면 비교적 쉽게 흐름을 이해할 수 있다.
MVI 패턴의 핵심은 View에서 발생한 모든 행동을 Intent로 캡슐화하여 Model에 전달하고, Model이 새로운 State를 방출하면 View가 이 State를 받아 화면을 업데이트하는 순환 구조인 것이다.

1) Model(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)
    }
}

2) Intent: 사용자의 모든 행동

사용자가 화면에서 수행하는 모든 상호작용(버튼 탭, 입력, 스와이프 등)을 열거형으로 정의한다.

// MARK: - 2. Intent

enum CounterIntent {
    case incrementButtonTapped // 증가 버튼 탭
    case decrementButtonTapped // 감소 버튼 탭
    case loadInitialData     // 초기 데이터 로드 시도
}

3) Presenter (Business Logic)

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)
    }
}

4) View (ViewController)

ViewPresenterstate를 구독하여 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)
    }
}

5) 동작 원리 요약

  • 사용자가 증가 버튼(incrementButton)을 누른다 → View
  • View는 이를 .incrementButtonTapped라는 Intent로 변환하여 Presenter에 전달
  • Presenter는 현재 State(count: 0)와 Intent를 받아 count를 1 증가시킨 새로운 State(count: 1)를 생성하고 방출
  • View는 새로운 State를 구독하고 있다가 count: 1을 받아 countLabel1로 업데이트

✅ MVI 패턴의 장/단점

1) 장점

MVI 패턴은 특히 복잡한 상태 변화와 비동기 이벤트 처리가 많은 앱에서 강력한 이점을 제공한다.

1. 예측 가능한 상태 관리 (Predictable State)

  • MVI 패턴은 Intent → Model → View라는 명확한 단방향 흐름을 강제한다. 이는 상태가 변경되는 경로를 하나로 고정하여, 어디서 어떻게 상태가 바뀌었는지 쉽게 추적할 수 있게 한다.
  • 상태 변화의 경로가 단순하기 때문에 특정 시점의 버그를 재현하고 디버깅하기가 용이하다.

2. 불변성으로 인한 안정성

  • MVI의 핵심인 Model(State)는 불변 객체이다. 이는 상태가 변경될 때 기존 객체를 수정하는 대신 새로운 상태 객체를 만들어 방출함을 의미한다. 덕분에 여러 곳에서 동시에 상태를 변경하려고 시도하는 경쟁 조건(Race Condition) 발생 위험을 줄여 앱의 안정성을 높일 수 있다.

3. 높은 테스트 용이성 (Testability)

  • MVI의 로직을 담당하는 Presenter는 기본적으로 입력(Input)이 주어지면 항상 동일한 출력(Staee)을 내는 순수 함수와 유사하다.
  • UI/View를 제외한 비즈니스 로직은 특정 Intent를 입력으로 주고, 예상되는 State를 출력으로 비교하는 방식으로만 테스트하면 되므로 단위 테스트(Unit Test) 작성이 매우 간단해진다.

2) 단점

MVI 패턴은 강력한 구조를 제공하지만, 그 대가로 코드가 더 길어지고 오버헤드가 발생할 수 있다는 문제가 있다.

1. 상태 객체의 불필요한 재생성 (Boilerplate & Overhead)

  • 작은 상태 변화라도 새로운 State 객체를 생성해야 하므로, 간단한 앱에서는 MVVM이나 MVC에 비해 반복되는 코드(Boilerplate Code)가 많아진다.
  • 복잡한 State 구조체 내의 일부 작은 프로퍼티만 변경되더라도 전체 State 객체를 새로 만들고 방출해야 한다. 이는 성능에 큰 영향을 미치진 않지만, 메모리 오버헤드가 발생할 가능성이 있다.

2. 높은 학습 곡선 및 러닝 커브

  • MVI는 RxSwift, Combine과 같은 반응형 프로그래밍 라이브러리를 거의 필수로 요구한다. 이 라이브러리에 익숙하지 않은 개발자에게는 진입 장벽이 높을 수 있다.
  • Intent, State, Reducer 등 새로운 개념과 단방향 루프 구조 자체를 이해하는 데 시간이 필요하다.

3. 단순한 작업에 대한 비효율성

  • 뷰 컨트롤러가 몇 개 없고 비즈니스 로직이 간단한 소규모 앱의 경우, MVI의 엄격한 구조를 적용하는 것은 과도한 설계일 수 있다. MVVM이나 심지어 MVC가 훨씬 빠르고 간결하게 개발을 완료할 수 있다.
profile
이유있는 코드를 쓰자!!

0개의 댓글