LODY/정리

TCA 실전 / TCA Under the Hood

TCA Under the Hood

TCA(The Composable Architecture)를 쓰면서 "왜 이렇게 동작하지?" 싶었던 순간들이 있었다. send가 동기인 것 같으면서도 Effect가 비동기고, 같은 액션을 두 번 보내면 경고가 뜨고, scope를 호출할 때마다 새 Store가 생기지 않는 것처럼 느껴지고.

이 글은 그 질문들에 소스코드로 직접 답한다. Store와 Core 계층부터 Reducer 합성, Effect 생명주기, Observation, 취소 시스템까지 — 내부 구조를 하나씩 뜯고, 마지막에는 왜 이렇게 설계됐는지, MVI와 무엇이 다른지까지 짚는다.

전체 아키텍처 흐름

TCA는 단방향 데이터 흐름 위에서 작동한다. View가 액션을 보내면 Reducer가 State를 바꾸고, Effect를 반환한다. Effect가 완료되면 다시 액션을 보낸다.

SwiftUI Viewobserve / @BindableStore<State, Action>Core (RootCore)Reducerreduce(&state, action)Effect<Action>.none / .run / .publisherTask(async work)send(action)delegates_reducereturnsCore가 구독send(action)state observationTCA 단방향 흐름PresentationStoreCoreReducerEffect / Task

핵심은 Store가 직접 Reducer를 호출하지 않는다는 것이다. Store → Core → Reducer 계층을 거친다. 이 간접 계층이 scoping, optional 처리, 테스트를 가능하게 하는 설계 포인트다.


Store: 공개 인터페이스

Store<State, Action>은 외부에 노출되는 타입이다. 상태를 직접 들고 있지 않고 Core 프로토콜에 모든 것을 위임한다.

send(_:)→ StoreTaskscope(state:action:)→ Store<Child, ChildAction>currentState: State(observation 트리거)children:[ScopeID: AnyObject] (캐시)core: any Core<State, Action>rawValue: Task<Void, Never>?cancel()finish() asyncdelegates캐시 조회/저장core.statereturnsStore<State, Action>공개 API내부 의존 (Core)StoreTask

StoreTask는 Effect에서 시작된 비동기 작업의 핸들이다. .finish()로 완료를 기다리거나 .cancel()로 취소할 수 있다. 테스트에서 await store.send(.action).finish()처럼 쓰는 이유가 여기에 있다.

Store가 상태를 직접 들고 있지 않고 Core에 위임하는 설계가 없었다면, scopeifLet처럼 다른 합성 전략이 필요할 때마다 Store 공개 API까지 함께 수정해야 했을 것이다.


Core 프로토콜 계층

Store의 실제 두뇌는 Core 프로토콜이다. 4가지 구현체가 있고 각각 다른 시나리오를 담당한다.

Core<State, Action>state · send · canStoreCacheChildren · effectCancellablesRootCoreReducer·State 보유isSending·버퍼링ScopedCoreKeyPath 투영캐시 가능IfLetCoreOptional Statenil → Effect 취소ClosureScopedCore클로저 투영캐시 불가루트 Storescope() KeyPathoptional scope클로저 scopeCore 프로토콜 계층Core<State, Action> 프로토콜구현체

canStoreCacheChildrenfalseClosureScopedCore는 자식 Store를 캐시할 수 없다. 클로저 기반 투영은 매 호출마다 다른 결과를 낼 수 있기 때문이다. 이것이 scope(state:action:)에 KeyPath를 권장하는 이유다.


RootCore: 액션 처리 루프

TCA에서 가장 중요한 코드는 RootCore._send()다. 이 함수가 액션을 받아서 Reducer를 호출하고, Effect를 구독하고, 재진입을 처리한다.

메인 처리 루프부터 보자. 액션이 들어오면 일곱 단계를 거친다.

