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

Day 14 · 2부 · 레거시 읽기와 14일 총정리

1.7 이전 TCA 코드를 연대 감별하고 현행 API로 옮긴 뒤, 14일 동안 배운 도구를 문제별 지도로 정리한다.
진행률
0 / 0 완료
1부에서 SyncUps로 앱 전체 지도를 그리고, 모듈 경계와 UIKit 연결, TCA를 쓰지 말아야 할 때를 정리했다. 회사 코드베이스를 열면 이 코스에서 한 번도 안 본 코드가 나온다. WithViewStore, IfLetStore, @BindingState, @PresentationState. 2부는 이런 옛 TCA 코드를 읽고 현행 API로 옮기는 법을 다룬다. 그리고 14일을 지도 한 장으로 정리하고 다음에 공부할 것을 고른다.

2부 목표 · 24분

  • 코드 조각의 API만 보고 "1.7 이전 코드인지" 판별하고, 어떤 마이그레이션 가이드를 펼칠지 고른다.
  • ViewStore·WithViewStore·IfLetStore·ForEachStore·@BindingState·@PresentationState를 현행 API로 한 줄씩 옮긴다.
  • 큰 코드베이스를 루트부터 바깥 → 안쪽 순서로 점진 이전하는 이유를 설명한다.
  • 14일 동안 배운 도구를 "어떤 문제에 어떤 도구" 표로 꺼내 쓴다.

1. 옛 TCA 코드 알아보기 — 연대 감별 🔥 핵심

TCA는 1.x 안에서도 API가 크게 바뀌었다. 옛 API는 지우지 않고 Internal/Deprecations.swift에 모아 뒀다. 1.26.2에서 이 파일은 3,941줄이다(소스 기준). 그래서 옛 코드도 경고와 함께 아직 컴파일된다. 이 페이지의 "📜 옛 API" 코드 블록은 모두 1.26.2로 실제 컴파일해 확인했다. 볼트의 "Deprecated API Map"과 "TCA Version History" 노트가 이 절의 뼈대다.

① 키패스 시대 — 뷰는 아직 ViewStore ② 관찰 시대 — store에서 바로 읽는다 1.4 1.5 1.6 1.7 1.8 1.10 1.13 1.25 @Reducer 케이스 키패스 scope(키패스) 모디파이어 통합 receive 페이로드 단언 @ObservableState ViewStore 폐기 @Reducer enum @Shared UIKit observe 2.0 준비 deprecation 감별 신호: /Action.child → 1.4 이전 스타일 · { .child($0) } scope → 1.5 이전 viewStore · …Store 뷰 · @BindingState · @PresentationState → 1.7 이전
1.7이 분수령이다. 그 전 코드는 뷰가 ViewStore를 거쳐 상태를 보고, 그 후 코드는 store에서 바로 읽는다.

왜 ViewStore가 있었고, 왜 사라졌나

Observation이 없던 시절에는 스토어 상태 전체가 바뀔 때마다 뷰를 다시 그리면 낭비였다. 그래서 뷰가 쓸 부분만 담은 ViewState 구조체를 손으로 만들고, WithViewStore(store, observe: ViewState.init)가 이전 값과 ==로 비교해 달라졌을 때만 objectWillChange를 보냈다(소스 기준: removeDuplicates: { $0 == $1 }). Swift 5.9 Observation이 나오자 "실제로 읽은 필드"를 자동으로 추적할 수 있게 됐고, 1.7에서 @ObservableState가 이 일을 넘겨받았다.

1.7 이전 — ViewStore Store의 State 전체 observe: ViewState.init — 사람이 고름 == 비교 → objectWillChange → body 1.7 이후 — @ObservableState Store (State가 관찰 가능) body가 store.x를 읽음 → 자동 추적 읽은 필드가 바뀔 때만 body 재계산 가운데 단계(무엇을 볼지 고르기)가 사람 손에서 런타임으로 넘어갔다
옛 코드의 ViewState는 버그가 아니라 당시의 성능 최적화였다. 지금은 SwiftUI의 @Observable과 같은 방식으로 추적한다(SwiftUI 코스 Day 5).
📝 TCA 문서 원문

