LODY/정리

애플 플랫폼 백과사전 / 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가 완료되면 다시 액션을 보낸다.

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


Store: 공개 인터페이스

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

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


Core 프로토콜 계층

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

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


RootCore: 액션 처리 루프

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

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

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

재진입(Reentrancy) 처리

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

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


Reducer 프로토콜과 Body 합성

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

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


ReducerBuilder: 순차 실행과 Effect 병합

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

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


Effect 시스템: 세 가지 종류

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

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


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

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

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


Effect 생명주기: UUID 기반 관리

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

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


Effect.cancellable: ID 기반 취소

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

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


Observation 시스템

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

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


Scope 메커니즘과 자식 Store 캐싱

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

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


ifLet: Optional 자식 State 처리

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

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


forEach: 컬렉션 자식 State 처리

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

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


TestStore: 완전한 테스트 강제

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

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


Dependency 시스템

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

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


@Reducer 매크로: 코드 생성

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

@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가 담당하도록 했다. 부수 효과가 항상 한 곳에서 관리된다.

이 세 동기가 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>)과 동일하다.

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


MVI vs TCA: 어떻게 다른가

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

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

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

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

또 다른 차이는 합성 단위다. 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 소스코드를 다 뜯어본 결과, 설계 결정의 이유가 보이기 시작한다.

설계 결정의도결과
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가 그토록 엄격한 이유 — 전부 연결되어 있다.