① send(action)② isSending == true?actionQueue에 적재(나중에 처리)③ isSending = true④ Reducer 호출reduce(&state, action)⑤ Effect 구독UUID 할당, Task 시작⑥ actionQueue 비어있나?⑦ isSending = falseYES (처리 중)NOEffect 있음Effect 없음구독 후비어있음남은 액션 재처리RootCore._send()진입버퍼링처리 루프

⑥에서 actionQueue에 남은 액션이 있으면 ④로 돌아가 계속 처리한다. 이 루프가 끝난 뒤에야 isSending = false가 된다.

재진입(Reentrancy) 처리

isSending 플래그는 액션 처리 중 또 다른 액션이 들어오는 상황을 제어한다. 어디서 왔느냐에 따라 처리 방식이 달라진다.

Effect Task 실행await send(.loaded(data))isSending 중→ actionQueue에 적재현재 루프 종료 후자동 처리store.send(.a) 호출Reducer 처리 중isSending == truestore.send(.b) 호출런타임 경고 출력actionQueue 적재의도치 않은 순서로처리될 수 있음재진입 처리허용: Effect 내부에서 send (origin: .effect)경고: View에서 처리 중 send (origin: .store)

origin 파라미터가 이 둘을 구분한다. Effect 내부에서 send를 여러 번 호출하는 것은 TCA의 정상적인 패턴이다. 문제가 되는 건 View나 외부에서 Reducer 처리 도중 동기적으로 send를 두 번 연달아 호출하는 경우다.


Reducer 프로토콜과 Body 합성

Reducer는 단순한 함수가 아니다. body 프로퍼티를 통해 다른 Reducer를 합성할 수 있는 프로토콜이다.

protocol Reducer<State, Action>• associatedtype Body• var body: Body { get }• func _reduce(into:action:) → EffectBody == Never→ _reduce 직접 구현(primitive reducer)Body: Reducer<State, Action>→ body._reduce에 위임(composed reducer)@Reducerstruct Feature { var body: some Reducer<...> { ChildReducer() }}@ReducerBuilderReducer 프로토콜과 Body 합성Reducer 프로토콜두 가지 구현 경로@Reducer 매크로 예시

@ReducerBuilder는 Swift의 result builder로, 여러 Reducer를 _Sequence로 묶는다. 중요한 것은 이 Sequence가 Reducer를 순서대로 실행하고 Effect를 병렬로 병합한다는 점이다.

두 경로가 하나의 인터페이스로 통합되지 않았다면, 합성 Reducer와 순수 구현 Reducer를 같은 방식으로 조합하는 것이 불가능하다. body 프로퍼티가 이 통합 지점이다.


ReducerBuilder: 순차 실행과 Effect 병합

body 안에 여러 Reducer를 나열하면 @ReducerBuilder가 이를 _Sequence로 묶는다. 핵심은 실행 순서와 Effect 처리 방식이다.

Reducer1(ChildFeature)Reducer2(Analytics)Reducer3(Logging)_Sequence<R0, R1>• r0._reduce → e0• r1._reduce → e1• return e0.merge(e1)Effect1Effect2Effect3Merged Effect(동시 실행)ReducerBuilder: 순차 실행과 Effect 병합합성된 Reducer body_Sequence 래퍼Effect 병합

세 개의 Reducer가 모두 같은 state에 대해 순서대로 실행된다는 점이 중요하다. R1이 state를 변경하면 R2는 이미 변경된 state를 받는다. Effect는 병합되어 동시에 실행된다.

순서가 보장되지 않으면 R2가 R1의 변경을 모른 채 state를 덮어쓸 수 있다. 로깅이나 분석 Reducer가 앱 Reducer보다 먼저 실행되어야 한다면 body 내 나열 순서를 바꾸는 것으로 충분히 제어 가능하다.


Effect 시스템: 세 가지 종류