"Often people complain of boilerplate in TCA, especially with regards a legacy concept known as “view stores”. … they were deprecated a long time ago after Swift 5.9 released with the Observation framework." — TCA 문서 · FAQ

"TCA는 보일러플레이트가 많다"는 평은 대부분 view store 시절 이야기이고, Observation이 나온 뒤 오래전에 폐기됐다는 뜻이다. 옛 블로그 글을 읽을 때 날짜부터 확인해야 하는 이유다.

2. 대응표 — 옛 API를 현행 API로

근거는 1.4 · 1.5 · 1.6 · 1.7 마이그레이션 가이드다. 1.7 가이드는 "1.6까지 먼저 끝내고 오라"고 적는다. 즉 키패스 문법(1.4~1.6)을 먼저 정리한 뒤 관찰 도구(1.7)로 넘어간다.

옛 코드현행 (1.26)가이드
.ifLet(\.child, action: /Action.child).ifLet(\.child, action: \.child) — 케이스 키패스1.4
case row(id:action:) + forEach(…, action: /Action.row)case rows(IdentifiedActionOf<Row>) + forEach(\.rows, action: \.rows)1.4
TaskResultSwift 표준 Result1.4
store.receive(.child(.response(.success(x))))receive(\.child.response.success) · 값까지 보려면 receive(\.child.response.success, x)1.4 · 1.6
store.scope(state: \.child, action: { .child($0) })store.scope(state: \.child, action: \.child) — 키패스라 캐시됨1.5
Scope(state:action:) · store.scope(state:action:)라벨 없는 Scope(\.child, action: \.child) · store.scope(\.child, action: \.child) — 1.26에서 이름만 바뀜(Day 5)소스
WithViewStore(store, observe:) { viewStore in … }삭제. store.count · store.send 직접1.7
ViewState의 계산값State의 계산 프로퍼티1.7
IfLetStoreif let s = store.scope(\.child, action: \.child)1.7
ForEachStoreForEach(store.scope(\.rows, action: \.rows))1.7
SwitchStore · CaseLetswitch store.case { … } (1.8의 @Reducer enum)1.7
@PresentationState var child@Presents var child1.7
.sheet(store: store.scope(state: \.$child, …)).sheet(item: $store.scope(\.child, …)) — $가 store 쪽으로1.7
.alert(store:) · .confirmationDialog(store:).alert($store.scope(…)) · .confirmationDialog($store.scope(…))1.7
NavigationStackStoreNavigationStack(path: $store.scope(…))1.7
@BindingState + viewStore.$text평범한 var + @Bindable + $store.text1.7
viewStore.binding(get:send:)$store.tab.sending(\.tabChanged)1.7
WithViewStore(…, send: Action.view)@ViewAction(for:) + send(…)1.7
UIKit store.publisher.x.sink · store.ifLet(then:)observe { } + if let + store.scope(…) (1부 3절)1.7

① WithViewStore + @BindingState

📜 옛 API (1.7 이전) — ViewState · @BindingState · WithViewStore. 1.26.2에서 deprecated 경고 8종과 함께 컴파일된다
// 1.7 이전 스타일 — State에 @ObservableState가 없고, 뷰는 ViewStore를 거친다
@Reducer
struct Profile {
  struct State: Equatable {
    var firstName = ""
    var lastName = ""
    @BindingState var nickname = ""        // 바인딩할 필드마다 표시
    @BindingState var isPublic = false
    var saveCount = 0
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
    case saveButtonTapped
  }
  var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      case .binding:
        return .none
      case .saveButtonTapped:
        state.saveCount += 1
        return .none
      }
    }
  }
}

struct ProfileView: View {
  let store: StoreOf<Profile>

