@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).
SwiftUI에서 enum 라우트를 쓰면
SwiftUI에서도 목적지를 enum으로 묶을 수 있다. 문제는 케이스마다 다른 모디파이어(시트는 sheet, 삭제는 alert)에 연결할 때다. sheet(item:)은 Binding<Item?>을 원하니 케이스별 바인딩을 손으로 뽑아야 한다.
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)을 그대로 가져온다.
@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줄이 된다고 적혀 있다).
@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이라 합성된다.
}ifLet에 클로저가 없다:@Reducer enum으로 선언한 목적지는 리듀서를 추론할 수 있어서 생략한다(문서 Note).extension … Destination.State: Equatable {}:State는 매크로가 만든 타입이라 선언부에 프로토콜을 못 적는다. 부모State가Equatable이면 이 한 줄이 필요하다.- 부모가 자식 깊숙한 액션을 매칭:
.destination(.presented(.addContact(.delegate(…)))). 괄호가 깊지만 "목적지 → 떠 있는 것 → 어느 케이스 → 어느 액션" 순서 그대로다.
뷰 — 케이스를 점으로 골라 모디파이어에 연결
스코프한 바인딩 뒤에 케이스 이름을 점으로 이어 붙이면 그 케이스일 때만 non-nil인 자식 스토어 바인딩이 된다. SwiftUI 쪽에서 손으로 만들던 addBinding·deleteAlertShown이 이 한 줄로 대체된다.
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).
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에서 제목·버튼을 꺼내 그리고, 버튼을 누르면 그 버튼의 액션을 스토어에 보낸다.
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의 흐름을 이 페이지 코드에 맞춘 것이다.
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
}
}\.destination.addContact.setName: 액션을 case key path로 적는다..destination(.presented(.addContact(.setName("민지"))))를 줄인 것이다.$0.destination?.modify(\.addContact) { … }: enum 상태의 특정 케이스 안을 고치는 헬퍼다. 옵셔널 하나(1부 구조)라면$0.addContact?.contact.name = "민지"로 충분하다.- 두 번의 receive: 저장 이펙트가 델리게이트를 먼저,
dismiss()를 나중에 보내므로 그 순서대로 받는다. 순서를 바꾸면 테스트가 실패한다.
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로 확인한다.
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
}
}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을 쓰면 무엇이 좋아지나? (두 가지)
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
}
}
}
}clearAllButtonTapped를 받으면 "모두 지울까요?" 알럿을AlertState로 띄운다. 버튼은 "지우기"(destructive, 액션 있음)와 "취소"(cancel).- "지우기" 액션을 받으면
notes를 비운다. 알럿 상태를 직접 nil로 만들지 않는다. - TestStore 테스트로 "버튼 → 알럿 → 지우기 → 목록 비고 알럿 nil"을 exhaustive하게 확인한다.
확인 방법: 테스트가 통과하면 된다. 뷰까지 붙이고 싶다면 .alert($store.scope(\.alert, action: \.alert)) 한 줄이면 된다.
정답 보기
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:)처럼 정적 헬퍼로 빼서 리듀서와 테스트가 같이 쓴다.