Effect는 값 타입이다. 직접 무언가를 실행하지 않고, "무엇을 실행할지"를 기술한다. 실제 실행은 Core가 Effect를 구독할 때 일어난다.

Effect<Action>enum Operation (Sendable).none아무 액션도 보내지 않음(동기 Reducer용).publisherAnyPublisher<Action, Never>Combine 기반, 메인 스레드 보장.run(Send<Action>) async → VoidSwift Concurrency 기반여러 액션 방출 가능Effect.send(.action)(단일 액션 즉시 방출)effect1.merge(with: effect2)(두 Effect 동시 실행)Effect.run { send in let d = try await api.fetch() await send(.loaded(d))}Effect<Action>Effect 타입세 가지 종류사용 예

.runSend<Action>@MainActor에 격리된 callable이다. Effect 내부에서 여러 번 await send(.action)을 호출할 수 있고, Task가 취소되면 자동으로 중단된다.


Send<Action>: 스레드 안전 액션 발신

.run Effect 안에서 쓰는 send는 그냥 클로저가 아니다. @MainActor에 격리된 구조체이고, Task 취소 상태를 자동으로 확인한다.

callAsFunction(_ action: Action)@MainActorSendablecallAsFunction(_ action:, animation:)애니메이션 트랜잭션 포함isCancelled 체크→ 취소된 Task에서액션 발신 방지for await item in stream { await send(.received(item))}await send(.loaded(data))await send(.dismissed)isCancelled == falseisCancelled == falseSend<Action>Send<Action>사용 패턴

Task가 취소된 후에도 send가 호출되면 무시된다. 이것이 TCA에서 long-running Effect를 안전하게 다룰 수 있는 이유다.


Effect 생명주기: UUID 기반 관리

Core는 각 Effect에 UUID를 할당하고 effectCancellables 딕셔너리로 관리한다.

return .run { send in await someAsyncWork() await send(.done)}UUID 생성Effect 구독 시작(Task 생성)effectCancellables[uuid]= AnyCancellableeffectCancellables[uuid]제거 (완료 시)someAsyncWork()send(.done)Task 완료Effect 생명주기ReducerRootCore비동기 Task

Effect가 완료되면 effectCancellables에서 UUID 키를 삭제한다. Store가 해제될 때 effectCancellables도 해제되면서 모든 진행 중인 Effect가 취소된다.


Effect.cancellable: ID 기반 취소

Effect에 ID를 붙이면 같은 ID를 가진 Effect를 명시적으로 취소할 수 있다. cancelInFlight: true는 새 Effect 시작 전 이전 것을 자동 취소한다.

.run { ... }.cancellable(id: CancelID.search, cancelInFlight: true)Effect.cancel(id: CancelID.search)_cancellationCancellables[(ID, NavigationPath): [AnyCancellable]]NavigationID (scope 깊이 추적)→ 같은 ID라도 다른 scope면 구분첫 번째 검색 Effect 시작ID: search두 번째 검색 Effect 시작ID: search첫 번째 Effect 취소두 번째 Effect 실행조회 후 취소새 액션 도착Effect.cancellablecancellable API내부 구조cancelInFlight 흐름

NavigationID는 Store의 scope 깊이를 추적한다. 서로 다른 scope에 있는 두 Feature가 같은 ID로 Effect를 취소해도 서로 영향을 주지 않는다.


Observation 시스템

TCA의 상태 변경이 SwiftUI 뷰를 어떻게 업데이트하는지 내부를 보자.

@ObservableStatestruct State합성 코드:• var _$id: ObservableStateID• var _$observationRegistrar• _$willModify()ObservableStateID• Location 기반 UUID• CoW 시맨틱• 변경 시 새 ID 발급PerceptionRegistrar(Perception 라이브러리)didSet: CurrentValueRelay<Void>상태 변경 신호withPerceptionTracking { }접근한 KeyPath 추적접근한 KeyPath 변경 시View 재빌드mutation 감지신호 전달Observation 시스템@ObservableState 매크로Store 내부SwiftUI View

