본문 바로가기
Architecture

[Architecture] MVI

by Bokoo14 2026. 2. 19.

  • 단방향 데이터 흐름을 기반으로 한 아키텍처 패턴
  • 사용자의 의도(Intent)를 통해서만 상태를 변경할 수 있고, 상태는 항상 하나의 경로로만 흐름
  • “상태 변경 경로를 하나로 제한한다”가 핵심
    • MVVM에서 여러 곳에서 상태가 변경되어 추적이 어려운 문제를 해결함
  • TCA는 사실 MVI의 개념을 Swift/SwiftUI에 맞게 발전시킨 거임. 구조가 거의 동일함

각 계층의 역할

Intent

  • 사용자의 의도
  • “이런 일을 하고 싶다”는 이벤트
  • 버튼 탭, 화면 진입, 텍스트 입력 등

Model

  • 앱의 상태(State)를 관리
  • Intent를 받아서 상태를 변경하는 유일한 곳
  • MVVM의 데이터 모델이 아니라 상태 관리자에 가까움

View

  • State를 받아서 화면에 그리기만 함
  • 직접 상태를 변경하지 않음

View가 state를 직접 수정하지 못하고(private(set)), 반드시 send()를 통해 Intent를 보내야만 상태가 바뀐다

User → Intent → Model → View → User
         ↑                  ↓
         └──────────────────┘

View는 State를 읽기만 함
변경하려면 반드시 Intent를 보내야 함
// State — 화면의 모든 상태를 하나의 구조체로
struct WaitingListState {
    var waitings: [Waiting] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
}

// Intent — 발생 가능한 모든 사용자 의도
enum WaitingListIntent {
    case loadList
    case cancelWaiting(id: Int)
    case refresh
}

// Model — Intent를 받아서 State를 변경
class WaitingListModel: ObservableObject {
    @Published private(set) var state = WaitingListState()
    
    func send(_ intent: WaitingListIntent) {
        switch intent {
        case .loadList:
            state.isLoading = true
            // 데이터 로드 후
            state.waitings = fetchedData
            state.isLoading = false
            
        case .cancelWaiting(let id):
            state.waitings.removeAll { $0.id == id }
            
        case .refresh:
            state.isLoading = true
            // 새로고침
        }
    }
}

// View — State를 읽기만 하고, Intent를 보내기만 함
struct WaitingListView: View {
    @StateObject var model = WaitingListModel()
    
    var body: some View {
        Group {
            if model.state.isLoading {
                ProgressView()
            } else {
                List(model.state.waitings) { waiting in
                    Text(waiting.customerName)
                }
            }
        }
        .onAppear { model.send(.loadList) } // Intent 전달
        .refreshable { model.send(.refresh) } // Intent 전달
    }
}

장점

  • 상태 예측 가능
    • 상태가 하나의 구조체로 통합되어 있고, 변경 경로가 하나뿐이라 현재 상태가 왜 이렇게 되었는지 추적하기 쉬움
  • 디버깅 용이
    • Intent 로그만 보면 어떤 순서로 상태가 변했는지 알 수 있음
  • 모순된 상태 방지
    • State를 하나로 묶으니까 “로딩 중이면서 에러”같은 모순을 구조적으로 막을 수 있음
// 모순 방지 — enum으로 상태를 배타적으로 관리
enum ViewState {
    case idle
    case loading
    case loaded([Waiting])
    case error(String)
    // loading이면서 error일 수 없음
}
  • 테스트 용이
    • Intent를 보내고 State를 확인하면 됨
func testCancelWaiting() {
    let model = WaitingListModel()
    model.state = WaitingListState(waitings: [Waiting(id: 1)])
    
    model.send(.cancelWaiting(id: 1))
    
    XCTAssertTrue(model.state.waitings.isEmpty)
}

단점

  • 보일러플레이트가 많음
    • 간단한 화면에도 State, Intent를 다 정의해야 함
  • 학습곡선
    • MVVM보다 개념 복잡

MVVM과 비교

MVVM

  • 여러 곳에서 상태 변경 가능
  • 상태가 흩어져 있음
    • waitings, isLoading, errorMessage가 각각 독립적으로 변경됨
    • isLoading = true인데 errorMessage도 있는 모순된 상태가 가능
class ViewModel: ObservableObject {
    @Published var waitings: [Waiting] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    
    func loadList() {
        isLoading = true
        // ...
    }
    
    func cancel(id: Int) {
        waitings.removeAll { $0.id == id }
    }
}

MVI

  • 상태가 하나로 묶여 있고, 변경 경로 하나
  • 상태 전체가 하나의 구조체 → 모순된 상태를 방지하기 쉬움
  • 변경은 오직 send(Intent)를 통해서만 → 추적이 쉬움
struct State {
    var waitings: [Waiting] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
}

MVI가 해결하려는 문제

기존 MVVM 패턴으로 해결하지 못하는 문제

  • 상태 문제 (State Problem)
    • MVVM에서 상태가 여러 개로 흩어져 있으면, 서로 모순되는 상태가 만들어질 수 있음
    • 상태가 여러 곳에서 독립적으로 변경되다 보니, 의도하지 않은 조합이 만들어짐
    • 화면이 복잡해질수록 이런 모순이 생길 확률이 높아짐
// MVVM — 상태가 독립적으로 존재
class ViewModel: ObservableObject {
    @Published var waitings: [Waiting] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    @Published var isEmpty: Bool = true
}

// 이런 모순된 상태가 가능함
// isLoading = true + errorMessage = "에러 발생" → 로딩 중인데 에러?
// isLoading = false + waitings = [] + isEmpty = false → 비어있는데 안 비었다고?
  • 부수 효과 (Side Effect)
    • API 호출, DB저장, 타이머 같은 외부 세계와의 상호작용을 부수 효과라고 함 → 이것들은 결과를 예측하기 어려움
    • 상태 변경과 부수 효과가 뒤섞여 있어서, 어떤 순서로 일어나는지, 어디서 상태가 바뀌는지 추적이 어려움
