
지난 글들에서 State, Action, Reducer를 코드로 작성해봤다. 이번 글에서는 Splash → Home 화면 전환을 구현하면서 궁금해졌던 부분, 즉 Store가 실제로 어떻게 생성되고, 여러 화면에 어떻게 나눠지고, send를 호출했을 때 내부적으로 어떤 경로를 거쳐 Reducer의 로직까지 도달하는지를 정리한다.
TCA를 쓰는 앱은 진입점(@main App)에서 최상위 Reducer(여기서는 Root)로 Store를 딱 한 번 생성한다.
@main
struct WeatherApp: App {
let store = Store(initialState: Root.State()) {
Root()
}
var body: some Scene {
WindowGroup {
RootView(store: store)
}
}
}
이 하나의 Store가 앱이 실행되는 동안 유지되는 유일한 "진짜" Store다. Login이나 Home처럼 화면이 여러 개 있어도, 화면마다 별도의 Store가 새로 생성되는 게 아니다.
여기서 "단 하나"라는 표현은 정확히 짚고 넘어갈 필요가 있다. Store 타입의 인스턴스 자체는 여러 개 만들어질 수 있다. 곧 살펴볼 scope를 호출할 때마다 Store<ChildState, ChildAction> 타입의 새로운 객체가 실제로 생성된다. "단 하나"라는 말은 객체의 개수를 뜻하는 게 아니라, 실제로 State를 저장하고 Reducer를 실행하는 주체가 하나라는 뜻이다.
Root Store는 실제로 state를 소유하고, reducer를 갖고 있으며, send가 호출되면 진짜로 reduce를 실행한다. 반면 이후 scope로 만들어지는 Store들은 같은 Store 타입을 쓰지만 자기만의 State 저장소도, 자기만의 Reducer도 갖고 있지 않다. State를 읽으면 Root의 State를 그대로 비춰서 보여줄 뿐이고, Action을 보내면 자신이 처리하지 않고 Root에게 그대로 넘긴다.
비유하자면 Root Store는 실제 데이터와 처리 권한을 가진 은행 본점이고, scope로 만들어지는 Store는 그 지점에 놓인 화면 안내 키오스크에 가깝다. 키오스크도 물리적으로는 별개의 기계지만, 실제 잔고 데이터나 처리 권한은 전부 본점 서버에 있고 키오스크는 그걸 보여주고 요청을 본점으로 중계할 뿐이다. 즉 객체 개수로 보면 Root 하나에 scoped Store가 여러 개 존재할 수 있지만, 그중 실제 저장소와 실행 로직을 갖는 "진짜"는 Root 하나뿐이다.
각 화면은 이 하나뿐인 Store의 특정 부분만 들여다볼 수 있는 좁은 창구를 받는다. 이 창구를 만드는 게 scope다.
SplashView(store: store.scope(state: \.splash, action: \.splash))
여기서 중요한 점은, scope가 Splash용 데이터를 Root에 새로 끼워 넣는 게 아니라는 것이다. Root.State는 애초에 var splash = Splash.State()를 갖고 있었고, scope는 그중 splash 부분만 읽고 쓸 수 있는 새로운 Store<Splash.State, Splash.Action> 객체를 만들어서 반환할 뿐이다. 데이터 자체는 여전히 Root 안에 하나만 존재한다.
scope가 반환하는 이 객체는 두 가지 역할만 한다. 상태를 읽을 때는 Root의 state.splash를 그대로 비춰서 보여주고, Action을 보낼 때는 그걸 .splash(...)로 포장해서 다시 Root의 진짜 send를 호출한다.
실무에서는 이 scope 호출이 View가 다시 그려질 때마다 반복되므로, 매번 새 객체를 만들지 않고 이미 만들어둔 걸 재사용하는 캐싱이 내부에 들어있다. Store는 자신이 내어준 자식들을 children 딕셔너리에 기억해두고, 같은 경로로 다시 scope를 호출하면 캐시된 걸 그대로 돌려준다. 자식은 부모를 가리키는 약한 참조(weak var parent)를 들고 있어서, Action이 오면 그 참조를 타고 부모의 send를 호출할 수 있다.
SplashView 안에서 store.send(.startLoadData)를 호출했다고 하자. 이 store는 앞서 만든 scoped store다.
@Reducer
struct Splash {
@ObservableState
struct State {
var isLoading = false
}
enum Action {
case startLoadData
case dataLoaded(Result<InitialData, Error>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .startLoadData:
state.isLoading = true
return .run { send in
let data = try await weatherClient.fetchInitialData()
await send(.dataLoaded(.success(data)))
}
case .dataLoaded:
return .none
}
}
}
}
이 Action이 실제 로직에 도달하기까지 거치는 경로는 다음과 같다.
send가 .startLoadData를 .splash(.startLoadData)로 포장해서, 부모인 Root Store의 진짜 send를 호출한다.send는 자신이 갖고 있는 전체 reducer, 즉 Root의 body(Scope + Scope + Reduce가 합쳐진 것)를 실행한다.Scope(state: \.splash, action: \.splash) { Splash() }가 이 Action이 자기 담당인지 확인하고, 맞으면 state.splash를 좁혀서 Splash()의 reduce를 실제로 호출한다. 여기서 비로소 위 코드의 case .startLoadData:가 실행된다.Splash()가 반환한 Effect<Splash.Action>은 Scope가 Effect<Root.Action>으로 다시 포장한다. 나중에 이 Effect 안에서 send(.dataLoaded(...))가 호출되면, 그것도 자동으로 .splash(.dataLoaded(...))로 포장되어 다시 Root Store의 send를 호출하게 된다.즉 SplashView가 Action을 보내는 순간부터, 그리고 비동기 작업이 끝나서 결과를 돌려보낼 때도, 실제로 실행되는 건 처음부터 끝까지 하나의 Root Store뿐이다. 중간에 있는 scoped store들은 로직 없이 포장하고 전달하기만 하는 얇은 창구다.
Scope는 Root의 body 안에서 다음 네 가지를 순서대로 수행한다.
var body: some ReducerOf<Self> {
Scope(state: \.splash, action: \.splash) { Splash() }
Scope(state: \.main, action: \.main) { Main() }
Reduce { state, action in
// ...
}
}
들어온 Action이 .splash(...) 케이스인지 판별하고, 맞으면 Root의 State 중 state.splash 부분만 좁혀서, 그 좁혀진 State를 갖고 Splash()의 로직을 실제로 실행시킨다. 그리고 Splash()가 반환한 Effect를 다시 Root의 Action 타입으로 포장해서 돌려준다. Scope 자신은 어떤 비즈니스 로직도 갖고 있지 않다. Splash가 정확히 무슨 일을 하는지는 전혀 모른 채로, 그저 Action이 자기 담당이면 실제 처리를 위임하고, 아니면 그냥 넘어가는 중계 역할만 한다.
body에 나열된 Scope와 Reduce는 switch문의 case들처럼 하나만 매칭되고 끝나는 구조가 아니다. 나열된 모든 항목이 같은 Action을 각자 독립적으로 본다. 그래서 자식(Splash)이 처리하는 Action을 부모(Root)도 똑같이 감지해서 추가 로직을 붙일 수 있다.
Reduce { state, action in
switch action {
case .splash(.startLoadData):
print("분석 로그: 스플래시 로딩 시작됨")
return .none
default:
return .none
}
}
이렇게 해두면 .startLoadData가 들어왔을 때 Splash의 원래 로직(isLoading = true, API 호출)과 Root의 추가 로직(로그 남기기)이 둘 다 실행된다. 자식의 로직을 건드리거나 대체하지 않고, 그 위에 부모가 관찰하며 추가 반응을 얹을 수 있는 것이다.
이때 걱정할 수 있는 부분이 두 코드가 동시에 State를 건드리다가 경쟁 상태(race condition)가 생기지 않을까 하는 점인데, 그런 일은 일어나지 않는다. body에 나열된 항목들은 병렬로 실행되는 게 아니라 위에서 아래로 완전히 순서대로, 한 스레드에서 하나씩 실행된다. Scope(splash)의 실행이 끝나야 다음 항목이 시작되므로, 두 코드가 동시에 같은 State를 두고 경합할 여지가 구조적으로 없다. 실제 동시성이 등장하는 곳은 .run 안의 비동기 작업뿐인데, 그 결과로 호출되는 send조차도 TCA의 Store가 메인 액터로 격리되어 있어서 어느 비동기 작업에서 왔든 결국 한 번에 하나씩 순서대로 처리된다.
Store는 앱 전체에 하나뿐이고, 각 화면이 받는 Store는 그 하나를 좁혀서 비추는 창구라는 것, 그리고 Action을 보내면 결국 이 하나의 Store가 자신이 가진 전체 reducer 트리를 한 번에 순서대로 실행한다는 것이 오늘 정리한 핵심이다. Scope는 이 과정에서 Action을 담당 자식에게 위임하고 Effect를 다시 포장해주는 배관 역할을 하며, 이 구조 덕분에 부모가 자식의 Action에 안전하게 추가 반응을 붙이는 것도 가능해진다.