어쩌다가 TCA라는 것을 듣게 됐는데 찾아보니 Swift와 관련된 용어였어요. 그래서 이번 글에서는 TCA에 대해 알아볼게요
TCA는 The Composable Architecture의 약자로 Point-Free(Brandon Williams, Stephen Celis)가 만든 Swift/SwiftUI용 단방향 상태 관리 오픈소스 아키텍처 라이브러리입니다.
핵심 개념은 다음과 같아요
예전에 React를 공부하며 배운 Redux 같다고 느껴졌었는데 실제로 Redux에서 영감을 받았다고 해요
User Interaction
↓
Action
↓
Reducer → Effect (API 호출, 타이머 등)
↓ ↓
State ← Action (결과 반환)
↓
View
위에 핵심 구조에서 말했듯이 단방향 데이터 흐름이죠? 버튼을 눌렀을 때 숫자가 올라가는 카운터 앱을 예시로 설명할게요.
[사용자가 + 버튼 누름]
↓
Action 발생
(.increment)
↓
Reducer
(state.count += 1)
↓
State 변경
(count: 0 → 1)
↓
View 업데이트
("1" 표시)
화면에 필요한 모든 데이터로 지금 이 화면이 알아야 할 정보를 모아놓은 구조체예요.
struct State: Equatable {
var count = 0 // 현재 숫자
var isLoading = false // 로딩 중인지
}
State는 직접 수정할 수 없어요. 반드시 Action을 통해서만 바꿀 수 있어요.
발생 가능한 모든 이벤트로 "이 화면에서 일어날 수 있는 일"을 열거형으로 정의해요.
버튼 탭, API 응답, 타이머 등 모든 이벤트가 여기에 포함돼요.
enum Action {
case increment // + 버튼 탭
case decrement // - 버튼 탭
case fetchUser // API 호출 요청
case fetchUserResponse(User) // API 응답 도착
}
상태를 바꾸는 유일한 장소로 Action이 들어오면 State를 어떻게 바꿀지 결정하는 함수예요.
항상 같은 입력 → 같은 출력 이라는 규칙이 있어요. 단순한 상태 변화는 바로 처리하고, API 호출처럼 시간이 걸리는 작업은 Effect 로 넘겨요.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
// 단순 상태 변화 → 바로 처리
case .increment:
state.count += 1
return .none // 추가 작업 없음
// 시간이 걸리는 작업 → Effect로 넘김
case .fetchUser:
state.isLoading = true
return .run { send in
let user = try await apiClient.fetchUser()
await send(.fetchUserResponse(user)) // 완료되면 Action으로 돌아옴
}
case .fetchUserResponse(let user):
state.isLoading = false
state.user = user
return .none
}
}
}
Reducer는 순수 함수여야 하기 때문에 API 호출, 타이머 같은 작업은 직접 처리하지 않아요. 대신 Effect로 감싸서 반환하고, 완료되면 결과를 다시 Action으로 돌려보내요.
fetchUser Action 들어옴
↓
Reducer에서 Effect 반환
↓
Effect가 API 호출 (비동기)
↓
결과를 fetchUserResponse Action으로 반환
↓
Reducer가 State 업데이트
.none 은 "추가 작업 없음", .run { } 은 비동기 작업이 있을 때 써요
단어들은 처음 들어보지만 하는 역할들이 다 익숙하죠?
Swift에서 자주 사용하는 MVVM과 비슷한 구조라서 그럴거에요
| MVVM | TCA | 하는 일 |
|---|---|---|
| View | View | 화면 그리기 |
| ViewModel | Store | 상태 보유, 로직 처리 |
| ViewModel 메서드 | Action + Reducer | 상태 변경 |
| ViewModel 프로퍼티 | State | 화면에 필요한 데이터 |
| async 함수 | Effect | 비동기 작업 |
MVVM에서 ViewModel이 상태를 들고 있고 View가 이를 구독하듯이, TCA에서는 Store가 그 역할을 해요. Repository 패턴과도 닮아있는데, 둘 다 "직접 건드리지 말고 나를 통해서 해"라는 철학이에요
상태(State)를 보유하고 Action을 받아 Reducer로 전달하는 런타임이에요. 보통 앱 진입점에서 한 번만 만들고 하위 View로 내려줘요. ViewModel을 초기화하는 것과 같다고 보면 돼요.
let store = Store(initialState: Counter.State()) {
Counter()
}
MVVM에서 View가 @Published 프로퍼티를 구독하듯, ViewStore는 Store의 State 변화를 감지해 View를 업데이트해요. View와 Store를 연결하는 창구 역할이에요. 하지만 MVVM과 다른 점이라면 MVVM에서는 View가 ViewModel을 직접 구독하지만 TCA에서는 View가 Store를 직접 구독하지 않아요. ViewStore라는 창구를 통해서만 State를 읽고 Action을 보내요
struct CounterView: View {
let store: StoreOf<Counter>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Text("\(viewStore.count)") // State 읽기
Button("+") {
viewStore.send(.increment) // Action 보내기
}
}
}
}
전체 흐름을 MVVM과 나란히 보면 이렇게 돼요.
TCA는 자체 DI 시스템을 제공해요. MVVM에서 생성자 주입으로 Mock을 넣어주던 것과 비슷하지만, 전역으로 등록해두고 어디서든 꺼내 쓸 수 있어요
struct Counter: Reducer {
@Dependency(\.apiClient) var apiClient
@Dependency(\.continuousClock) var clock
var body: some ReducerOf<Self> {
Reduce { state, action in
case .fetchUser:
return .run { send in
let user = try await apiClient.fetchUser() // 주입된 의존성 사용
await send(.fetchUserResponse(user))
}
}
}
}
그래서 테스트 하기가 용이하죠. 테스트할 때 withDependencies로 실제 구현체를 Mock으로 교체하기만 하면 테스트할 수 있으니까요
// MVVM — 생성자에 Mock 주입
let viewModel = CounterViewModel(apiClient: MockAPIClient())
// TCA — withDependencies로 교체
withDependencies {
$0.apiClient.fetchUser = { .mock }
} operation: {
// 이 블록 안에서는 Mock이 사용됨
}
장단점은 다음과 같아요
장점
단점
마지막으로 언제 사용하면 좋을지 표로 정리하고 마무리 할게요
| 상황 | 추천 여부 |
|---|---|
| 상태가 복잡하고 여러 화면이 공유 | ✅ |
| 테스트 커버리지가 중요한 프로젝트 | ✅ |
| 팀 프로젝트, 협업 필요 | ✅ |
| 간단한 토이 프로젝트 | ❌ |
| SwiftUI 처음 배우는 단계 | ❌ |