← TCA 14일 마스터 14 / 14 · 1부

Day 14 · 1부 · 실전 구조 — 앱 지도·모듈화·UIKit

공식 예제로 큰 앱의 기능 트리와 모듈 경계를 그리고, UIKit 화면에 같은 스토어를 연결하고, FAQ로 도입 기준을 정한다.
진행률
0 / 0 완료
13일 동안 부품을 하나씩 만들었다. 리듀서, 이펙트, 의존성, 테스트, 내비게이션, 공유 상태. 회사 앱에 넣으려면 질문이 셋 남는다. "부품들이 앱 하나로 어떻게 맞물리나?", "기존 UIKit 화면과 같이 살 수 있나?", "애초에 이 화면에 TCA가 맞나?" 1부는 이 세 질문을 다룬다. 공식 예제 SyncUps로 앱 전체 지도를 그리고, TicTacToe 예제로 모듈 경계를 긋고, observe { }로 UIKit에 연결하고, FAQ로 쓰지 말아야 할 때를 정리한다.

1부 목표 · 29분

  • SyncUps의 기능 트리를 그리고, 각 연결에 쓰인 도구(Scope · forEach · @Presents · @Shared · @Dependency)를 해당 Day와 짝짓는다.
  • 기능을 Core / UI / 의존성 인터페이스 / live 구현 모듈로 나누고, 어느 쪽이 어느 쪽을 import해도 되는지 정한다.
  • UIKit 뷰 컨트롤러에서 observe { }·@UIBindable·present(item:)·NavigationStackController로 같은 스토어를 그린다.
  • "이 화면에 TCA를 쓸까?"를 FAQ 기준으로 판단하고, 흔한 반론이 지금 버전에서도 맞는지 가려낸다.

1. 앱 전체 지도 — SyncUps를 위에서 내려다보기 🔥 핵심

SyncUps 예제는 공식 튜토리얼 Building SyncUps의 완성본이다. 기능 파일 6개(AppFeature, SyncUpsList, SyncUpForm, SyncUpDetail, RecordMeeting, Meeting)와 의존성 2개(SpeechRecognizer, OpenSettings)로 되어 있다. 코스에서 배운 도구가 거의 다 들어 있어서 지도로 쓰기 좋다.

AppFeature Scope + StackState path SyncUpsList Scope · 루트 화면 Path (@Reducer enum) forEach(\.path) · push되는 화면 @Presents destination add(SyncUpForm) · alert SyncUpDetail .detail Meeting .meeting (값만) RecordMeeting .record @Presents destination edit(SyncUpForm) · alert @Presents alert 저장·폐기 확인 실선 = 부모가 자식 State를 품음 점선 = 띄웠을 때만 존재 @Dependency uuid · date.now continuousClock speechClient openSettings dismiss @Shared(.syncUps) fileStorage sync-ups.json
SyncUps는 루트 하나 아래 "항상 있는 화면(Scope)", "쌓이는 화면(StackState)", "잠깐 뜨는 화면(@Presents)"이 섞인 트리다. 의존성과 공유 상태는 트리 밖에서 필요한 기능만 가져다 쓴다.

지도에서 Day 찾기

SyncUps에서쓰인 도구배운 곳
AppFeature가 목록 화면을 항상 품음Scope(_:action:)Day 5
상세 → 기록 화면 push, 뒤로 가기StackState · forEach(\.path) · @Reducer enum PathDay 11
추가·편집 시트, 삭제 알럿@Presents · @Reducer enum Destination · AlertStateDay 10
편집 폼의 텍스트 필드BindableAction · BindingReducerDay 4
회의 타이머, 음성 인식 스트림.run · continuousClock · speechClientDay 3 · Day 7
목록·상세·기록이 같은 SyncUp을 봄, 앱 재시작 후에도 유지@Shared(.syncUps) + .fileStorageDay 12
상세가 "회의 시작"을 부모에게 알림delegate 액션Day 5
기능별 테스트 파일 5개 (SyncUpsTests)TestStoreDay 8 · 9

루트 리듀서의 뼈대는 짧다. 자식 기능을 먼저 골격만 둔다.

자식 기능 골격 — 상세 화면은 결정하지 않고 delegate로 "요청"만 한다
// SyncUps의 자식 기능들 — 여기서는 골격만 남겼다 (원본: Examples/SyncUps)
@Reducer
struct SyncUpsList {
  @ObservableState
  struct State: Equatable { var titles: [String] = [] }
  enum Action { case addSyncUpButtonTapped }
  var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}

