배경: 왜 상태 관리 프레임워크가 필요했는가
iEvent는 자녀의 학교·학원 일정을 가족이 함께 관리하는 앱입니다. 홈 화면 하나에서 여러 자녀의 오늘 일정, 등하원 시각, 날씨, AI 브리핑을 동시에 보여주어야 합니다. 여기에 LiveActivity, 알림장 OCR, 학원 등록 플로우까지 얽히면 상태가 폭발적으로 복잡해집니다.
초기에는 @StateObject + ObservableObject 조합으로 시작했지만, 화면이 4개를 넘어서자 상태 흐름을 추적하기 어려워졌습니다. “어디서 이 값이 바뀌었지?”라는 질문에 답하려면 10개 이상의 파일을 열어봐야 했습니다.
TCA를 선택한 핵심 이유
1. 단방향 데이터 흐름
TCA는 State → View → Action → Reducer → State의 단방향 흐름을 강제합니다. 뷰는 상태를 읽기만 하고, 변경은 반드시 Action을 통해서만 가능합니다. 버그가 생기면 Reducer 하나만 보면 됩니다.
// HomeFeature.swift
@Reducer
struct HomeFeature {
@ObservableState
struct State: Equatable {
var children: [Child] = []
var selectedChildId: String = ""
var todaySchedules: [Schedule] = []
var aiBriefing: String? = nil
var isLoading: Bool = false
@Presents var childDetail: ChildDetailFeature.State?
@Presents var scheduleAdd: ScheduleAddFeature.State?
}
enum Action {
case onAppear
case childSelected(String)
case schedulesLoaded(Result<[Schedule], Error>)
case aiBriefingLoaded(Result<String, Error>)
case childDetail(PresentationAction<ChildDetailFeature.Action>)
case scheduleAdd(PresentationAction<ScheduleAddFeature.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in
await send(.schedulesLoaded(Result { try await scheduleRepo.fetchToday() }))
}
case let .childSelected(id):
state.selectedChildId = id
return .none
default: return .none
}
}
.ifLet(\.$childDetail, action: \.childDetail) { ChildDetailFeature() }
.ifLet(\.$scheduleAdd, action: \.scheduleAdd) { ScheduleAddFeature() }
}
}
2. @Dependency 주입으로 테스트 용이성
TCA의 @Dependency는 의존성 주입을 프레임워크 수준에서 지원합니다. 실제 API 클라이언트 대신 테스트용 mock을 손쉽게 주입할 수 있습니다. 실제 ChildRepository의 DependencyKey 패턴입니다.
// ChildRepository.swift — DependencyKey 패턴
private enum ChildRepositoryKey: DependencyKey {
static var liveValue: ChildRepository {
@Dependency(\.syncEngineClient) var syncEngine
return .live(syncEngine: syncEngine)
}
static var testValue: ChildRepository {
.mock()
}
}
extension DependencyValues {
var childRepository: ChildRepository {
get { self[ChildRepositoryKey.self] }
set { self[ChildRepositoryKey.self] = newValue }
}
}
// Reducer에서 사용
struct HomeFeature {
@Dependency(\.childRepository) var childRepo
}
3. @ObservableState로 세밀한 재렌더링 제어
TCA 1.7+의 @ObservableState 매크로는 Swift Observation 프레임워크를 활용해, 실제로 변경된 프로퍼티에 의존하는 뷰만 재렌더링합니다. @State + ObservableObject 조합보다 성능이 크게 향상됩니다.
// 뷰에서는 단순히 store를 관찰
struct HomeView: View {
@Bindable var store: StoreOf<HomeFeature>
var body: some View {
// store.children이 바뀔 때만 이 뷰가 재렌더링됨
ForEach(store.children) { child in
ChildCardView(child: child)
}
}
}
실제 겪은 함정: @Presents와 옵셔널 상태
TCA에서 모달/시트를 띄울 때는 @Presents 매크로와 .ifLet 체이닝을 사용합니다. 초기에는 이 패턴을 모르고 직접 Bool 플래그로 관리했는데, 시트가 닫힐 때 상태가 남아있거나 두 번 닫히는 버그가 발생했습니다.
// ❌ 잘못된 패턴 — Bool 플래그로 직접 관리
struct State {
var isShowingDetail: Bool = false
var detailChild: Child? = nil
}
// ✅ 올바른 패턴 — @Presents 사용
struct State {
@Presents var childDetail: ChildDetailFeature.State?
}
// Reducer에서 .ifLet 체이닝
var body: some ReducerOf<Self> {
Reduce { state, action in ... }
.ifLet(\.$childDetail, action: \.childDetail) {
ChildDetailFeature()
}
}
TCA 도입 후 달라진 것
- 버그 추적 시간 80% 감소: 상태 변경이 항상 Action → Reducer를 통하므로 중단점 하나로 모든 변경 추적 가능
- 테스트 커버리지 확대: TestStore를 사용해 Action 시퀀스별 상태 변화를 단위 테스트로 검증
- 팀 온보딩 단순화: 새 기능 추가 시 State/Action/Reducer 세 곳만 보면 됨
교훈
TCA는 학습 곡선이 있지만, 상태가 복잡한 앱일수록 그 가치가 커집니다. 특히 가족 공유처럼 여러 사용자의 데이터가 실시간으로 동기화되는 앱에서는 단방향 데이터 흐름이 필수적입니다. 처음부터 TCA를 선택한 것은 이 프로젝트에서 가장 잘한 결정 중 하나였습니다.