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

Day 10 · 2부 · Destination enum·AlertState·트리 테스트

여러 목적지를 @Reducer enum Destination으로 모델링하고 AlertState·ConfirmationDialogState와 트리 기반 테스트까지 다룬다.
진행률
0 / 0 완료
1부에서 옵셔널 하나(@Presents var addContact)로 시트를 띄우고, ifLet으로 잇고, 자식이 dismiss로 스스로 닫게 했다. 실제 화면은 갈 곳이 하나가 아니다. 추가 시트, 삭제 확인 알럿, 정렬 다이얼로그가 한 화면에 붙는다. 옵셔널을 그만큼 늘리면 "시트와 알럿이 동시에 떠 있는" 말도 안 되는 상태를 코드가 허용해 버린다. 2부는 목적지를 enum 하나로 묶는 @Reducer enum Destination, 알럿을 데이터로 만드는 AlertState, 그리고 이 모든 흐름을 TestStore로 증명하는 법이다.

2부 목표 · 26분

  • 옵셔널 여러 개로 목적지를 표현한 코드를 @Reducer enum Destination + @Presents var destination 하나로 바꾼다.
  • $store.scope(\.destination, action: \.destination).addContact처럼 케이스를 골라 시트·알럿에 각각 연결한다.
  • 알럿·확인 다이얼로그를 AlertState·ConfirmationDialogState로 리듀서에서 만들고, 버튼 액션을 부모에서 처리한다.
  • TestStore로 "시트 열기 → 입력 → 저장 → 닫힘"과 "알럿 → 삭제" 흐름을 exhaustive·non-exhaustive 두 방식으로 테스트한다.

1. 목적지는 enum 하나로 — @Reducer enum Destination 🔥 핵심

연락처 화면에 삭제 확인 알럿과 상세 화면이 더해졌다고 하자. 1부 방식을 그대로 늘리면 이렇게 된다.

❌ 목적지마다 옵셔널 하나씩 — 불가능한 조합이 표현된다
// ContactsFeature 안
  @ObservableState
  struct State {
    @Presents var addContact: AddContactFeature.State?
    @Presents var detail: ContactDetailFeature.State?
    @Presents var alert: AlertState<Action.Alert>?
  }

세 옵셔널이 모두 non-nil일 수 있다. SwiftUI는 한 뷰에서 여러 프레젠테이션을 동시에 띄우지 못하므로, 상태와 화면이 어긋날 위험이 생긴다. 무엇이 떠 있는지 알려면 옵셔널 셋을 다 검사해야 한다. 문서는 무효 상태 수가 목적지 수에 따라 폭증한다고 적는다. 3개면 4개, 4개면 11개, 5개면 26개다(TCA 문서 · Tree-based navigation · Enum state).

옵셔널 3개 (add · detail · alert) = 2×2×2 = 8가지 ○ ○ ○ ● ○ ○ ○ ● ○ ○ ○ ● ● ● ○ ● ○ ● ○ ● ● ● ● ● 윗줄 4개 유효 · 아랫줄 4개 무효(동시에 두 개 이상) enum destination: Destination.State? nil .addContact(…) .detail(…) .alert(…) 정확히 4가지 — 무효 상태 0개 목적지 n개 · 옵셔널 n개 → 2ⁿ가지 중 n+1개만 유효 · enum 하나 → n+1가지 그대로
옵셔널을 곱하면 조합이 곱으로 늘고, enum은 더하기로 늘어난다. "한 번에 하나만 뜬다"를 컴파일러가 보장하게 만드는 게 enum의 역할이다.

SwiftUI에서 enum 라우트를 쓰면

SwiftUI에서도 목적지를 enum으로 묶을 수 있다. 문제는 케이스마다 다른 모디파이어(시트는 sheet, 삭제는 alert)에 연결할 때다. sheet(item:)은 Binding<Item?>을 원하니 케이스별 바인딩을 손으로 뽑아야 한다.

SwiftUI(@Observable) — enum 라우트는 케이스별 Binding을 직접 만들어야 한다
import SwiftUI
import Observation

struct Contact: Identifiable, Equatable, Hashable {
    let id: UUID
    var name: String
}