@Reducer
struct SyncUpDetail {
  @ObservableState
  struct State: Equatable { var title = "" }
  enum Action {
    case delegate(Delegate)
    case startMeetingButtonTapped
    @CasePathable
    enum Delegate { case startMeeting(title: String) }   // 원본은 Shared<SyncUp>을 넘긴다
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .delegate:
        return .none
      case .startMeetingButtonTapped:
        return .send(.delegate(.startMeeting(title: state.title)))  // "부모야, 회의 시작해 줘"
      }
    }
  }
}

@Reducer
struct RecordMeeting {
  @ObservableState
  struct State: Equatable { var title: String }
  enum Action { case endMeetingButtonTapped }
  var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}
루트 AppFeature — Scope + forEach + delegate 가로채기 (SyncUps 원본을 줄인 것)
@Reducer
struct AppFeature {
  @Reducer(state: .equatable)          // TestStore로 테스트하려면 State가 Equatable이어야 한다
  enum Path {                          // push될 수 있는 화면 목록 (Day 11)
    case detail(SyncUpDetail)
    case record(RecordMeeting)
  }

  @ObservableState
  struct State: Equatable {
    var path = StackState<Path.State>()
    var syncUpsList = SyncUpsList.State()   // 루트 화면은 항상 존재
  }

  enum Action {
    case path(StackActionOf<Path>)
    case syncUpsList(SyncUpsList.Action)
  }

  var body: some ReducerOf<Self> {
    Scope(\.syncUpsList, action: \.syncUpsList) {   // Day 5
      SyncUpsList()
    }
    Reduce { state, action in
      switch action {
      // 상세 화면의 delegate 액션을 가로채 기록 화면을 push
      case let .path(.element(_, .detail(.delegate(.startMeeting(title))))):
        state.path.append(.record(RecordMeeting.State(title: title)))
        return .none
      case .path, .syncUpsList:
        return .none
      }
    }
    .forEach(\.path, action: \.path)       // 스택의 각 화면에 자식 리듀서 연결
  }
}

루트가 하는 일은 조립과 화면 간 연결뿐이다. 회의 로직은 RecordMeeting에, 편집 로직은 SyncUpForm에 있다. 상세 화면은 기록 화면을 모른다. .delegate(.startMeeting)를 보내면 스택을 가진 부모가 push한다. 그래서 상세 기능은 기록 기능 없이 컴파일된다. 2절의 모듈화가 이 성질을 이용한다.

뷰와 진입점 — 스토어는 앱에 하나, 뷰는 scope로 잘라 받는다
struct AppView: View {
  @Bindable var store: StoreOf<AppFeature>

  var body: some View {
    NavigationStack(path: $store.scope(\.path, action: \.path)) {
      Text("SyncUps 목록")                 // 원본: SyncUpsListView(store: …)
    } destination: { store in
      switch store.case {
      case let .detail(store): Text(store.title)
      case let .record(store): Text("회의 중: \(store.title)")
      }
    }
  }
}

@main
struct SyncUpsApp: App {
  // 앱 전체에 스토어는 하나. static으로 둬서 Preview가 앱 진입점을 다시 만들 때 섞이지 않게 한다
  static let store = Store(initialState: AppFeature.State()) {
    AppFeature()
      ._printChanges()                     // 모든 액션과 상태 diff를 콘솔에
  }

  var body: some Scene {
    WindowGroup {
      if isTesting {
        EmptyView()                        // 테스트 중엔 앱 코드를 띄우지 않는다
      } else {
        AppView(store: Self.store)
      }
    }
  }
}
같은 앱을 SwiftUI(@Observable)로TCA로
화면마다 모델 클래스. 부모 모델이 자식 모델 인스턴스를 들고 있음화면마다 리듀서. 부모 State가 자식 State 값을 들고 있음
상세 → 기록 이동: 상세 모델에 클로저(onStartMeeting)를 주입해 부모가 받음delegate 액션을 부모 리듀서가 패턴 매칭으로 받음
"앱이 지금 어떤 상태인가"를 보려면 모델 그래프를 따라가야 함AppFeature.State 값 하나가 전부. _printChanges()가 액션마다 diff를 찍음
통합 테스트: 모델 여러 개를 직접 엮어 호출루트 TestStore 하나로 push·delegate·공유 상태까지 단언(그래서 위 코드는 @Reducer(state: .equatable) enum Path + State: Equatable로 두었다 — 없으면 TestStore를 만들 수 없다)
🔑 핵심