  struct ViewState: Equatable {            // 뷰가 볼 상태를 손으로 고른다
    let fullName: String
    @BindingViewState var nickname: String
    @BindingViewState var isPublic: Bool
    init(store: BindingViewStore<Profile.State>) {
      fullName = "\(store.firstName) \(store.lastName)"
      _nickname = store.$nickname
      _isPublic = store.$isPublic
    }
  }

  var body: some View {
    WithViewStore(store, observe: ViewState.init) { viewStore in
      Form {
        Text(viewStore.fullName)
        TextField("닉네임", text: viewStore.$nickname)
        Toggle("공개", isOn: viewStore.$isPublic)
        Button("저장") { viewStore.send(.saveButtonTapped) }
      }
    }
  }
}
✅ 현행 — 1.7 가이드의 다섯 단계를 번호로 표시했다
@Reducer
struct Profile {
  @ObservableState                          // ① 추가
  struct State: Equatable {
    var firstName = ""
    var lastName = ""
    var nickname = ""                       // ② @BindingState 삭제
    var isPublic = false
    var saveCount = 0
    var fullName: String { "\(firstName) \(lastName)" }   // ③ ViewState의 계산값은 State로
  }
  enum Action: BindableAction {             // BindableAction · BindingReducer는 남긴다
    case binding(BindingAction<State>)
    case saveButtonTapped
  }
  var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      case .binding:
        return .none
      case .saveButtonTapped:
        state.saveCount += 1
        return .none
      }
    }
  }
}

struct ProfileView: View {
  @Bindable var store: StoreOf<Profile>     // ④ 바인딩이 필요하면 @Bindable

  var body: some View {                     // ⑤ WithViewStore·ViewState 삭제
    Form {
      Text(store.fullName)
      TextField("닉네임", text: $store.nickname)
      Toggle("공개", isOn: $store.isPublic)
      Button("저장") { store.send(.saveButtonTapped) }
    }
  }
}
⚠️ BindableAction은 지우지 않는다

1.7 가이드의 Important 박스다. @BindingState는 지우지만 Action의 BindableAction 채택과 BindingReducer()는 남겨야 바인딩이 동작한다(Day 4). 배포 타깃이 iOS 16 이하라면 @Bindable 대신 @Perception.Bindable을 쓰고, body를 WithPerceptionTracking { }으로 감싼다(Observation backport).

② IfLetStore · ForEachStore · sheet(store:) · @PresentationState

준비 — 두 버전이 함께 쓰는 자식 기능 Row · Editor
// 아래 두 버전이 함께 쓰는 자식 기능
@Reducer
struct Row {
  @ObservableState
  struct State: Equatable, Identifiable { let id: Int; var title = "" }
  enum Action { case tapped }
  var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}

@Reducer
struct Editor {
  @ObservableState
  struct State: Equatable { var text = "" }
  enum Action { case saveButtonTapped }
  var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}

struct RowView: View {
  let store: StoreOf<Row>
  var body: some View { Text(store.title) }
}

struct EditorView: View {
  let store: StoreOf<Editor>
  var body: some View { Text(store.text) }
}
📜 옛 API (1.7 이전) — 스토어 전용 컨테이너 뷰와 모디파이어. 1.26.2에서 경고와 함께 컴파일된다
@Reducer
struct Inbox {
  struct State: Equatable {
    var banner: Row.State?
    var rows: IdentifiedArrayOf<Row.State> = []
    @PresentationState var editor: Editor.State?
    @PresentationState var alert: AlertState<Action.Alert>?
  }
  enum Action {
    case alert(PresentationAction<Alert>)
    case banner(Row.Action)
    case editor(PresentationAction<Editor.Action>)
    case rows(IdentifiedActionOf<Row>)
    enum Alert: Equatable { case confirmDelete }
  }
  var body: some ReducerOf<Self> {
    Reduce { _, _ in .none }
      .ifLet(\.banner, action: \.banner) { Row() }
      .forEach(\.rows, action: \.rows) { Row() }
      .ifLet(\.$editor, action: \.editor) { Editor() }
      .ifLet(\.$alert, action: \.alert)
  }
}