핵심은 withPerceptionTracking이다. View가 렌더링될 때 접근한 State 프로퍼티만 추적한다. state.count에만 접근했다면 state.name이 바뀌어도 재빌드가 발생하지 않는다.


Scope 메커니즘과 자식 Store 캐싱

store.scope(state:action:)은 호출할 때마다 새 Store 객체를 생성하지 않는다. ScopeID를 키로 children 딕셔너리에 캐시한다.

Store<AppState, AppAction>children: [ScopeID: AnyObject]ScopeID<State, Action>• stateKeyPath: 타입 정보• actionKeyPath: 타입 정보• HashValue로 캐시 키캐시 HIT기존 Store 반환캐시 MISSScopedCore 생성→ 새 Store<Child, ChildAction>ScopedCore• parent.state[keyPath: stateKP]• action → parent.send(parentAction)• 변경 → parent 위임scope(state:action:) 호출children에 존재children에 없음모든 작업 위임Scope 메커니즘과 자식 Store 캐싱부모 Store<AppState, AppAction>ScopeID 생성캐시 분기

View가 매 렌더링마다 scope를 호출해도 같은 Store 인스턴스를 받는다. SwiftUI는 @StateObject를 처음 생성한 뒤 이후 렌더링에서 재사용한다. 만약 scope가 매번 새 Store를 만들면 @StateObject가 초기화를 반복하면서 상태가 리셋된다. 캐싱이 없으면 자식 Feature의 상태가 View 재빌드마다 날아가는 것이다.


ifLet: Optional 자식 State 처리

ifLet은 자식 State가 nil이 될 때 자동으로 Effect를 취소하는 특수한 합성 패턴이다.

AppReducer• state.child: ChildState?.ifLet(\.child, action: \.child) { ChildReducer()}child == nil?ChildReducer._reduce(state.child!, action)isInvalid = true자식 EffectCancellables모두 취소non-nil State 캐시(View 전환 애니메이션용)if let childStore = store.scope(state: \.child, action: \.child) { ChildView(store: childStore)}non-nilnil로 변경ifLet: Optional 자식 State 처리부모 ReducerIfLetCore 동작View 패턴

자식 State가 nil이 되는 순간 IfLetCore가 무효화되면서 모든 자식 Effect가 취소된다. Navigation에서 화면을 닫을 때 자동으로 비동기 작업이 정리되는 이유다.


forEach: 컬렉션 자식 State 처리

ifLet이 단일 Optional 자식을 다룬다면, forEach는 동일한 구조의 아이템이 여러 개인 컬렉션을 다룬다. 리스트 화면의 각 셀이 독립적인 Reducer를 갖는 패턴이다.

.forEach(\.items, action: \.items) { ItemReducer()}state.items:IdentifiedArrayOf<Item.State>(ID 기반 컬렉션)IdentifiedAction<ID, ItemAction>→ .element(id: uuid, action: .tapped)items[id: uuid] 조회ItemReducer._reduce(&items[id: uuid]!, action)items에서 ID 제거 시해당 ID의Effect 자동 취소삭제 액션forEach: 컬렉션 자식 State 처리forEach 설정액션 디스패치자동 정리

IdentifiedArray를 쓰는 이유가 여기에 있다. 일반 배열은 인덱스로 접근하는데, 인덱스는 삭제/추가에 따라 바뀐다. ID 기반 컬렉션은 특정 아이템의 액션을 항상 정확한 원소에 전달할 수 있다.


TestStore: 완전한 테스트 강제

TestStore는 모든 상태 변화와 Effect를 빠짐없이 검증하도록 강제한다.