큰 앱의 TCA 구조는 "루트 하나 + 자식은 세 가지 방식으로 매달림"이다. 항상 있음 → Scope, 잠깐 뜸 → @Presents + ifLet, 쌓임 → StackState + forEach. 목록 행이 여럿이면 여기에 IdentifiedArray + forEach(Day 6)가 붙는다. 새 화면을 만나면 넷 중 어디에 해당하는지부터 정한다.

2. 기능 모듈화 — 패키지로 경계 긋기

기능이 30개를 넘으면 앱 타깃 하나로는 빌드가 느리고, Preview 하나 보려고 전체를 컴파일한다. 공식 예제 TicTacToe의 README는 목표를 이렇게 적는다. 기능마다 모듈을 따로 두고, 의존을 최소로 해서 앱 전체를 빌드하지 않고도 기능 하나를 컴파일·실행한다. 로직은 *Core 모듈에 두고 UI 모듈과 분리해서 SwiftUI 앱과 UIKit 앱이 같은 Core를 공유한다.

모듈 (TicTacToe 실제 이름)들어 있는 것의존하는 것
AuthenticationClient의존성 인터페이스(@DependencyClient 구조체, testValue)swift-dependencies만
AuthenticationClientLiveliveValue — 실제 구현AuthenticationClient
LoginCore · TwoFactorCore리듀서(State · Action · body)ComposableArchitecture, 인터페이스, 자식 Core
LoginSwiftUI · LoginUIKit뷰 / 뷰 컨트롤러LoginCore, 자식 UI 모듈
LoginCoreTestsTestStore 테스트LoginCore만
앱 타깃진입점, live 조립App UI 모듈 + AuthenticationClientLive
앱 타깃 진입점 · live 조립 LoginSwiftUI LoginUIKit AuthenticationClientLive LoginCore TwoFactorCore AuthenticationClient 화살표 = import. Core는 UI를 모르고, 인터페이스는 live 구현을 모른다.
무거운 live 구현(네트워크·서드파티 SDK)을 import하는 곳은 앱 타깃 하나뿐이다. Core와 테스트는 가벼운 인터페이스만 보고 빌드된다.

의존성: 인터페이스와 live를 다른 모듈에

swift-dependencies 문서 Separating interface and implementation의 방법이다. 인터페이스 모듈에서는 TestDependencyKey만 채택하고, live 모듈에서 같은 타입에 DependencyKey를 추가로 채택해 liveValue를 준다. previewValue도 인터페이스 모듈에 둬야 한다(같은 문서의 Note).

세 모듈에 나눠 둔 코드 — 인터페이스 · live · Core (실제로는 모듈이 다르다. 한 블록에 합쳐 보였다)
// ── Module: AuthenticationClient (인터페이스) ──
@DependencyClient
public struct AuthenticationClient: Sendable {
  public var login: @Sendable (_ email: String, _ password: String) async throws -> String
}

extension AuthenticationClient: TestDependencyKey {
  public static let testValue = Self()     // 테스트에서 안 덮어쓰고 부르면 실패
}

extension DependencyValues {
  public var authenticationClient: AuthenticationClient {
    get { self[AuthenticationClient.self] }
    set { self[AuthenticationClient.self] = newValue }
  }
}

// ── Module: AuthenticationClientLive (실제 구현) ──
extension AuthenticationClient: DependencyKey {
  public static let liveValue = Self(
    login: { email, password in
      try await Task.sleep(for: .seconds(1))  // 실제로는 네트워크·서드파티 SDK
      return "token-for-\(email)"
    }
  )
}

// ── Module: LoginCore ── 다른 모듈에서 쓰려면 타입·init·body가 public
@Reducer
public struct Login {
  @ObservableState
  public struct State: Equatable {
    public var email = ""
    public var isLoading = false
    public init(email: String = "") { self.email = email }
  }

  public enum Action {
    case loginButtonTapped
    case loginResponse(Result<String, any Error>)
  }

  @Dependency(\.authenticationClient) var authenticationClient   // 인터페이스만 안다