struct InboxView: View {
  let store: StoreOf<Inbox>

  var body: some View {
    List {
      IfLetStore(store.scope(state: \.banner, action: \.banner)) { bannerStore in
        RowView(store: bannerStore)
      }
      ForEachStore(store.scope(state: \.rows, action: \.rows)) { rowStore in
        RowView(store: rowStore)
      }
    }
    .sheet(store: store.scope(state: \.$editor, action: \.editor)) { editorStore in
      EditorView(store: editorStore)
    }
    .alert(store: store.scope(state: \.$alert, action: \.alert))
  }
}
✅ 현행 — 순정 Swift·SwiftUI 문법(if let · ForEach · sheet(item:))으로 돌아왔다
@Reducer
struct Inbox {
  @ObservableState
  struct State: Equatable {
    var banner: Row.State?
    var rows: IdentifiedArrayOf<Row.State> = []
    @Presents var editor: Editor.State?           // @PresentationState → @Presents
    @Presents var alert: AlertState<Action.Alert>?
  }
  enum Action {
    case alert(PresentationAction<Alert>)
    case banner(Row.Action)
    case editor(PresentationAction<Editor.Action>)
    case rows(IdentifiedActionOf<Row>)
    enum Alert: Equatable { case confirmDelete }
  }
  var body: some ReducerOf<Self> {                 // 리듀서 쪽은 한 글자도 안 바뀐다
    Reduce { _, _ in .none }
      .ifLet(\.banner, action: \.banner) { Row() }
      .forEach(\.rows, action: \.rows) { Row() }
      .ifLet(\.$editor, action: \.editor) { Editor() }
      .ifLet(\.$alert, action: \.alert)
  }
}

struct InboxView: View {
  @Bindable var store: StoreOf<Inbox>

  var body: some View {
    List {
      if let bannerStore = store.scope(\.banner, action: \.banner) {   // IfLetStore → if let
        RowView(store: bannerStore)
      }
      ForEach(store.scope(\.rows, action: \.rows)) { rowStore in       // ForEachStore → ForEach
        RowView(store: rowStore)
      }
    }
    .sheet(item: $store.scope(\.editor, action: \.editor)) { editorStore in   // \.$editor 아님
      EditorView(store: editorStore)
    }
    .alert($store.scope(\.alert, action: \.alert))
  }
}
🚨 흔한 실수 — scope에 \.$editor를 그대로 옮기기

옛 코드는 state: \.$editor(projected value)로 scope했다. 새 sheet(item: $store.scope(…))에서는 \.editor다. $가 키패스에서 빠지고 $store 쪽으로 옮겨 간다. 리듀서의 .ifLet(\.$editor, action: \.editor)는 그대로 $를 쓴다. 옮길 때 가장 자주 헷갈리는 한 글자다. 참고로 @PresentationState 타입 자체는 deprecated가 아니다. @Presents 매크로가 펼쳐 놓는 저장 타입이 PresentationState다(소스 기준). 문제는 프로퍼티 래퍼가 @ObservableState 매크로와 같이 쓰이지 못한다는 점이다.

③ viewStore.binding → sending

📜 옛 API — 전용 액션이 있는 바인딩을 viewStore.binding(get:send:)으로
@Reducer
struct Tabs {
  struct State: Equatable { var tab = 0 }
  enum Action { case tabChanged(Int) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .tabChanged(tab):
        state.tab = tab
        return .none
      }
    }
  }
}

struct TabsView: View {
  let store: StoreOf<Tabs>
  var body: some View {
    WithViewStore(store, observe: { $0 }) { viewStore in
      TabView(selection: viewStore.binding(get: \.tab, send: { .tabChanged($0) })) {
        Text("홈").tag(0)
        Text("설정").tag(1)
      }
    }
  }
}
✅ 현행 — $store.tab.sending(\.tabChanged)
@Reducer
struct Tabs {
  @ObservableState
  struct State: Equatable { var tab = 0 }
  enum Action { case tabChanged(Int) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .tabChanged(tab):
        state.tab = tab
        return .none
      }
    }
  }
}