@Observable
final class ContactsModel {
    enum Destination {
        case add(Contact)
        case confirmDelete(Contact)
    }
    var contacts: [Contact] = []
    var destination: Destination?
}

struct ContactsScreen: View {
    @Bindable var model: ContactsModel

    private var addBinding: Binding<Contact?> {
        Binding(
            get: { if case .add(let c) = model.destination { c } else { nil } },
            set: { if $0 == nil { model.destination = nil } }
        )
    }
    private var deleteAlertShown: Binding<Bool> {
        Binding(
            get: { if case .confirmDelete = model.destination { true } else { false } },
            set: { if !$0 { model.destination = nil } }
        )
    }

    var body: some View {
        List(model.contacts) { Text($0.name) }
            .sheet(item: addBinding) { contact in Text("\(contact.name) 추가") }
            .alert("삭제할까요?", isPresented: deleteAlertShown) {
                Button("삭제", role: .destructive) { /* 어떤 연락처였는지 다시 꺼내야 한다 */ }
            }
    }
}

TCA — @Reducer를 enum에 붙인다

먼저 1부의 최종 자식(델리게이트 + dismiss)을 그대로 가져온다.

✅ 1부의 자식 기능과 뷰(그대로)
@Reducer
struct AddContactFeature {
  @ObservableState
  struct State: Equatable { var contact: Contact }
  enum Action {
    case cancelButtonTapped
    case delegate(Delegate)
    case saveButtonTapped
    case setName(String)
    @CasePathable
    enum Delegate: Equatable { case saveContact(Contact) }
  }
  @Dependency(\.dismiss) var dismiss

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .cancelButtonTapped:
        return .run { _ in await self.dismiss() }
      case .delegate:
        return .none
      case .saveButtonTapped:
        return .run { [contact = state.contact] send in
          await send(.delegate(.saveContact(contact)))
          await self.dismiss()
        }
      case let .setName(name):
        state.contact.name = name
        return .none
      }
    }
  }
}

struct AddContactView: View {
  @Bindable var store: StoreOf<AddContactFeature>
  var body: some View {
    Form {
      TextField("이름", text: $store.contact.name.sending(\.setName))
      Button("저장") { store.send(.saveButtonTapped) }
    }
    .toolbar { Button("취소") { store.send(.cancelButtonTapped) } }
  }
}

부모에 Destination을 중첩해서 선언한다. 케이스마다 그 목적지를 움직이는 리듀서(또는 알럿 데이터)를 적으면, @Reducer 매크로가 Destination.State·Destination.Action enum과 각 케이스를 Scope로 합친 body를 전부 만들어 준다(TCA 문서 · Reducer · Destination and path reducers — 손으로 쓰면 24줄이 6줄이 된다고 적혀 있다).

✅ 목적지 enum + 옵셔널 하나 — 동시에 하나만 뜬다는 컴파일 타임 보장
@Reducer
struct ContactsFeature {
  @Reducer
  enum Destination {
    case addContact(AddContactFeature)                        // 리듀서가 있는 목적지
    case alert(AlertState<ContactsFeature.Action.Alert>)      // 데이터뿐인 목적지
  }

  @ObservableState
  struct State: Equatable {
    var contacts: IdentifiedArrayOf<Contact> = []
    @Presents var destination: Destination.State?             // 옵셔널은 이거 하나
  }
  enum Action {
    case addButtonTapped
    case deleteButtonTapped(id: Contact.ID)
    case destination(PresentationAction<Destination.Action>)
    @CasePathable
    enum Alert: Equatable { case confirmDeletion(id: Contact.ID) }
  }
  @Dependency(\.uuid) var uuid

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .addButtonTapped:
        state.destination = .addContact(AddContactFeature.State(contact: Contact(id: uuid(), name: "")))
        return .none

      case let .deleteButtonTapped(id):
        state.destination = .alert(Self.deleteAlert(id: id))   // 헬퍼는 아래, 2절에서 설명
        return .none

      case let .destination(.presented(.addContact(.delegate(.saveContact(contact))))):
        state.contacts.append(contact)
        return .none