  public init() {}

  public var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .loginButtonTapped:
        state.isLoading = true
        return .run { [email = state.email] send in
          await send(.loginResponse(Result {
            try await authenticationClient.login(email: email, password: "password")
          }))
        }
      case .loginResponse:
        state.isLoading = false
        return .none
      }
    }
  }
}

Core 모듈의 리듀서는 접근 제어(public)만 늘었지 구조는 그대로다. LoginCore는 AuthenticationClientLive를 import하지 않는다. 테스트는 testValue를 덮어쓰고, 앱 타깃이 live를 링크하면 실행 시 liveValue가 쓰인다.

⚠️ 모듈로 나누면 생기는 일

① public init 필요: 다른 모듈에서 Login.State()를 만들려면 public init을 직접 써야 한다. State에 @Shared가 있으면 init 작성 규칙이 따로 있다(SharingState · Initialization rules, Day 12 2부). ② 테스트 호스트 함정이 사라짐: 앱 타깃에서 테스트를 돌리면 앱 진입점이 같이 실행돼 의존성 오류가 난다(Day 9). TestingTCA는 프레임워크·SPM 라이브러리 테스트에서는 이 문제가 없다며 "모듈화할 또 하나의 이유"라고 적는다.

내비게이션 방식이 모듈 경계를 정한다

What is navigation?은 트리와 스택의 장단점을 모듈화 관점으로도 비교한다.

트리 기반 (@Presents, Day 10)스택 기반 (StackState, Day 11)
모듈 결합상세가 편집을 품으므로 상세를 빌드하려면 편집도 빌드스택의 각 기능은 서로 몰라서 모듈끼리 의존이 없음
기능 모듈 단독 Preview상세 Preview에서 편집 화면까지 동작상세 Preview에서 push 버튼은 동작 안 함(목적지를 모름)
통합 테스트상세+편집 통합을 기능 모듈 안에서 테스트통합은 스택을 가진 루트(앱)에서만 테스트

SyncUps가 둘을 섞은 이유가 여기 있다. 짧고 닫힌 흐름(편집 시트, 알럿)은 트리로 묶고, 화면 간 이동은 스택으로 풀었다. NavigationLink(state:)는 Path.State를 알아야 해서 모든 기능을 같이 빌드하게 만든다. 그래서 StackBasedNavigation은 대안으로 버튼 → 자식 액션(위의 delegate) 방식을 보여 준다.

3. UIKit과 함께 — observe { } 와 UIKit 내비게이션

회사 앱은 대부분 UIKit 화면이 섞여 있다. TCA의 UIKit Integration 문서는 "SwiftUI를 염두에 두고 설계했지만 UIKit 연동 도구도 있다"고 적는다. 도구의 정의는 대부분 형제 라이브러리 swift-navigation의 UIKitNavigation에 있고 TCA가 다시 내보낸다. TCA의 UIKit/ 폴더에는 파일이 두 개(AlertStateUIKit.swift, NavigationStackControllerUIKit.swift)뿐이다(소스 기준).

SwiftUI 뷰에서 (Day 2~11)UIKit 뷰 컨트롤러에서
body가 읽은 상태 자동 추적observe { } 클로저가 읽은 상태 자동 추적
@Bindable var store@UIBindable var store
TextField(text: $store.text)UITextField(text: $store.code)
.sheet(item: $store.scope(…))present(item: $store.scope(…)) { … }
.alert($store.scope(…))present(item:) + UIAlertController(store:)
NavigationStack(path: $store.scope(…))NavigationStackController 서브클래스
observe { } — viewDidLoad에서 한 번 부르면 끝 (UIKitCaseStudies/CounterViewController 요약)
@Reducer
struct Counter {
  @ObservableState
  struct State: Equatable { var count = 0 }
  enum Action { case incrementButtonTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementButtonTapped:
        state.count += 1
        return .none
      }
    }
  }
}

final class CounterViewController: UIViewController {
  private let store: StoreOf<Counter>

  init(store: StoreOf<Counter>) {
    self.store = store
    super.init(nibName: nil, bundle: nil)
  }
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

