TCA(The Composable Architecture)를 선택한 이유 — 예측 가능한 iOS 앱 상태 관리

배경: 왜 상태 관리 프레임워크가 필요했는가

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를 선택한 것은 이 프로젝트에서 가장 잘한 결정 중 하나였습니다.

Leave a Reply

Your email address will not be published. Required fields are marked *