      case let .destination(.presented(.alert(.confirmDeletion(id)))):
        state.contacts.remove(id: id)
        return .none                                          // 알럿은 알아서 nil이 된다

      case .destination:
        return .none
      }
    }
    .ifLet(\.$destination, action: \.destination)             // 트레일링 클로저 불필요
  }

  // 알럿 값을 만드는 정적 헬퍼 — 리듀서와 테스트가 같은 값을 쓴다
  static func deleteAlert(id: Contact.ID) -> AlertState<Action.Alert> {
    AlertState {
      TextState("정말 삭제할까요?")
    } actions: {
      ButtonState(role: .destructive, action: .confirmDeletion(id: id)) {
        TextState("삭제")
      }
      ButtonState(role: .cancel) {
        TextState("취소")                                     // 액션 없는 버튼
      }
    } message: {
      TextState("삭제한 연락처는 되돌릴 수 없다.")
    }
  }
}
extension ContactsFeature.Destination.State: Equatable {
  // 매크로가 만든 타입에 프로토콜만 붙인다.
  // 케이스 값(AddContactFeature.State · AlertState), 그 안에 든 Contact 값까지 모두 Equatable이라 합성된다.
}

뷰 — 케이스를 점으로 골라 모디파이어에 연결

스코프한 바인딩 뒤에 케이스 이름을 점으로 이어 붙이면 그 케이스일 때만 non-nil인 자식 스토어 바인딩이 된다. SwiftUI 쪽에서 손으로 만들던 addBinding·deleteAlertShown이 이 한 줄로 대체된다.

✅ 목적지 케이스별로 sheet와 alert에 연결
struct ContactsView: View {
  @Bindable var store: StoreOf<ContactsFeature>

  var body: some View {
    NavigationStack {
      List {
        ForEach(store.contacts) { contact in
          HStack {
            Text(contact.name)
            Spacer()
            Button("삭제", role: .destructive) {
              store.send(.deleteButtonTapped(id: contact.id))
            }
          }
        }
      }
      .navigationTitle("연락처")
      .toolbar { Button("추가") { store.send(.addButtonTapped) } }
    }
    .sheet(item: $store.scope(\.destination, action: \.destination).addContact) { addStore in
      NavigationStack { AddContactView(store: addStore) }
    }
    .alert($store.scope(\.destination, action: \.destination).alert)
  }
}

같은 뜻을 $store.scope(\.destination?.addContact, action: \.destination.addContact)처럼 키 패스 안에서 케이스까지 내려가 적어도 된다. SyncUps 예제의 목록 화면(SyncUpsList.swift)이 이 모양이다. 문서는 이 구조 덕분에 "추가 시트가 떠 있을 때 destination을 다른 케이스로 바꾸면 시트가 닫히고 새 목적지가 즉시 뜬다"고 설명한다.

⚠️ 두 가지 걸림돌

#Preview 안에서 목적지 케이스를 못 쓴다. #Preview도 매크로라서 @Reducer가 만든 .addContact 케이스를 볼 수 없다. 문서의 해법은 뷰를 private var preview: some View { … } 같은 매크로 밖 헬퍼로 빼고 #Preview { preview }로 부르는 것이다.

리듀서가 없는 목적지. 상세 화면이 TCA 기능이 아니라 그냥 값 하나를 보여 주는 뷰라면 그 케이스에 @ReducerCaseIgnored를 붙인다(Reducer 문서 "Navigating to non-reducer features").

2. 알럿과 다이얼로그도 상태다 — AlertState · ConfirmationDialogState

SwiftUI의 alert는 isPresented: Binding<Bool>과 버튼 클로저를 뷰에서 받는다. 삭제 버튼을 누르면 무엇을 할지가 뷰 안 클로저에 있으니 테스트로 누를 방법이 없다. TCA는 알럿의 제목·메시지·버튼을 AlertState라는 값으로 만들고, 버튼에는 클로저 대신 액션을 단다. 그래서 알럿을 리듀서에서 띄우고 TestStore로 버튼을 "누를" 수 있다(케이스 스터디 · Alerts and Confirmation Dialogs).

