@State var editing: Contact?가 nil이 아니면 뜬다"를 배웠다. SwiftUI도 상태 주도 내비게이션이다. TCA가 바꾸는 건 그 옵셔널이 어디에 사느냐다. 옵셔널을 리듀서의 State 안으로 옮기고, 시트 안 화면의 로직도 부모 리듀서에 이어 붙인다. 그러면 "시트가 떠 있는 상태"도 값 하나로 만들 수 있고, 테스트할 수 있다. 1부는 옵셔널 하나로 시트를 띄우고 닫는 기본기다. 도구는 @Presents, PresentationAction, ifLet 세 개와 @Dependency(\.dismiss)가 전부다.1부 목표 · 29분
- "내비게이션 = 상태가 없다가 생기는 것"이라는 정의로 시트·드릴다운·알럿을 한 가지 틀로 설명한다.
- 부모 State에
@Presents옵셔널, Action에PresentationAction케이스, body에.ifLet(\.$child, action: \.child)를 붙여 두 기능을 잇는다. @Bindable스토어에서$store.scope(\.child, action: \.child)를 만들어sheet(item:)·navigationDestination(item:)에 넘긴다.- 자식이
@Dependency(\.dismiss)로 스스로 닫게 바꾸고,dismiss()뒤에 액션을 보내는 코드를 찾아 고친다.
1. 내비게이션 = 상태가 생기고 사라지는 것 🔥 핵심
TCA 문서는 "내비게이션"을 먼저 정의하고 시작한다. push만 내비게이션이라고 하면 시트는 뭔가? 아래에서 올라온다는 것 말고 차이가 없다. 그럼 팝오버·알럿·확인 다이얼로그도 마찬가지다. 그래서 문서는 이 모두를 하나로 묶는다.
"Navigation is a change of mode in the application." / "A change of mode is when some piece of state goes from not existing to existing, or vice-versa." — TCA 문서 · What is navigation?
내비게이션은 앱의 "모드"가 바뀌는 것이고, 모드가 바뀐다는 건 어떤 상태가 없다가 생기거나, 있다가 없어지는 것이라는 뜻이다. 시트가 뜬다 = 상태가 생긴다. 시트가 닫힌다 = 상태가 사라진다.
"존재"를 무엇으로 표현하나 — 트리와 스택
"상태가 있다/없다"를 Swift로 적는 방법이 두 가지라서 내비게이션도 두 갈래다. 옵셔널·enum으로 적으면 트리 기반이다. 목록 → 상세 → 편집 시트 → 알럿처럼 옵셔널 안에 옵셔널이 들어가 나무 모양이 된다. 배열로 적으면 스택 기반이다. NavigationStack의 path처럼 평평하다. 오늘(Day 10)은 트리, Day 11은 스택이다. 문서는 실제 앱이 둘을 섞는다고 못 박는다. 루트는 스택, 각 화면 안의 시트·알럿은 트리다.
SwiftUI도 이미 상태 주도다 — 그럼 뭐가 다른가
SwiftUI 코스에서 쓴 방식을 먼저 보자. 옵셔널 자식 모델을 부모 모델이 들고 있고, sheet(item:)이 그 옵셔널에 묶인다.
import SwiftUI
import Observation
struct Contact: Identifiable, Equatable, Hashable {
let id: UUID
var name: String
}
@Observable
final class AddContactModel: Identifiable {
let id = UUID()
var contact: Contact
init(contact: Contact) { self.contact = contact }
}
@Observable
final class ContactsModel {
var contacts: [Contact] = []
var addContact: AddContactModel? // nil이면 시트 닫힘
func addButtonTapped() {
addContact = AddContactModel(contact: Contact(id: UUID(), name: ""))
}
func saveButtonTapped() {
guard let draft = addContact?.contact else { return }
contacts.append(draft)
addContact = nil // 닫기 = nil 대입
}
}
struct ContactsScreen: View {
@Bindable var model: ContactsModel
var body: some View {
List(model.contacts) { Text($0.name) }
.toolbar { Button("추가") { model.addButtonTapped() } }
.sheet(item: $model.addContact) { addModel in
@Bindable var addModel = addModel
Form { TextField("이름", text: $addModel.contact.name) }
.toolbar { Button("저장") { model.saveButtonTapped() } }
}
}
}이 코드도 잘 돈다. 차이는 규모가 커질 때 드러난다. 자식 모델이 자기 일을 끝냈다고 부모에게 알리려면 클로저나 델리게이트를 따로 연결해야 하고, "편집 시트가 열린 채로 알럿이 떠 있는 상태"를 테스트에서 만들려면 객체를 차례로 만들고 메서드를 불러야 한다. TCA는 이 부분을 라이브러리가 맡는다.
| 관심사 | SwiftUI (@Observable) | TCA (트리 기반) |
|---|---|---|
| "떠 있음"의 표현 | 부모 모델의 옵셔널 프로퍼티 | 부모 State의 @Presents 옵셔널 |
| 자식 → 부모 알림 | 클로저·델리게이트를 손으로 연결 | 부모가 PresentationAction으로 자식 액션을 전부 본다 |
| 닫힐 때 자식 작업 정리 | 직접 취소해야 한다 | ifLet이 자식 이펙트를 자동 취소 |
| 자식이 스스로 닫기 | 뷰에서만 @Environment(\.dismiss) | 리듀서에서 @Dependency(\.dismiss) |
| 딥링크 / 특정 화면 상태로 시작 | 객체를 순서대로 조립 | 중첩된 State 값 하나를 만들면 끝 |
| 코드 양 | 적다 | State·Action·ifLet 세 군데를 손봐야 한다 |
2. 도메인 연결 — @Presents · PresentationAction · ifLet 🔥 핵심
트리 기반 도구는 딱 세 개다(TCA 문서 · Tree-based navigation). 예제는 Point-Free의 Meet TCA 튜토리얼 · Your first presentation과 같은 "연락처 + 추가 시트"다. 먼저 자식 기능을 평범하게 만든다. 자식은 자기가 시트로 뜨는지 전혀 모른다.
@Reducer
struct AddContactFeature {
@ObservableState
struct State: Equatable {
var contact: Contact
}
enum Action {
case cancelButtonTapped
case saveButtonTapped
case setName(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped, .saveButtonTapped:
return .none // 자식은 아무것도 안 한다 — 부모가 이 액션을 듣는다
case let .setName(name):
state.contact.name = name
return .none
}
}
}
}이제 부모에 세 군데를 붙인다. ① State에 @Presents 옵셔널, ② Action에 PresentationAction으로 감싼 자식 액션, ③ body 끝에 ifLet.
@Reducer
struct ContactsFeature {
@ObservableState
struct State: Equatable {
@Presents var addContact: AddContactFeature.State? // ① nil = 닫힘
var contacts: IdentifiedArrayOf<Contact> = []
}
enum Action {
case addButtonTapped
case addContact(PresentationAction<AddContactFeature.Action>) // ②
}
@Dependency(\.uuid) var uuid
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addButtonTapped:
// 값을 넣는 것 자체가 "시트를 띄워라"
state.addContact = AddContactFeature.State(contact: Contact(id: uuid(), name: ""))
return .none
case .addContact(.presented(.cancelButtonTapped)):
state.addContact = nil // 닫기
return .none
case .addContact(.presented(.saveButtonTapped)):
guard let contact = state.addContact?.contact else { return .none }
state.contacts.append(contact)
state.addContact = nil
return .none
case .addContact:
return .none
}
}
.ifLet(\.$addContact, action: \.addContact) { // ③
AddContactFeature()
}
}
}세 도구가 각각 하는 일
PresentationState 래퍼로 감싸는 매크로다. $addContact 프로젝션이 생기고, 힙에 할당되는 copy-on-write 저장소를 쓴다. FAQ는 "부모 State가 자식 State를 통째로 품지 않으니 큰 값 타입이 스택을 넘치지 않는다"고 설명한다..presented(자식 액션)과 .dismiss. 부모는 이걸로 자식 안에서 일어나는 모든 일을 본다.\.$addContact인가ifLet은 옵셔널 값이 아니라 PresentationState 자체가 필요하다. 그래서 $가 붙은 프로젝션 키 패스를 넘긴다. 액션 쪽 \.addContact는 enum용 case key path다.순서도 정해져 있다. ifLet 소스의 설명(PresentationReducer.swift)은 이렇다. .presented 액션은 자식 먼저, 부모 나중이다. 부모가 먼저 돌아 상태를 nil로 만들어 버리면 자식이 그 액션을 처리할 수 없기 때문이다. .dismiss는 반대로 부모 먼저 돌고, 그다음 상태가 nil이 된다. 부모가 닫히기 직전의 자식 상태를 마지막으로 볼 수 있게 하려는 것이다.
var body: some ReducerOf<Self> {
Reduce { state, action in
// ... 위와 같은 switch
}
// .ifLet(\.$addContact, action: \.addContact) { AddContactFeature() } ← 빠짐
}증상이 두 개다. 시트의 텍스트 필드에 글자를 쳐도 setName을 처리할 자식 리듀서가 없으니 값이 안 바뀐다. 시트를 쓸어내리면 스토어가 .dismiss를 보내지만 아무도 nil로 만들지 않는다. 이때 라이브러리가 보라색 런타임 경고를 띄운다. 소스(Store+Observation.swift)의 문구가 "binding … was set to "nil", but the store destination wasn't nil'd out … ensure that "ifLet" is invoked"다. 이 경고가 보이면 ifLet부터 찾는다.
3. 뷰 연결 — $store.scope로 sheet(item:)
뷰에서는 SwiftUI의 원래 모디파이어를 그대로 쓴다. TCA가 새 시트 API를 만든 게 아니다. sheet(item:)에 "옵셔널 자식 스토어의 바인딩"을 넘길 뿐이다. 바인딩을 만들어야 하니 스토어를 @Bindable로 받는다(Day 4의 바인딩과 같은 이유).
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) }
}
}
}
struct ContactsView: View {
@Bindable var store: StoreOf<ContactsFeature>
var body: some View {
NavigationStack {
List(store.contacts) { contact in
Text(contact.name)
}
.navigationTitle("연락처")
.toolbar {
Button("추가") { store.send(.addButtonTapped) }
}
}
.sheet(item: $store.scope(\.addContact, action: \.addContact)) { addStore in
NavigationStack {
AddContactView(store: addStore) // StoreOf<AddContactFeature>가 들어온다
}
}
}
}SwiftUI 코드와 나란히 놓으면 모양이 거의 같다. $model.addContact(옵셔널 모델의 바인딩) 자리에 $store.scope(\.addContact, action: \.addContact)(옵셔널 스토어의 바인딩)가 들어갔을 뿐이다. 이 바인딩은 두 방향으로 일한다.
- 읽기:
addContact가 nil이 아니게 되면 그 부분만 보는 자식 스토어를 만들어 시트 클로저에 넘긴다. - 쓰기: 사용자가 시트를 쓸어내려 SwiftUI가 nil을 쓰면, 스토어가
.addContact(.dismiss)를 보낸다(위 도해의 점선 경로).
여기서도 Day 5에서 본 라벨 없는 scope(_:action:)를 쓴다. 옛 코드의 $store.scope(state:action:)는 이전 이름이고 뜻은 같다.
시트만이 아니다 — 모디파이어만 바꾸면 된다
같은 바인딩을 popover(item:), fullScreenCover(item:), navigationDestination(item:)에 그대로 넘길 수 있다. navigationDestination(item:destination:)은 Apple 문서 기준 iOS 17+다. 이 코스의 기준과 같다.
struct ContactsPushView: View {
@Bindable var store: StoreOf<ContactsFeature>
var body: some View {
NavigationStack {
List(store.contacts) { Text($0.name) }
.toolbar { Button("추가") { store.send(.addButtonTapped) } }
.navigationDestination(
item: $store.scope(\.addContact, action: \.addContact)
) { addStore in
AddContactView(store: addStore)
}
}
}
}이것이 문서가 말하는 트리 기반의 장점이다. 시트냐 push냐는 뷰가 고르는 표현 방식이고, 리듀서는 "추가 화면이 떠 있다"는 사실만 안다.
딥링크 = 중첩된 값을 만드는 것
"시트가 열린 채로" 앱을 시작하고 싶다면? SwiftUI에서는 객체를 만들고 메서드를 부르는 순서를 신경 써야 한다. TCA에서는 원하는 상태를 값으로 적으면 끝이다. Preview에서 바로 확인할 수 있다.
#Preview("추가 시트가 열린 상태") {
ContactsView(
store: Store(
initialState: ContactsFeature.State(
addContact: AddContactFeature.State( // 이 값이 있으면 시트가 떠 있다
contact: Contact(id: UUID(), name: "민지")
)
)
) {
ContactsFeature()
}
)
}4. 닫기 — 부모가 nil로, 자식은 @Dependency(\.dismiss)
지금까지는 부모가 자식의 cancelButtonTapped·saveButtonTapped를 엿듣고 직접 nil을 대입했다. 동작은 하지만 두 가지가 불편하다. 부모가 "저장 버튼이 눌렸으면 저장하고 닫는다"는 자식 사정을 추측해야 하고, "취소 = 닫기" 같은 뻔한 일마다 부모 코드가 늘어난다. 튜토리얼은 이것을 두 단계로 정리한다.
- 델리게이트 액션: 자식이 부모에게 원하는 일을
Action.Delegate로 명시한다(Day 5의 delegate 패턴). - DismissEffect: "나 닫아 줘"는 델리게이트도 필요 없다. 자식이
@Dependency(\.dismiss)를 부른다.
@Reducer
struct AddContactFeature {
@ObservableState
struct State: Equatable { var contact: Contact }
enum Action {
case cancelButtonTapped
case delegate(Delegate)
case saveButtonTapped
case setName(String)
@CasePathable // 테스트에서 \.addContact.delegate.saveContact 경로를 쓰려면 필요
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
}
}
}
}
@Reducer
struct ContactsFeature {
@ObservableState
struct State: Equatable {
@Presents var addContact: AddContactFeature.State?
var contacts: IdentifiedArrayOf<Contact> = []
}
enum Action {
case addButtonTapped
case addContact(PresentationAction<AddContactFeature.Action>)
}
@Dependency(\.uuid) var uuid
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addButtonTapped:
state.addContact = AddContactFeature.State(contact: Contact(id: uuid(), name: ""))
return .none
case let .addContact(.presented(.delegate(.saveContact(contact)))):
state.contacts.append(contact) // nil 대입이 사라졌다
return .none
case .addContact:
return .none
}
}
.ifLet(\.$addContact, action: \.addContact) { AddContactFeature() }
}
}dismiss()는 어떻게 부모를 찾나
Dismiss.swift를 보면 DismissEffect의 기본값은 닫기 클로저가 없는(nil) 값이다. ifLet이 자식 리듀서를 돌릴 때 "나를 닫는 클로저"를 의존성으로 주입한다. 그래서 자식은 부모 타입을 전혀 몰라도 된다. 호출되면 PresentationAction.dismiss가 시스템으로 돌아가고, 부모의 ifLet이 상태를 nil로 만든다.
case .saveButtonTapped:
return .run { [contact = state.contact] send in
await self.dismiss()
await send(.delegate(.saveContact(contact))) // ⚠️ 자식 상태는 이미 nil
}문서가 직접 경고하는 실수다. 닫기도 결국 액션으로 처리되므로, dismiss() 이후에 보낸 액션은 상태가 nil인 기능에 도착한다. 문서는 이것이 런타임 경고·테스트 실패가 된다고 설명하지만, 1.26.2에서 실제로 돌려 보면 닫힘과 함께 자식 이펙트가 취소되어 이 액션은 경고 없이 버려진다(20회 모두 저장 누락). 조용히 사라지니 더 찾기 어려운 버그다. 알릴 게 있으면 먼저 보내고 마지막에 닫는다(위 ✅ 코드의 순서).
SwiftUI의 dismiss와는 다른 물건
struct AddContactScreen: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("취소") { dismiss() } // 검증·비동기 작업을 끼우기 어렵다
}
}SwiftUI @Environment(\.dismiss) | TCA @Dependency(\.dismiss) | |
|---|---|---|
| 타입 | DismissAction | DismissEffect |
| 쓰는 곳 | 뷰 안에서만 | 리듀서 안, .run 이펙트에서만(async) |
| 닫는 방법 | 가장 가까운 프레젠테이션의 바인딩에 false/nil 기록 | .dismiss(트리) 또는 .popFrom(스택) 액션 전송 |
| 테스트 | 모델 테스트로 확인 불가 | TestStore가 receive(\.addContact.dismiss)로 확인 |
문서는 두 이름이 같아도 완전히 다른 타입이라고 경고한다. 리듀서에서 @Environment를 쓸 수 없고, 뷰에서 @Dependency(\.dismiss)를 부르는 것도 뜻이 없다.
자식이 타이머나 긴 요청을 돌리는 중에 시트가 닫히면? ifLet은 상태가 nil이 되는 것을 감지하면 자식이 시작한 이펙트를 자동 취소한다(ifLet 문서 주석). SwiftUI에서 .task가 뷰와 함께 취소되는 것과 같은 효과를 리듀서 수준에서 얻는다. Day 3의 .cancellable(id:)를 손으로 달 필요가 없다.
@MainActor
func saveFlowPrediction() async {
let store = TestStore(
initialState: ContactsFeature.State(
addContact: AddContactFeature.State(contact: Contact(id: UUID(0), name: "민지"))
)
) {
ContactsFeature()
}
await store.send(\.addContact.saveButtonTapped)
// { $0.??? } 와 receive(...) 를 채워 보자
}✅ 복습 · 5분
.ifLet(\.$addContact, action: \.addContact)에서 상태 쪽에만 $가 붙는 이유는?
@Presents가 옵셔널을 PresentationState로 감싸기 때문이다. ifLet은 옵셔널 값이 아니라 그 래퍼(표시 여부·식별자까지 가진 것)가 필요해서 $ 프로젝션 키 패스를 받는다. 액션 쪽은 enum 케이스를 가리키는 case key path라서 $가 없다.사용자가 추가 시트를 손가락으로 쓸어내려 닫았다. 리듀서에서는 어떤 일이 어떤 순서로 일어나나?
sheet(item:) 바인딩에 nil을 쓴다 → 스토어가 .addContact(.dismiss)를 보낸다 → ifLet이 부모 Reduce를 먼저 돌린다(부모가 원하면 닫히기 직전 자식 상태를 본다) → addContact가 nil이 되고 자식 이펙트가 취소된다. ifLet이 없으면 nil이 안 되어 런타임 경고가 난다.🔮 위 코드 예측 — send 뒤 상태 변화와 receive 횟수는?
.addContact(.presented(.saveButtonTapped))를 받는 그 자리에서 연락처를 추가하고 addContact = nil을 대입한다. 자식은 .none을 돌려준다. 그래서 await store.send(\.addContact.saveButtonTapped) { $0.contacts = [Contact(id: UUID(0), name: "민지")]; $0.addContact = nil }로 끝나고 receive는 0번이다. 섹션 4의 델리게이트+dismiss 버전이라면 이펙트가 액션을 두 번 보낸다. 튜토리얼 테스트처럼 receive(\.addContact.delegate.saveContact)(여기서 contacts 추가)와 receive(\.addContact.dismiss)(여기서 nil)를 순서대로 받아야 한다.🔁 Day 9 복습: store.exhaustivity = .off는 언제 쓰고, 무엇을 포기하나?
2부는 목적지가 여러 개일 때다. 옵셔널을 여러 개 두면 생기는 "불가능한 상태"를 @Reducer enum Destination 하나로 없애고, 알럿·확인 다이얼로그를 AlertState·ConfirmationDialogState로 같은 틀에 넣은 뒤, TestStore로 시트·알럿 흐름을 테스트한다.