  override func viewDidLoad() {
    super.viewDidLoad()
    let countLabel = UILabel()
    let incrementButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
      self?.store.send(.incrementButtonTapped)       // 입력: 액션을 보낸다
    })
    incrementButton.setTitle("+", for: .normal)
    view.addSubview(UIStackView(arrangedSubviews: [countLabel, incrementButton]))

    observe { [weak self] in                         // 출력: 읽은 상태가 바뀌면 다시 실행
      guard let self else { return }
      countLabel.text = "\(store.count)"
    }
  }
}
① viewDidLoad observe { } 1회 등록 ② 클로저 실행 읽은 필드 추적: count ③ 탭 → send 리듀서가 count 변경 ④ 변경 감지 main 큐에서 재실행 다시 추적 등록 — 이번 실행에서 읽은 필드만 토큰은 VC에 붙어 있어 VC가 해제되면 관찰도 끝난다 (소스 기준)
observe는 SwiftUI body 재계산을 UIKit으로 옮긴 것이다. 내부는 withObservationTracking이고, 변경 후 재실행은 DispatchQueue.main.async로 예약된다(swift-navigation 소스 기준).

observe 문서 주석의 사용 규칙은 세 가지다. ① 보통 viewDidLoad에서 한 번 부르고 라벨 여러 개를 한 클로저에서 갱신한다. 관련 없는 필드가 바뀌어 label.text를 다시 대입해도 성능 문제는 아니다. ② reloadData()처럼 무거운 일은 별도 observe로 떼어 낸다. ③ 반환된 ObserveToken으로 취소할 수 있다(예: viewWillAppear에서 시작, viewWillDisappear에서 cancel()).

❌ 옛 방식 — store.publisher + Combine 구독 (1.7 가이드가 observe로 교체를 권함)
// 1.7 이전 UIKit 관찰 — Combine 구독을 필드마다 손으로 관리했다
private var cancellables: Set<AnyCancellable> = []

override func viewDidLoad() {
  super.viewDidLoad()
  store.publisher.count
    .sink { [weak self] in self?.countLabel.text = "\($0)" }
    .store(in: &cancellables)
}
바인딩과 알럿까지 — @UIBindable · UITextField(text:) · present(item:) (TicTacToe의 TwoFactorViewController 패턴)
@Reducer
struct CodeEntry {
  @ObservableState
  struct State: Equatable {
    @Presents var alert: AlertState<Action.Alert>?
    var code = ""
    var isRequestInFlight = false
  }
  enum Action: BindableAction {
    case alert(PresentationAction<Alert>)
    case binding(BindingAction<State>)
    case submitButtonTapped
    enum Alert: Equatable {}
  }
  var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      case .submitButtonTapped where state.code.count < 4:
        state.alert = AlertState { TextState("코드는 4자리 이상입니다") }
        return .none
      case .submitButtonTapped:
        state.isRequestInFlight = true
        return .none
      case .alert, .binding:
        return .none
      }
    }
    .ifLet(\.$alert, action: \.alert)
  }
}

final class CodeEntryViewController: UIViewController {
  @UIBindable var store: StoreOf<CodeEntry>        // SwiftUI의 @Bindable 자리

  init(store: StoreOf<CodeEntry>) {
    self.store = store
    super.init(nibName: nil, bundle: nil)
  }
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

  override func viewDidLoad() {
    super.viewDidLoad()
    let codeField = UITextField(text: $store.code)  // 양방향 바인딩 (Day 4의 $store.text)
    let submitButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
      self?.store.send(.submitButtonTapped)
    })
    let spinner = UIActivityIndicatorView(style: .medium)
    view.addSubview(UIStackView(arrangedSubviews: [codeField, submitButton, spinner]))

    observe { [weak self] in
      guard let self else { return }
      spinner.isHidden = !store.isRequestInFlight
      submitButton.isEnabled = !store.isRequestInFlight
    }

    // 상태가 nil이 아니게 되면 띄우고, 닫히면 nil로 되돌린다 (Day 10의 .alert($store.scope…))
    present(item: $store.scope(\.alert, action: \.alert)) { store in
      UIAlertController(store: store)
    }
  }
}

스택 내비게이션은 UINavigationController 대신 NavigationStackController를 서브클래싱한다. 1절의 AppFeature를 그대로 쓴다. SwiftUI판 NavigationStack(path:root:destination:)과 모양이 거의 같다.

NavigationStackController — StackState가 UINavigationController를 몬다 (StackBasedNavigation · UIKit 절)
final class AppController: NavigationStackController {
  private var store: StoreOf<AppFeature>!