struct TabsView: View {
  @Bindable var store: StoreOf<Tabs>
  var body: some View {
    TabView(selection: $store.tab.sending(\.tabChanged)) {   // 읽기는 tab, 쓰기는 액션으로
      Text("홈").tag(0)
      Text("설정").tag(1)
    }
  }
}

큰 코드베이스는 바깥 → 안쪽으로

1.7 가이드의 "Incrementally migrating" 절은 한 번에 다 바꾸지 말라고 한다. 옛 기능(objectWillChange로 동기 무효화)과 새 기능(withObservationTracking)이 섞이면 body가 필요 이상으로 여러 번 재계산될 수 있다. 무효화 방식이 달라 스레드 홉을 사이에 두고 따로따로 일어나기 때문이다. 가이드의 경험상 새 기능이 옛 자식을 품는 쪽이 반대보다 낫다. 그래서 루트 기능부터 @ObservableState로 바꾸고 잎(leaf) 기능 쪽으로 내려간다. 한 기능을 바꾼 뒤 이상하면, 그 기능과 맞물린 기능 몇 개를 더 바꿔 본다.

옛 TCA를 순수 SwiftUI로 옮기던 시절의 비유TCA 안에서의 대응
ObservableObject + @Published → @Observable (SwiftUI 코스 Day 5)ViewStore + ViewState → @ObservableState
@ObservedObject var model → 그냥 var model / @BindableWithViewStore { viewStore in } → let store / @Bindable var store
$model.text (ObservedObject 투영)viewStore.$text → $store.text

3. 14일 총정리 지도 + 다음에 공부할 것

기초 (Day 1~4) 조립 (Day 5~6) 의존성 Day 1 왜 TCA인가 Day 2 Reducer Store Day 3 Effect 비동기·취소 Day 4 바인딩 Day 5 조립 ① Scope Day 6 조립 ② 컬렉션 Day 7 의존성 @Dependency 1부·2부 테스트 (Day 8~9) 내비게이션 (Day 10~11) 공유·성능 (Day 12~13) 실전 Day 8 테스트 ① TestStore Day 9 테스트 ② 실전 Day 10 내비 ① 트리 1부·2부 Day 11 내비 ② 스택 1부·2부 Day 12 공유 상태 @Shared 1부·2부 Day 13 성능과 동시성 1부·2부 Day 14 실전 구조 총정리 1부·2부 Day 1~3의 State · Action · Reducer · Store · Effect 위에 나머지가 모두 쌓인다
14일, 20개 챕터. 7·10·11·12·13·14일은 1부·2부로 나뉜다. 색은 묶음이다: 기초 · 조립 · 의존성과 테스트 · 내비게이션 · 공유와 성능 · 실전.

문제에서 도구로 — 꺼내 쓰는 표

이런 문제가 생기면꺼낼 도구Day
상태가 어디서 바뀌는지 추적이 안 된다Action enum + Reduce + _printChanges()1 · 2
API 호출·타이머를 어디 둘지.run { send in } · .cancellable(id:)3
폼 필드가 많다BindableAction · BindingReducer · $store.x4
부모가 자식 이벤트에 반응해야 한다Scope · delegate 액션 · ifLet5
행마다 로직이 있는 목록IdentifiedArray · forEach · IdentifiedAction6
네트워크·시계·UUID 때문에 테스트가 흔들린다@Dependency · @DependencyClient · withDependencies7
흐름 전체를 고정하는 테스트TestStore · receive · TestClock · exhaustivity8 · 9
시트·알럿·드릴다운@Presents · @Reducer enum Destination · AlertState10
깊고 재귀적인 push, 딥링크StackState · StackAction · NavigationStack(path:)11
여러 화면이 같은 데이터, 앱 재시작 후 유지@Shared · .appStorage · .fileStorage · withLock12
화면이 버벅인다, Swift 6 경고메서드로 로직 공유 · 스코프 비용 · signpost · @MainActor·Sendable13
앱이 커진다, UIKit이 섞였다, 옛 코드가 있다모듈 분리 · observe { } · 이 페이지의 대응표14