SwiftUI — 알럿 버튼의 할 일이 뷰 클로저 안에 있다
struct DeleteButtonScreen: View {
    @State private var isConfirming = false
    let onDelete: () -> Void

    var body: some View {
        Button("삭제", role: .destructive) { isConfirming = true }
            .alert("정말 삭제할까요?", isPresented: $isConfirming) {
                Button("삭제", role: .destructive) { onDelete() }   // 테스트로 누를 수 없다
                Button("취소", role: .cancel) {}
            } message: {
                Text("삭제한 연락처는 되돌릴 수 없다.")
            }
    }
}

TCA 쪽은 1절 ContactsFeature 맨 아래의 static func deleteAlert(id:)다. AlertState { 제목 } actions: { ButtonState… } message: { … } 모양이고, 버튼마다 role과 action을 준다. 버튼 클로저 대신 .confirmDeletion(id:)라는 액션 값이 들어 있다는 점이 핵심이다. 액션이 없는 버튼(취소)도 만들 수 있다.

SyncUps의 SyncUpDetail.swift는 같은 헬퍼를 extension AlertState where Action == SyncUpDetail.Destination.Alert { static let deleteSyncUp = … }로 둔다. 어디에 두든 "리듀서와 테스트가 같은 알럿 값을 쓴다"가 목적이다. 뷰의 .alert($store.scope(…).alert)는 소스(Alert+Observation.swift)를 보면 SwiftUI의 alert(_:isPresented:presenting:actions:message:)를 부르는 얇은 포장이다. AlertState에서 제목·버튼을 꺼내 그리고, 버튼을 누르면 그 버튼의 액션을 스토어에 보낸다.

deleteButtonTapped destination = .alert 삭제 취소 .presented(.alert( .confirmDeletion(id))) 부모가 연락처 삭제 → ifLet이 알럿을 자동 nil 버튼에 액션 없음 SwiftUI가 바인딩에 nil 기록 .destination(.dismiss) → destination = nil 어느 쪽이든 알럿이 닫힌 뒤 destination은 nil — 부모가 nil을 대입할 필요가 없다
알럿 버튼은 "액션이 있으면 그 액션", "없으면 dismiss"로 끝난다. 두 경로 모두 리듀서를 지나므로 테스트할 수 있다.
🔑 알럿은 버튼 액션 뒤 자동으로 nil

ifLet 문서 주석은 "Automatically nils out child state when an action is sent for alerts and confirmation dialogs"라고 적는다. 소스에서는 AlertState·ConfirmationDialogState를 "ephemeral(일회성)" 상태로 보고, 버튼 액션을 처리한 뒤 상태를 nil로 만든다. 그래서 위 리듀서의 confirmDeletion 케이스에 state.destination = nil이 없다. 단, 상태는 실제로 바뀌므로 exhaustive 테스트에서는 $0.destination = nil을 단언해야 한다(섹션 3).

확인 다이얼로그도 같은 틀

ConfirmationDialogState는 AlertState와 모양이 같고, 뷰에서는 .confirmationDialog(바인딩)를 쓴다. 목적지가 이것 하나뿐이면 enum 없이 @Presents 옵셔널로 충분하다.

✅ 정렬 방식을 고르는 확인 다이얼로그
@Reducer
struct SortFeature {
  @ObservableState
  struct State: Equatable {
    @Presents var dialog: ConfirmationDialogState<Action.Dialog>?
    var ascending = true
  }
  enum Action {
    case dialog(PresentationAction<Dialog>)
    case sortButtonTapped
    @CasePathable
    enum Dialog: Equatable { case ascending, descending }
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .sortButtonTapped:
        state.dialog = ConfirmationDialogState {
          TextState("정렬")
        } actions: {
          ButtonState(action: .ascending) { TextState("이름 오름차순") }
          ButtonState(action: .descending) { TextState("이름 내림차순") }
          ButtonState(role: .cancel) { TextState("취소") }
        }
        return .none
      case .dialog(.presented(.ascending)):
        state.ascending = true
        return .none
      case .dialog(.presented(.descending)):
        state.ascending = false
        return .none
      case .dialog:
        return .none
      }
    }
    .ifLet(\.$dialog, action: \.dialog)
  }
}

