🟣 상태가 흩어지면 무슨 일이 생기나
SwiftUI로 화면 몇 개를 만들어 보면 상태 관리가 곧 벽이 된다. 작은 뷰는 @State로 충분하지만, 화면이 커지면 여러 뷰가 같은 값을 공유해야 하고 @ObservedObject, @EnvironmentObject가 섞여 들어온다. 문제는 값을 바꾸는 경로가 정해져 있지 않다는 데 있다. 어떤 버튼이 어디의 상태를 직접 고치고, 그 변화가 또 다른 상태를 건드리기 시작하면 "지금 이 값이 왜 이렇게 됐는지"를 되짚을 수가 없다.
이 추적 불가능성이 상태 관리 아키텍처가 풀려는 핵심 문제다. TCA는 그 답으로 하나의 규칙을 강제한다. 상태를 바꾸는 길을 딱 하나로 만들고, 모든 변화가 그 길을 지나가게 하는 것이다. 이 장은 그 길을 이루는 네 조각을 정리한다.
🟣 네 조각의 역할
TCA는 하나의 기능(feature)을 네 가지로 나눈다.
- State: 그 화면이 가진 모든 데이터다
- Action: 그 화면에서 일어날 수 있는 모든 사건이다. 버튼을 눌렀다, 텍스트가 바뀌었다, 서버 응답이 도착했다가 전부 Action이다
- Reducer: "이 Action이 오면 State를 이렇게 바꾼다"는 규칙이다
- Store: 이 셋을 담아 돌리는 실행 주체다. 뷰는 Store를 통해서만 State를 읽고 Action을 보낸다
핵심은 단방향이라는 점이다.
- 뷰가 Action을 Store에 보낸다
- Store가 Reducer를 불러 State를 새로 만든다
- 바뀐 State가 다시 뷰를 그린다
- 뷰는 State를 직접 고치지 못한다. 오직 Action을 보낼 뿐이다
이 한 방향 고리가 TCA의 전부라고 해도 된다. 상태가 바뀌는 지점이 Reducer 하나로 모이니까, 버그가 생기면 "어떤 Action이 들어왔고 Reducer가 뭘 했나"만 보면 된다. 변화의 입구가 하나로 좁혀지는 것이 흩어진 상태 관리와의 가장 큰 차이다.
🟣 실제 코드로 보는 한 사이클
간단한 카운터를 예로 든다. 기능 하나를 하나의 타입으로 묶고, 그 안에 State와 Action을 중첩해 선언한다.
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
}
enum Action {
case incrementTapped
case decrementTapped
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .incrementTapped:
state.count += 1
return .none
case .decrementTapped:
state.count -= 1
return .none
}
}
}
}Reduce 클로저가 규칙의 본체다. State를 inout으로 받아 제자리에서 바꾸고, 뒤이어 실행할 비동기 작업이 없으면 .none을 돌려준다. 여기서 State를 직접 수정하는 것 말고 바깥 세계를 건드리는 코드(네트워크, 타이머 등)는 절대 넣지 않는다. 그건 다음 절의 Effect가 맡는다.
뷰는 이 기능을 감싼 Store를 받아 그린다. @ObservableState 덕분에 Store의 State를 뷰에서 바로 읽는다.
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
HStack {
Button("-") { store.send(.decrementTapped) }
Text("\(store.count)")
Button("+") { store.send(.incrementTapped) }
}
}
}버튼이 하는 일은 store.send(.incrementTapped)가 전부다. 값을 직접 더하지 않는다. Action을 던지면 Reducer가 받아 count를 올리고, 바뀐 값이 Store를 통해 다시 흘러 들어와 Text가 갱신된다. 앞의 다이어그램이 코드로 그대로 나타난 셈이다.
관찰이 Store·Observation 계층에서 어떻게 이뤄지는지는 「TCA Under the Hood」에서 다룬다.
🟣 왜 하필 이 구조인가
처음엔 이 구조가 과하게 느껴졌다. 카운터 하나에 타입 네 개는 분명 많다. 하지만 화면이 복잡해질수록 이 강제가 이점으로 바뀌었다.
첫째, 모든 사건이 Action이라는 하나의 타입으로 나열된다. enum을 열어 보면 이 화면에서 무슨 일이 일어날 수 있는지가 목록으로 보인다. 화면의 동작 명세가 곧 코드가 되는 셈이다.
둘째, State가 Equatable이고 Reducer가 State를 값으로 다루니, 특정 시점의 상태를 그대로 찍어 비교할 수 있다. 이게 뒤에서 볼 테스트 가능성의 뿌리가 된다.
셋째, 기능을 타입 하나로 묶어 두면 조립이 쉽다. 큰 화면은 작은 기능들을 합쳐 만드는데, TCA는 Scope로 부모 Reducer 안에 자식 Reducer를 끼워 넣는다. 부모의 State 일부를 자식의 State로 넘기고, 부모 Action 일부를 자식 Action으로 연결하는 방식이다.
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var counter = CounterFeature.State()
var profile = ProfileFeature.State()
}
enum Action {
case counter(CounterFeature.Action)
case profile(ProfileFeature.Action)
}
var body: some Reducer<State, Action> {
Scope(state: \.counter, action: \.counter) {
CounterFeature()
}
Scope(state: \.profile, action: \.profile) {
ProfileFeature()
}
}
}작은 기능을 독립적으로 만들고 부모가 조립하는 이 구성이 "Composable"이라는 이름의 이유다. 큰 화면을 통째로 하나의 거대한 상태로 두지 않고, 자기 완결적인 기능 단위로 쪼개 합칠 수 있다는 점이 이 아키텍처의 실질적인 값어치다.
🟣 순수한 변경과 나머지 세계를 가르는 Effect
Reducer 안에서는 State를 바꾸는 일만 한다고 했다. 그런데 실제 화면은 서버를 부르고, 타이머를 돌리고, 파일을 읽는다. 이 바깥 작업을 Reducer가 직접 하면 다시 추적이 어려워지고 테스트가 불가능해진다. TCA는 이 둘을 갈라놓는다. 상태를 바꾸는 동기 코드는 reduce 본체에, 바깥 세계와 얽히는 비동기 작업은 Effect로 분리해 반환한다.
case .refreshTapped:
state.isLoading = true
return .run { send in
let items = try await apiClient.fetchItems()
await send(.itemsResponse(items))
}
case let .itemsResponse(items):
state.isLoading = false
state.items = items
return .none버튼을 누른 시점에는 isLoading만 켜고, 실제 네트워크 호출은 Effect로 넘긴다. 응답이 도착하면 그 결과가 다시 .itemsResponse라는 또 하나의 Action으로 되돌아와 State를 바꾼다. 서버 응답조차 단방향 흐름의 입구인 Action을 통해 들어오는 것이다. 덕분에 비동기 작업도 예외 없이 같은 고리를 돈다.
여기서 apiClient 같은 바깥 의존성을 어떻게 주입하고, 그걸 테스트에서 어떻게 가짜로 바꿔 끼우느냐가 자연스러운 다음 질문이 된다. 그게 2장의 주제다.
🟣 정리
- TCA는 상태를 바꾸는 경로를 Reducer 하나로 좁혀, "이 값이 왜 이렇게 됐는가"를 추적 가능하게 만든다.
- State는 데이터, Action은 일어날 수 있는 모든 사건, Reducer는 변경 규칙, Store는 실행 주체다. 뷰는 Action만 보내고 State는 직접 못 고친다.
- 데이터는 한 방향으로만 돈다. 서버 응답 같은 비동기 결과도 새 Action이 되어 같은 고리로 들어온다.
- 바깥 세계와 얽히는 작업은 Reducer 본체가 아니라 Effect로 분리한다. 이 분리가 테스트 가능성의 전제다.
- 기능을 타입 하나로 묶고
Scope로 조립하는 구성이 "Composable"의 실체다. 큰 화면을 작은 기능의 합으로 다룬다. - 실무에서는 상태 변화가 많은 복잡한 화면일수록 이득이 크고, 단순한 화면에는 과하다. 면접에서는 "왜 단방향인가"를 추적 가능성과 테스트 가능성으로 설명할 수 있으면 된다.
단방향 흐름은 잡혔다. 그런데 방금 Effect 안에서 부른 apiClient는 어디서 왔고, 테스트할 때는 어떻게 진짜 서버 대신 가짜를 끼우나. 다음 장에서 의존성 관리와 테스트를 다룬다.