  convenience init(store: StoreOf<AppFeature>) {
    @UIBindable var store = store

    self.init(path: $store.scope(\.path, action: \.path)) {
      UIHostingController(rootView: Text("SyncUps 목록"))    // 루트는 SwiftUI여도 된다
    } destination: { store in
      switch store.case {
      case let .detail(store):
        UIHostingController(rootView: Text(store.title))
      case let .record(store):
        UIHostingController(rootView: Text("회의 중: \(store.title)"))
      }
    }
    self.store = store
  }
}
📝 반대 방향: UIKit 앱 안에 TCA SwiftUI 화면

위 코드처럼 TCA로 만든 SwiftUI 뷰를 UIHostingController(rootView: FeatureView(store: …))로 감싸면 기존 UIKit 내비게이션에 그대로 끼울 수 있다(SwiftUI 코스 Day 6). 점진 도입은 보통 이 방향이 쉽다. 새 화면을 TCA+SwiftUI로 만들어 호스팅하고, 기존 VC는 그대로 둔다.

4. 언제 TCA를 쓰지 말까 — FAQ 쟁점 정리

📝 TCA 문서 원문

"We also don't think TCA really shines when building simple “reader” apps that mostly load JSON from the network and display it. … In general it can be fine to start a project with vanilla SwiftUI … and then transition to TCA later if there is a need for any of its powers." — TCA 문서 · FAQ

JSON을 받아 보여 주기만 하는 "리더" 앱에서는 TCA의 장점이 잘 드러나지 않는다는 뜻이다. 순수 SwiftUI로 시작해 필요할 때 TCA로 옮겨도 된다고 한다. 같은 절은 Swift·SwiftUI를 처음 배우는 단계에서는 권하지 않는다고도 적는다. TCA는 SwiftUI를 대체하지 않고 SwiftUI 위에 얹는 도구다.

판단 기준

TCA가 값을 하는 화면
상태 전이가 복잡함(폼 검증 + 비동기 + 취소), 부수효과가 여럿 얽힘, 화면 간 조율(delegate·공유 상태), 테스트로 흐름을 고정해야 함, 같은 로직을 여러 UI(SwiftUI·UIKit)가 공유.
순수 SwiftUI로 충분한 화면
목록 조회 + 상세 보기가 전부, 로직이 거의 없는 설정 화면, 팀이 SwiftUI 기초부터 익히는 중, 일회성 프로토타입.

흔한 반론과 FAQ의 답

반론FAQ의 답 (1.26 기준)
보일러플레이트가 많다대부분 옛 ViewStore 시절 이야기다. 1.7에서 폐기됐고 지금은 store.count를 직접 읽는다(2부).
Action enum은 쓸데없는 일이다액션이 데이터라서 다른 리듀서가 가로채 로직을 덧댈 수 있고(isowords 온보딩 예), _printChanges()·signpost 같은 도구와 exhaustive 테스트가 가능하다.
앱 상태 전체가 거대한 값 하나라 느리다띄우지 않은 화면은 옵셔널이라 nil이다. 뷰는 읽은 상태만 관찰한다. 내비게이션 도구가 표시 지점마다 힙 할당 copy-on-write 래퍼를 넣어서 스택 오버플로도 실제로는 드물다.
액션 핑퐁이 심하다중간에 상태를 바꿀 필요가 없으면 비동기 작업 여러 개를 이펙트 하나에 넣는다(아래 코드).
값 타입이라 상태를 공유 못 한다1.10부터 @Shared가 있고, 공유 상태도 exhaustive 테스트가 된다(Day 12).
✅ 이어지는 비동기 작업은 이펙트 하나에 — 사용자 응답 액션을 따로 두고 왕복시키지 않는다 (FAQ의 예를 컴파일되게 옮김)
struct User: Equatable, Sendable { var id: Int }
struct Movie: Equatable, Sendable { var title: String }

@DependencyClient
struct MovieClient {
  var fetchCurrentUser: @Sendable () async throws -> User
  var fetchMovies: @Sendable (_ userID: Int) async throws -> [Movie]
}
extension MovieClient: TestDependencyKey { static let testValue = Self() }
extension DependencyValues {
  var movieClient: MovieClient {
    get { self[MovieClient.self] }
    set { self[MovieClient.self] = newValue }
  }
}