struct SortButton: View {
  @Bindable var store: StoreOf<SortFeature>
  var body: some View {
    Button("정렬") { store.send(.sortButtonTapped) }
      .confirmationDialog($store.scope(\.dialog, action: \.dialog))
  }
}
표현SwiftUI (@Observable)TCA
시트 / 팝오버 / 커버sheet(item: $model.x)sheet(item: $store.scope(\.x, action: \.x))
드릴다운 1단계navigationDestination(item:)같은 모디파이어 + 스코프 바인딩
알럿alert(_:isPresented:) + 뷰 안 클로저AlertState 값 + 버튼 액션 → .alert(바인딩)
확인 다이얼로그confirmationDialog(_:isPresented:)ConfirmationDialogState → .confirmationDialog(바인딩)
목적지 여러 개enum + 케이스별 Binding(get:set:) 수작업@Reducer enum Destination + .케이스 점 연결

3. 트리 기반 테스트 — 흐름을 통째로 증명한다

도메인을 이렇게 잇고 나면 "시트를 열고, 이름을 치고, 저장하면, 목록에 추가되고 시트가 닫힌다"를 뷰 없이 테스트할 수 있다. @Observable 모델이라면 model.addButtonTapped() 뒤 #expect(model.addContact != nil)처럼 부분 부분을 확인할 수는 있지만, 자식 모델이 부모에 무엇을 알렸는지, 닫힐 때 작업이 취소됐는지는 직접 장치를 달아야 한다. TestStore는 받은 액션과 상태 변화를 전부 따라간다. 아래는 Meet TCA 튜토리얼 · Testing presentation의 흐름을 이 페이지 코드에 맞춘 것이다.

✅ exhaustive — 시트 열기부터 닫힘까지 모든 액션·상태를 단언
import Testing

@MainActor
@Test
func addFlow() async {
  let store = TestStore(initialState: ContactsFeature.State()) {
    ContactsFeature()
  } withDependencies: {
    $0.uuid = .incrementing                      // UUID(0), UUID(1)… 예측 가능
  }

  await store.send(.addButtonTapped) {
    $0.destination = .addContact(
      AddContactFeature.State(contact: Contact(id: UUID(0), name: ""))
    )
  }
  // 목적지 → 케이스 → 자식 액션을 key path로 이어 보낸다
  await store.send(\.destination.addContact.setName, "민지") {
    $0.destination?.modify(\.addContact) { $0.contact.name = "민지" }
  }
  await store.send(\.destination.addContact.saveButtonTapped)
  await store.receive(\.destination.addContact.delegate.saveContact, Contact(id: UUID(0), name: "민지")) {
    $0.contacts = [Contact(id: UUID(0), name: "민지")]
  }
  await store.receive(\.destination.dismiss) {   // 자식의 dismiss()가 보낸 액션
    $0.destination = nil
  }
}
✅ 알럿 흐름 — 버튼을 "누르고", 자동 nil까지 단언
import Testing

@MainActor
@Test
func deleteContact() async {
  let store = TestStore(
    initialState: ContactsFeature.State(
      contacts: [Contact(id: UUID(0), name: "민지"), Contact(id: UUID(1), name: "준호")]
    )
  ) {
    ContactsFeature()
  }

  await store.send(.deleteButtonTapped(id: UUID(1))) {
    $0.destination = .alert(ContactsFeature.deleteAlert(id: UUID(1)))   // 헬퍼 재사용
  }
  await store.send(\.destination.alert.confirmDeletion, UUID(1)) {
    $0.contacts = [Contact(id: UUID(0), name: "민지")]
    $0.destination = nil          // 부모가 대입하지 않았지만 ifLet이 nil로 만들었다
  }
}

통합 테스트가 길어지면 관심 있는 결과만 보는 non-exhaustive(Day 9)가 편하다. 문서도 내비게이션 테스트에 이 방식을 권한다. 중간 액션은 skipReceivedActions()로 건너뛰고 마지막 상태만 assert로 확인한다.

✅ non-exhaustive — "결국 추가되고 닫혔다"만 확인
import Testing