다음에 공부할 것

① SyncUps 튜토리얼 끝까지
Building SyncUps를 직접 따라 치며 1부의 지도를 손으로 만든다. 테스트 파일 5개까지 옮겨 보면 Day 8~12가 한 번에 복습된다.
② 예제 앱 읽기
Examples의 Todos(컬렉션) · VoiceMemos(이펙트·권한) · Search(디바운스·취소) · TicTacToe(모듈화·UIKit). 큰 실제 앱으로는 isowords가 있다.
③ 형제 라이브러리 문서
swift-dependencies · swift-sharing · swift-navigation는 TCA 없이도 쓸 수 있다. 순수 SwiftUI 프로젝트에 @Dependency·@Shared만 먼저 들여오는 길도 있다.
④ 2.0 대비
Migrating to 1.25의 ComposableArchitecture2Deprecations 트레이트를 켜 두면 2.0에서 바뀔 API에 미리 경고가 뜬다. enum 목적지 scope 문법($store.scope(\.$destination, action: \.destination).edit)도 여기서 바뀐다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
옛 코드에 struct ViewState: Equatable와 WithViewStore(store, observe: ViewState.init)가 있다. 이 구조는 원래 무엇을 위한 것이었고, 옮길 때 ViewState 안의 계산값은 어디로 가나?
Observation이 없던 시절 뷰가 볼 상태를 사람이 골라서, 그 부분이 ==로 달라졌을 때만 뷰를 다시 그리게 하는 성능 최적화였다. 옮길 때 ViewState는 지우고, 계산값(예: fullName)은 State의 계산 프로퍼티로 옮긴다(1.7 가이드 "Computed view state").
쉽게 말하면 예전엔 "이것만 보면 돼"라는 쪽지를 손으로 써서 줬는데, 이제는 뷰가 무엇을 봤는지 자동으로 기록된다.
50개 기능을 가진 앱을 1.7 도구로 옮긴다. 잎 기능부터 할까, 루트부터 할까? 이유는?
루트부터 바깥 → 안쪽으로 간다. 옛 방식(objectWillChange)과 새 방식(withObservationTracking)이 섞이면 body가 여러 번 재계산될 수 있는데, 1.7 가이드의 경험상 새 기능이 옛 자식을 품는 쪽이 반대보다 문제가 적다. 그 전에 1.4~1.6의 키패스 문법부터 정리한다.
쉽게 말하면 집 리모델링을 현관(루트)부터 시작해 방(잎)으로 들어간다. 반대로 하면 새 방과 옛 복도가 계속 부딪친다.
🔮 코드 예측: 2절 ①의 옛 ProfileView에서 "저장"을 눌러 saveCount만 1 늘었다. WithViewStore의 content는 다시 계산되나? 현행 ProfileView에서는?
둘 다 다시 계산되지 않는다. 옛 버전: ViewState에는 fullName·nickname·isPublic만 있어서 이전 값과 ==로 같다. WithViewStore는 removeDuplicates: { $0 == $1 }로 중복을 걸러 objectWillChange를 보내지 않는다(소스 기준). 현행 버전: body가 saveCount를 읽지 않으므로 추적 대상이 아니다. 같은 결과를 옛 코드는 사람이 고른 ViewState로, 새 코드는 런타임 추적으로 얻는다. 리듀서 로직은 같으므로 TestStore에서 send(.saveButtonTapped) { $0.saveCount = 1 }은 두 버전 모두 통과한다.
쉽게 말하면 옛날엔 "이름·닉네임·공개 여부만 알려 줘"라고 적어 뒀고, 지금은 "내가 본 것만 알려 줘"라고 한다. 저장 횟수는 둘 다 안 봤으니 알림이 안 온다.
🔁 1부 복습: SwiftUI의 @Bindable var store와 .sheet(item: $store.scope(…))는 UIKit 뷰 컨트롤러에서 각각 무엇으로 바뀌나? 상태 변화를 라벨에 반영하는 곳은?
@UIBindable var store와 present(item: $store.scope(…)) { store in … }다. 상태 반영은 viewDidLoad에서 한 번 부르는 observe { [weak self] in … } 클로저에서 한다.
쉽게 말하면 같은 리모컨(스토어)을 SwiftUI TV와 UIKit TV에 쓰는데, 버튼 이름만 조금 다르다.