TestStore( initialState: Feature.State(), reducer: { Feature() }, withDependencies: { $0.api = .mock })await store.send(.buttonTapped) { $0.isLoading = true}실제 상태 vs 예상 상태diff 출력 (불일치 시 실패)선언하지 않은상태 변경 → 실패await store.receive(\.response) { $0.data = [...] $0.isLoading = false}Timeout 내액션 수신 대기테스트 종료 전모든 Effect 완료 강제store.exhaustivity = .off(일부 변화 무시 허용)Effect 발생 시대안TestStore: 완전한 테스트 강제TestStore 초기화send 검증receive 검증

store.receive는 Effect에서 발생하는 액션을 받아서 검증한다. 이를 처리하지 않고 다음 send를 호출하면 테스트가 실패한다. 이 강제성이 TCA 테스트의 신뢰성을 만든다.


Dependency 시스템

TCA는 Dependencies 라이브러리와 통합된 의존성 주입 시스템을 갖는다.

struct APIClientKey: DependencyKey { static let liveValue = APIClient.live static let testValue = APIClient.mock}extension DependencyValues { var apiClient: APIClient { get { self[APIClientKey.self] } set { ... } }}struct Feature: Reducer { @Dependency(\.apiClient) var api @Dependency(\.continuousClock) var clock}.run { send in let data = try await self.api.fetch() await send(.loaded(data))}TestStore( ..., withDependencies: { $0.apiClient = .failing $0.continuousClock = .immediate })자동 컨텍스트:• App 실행 → liveValue• 테스트 실행 → testValue• Preview → previewValueDependency 시스템Dependency 정의Reducer에서 사용테스트 오버라이드

@Dependency는 전역 변수를 쓰는 것처럼 편하지만, 컨텍스트(라이브/테스트/프리뷰)에 따라 다른 구현이 주입된다. 테스트에서 .failing 의존성을 쓰면 호출 시 바로 실패해서 예상치 못한 의존성 접근을 잡아낼 수 있다.


@Reducer 매크로: 코드 생성

@Reducer 매크로는 TCA 코드의 대부분을 자동 생성한다.

