
- 단방향 데이터 흐름을 기반으로 한 아키텍처 패턴
- 사용자의 의도(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 호출 등) (상태 변경)
- 사용자가 "목록 로드" Intent를 보냄
- Side Effect Cycle: API 호출 (부수 효과)
- API 결과가 "loaded" Intent로 변환됨
- Pure Cycle: loaded Intent를 받아서 State 변경 (순수 함수)
- 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 |