// MVVM에서 부수 효과가 섞여있으면
class ViewModel: ObservableObject {
    func loadList() {
        isLoading = true                    // 상태 변경
        api.fetchWaitings { result in       // 부수 효과 (API 호출)
            switch result {
            case .success(let data):
                self.waitings = data        // 상태 변경
                self.isLoading = false      // 상태 변경
                self.saveToCache(data)      // 부수 효과 (DB 저장)
            case .failure(let error):
                self.errorMessage = error   // 상태 변경
                self.isLoading = false      // 상태 변경
                self.logError(error)        // 부수 효과 (로깅)
            }
        }
    }
}

MVI의 해결 방법

  • Pure Cycle (순수 사이클)
    • 상태 변경을 순수 함수로만 처리하는 사이클
    • “순수”라는 건 같은 입력이면 항상 같은 출력이 나온다는 뜻
    • 외부 의존 없이 State + Intent만으로 새로운 State를 만들어냄
    • API를 호출하지 않고, DB에 저장하지 않음. 오직 현재 State와 Intent를 받아서 새로운 State를 만듦 → 그래서 순수하며, 테스트가 쉬움
    // Pure Cycle — 순수하게 상태만 변경
    struct State {
        var waitings: [Waiting] = []
        var isLoading: Bool = false
        var errorMessage: String? = nil
    }
    
    enum Intent {
        case startLoading
        case loaded([Waiting])
        case failed(String)
        case cancelWaiting(id: Int)
    }
    
    // 순수 함수: State + Intent → 새로운 State
    // API 호출 같은 부수 효과 없음, 오직 상태 변환만
    func reduce(state: State, intent: Intent) -> State {
        var newState = state
        
        switch intent {
        case .startLoading:
            newState.isLoading = true
            newState.errorMessage = nil
            
        case .loaded(let waitings):
            newState.isLoading = false
            newState.waitings = waitings
            
        case .failed(let message):
            newState.isLoading = false
            newState.errorMessage = message
            
        case .cancelWaiting(let id):
            newState.waitings.removeAll { $0.id == id }
        }
        
        return newState
    }
    
    • 테스트 간단
    // 테스트 간단해짐
    func testStartLoading() {
        let state = State(isLoading: false)
        let newState = reduce(state: state, intent: .startLoading)
        
        XCTAssertTrue(newState.isLoading)
        XCTAssertNil(newState.errorMessage)
    }
  • Side Effect Cycle (부수 효과 사이클)
    • API 호출, DB 저장같은 부수 효과를 Pure Cycle과 분리해서 별도로 관리하는 사이클
    • 핵심은 부수 효과의 결과가 다시 Intent로 변환된다는 거임. 그 Intent가 Pure Cycle로 들어가서 상태를 변경함
// Side Effect Cycle — 부수 효과를 별도로 처리
func handleSideEffect(intent: Intent) -> AnyPublisher<Intent, Never> {
    switch intent {
    case .startLoading:
        // 부수 효과: API 호출
        return api.fetchWaitings()
            .map { Intent.loaded($0) }           // 성공 → 새 Intent 생성
            .catch { Just(Intent.failed($0.localizedDescription)) }  // 실패 → 새 Intent 생성
            .eraseToAnyPublisher()
        
    case .cancelWaiting(let id):
        // 부수 효과: 서버에 취소 요청
        return api.cancelWaiting(id: id)
            .map { _ in Intent.loaded(updatedList) }
            .eraseToAnyPublisher()
        
    default:
        return Empty().eraseToAnyPublisher()  // 부수 효과 없음
    }
}

전체 흐름

User → Intent → [Side Effect Cycle] → 새로운 Intent → [Pure Cycle] → State → View
                  (API 호출 등)                         (상태 변경)
  1. 사용자가 "목록 로드" Intent를 보냄
  2. Side Effect Cycle: API 호출 (부수 효과)
  3. API 결과가 "loaded" Intent로 변환됨
  4. Pure Cycle: loaded Intent를 받아서 State 변경 (순수 함수)
  5. View가 새 State를 반영
Intent ──→ Pure Cycle (상태만 변경) ──→ State ──→ View
  │                                                │
  └──→ Side Effect Cycle (API, DB 등) ──→ 새 Intent ┘

왜 이렇게 나누는가?

  • Pure Cycle은 순수 함수라서 테스트가 쉽고, 결과를 예측할 수 있음
  • Side Effect Cycle은 예측이 어렵지만, 분리해두면 “어디서 API를 호출하는지”, “어디서 상태가 바뀌는지”가 명확해짐
  • MVVM에는 두가지가 섞여서 추적이 어려웠지만, MVI에서는 순수한 상태 변경과 예측 불가능한 부수 효과를 물리적으로 분리했음
  섞여있을 때 (MVVM) 분리했을 때 (MVI)
상태 변경 ViewModel 어디서든 가능 Pure Cycle에서만
부수 효과 ViewModel 어디서든 발생 Side Effect Cycle에서만
테스트 부수 효과 때문에 어려움 Pure Cycle은 쉬움
디버깅 어디서 뭐가 바뀌었는지 추적 어려움 각 사이클을 독립적으로 추적 가능

'Architecture' 카테고리의 다른 글

[Architecture] MVC  (0) 2026.02.19
[Architecture] 클린아키텍쳐  (0) 2026.02.18
[Architecture] MVVM 패턴  (0) 2024.02.17
[CS] 객체지향 프로그래밍(OOP)이란?  (0) 2024.02.12