@MainActor
@Test
func addFlowNonExhaustive() async {
  let store = TestStore(initialState: ContactsFeature.State()) {
    ContactsFeature()
  } withDependencies: {
    $0.uuid = .incrementing
  }
  store.exhaustivity = .off

  await store.send(.addButtonTapped)
  await store.send(\.destination.addContact.setName, "민지")
  await store.send(\.destination.addContact.saveButtonTapped)
  await store.skipReceivedActions()
  store.assert {
    $0.contacts = [Contact(id: UUID(0), name: "민지")]
    $0.destination = nil
  }
}
🚨 흔한 실수: 자식을 단독으로 테스트하는데 dismiss를 부른다

AddContactFeature만 TestStore에 넣고 cancelButtonTapped를 보내면, 닫기 클로저를 주입해 줄 ifLet이 없다. DismissEffect 문서 주석은 이것이 "couldn't be dismissed" 경고·테스트 실패가 된다고 적지만, 1.26.2에서 실제로 돌려 보면 TestStore가 스스로 "닫힘"(isDismissed) 상태가 될 뿐 실패하지 않는다. 그 뒤에 send하면 그때 "Can't send action to dismissed test store" 실패가 난다. 닫기를 불렀는지 확인하려면 withDependencies에서 $0.dismiss = DismissEffect { … }로 바꿔 호출을 기록하거나, 부모와 함께 테스트한다.

🔮 알럿이 떠 있는 상태로 시작한다. 사용자가 "취소"(액션 없는 버튼)를 누르면 스토어는 어떤 액션을 받고, 이 테스트에는 무엇을 적어야 할까?
import Testing

@MainActor
@Test
func cancelAlertPrediction() async {
  let store = TestStore(
    initialState: ContactsFeature.State(
      contacts: [Contact(id: UUID(0), name: "민지")],
      destination: .alert(ContactsFeature.deleteAlert(id: UUID(0)))
    )
  ) {
    ContactsFeature()
  }
  // await store.send(???) { ??? }
  _ = store
}

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
목적지가 시트·알럿·상세 세 개다. @Presents 옵셔널 세 개 대신 @Reducer enum Destination을 쓰면 무엇이 좋아지나? (두 가지)
① "둘 이상이 동시에 떠 있다"는 무효 조합(3개면 4가지)이 타입에서 사라진다. 컴파일러가 "한 번에 하나"를 보장한다. ② 지금 무엇이 떠 있는지 switch state.destination 하나로 안다. 덤으로 ifLet도 한 번이면 되고, 트레일링 클로저도 생략된다.
쉽게 말하면 스위치 세 개(각각 켜고 끔)는 동시에 두 개를 켤 수 있지만, 다이얼 하나(한 칸만 가리킴)는 절대 두 칸을 가리키지 못한다.
알럿의 "삭제" 버튼을 처리하는 부모 케이스에 state.destination = nil이 없는데 왜 알럿이 닫히나? 테스트에서는 무엇을 적어야 하나?
ifLet이 AlertState·ConfirmationDialogState를 일회성 상태로 보고, 버튼 액션을 처리한 뒤 자동으로 nil로 만든다. 상태가 실제로 바뀌었으므로 exhaustive 테스트의 send 클로저에 $0.destination = nil을 적어야 통과한다.
쉽게 말하면 쪽지는 한 번 읽으면 자동으로 사라지는 종이다. 내가 버리지 않아도 없어지지만, 검사관(테스트)은 "사라졌음"을 기록해야 한다.
🔮 위 코드 예측 — "취소"를 누르면?
"취소" 버튼에는 액션이 없다. 소스(Alert+Observation.swift)를 보면 액션이 nil인 버튼은 아무것도 보내지 않는다. 그다음 SwiftUI가 isPresented를 false로 쓰고, 그 바인딩이 nil로 바뀌면서 스토어가 .destination(.dismiss)를 보낸다. 그래서 테스트는 await store.send(\.destination.dismiss) { $0.destination = nil }이다. 연락처는 그대로다. 반대로 "삭제"를 누르면 confirmDeletion 액션 하나만 온다. 그 액션 처리 중 이미 nil이 되었으므로 바인딩 setter가 dismiss를 따로 보내지 않는다.
쉽게 말하면 "취소"는 그냥 창을 닫는 것이라 "닫혔다"는 소식만 오고, "삭제"는 "삭제해!"라는 명령이 오면서 창도 같이 사라진다.
🔁 1부 복습: 자식에서 await self.dismiss()를 부르면 부모 쪽에서는 구체적으로 어떤 액션이 처리되나? 그리고 왜 dismiss 뒤에 send하면 안 되나?
ifLet이 주입한 닫기 클로저가 돌아서 PresentationAction.dismiss(여기서는 .destination(.dismiss))가 시스템으로 돌아간다. 부모 Reduce가 먼저 돌고 상태가 nil이 된다. 닫기도 액션이라 그 뒤에 보낸 액션은 처리되지 않는다 — 1.26.2에서 실제로 돌려 보면 자식 이펙트가 취소되어 경고 없이 버려진다(문서는 경고·실패라고 설명).
쉽게 말하면 "나 퇴장합니다"라고 말한 뒤에 무대 위에서 대사를 하면 이미 조명이 꺼져서 아무도 못 듣는다.

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