@Reducerstruct Feature { @ObservableState struct State { var count = 0 } enum Action { case increment case decrement } var body: some ReducerOf<Self> { Reduce { state, action in ... } }}extension Feature.Action { // CaseScope 자동 생성 // enum 케이스별 KeyPath}extension Feature.State: ObservableState { var _$id: ObservableStateID var _$observationRegistrar func _$willModify()}State의 각 프로퍼티:• _$observationRegistrar 접근 추가• willSet에 _$willModify() 삽입@Reducer 매크로: 코드 생성작성하는 코드@Reducer 생성 코드@ObservableState 생성 코드

@Reducer 하나로 State의 Observable 프로토콜 채택, Action의 CaseScope KeyPath 합성, body의 타입 추론이 모두 해결된다. 매크로가 없던 초기 TCA에서는 이 코드를 전부 손으로 써야 했다. @Reducer는 편의 기능이 아니라 TCA를 현실적으로 쓸 수 있게 만드는 핵심 인프라다.


왜 이렇게 설계했는가

TCA는 Point-Free의 Brandon Williams와 Stephen Celis가 만들었다. 두 사람은 함수형 프로그래밍 강의를 하면서 "iOS 앱을 함수형으로 만들면 어떨까"를 실제로 구현한 결과가 TCA다. Elm Architecture에서 직접 영감을 받았고, 이것이 설계 전반에 걸쳐 드러난다.

설계의 핵심 동기는 세 가지였다.

테스트 가능성. UIKit/SwiftUI 앱은 비동기 코드와 부수 효과가 얽혀 있어서 테스트가 어렵다. TCA는 Reducer를 순수 함수에 가깝게 설계하고, 부수 효과를 Effect 값 타입으로 분리해서 테스트에서 쉽게 대체할 수 있게 했다.

합성 가능성. 앱을 작은 Feature로 쪼개고, 이 Feature들을 Reducer 합성으로 조립한다. 작은 Feature는 독립적으로 개발하고 테스트할 수 있다. ifLet, forEach, Scope가 이 합성을 가능하게 하는 도구들이다.

부수 효과의 명시성. 네트워크 요청, 타이머, 알림 같은 부수 효과를 코드 어디서나 실행할 수 있으면 추적이 어렵다. TCA는 Reducer가 Effect만 반환하고, 실제 실행은 Core가 담당하도록 했다. 부수 효과가 항상 한 곳에서 관리된다.

테스트 가능성(Testability)합성 가능성(Composability)부수 효과 명시성(Explicit Effects)Reducer ≈ 순수 함수(inout State, Action) → EffectifLet / forEach / ScopeReducer 계층 합성Effect = 값 타입실행 기술만 반환Core가 실제 실행TestStore완전 검증 강제Feature 독립 개발작게 시작, 크게 조립부수 효과 경계 명확추적, 취소, 교체 가능설계 동기와 결과설계 동기설계 결정결과

이 세 동기가 TCA의 "왜"를 설명한다. send가 동기처럼 보이는 이유, Effect가 값 타입인 이유, TestStore가 그토록 엄격한 이유가 모두 여기서 나온다.


Reducer는 (거의) 순수 함수다

함수형 프로그래밍에서 순수 함수는 입력이 같으면 출력이 항상 같고, 외부 상태를 바꾸지 않는다. TCA의 Reducer는 이 정의에 아주 가깝다.

func reduce(into state: inout State, action: Action) -> Effect<Action>

inout State를 받아서 Effect를 반환한다. 외부에서 뭔가를 직접 실행하지 않는다. 네트워크도, 타이머도, 파일 쓰기도 — 모두 Effect 값으로 기술해서 반환할 뿐이다.

inout 때문에 완전한 순수 함수는 아니지만, 이것은 성능을 위한 실용적 타협이다. Swift는 값 타입 복사 비용을 피하기 위해 inout을 쓴다. 의미적으로는 (State, Action) → (State, Effect<Action>)과 동일하다.

결정론적같은 (State, Action) →항상 같은 (State, Effect)직접 부수 효과 없음Effect 값만 반환외부 상태 직접 접근 없음@Dependency로만 의존성 주입inout State(성능을 위한 타협)의미론적으로는 순수@Dependency전역 의존성 컨테이너(테스트에서 교체 가능)Reducer 단독 테스트 가능Store 없이도 검증상태 재현 가능(State, Action) 기록 후 재생SwiftUI Preview에서실제 로직 실행 가능여전히 가능previewValue 사용Reducer (거의) 순수 함수순수 함수 속성순수하지 않은 부분순수성의 이점

이 순수성 덕분에 TestStore가 완전한 상태 검증을 할 수 있다. Reducer가 외부를 직접 건드리지 않으니, 테스트에서 모든 상태 변화를 추적할 수 있다.


MVI vs TCA: 어떻게 다른가

MVI(Model-View-Intent)는 Android 생태계에서 Cycle.js의 영향을 받아 발전한 패턴이다. TCA와 공유하는 아이디어가 많지만, 접근 방식이 다르다.

ViewIntentViewModelCoroutine / RxJava(상태 변경 + 효과 실행)Observable<State>ViewActionReducer(inout State만)Effect(값 타입 반환)Core효과 실행State (Observation)처리emit구독반환inoutsend()observeMVI vs TCAMVI (Android / Kotlin)TCA (iOS / Swift)

가장 큰 차이는 부수 효과 처리 방식이다.

MVI에서 ViewModel은 Intent를 받아 직접 Coroutine이나 RxStream을 실행한다. ViewModel이 부수 효과의 실행 주체다. 테스트에서 이 실행을 가로채려면 별도의 인터페이스가 필요하다.

TCA에서 Reducer는 Effect 값만 반환하고, 실행은 Core가 한다. Reducer 자체는 실행 주체가 아니다. 이 분리가 테스트를 단순하게 만든다 — Reducer 테스트는 반환된 Effect가 올바른지만 확인하면 된다.

Intent.search(query)ViewModelviewModelScope.launch { val r = api.search(query) // 효과 실행 _state.update { copy(results = r) }}StateFlow<State>Action.search(query)Reducerstate.isLoading = truereturn .run { send in let r = try await api.search(query) await send(.loaded(r))}CoreEffect 구독 → Task 실행State → Observation → View처리emitEffect 값 반환inout 변경send(.loaded)부수 효과 처리 방식 비교MVI: ViewModel이 상태 변경과 효과 실행을 모두 담당TCA: Reducer는 기술만, Core가 실행

또 다른 차이는 합성 단위다. MVI는 ViewModel이 기본 단위라서 ViewModel 간 합성이 명시적이지 않다. 공유 상태는 보통 별도 Repository나 SharedFlow로 처리한다. TCA는 Reducer 합성이 일급 시민이다. 작은 Feature Reducer를 ifLet, forEach로 조립해서 전체 앱을 하나의 Root Reducer로 만들 수 있다.

MVITCA
기원Android / Kotlin 생태계iOS / Swift, Point-Free
부수 효과 실행ViewModel 내부 (Coroutine/Rx)Core가 Effect 값을 구독하여 실행
Reducer 순수성보통 순수하지 않음거의 순수 (inout State만 예외)
합성 단위ViewModel (명시적 합성 없음)Reducer (ifLet / forEach / Scope)
테스트 접근StateFlow 값 검증TestStore 완전 강제 검증
의존성 주입생성자 / DI 프레임워크 (Hilt 등)@Dependency (컨텍스트 자동 전환)
상태 타입data class (Kotlin)struct @ObservableState (Swift)

MVI가 나쁘다는 말이 아니다. Kotlin/Android 생태계에서 자연스럽게 발전한 패턴이고, Coroutine과의 궁합이 좋다. TCA는 함수형 프로그래밍의 원칙을 Swift에 더 적극적으로 적용하려 했고, 그 결과 더 엄격하지만 더 예측 가능한 시스템이 됐다.


전체 구조 요약

TCA 소스코드를 다 뜯어본 결과, 설계 결정의 이유가 보이기 시작한다.

View(SwiftUI)Store<State, Action>Core(RootCore)Reducerreduce(&state, action)Effect<Action>값 타입 반환Send<Action>@MainActor, Sendablesend(action)delegates_reducereturnsCore가 구독actionobservationTCA 전체 구조PresentationStoreCoreReducerEffect / Send
설계 결정의도결과
Store ≠ Core추상화 계층 분리ScopedCore / IfLetCore로 다양한 scope 전략
Effect = 값 타입실행 기술만 반환, 실행 아님.merge() / .concatenate()로 합성 가능
액션 버퍼링재진입 안전 보장.effect 원점 허용, .store 원점 경고
UUID 기반 Effect 관리명시적 취소 지원.cancellable(id:)로 ID 기반 취소
ScopeID 캐싱자식 Store 재생성 방지View 재렌더링 시 상태 유지
TestStore 완전 검증모든 Effect 추적 강제놓친 Effect가 있으면 테스트 실패

TCA의 복잡성은 우연이 아니다. 상태 일관성, Effect 생명주기, 테스트 가능성 — 이 세 가지를 대규모 앱에서 동시에 만족시키려면 반드시 이 정도의 설계가 필요하다. Store → Core → Reducer → Effect → Send → Store로 돌아오는 루프 위에 모든 기능이 쌓여 있다.

처음에는 왜 이렇게 복잡한가 싶지만, 소스코드를 뜯어보면 각 계층이 명확한 이유로 존재한다는 걸 알게 된다. Store와 Core를 분리한 이유, Effect가 값 타입인 이유, TestStore가 그토록 엄격한 이유 — 전부 연결되어 있다.