@Reducer
struct Movies {
  @ObservableState
  struct State: Equatable { var movies: [Movie] = [] }
  enum Action {
    case refreshButtonTapped
    case moviesResponse([Movie])
  }
  @Dependency(\.movieClient) var movieClient

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .refreshButtonTapped:
        return .run { send in
          // 사이에 상태를 바꿀 일이 없으면 한 이펙트 안에서 이어서 await
          let user = try await movieClient.fetchCurrentUser()
          let movies = try await movieClient.fetchMovies(userID: user.id)
          await send(.moviesResponse(movies))
        }
      case let .moviesResponse(movies):
        state.movies = movies
        return .none
      }
    }
  }
}
🚨 흔한 실수 — 앱 전체를 한 번에 TCA로

전면 교체로 시작하면 학습 비용과 이전 비용을 한꺼번에 낸다. 복잡한 화면 하나(예: 결제 폼)를 TCA로 만들어 UIHostingController로 끼우고, TestStore로 효과를 확인한 뒤 넓힌다. 단순 조회 화면까지 억지로 옮기지 않는다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
SyncUps에서 상세 화면(SyncUpDetail)은 기록 화면(RecordMeeting)을 직접 push하지 않는다. 어떻게 넘어가고, 그 덕분에 무엇이 가능한가?
상세는 .delegate(.startMeeting)을 보내기만 하고, 스택을 가진 AppFeature가 .path(.element(_, .detail(.delegate(…))))를 패턴 매칭해 state.path.append(.record(…))를 한다. 상세 기능이 기록 기능을 몰라서 서로 다른 모듈에 두고 따로 컴파일할 수 있다.
쉽게 말하면 반장(상세)이 "체육관 가도 돼요?"라고 선생님(루트)께 묻고, 이동은 선생님이 시킨다. 반장은 체육관이 어디 있는지 몰라도 된다.
의존성 인터페이스 모듈과 live 모듈을 나눌 때, TestDependencyKey와 DependencyKey는 각각 어느 모듈에서 채택하나? 앱 타깃 말고 live 모듈을 import하는 곳이 있어야 하나?
인터페이스 모듈에서 TestDependencyKey(testValue, 필요하면 previewValue)를, live 모듈에서 DependencyKey(liveValue)를 채택한다. live 모듈은 앱 타깃만 import하면 된다. Core·테스트는 인터페이스만 보고 빌드된다.
쉽게 말하면 리모컨(인터페이스)은 모두에게 나눠 주고, 진짜 TV(live)는 거실(앱)에만 둔다.
🔮 코드 예측: 3절의 CounterViewController에서 화면이 뜬 뒤 + 버튼을 두 번 누르면 observe 클로저는 모두 몇 번 실행되나? State에 isLoading이 추가되고 다른 액션이 그것만 바꾼다면 클로저가 다시 실행되나?
3번이다. 등록할 때 즉시 1번, count가 바뀔 때마다 1번씩 2번. isLoading만 바뀌면 다시 실행되지 않는다. 클로저가 지난 실행에서 읽은 필드(count)만 추적되기 때문이다. swift-navigation 소스에서 observe는 withObservationTracking { apply() } onChange: { … }로 매번 추적을 새로 등록한다.
쉽게 말하면 "내가 방금 본 것만 다시 알려 줘"라고 부탁해 두는 것이다. 안 본 것은 바뀌어도 알림이 오지 않는다.
🔁 지난 Day 복습 (Day 13): 여러 액션이 같은 상태 변경·이펙트를 공유해야 할 때, Performance 아티클은 "공유용 액션을 보내기" 대신 무엇을 권하나? 이유는?
리듀서에 메서드를 두고(inout State를 받고 Effect를 반환) 각 case에서 호출한다. 액션 전송은 앱의 여러 계층을 거치며 각 계층 리듀서가 가로챌 수 있어서 메서드 호출보다 무겁다. 테스트에서도 공유용 액션을 매번 receive로 단언해야 한다.
쉽게 말하면 옆자리 친구에게 할 말을 교내 방송으로 하지 말고 그냥 직접 말하라는 것이다.
➡️ 2부 예고

회사 코드에서 WithViewStore, IfLetStore, @PresentationState를 만나면? 2부는 옛 TCA 코드를 현행 API로 읽고 옮기는 대응표를 만들고, 14일 전체를 지도 한 장으로 정리한다.

출처 · 참고자료