메모 목록에 "모두 지우기"를 붙인다. 시작 코드를 새 파일에 붙이고 요구사항을 채운 뒤, 테스트 하나로 확인한다.

시작 코드
@Reducer
struct NotesFeature {
  @ObservableState
  struct State: Equatable {
    var notes: [String] = ["장보기", "운동"]
  }
  enum Action {
    case clearAllButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .clearAllButtonTapped:
        return .none
      }
    }
  }
}
  1. clearAllButtonTapped를 받으면 "모두 지울까요?" 알럿을 AlertState로 띄운다. 버튼은 "지우기"(destructive, 액션 있음)와 "취소"(cancel).
  2. "지우기" 액션을 받으면 notes를 비운다. 알럿 상태를 직접 nil로 만들지 않는다.
  3. TestStore 테스트로 "버튼 → 알럿 → 지우기 → 목록 비고 알럿 nil"을 exhaustive하게 확인한다.

확인 방법: 테스트가 통과하면 된다. 뷰까지 붙이고 싶다면 .alert($store.scope(\.alert, action: \.alert)) 한 줄이면 된다.

정답 보기
✅ 정답 — @Presents 알럿 하나 + 테스트
import Testing

@Reducer
struct ClearableNotesFeature {
  @ObservableState
  struct State: Equatable {
    @Presents var alert: AlertState<Action.Alert>?
    var notes: [String] = ["장보기", "운동"]
  }
  enum Action {
    case alert(PresentationAction<Alert>)
    case clearAllButtonTapped
    @CasePathable
    enum Alert: Equatable { case confirmClear }
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .alert(.presented(.confirmClear)):
        state.notes.removeAll()
        return .none                      // 알럿은 ifLet이 자동으로 nil
      case .alert:
        return .none
      case .clearAllButtonTapped:
        state.alert = AlertState {
          TextState("모두 지울까요?")
        } actions: {
          ButtonState(role: .destructive, action: .confirmClear) { TextState("지우기") }
          ButtonState(role: .cancel) { TextState("취소") }
        }
        return .none
      }
    }
    .ifLet(\.$alert, action: \.alert)
  }
}

@MainActor
@Test
func clearAll() async {
  let store = TestStore(initialState: ClearableNotesFeature.State()) {
    ClearableNotesFeature()
  }
  await store.send(.clearAllButtonTapped) {
    $0.alert = AlertState {
      TextState("모두 지울까요?")
    } actions: {
      ButtonState(role: .destructive, action: .confirmClear) { TextState("지우기") }
      ButtonState(role: .cancel) { TextState("취소") }
    }
  }
  await store.send(\.alert.confirmClear) {
    $0.notes = []
    $0.alert = nil
  }
}

알럿을 테스트에서 한 번 더 적는 게 번거로우면, 본문의 deleteAlert(id:)처럼 정적 헬퍼로 빼서 리듀서와 테스트가 같이 쓴다.

출처 · 참고자료