🛠 미니 과제 (선택 · 10분)

옛 API로 쓴 카운터를 현행 API로 옮긴다. 세 번째 탭에 알럿이 뜬다.

📜 시작 코드 — @PresentationState · WithViewStore · alert(store:)
// 시작 코드 (옛 API · 1.26.2에서 경고와 함께 컴파일된다)
@Reducer
struct Tally {
  struct State: Equatable {
    var count = 0
    @PresentationState var alert: AlertState<Action.Alert>?
  }
  enum Action {
    case alert(PresentationAction<Alert>)
    case incrementButtonTapped
    enum Alert: Equatable {}
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .alert:
        return .none
      case .incrementButtonTapped:
        state.count += 1
        if state.count == 3 {
          state.alert = AlertState { TextState("3번 눌렀다") }
        }
        return .none
      }
    }
    .ifLet(\.$alert, action: \.alert)
  }
}

struct TallyView: View {
  let store: StoreOf<Tally>
  var body: some View {
    WithViewStore(store, observe: \.count) { viewStore in
      VStack {
        Text("\(viewStore.state)")
        Button("+") { viewStore.send(.incrementButtonTapped) }
      }
      .alert(store: store.scope(state: \.$alert, action: \.alert))
    }
  }
}

확인 방법: Preview에서 + 를 세 번 눌러 알럿이 뜨는지 보고, 알럿을 닫은 뒤 한 번 더 눌러 다시 뜨지 않는지(count가 4) 확인한다. 테스트 타깃에서 thirdTapShowsAlert를 돌린다.

정답 보기
✅ 정답 — 바뀐 곳은 State 두 줄과 뷰뿐이다. 테스트는 그대로 통과한다
@Reducer
struct Tally {
  @ObservableState
  struct State: Equatable {
    var count = 0
    @Presents var alert: AlertState<Action.Alert>?
  }
  enum Action {
    case alert(PresentationAction<Alert>)
    case incrementButtonTapped
    enum Alert: Equatable {}
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .alert:
        return .none
      case .incrementButtonTapped:
        state.count += 1
        if state.count == 3 {
          state.alert = AlertState { TextState("3번 눌렀다") }
        }
        return .none
      }
    }
    .ifLet(\.$alert, action: \.alert)
  }
}

struct TallyView: View {
  @Bindable var store: StoreOf<Tally>
  var body: some View {
    VStack {
      Text("\(store.count)")
      Button("+") { store.send(.incrementButtonTapped) }
    }
    .alert($store.scope(\.alert, action: \.alert))
  }
}

#Preview {
  TallyView(store: Store(initialState: Tally.State()) { Tally() })
}

@MainActor
@Test func thirdTapShowsAlert() async {
  let store = TestStore(initialState: Tally.State()) { Tally() }
  await store.send(.incrementButtonTapped) { $0.count = 1 }
  await store.send(.incrementButtonTapped) { $0.count = 2 }
  await store.send(.incrementButtonTapped) {
    $0.count = 3
    $0.alert = AlertState { TextState("3번 눌렀다") }
  }
}

출처 · 참고자료