StackState·StackAction·forEach·NavigationStack(path:root:destination:)로 영화 ↔ 배우를 끝없이 오가는 스택을 세웠다. 실제 앱에서는 스택을 다뤄야 한다. 저장이 끝나면 "그 화면"을 갱신하고, 메뉴에서 세 칸 전으로 돌아가고, 푸시 알림의 URL로 여러 칸을 한 번에 쌓는다. 이때 "몇 번째 칸"이라는 인덱스는 믿을 수 없다. 그사이 스택이 바뀌기 때문이다. 2부는 StackElementID로 칸을 가리키는 법, 스택 조작·닫기·딥링크, 트리와 스택을 고르는 기준, 그리고 스택 테스트다.2부 목표 · 21분
- 인덱스를 붙잡은 비동기 코드를 찾아
StackElementID와path[id:]로 고친다. append·popLast·pop(from:)·pop(to:)·removeAll과 자식의dismiss를 상황에 맞게 쓴다.- URL을
StackState값으로 바꿔 딥링크를 구현하고, 트리·스택을 섞는 구조를 설계한다. \.path[id: 0].movie.…·\.path.push·\.path.popFrom으로 스택 테스트를 쓴다.
1. StackElementID — 인덱스 대신 이름표 🔥 핵심
영화 화면에서 "즐겨찾기"를 누르면 루트가 1초짜리 저장 요청을 보내고, 끝나면 그 영화 칸의 isFavorite를 켠다고 하자. 요청 중에 사용자가 스택을 바꿀 수 있다. 앞 칸이 빠지면 인덱스가 당겨지고, 저장 결과가 엉뚱한 칸에 들어간다.
// MoviesFeature의 Reduce 안
case let .path(.element(id: id, action: .movie(.favoriteButtonTapped))):
let index = state.path.ids.firstIndex(of: id)! // 지금은 2번째 칸
return .run { send in
try await clock.sleep(for: .seconds(1))
await send(.favoriteSavedAt(index: index)) // 1초 뒤에도 2번째 칸일까?
}StackState는 칸을 넣을 때마다 절대 겹치지 않는 이름표를 자동으로 붙인다. 소스(StackReducer.swift)의 StackElementID 설명은 "positional indices, which can be error prone, especially when dealing with async effects"를 피하려고 만든 타입이라고 적는다. .element(id:action:)가 이미 이 id를 들고 오므로 그대로 붙잡으면 된다.
TCA 저장소의 테스트(testFirstChildWhileEffectInFlight_DeliversToCorrectID)가 정확히 이 상황을 검증한다. 두 칸에서 요청을 보낸 뒤 첫 칸을 없애도 응답이 올바른 id의 칸에 도착한다.
| 하고 싶은 일 | SwiftUI [Route] | TCA StackState |
|---|---|---|
| 특정 칸 읽기·고치기 | path[index] (인덱스) | path[id: id] — 없으면 nil |
| 특정 칸 + 케이스 | if case .movie(var m) = path[i] 후 다시 대입 | path[id: id, case: \.movie] · path[id: id]?.modify(\.movie) { … } |
| id와 함께 순회 | enumerated() (인덱스) | zip(path.ids, path) |
요소가 Hashable이어야 하나 | 예 | 아니오 — 해시는 id에서 나온다 |
StackElementID는 내부적으로 "세대(generation)" 번호다. 칸이 생길 때마다 0, 1, 2…로 늘고, 칸을 지워도 되돌아가지 않는다. ExpressibleByIntegerLiteral이라 0이라고 적을 수 있지만, 소스를 보면 테스트가 아닌 곳에서 정수 리터럴로 만들면 fatalError("not allowed outside of tests")다. 앱 코드에서는 항상 액션이 들고 온 id나 path.ids에서 꺼낸 id를 쓴다.
2. 스택 다루기 — 조작 · 닫기 · 딥링크
2부의 예제를 한곳에 모은다. 영화 칸은 즐겨찾기를, 배우 칸은 "닫기"를 가진다. 루트는 id로 저장 결과를 반영하고, 루트로 돌아가기·특정 칸으로 돌아가기·URL 열기를 처리한다.
struct Movie: Identifiable, Equatable { let id: Int; var title: String }
struct Person: Identifiable, Equatable { let id: Int; var name: String }
@Reducer
struct MovieFeature {
@ObservableState
struct State: Equatable {
let movie: Movie
var isFavorite = false
}
enum Action {
case favoriteButtonTapped
case personTapped(Person)
}
var body: some ReducerOf<Self> {
Reduce { _, _ in .none } // 두 액션 모두 루트가 처리한다
}
}
@Reducer
struct PersonFeature {
@ObservableState
struct State: Equatable { let person: Person }
enum Action {
case closeButtonTapped
case movieTapped(Movie)
}
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .closeButtonTapped:
return .run { _ in await self.dismiss() } // → .path(.popFrom(id: 내 id))
case .movieTapped:
return .none
}
}
}
}
struct MovieView: View {
let store: StoreOf<MovieFeature>
var body: some View {
Button(store.isFavorite ? "★ 즐겨찾기됨" : "☆ 즐겨찾기") { store.send(.favoriteButtonTapped) }
.navigationTitle(store.movie.title)
}
}
struct PersonView: View {
let store: StoreOf<PersonFeature>
var body: some View {
Button("닫기") { store.send(.closeButtonTapped) }
.navigationTitle(store.person.name)
}
}@Reducer
struct MoviesFeature {
@Reducer
enum Path {
case movie(MovieFeature)
case person(PersonFeature)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case favoriteSaved(id: StackElementID)
case goBack(to: StackElementID)
case openURL(URL)
case path(StackActionOf<Path>)
case popToRootButtonTapped
}
@Dependency(\.continuousClock) var clock
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .favoriteSaved(id):
state.path[id: id]?.modify(\.movie) { $0.isFavorite = true } // 칸이 없으면 아무 일 없음
return .none
case let .goBack(id):
state.path.pop(to: id) // 그 칸은 남기고 위쪽만 내린다
return .none
case let .openURL(url):
guard let path = Self.path(for: url) else { return .none }
state.path = path // 딥링크 = 배열을 통째로 바꾸기
return .none
case let .path(.element(id: id, action: .movie(.favoriteButtonTapped))):
return .run { send in
try await clock.sleep(for: .seconds(1)) // 서버 저장이라고 치자
await send(.favoriteSaved(id: id)) // 인덱스가 아니라 id를 돌려준다
}
case let .path(.element(id: _, action: .movie(.personTapped(person)))):
state.path.append(.person(PersonFeature.State(person: person)))
return .none
case let .path(.element(id: _, action: .person(.movieTapped(movie)))):
state.path.append(.movie(MovieFeature.State(movie: movie)))
return .none
case .path:
return .none
case .popToRootButtonTapped:
state.path.removeAll()
return .none
}
}
.forEach(\.path, action: \.path)
}
// "movies://movie/1/person/7" → [.movie(1), .person(7)]
static func path(for url: URL) -> StackState<Path.State>? {
guard url.scheme == "movies", let host = url.host() else { return nil }
let parts = [host] + url.pathComponents.dropFirst()
var path = StackState<Path.State>()
var i = 0
while i + 1 < parts.count, let id = Int(parts[i + 1]) {
switch parts[i] {
case "movie": path.append(.movie(MovieFeature.State(movie: Movie(id: id, title: "영화 \(id)"))))
case "person": path.append(.person(PersonFeature.State(person: Person(id: id, name: "배우 \(id)"))))
default: return nil
}
i += 2
}
return path
}
}
extension MoviesFeature.Path.State: Equatable {
// 칸(MovieFeature.State · PersonFeature.State)과 그 안의 Movie · Person 값이 Equatable이다.
}조작 도구 한눈에
| UIKit | SwiftUI [Route] | TCA StackState |
|---|---|---|
pushViewController | path.append(r) | path.append(.movie(…)) |
popViewController | path.removeLast() | path.popLast() · 자식 dismiss() |
popToViewController(vc) | path.removeSubrange((i+1)...) | path.pop(to: id) — 그 칸은 남긴다 |
| 그 화면까지 닫기 | path.removeSubrange(i...) | path.pop(from: id) — 그 칸부터 내린다 |
popToRootViewController | path.removeAll() | path.removeAll() |
setViewControllers | path = [a, b] | path = StackState([a, b]) |
pop(from:)과 pop(to:)는 한 글자 차이로 결과가 한 칸 다르다. 소스 주석대로 from은 "그 칸과 그 뒤 전부", to는 "그 칸 뒤만"이다. 문서의 통합 예제는 편집 화면의 저장 액션을 받으면 state.path.pop(from: id)로 편집 칸 자체를 내린다.
자식이 스스로 닫기 — 스택에서는 popFrom
Day 10의 @Dependency(\.dismiss)는 스택에서도 그대로 쓴다. 차이는 돌아가는 액션뿐이다. forEach가 주입한 닫기 클로저는 .popFrom(id: 그 칸의 id)를 보낸다(DismissEffect 문서). 배우 칸이 맨 위가 아니어도 그 칸부터 위가 모두 내려간다. 트리와 마찬가지로 dismiss() 뒤에 액션을 보내면 안 된다.
forEach는 칸이 빠지면 그 칸의 자식 리듀서가 시작한 이펙트를 취소한다. 케이스 스터디 Screen C의 타이머가 화면을 닫으면 멈추는 이유다. 위 예제의 저장 요청은 루트가 시작했으므로 취소되지 않는다. 그래서 favoriteSaved에서 path[id:]가 nil일 수 있다고 보고 옵셔널 체이닝으로 조용히 넘긴다.
SwiftUI 라우터와 비교 + 뷰 연결
import SwiftUI
import Observation
@Observable
final class MoviesRouter {
enum Route: Hashable { case movie(id: Int), person(id: Int) }
var path: [Route] = []
func popToRoot() { path.removeAll() }
func goBack(toIndex index: Int) { path.removeSubrange((index + 1)...) } // 인덱스가 바뀌면 틀린다
func open(_ url: URL) { path = [.movie(id: 1), .person(id: 7)] } // 파싱은 생략
}struct MoviesView: View {
@Bindable var store: StoreOf<MoviesFeature>
var body: some View {
NavigationStack(path: $store.scope(\.path, action: \.path)) {
Button("영화 1 열기") { store.send(.openURL(URL(string: "movies://movie/1")!)) }
} destination: { store in
switch store.case {
case let .movie(store): MovieView(store: store)
case let .person(store): PersonView(store: store)
}
}
.safeAreaInset(edge: .bottom) {
Menu("현재 스택") {
ForEach(Array(zip(store.path.ids, store.path)), id: \.0) { id, element in
Button(Self.title(of: element)) { store.send(.goBack(to: id)) }
}
Button("루트로") { store.send(.popToRootButtonTapped) }
}
}
.onOpenURL { store.send(.openURL($0)) } // 딥링크도 결국 액션 하나
}
static func title(of element: MoviesFeature.Path.State) -> String {
switch element {
case let .movie(state): state.movie.title
case let .person(state): state.person.name
}
}
}StackState는 요소가 Codable이면 Codable이다(소스의 Decodable·Encodable 조건부 준수). 디코딩하면 id는 새로 발급된다. 스택 전체를 저장했다가 되살리는 것도 "값을 만들어 넣기"라는 같은 원리다.
3. 트리냐 스택이냐 — 고르는 기준과 섞어 쓰기
문서(What is navigation? · Tree-based vs stack-based)의 장단점을 표로 옮기면 이렇다.
| 관점 | 트리 (@Presents · ifLet) | 스택 (StackState · forEach) |
|---|---|---|
| 말 안 되는 경로 차단 | 타입이 막는다 (편집은 상세에서만) | [.edit, .detail], [.edit, .edit]도 가능 |
| 재귀·복잡한 경로 | 재귀 타입이 되어 어렵다 | 배열이라 쉽다 |
| 기능 간 결합 | 부모가 자식을 빌드해야 한다(컴파일 시간↑) | 칸끼리 서로 몰라도 된다(모듈 분리) |
| 기능 단독 Preview·통합 테스트 | 자식이 안에 있어 끝까지 동작 | 분리된 만큼 앱 전체로 확인해야 한다 |
| 다루는 표현 | 시트·팝오버·커버·알럿·다이얼로그·드릴다운 | 드릴다운만 |
판단 순서는 짧다. ① 드릴다운이 아니면(시트·알럿·팝오버) 트리다. 스택은 드릴다운만 다룬다. ② 드릴다운인데 경로가 재귀적이거나 여러 화면에서 같은 화면으로 들어오면 스택이다. ③ 경로가 정해져 있고 짧으면(목록 → 상세 1단계) 트리의 navigationDestination(item:)도 좋다. 타입이 경로를 보장하고, 기능 단독 Preview에서 끝까지 눌러 볼 수 있다. SyncUps의 상세 화면에서 지난 회의로 가는 링크는 AppFeature.Path.State.meeting(…)을 쓰는 NavigationLink(state:)이고, 그 칸은 Path에서 리듀서 없는 케이스(case meeting(Meeting, syncUp: SyncUp))다.
4. 스택 테스트 — \.path[id:]로 칸을 가리킨다
스택 테스트의 핵심은 id다. 문서(Stack-based navigation · Testing)는 테스트에서 id가 0부터 시작해 칸이 생길 때마다 1씩 느는 정수라고 설명한다. 초기 상태에 칸 하나를 넣고 시작하면 그 칸이 0이고, 그다음 추가되는 칸이 1이다. 그래서 \.path[id: 0].movie.favoriteButtonTapped처럼 액션 경로에 id를 적는다.
import Testing
@MainActor
@Test
func favorite() async {
let clock = TestClock()
let store = TestStore(initialState: MoviesFeature.State()) {
MoviesFeature()
} withDependencies: {
$0.continuousClock = clock
}
let inception = MovieFeature.State(movie: Movie(id: 1, title: "인셉션"))
// NavigationLink(state:) 탭을 흉내 낸다 — push 액션
await store.send(\.path.push, (id: 0, .movie(inception))) {
$0.path[id: 0] = .movie(inception)
}
await store.send(\.path[id: 0].movie.favoriteButtonTapped)
await clock.advance(by: .seconds(1))
await store.receive(\.favoriteSaved) {
$0.path[id: 0, case: \.movie]?.isFavorite = true // id + 케이스로 한 번에
}
}import Testing
@MainActor
@Test
func personClosesItself() async {
let store = TestStore(
initialState: MoviesFeature.State(
path: StackState([
.movie(MovieFeature.State(movie: Movie(id: 1, title: "인셉션"))), // id 0
.person(PersonFeature.State(person: Person(id: 7, name: "배우 7"))), // id 1
])
)
) {
MoviesFeature()
}
await store.send(\.path[id: 1].person.closeButtonTapped)
await store.receive(\.path.popFrom) { // dismiss()가 보낸 액션
$0.path[id: 1] = nil
}
}
@MainActor
@Test
func deepLink() async {
let store = TestStore(initialState: MoviesFeature.State()) {
MoviesFeature()
}
await store.send(.openURL(URL(string: "movies://movie/1/person/7")!)) {
$0.path = StackState([
.movie(MovieFeature.State(movie: Movie(id: 1, title: "영화 1"))),
.person(PersonFeature.State(person: Person(id: 7, name: "배우 7"))),
])
}
}\.path.push, (id: 0, …): SyncUps의 AppFeatureTests가 쓰는 모양이다. push의 id는 다음에 나올 번호와 맞아야 한다. 건너뛴 번호를 주면forEach가 "unexpected generational ID"를 보고한다(소스 기준).$0.path[id: 1] = nil: id로 칸을 지우는 단언이다.$0.path.popLast()로 적어도 같다.- 딥링크 단언: 기대 상태를
StackState([...])로 만들어도 통과한다. TestStore가 단언 클로저를 돌릴 때 id 발급기를 send 직전 상태로 되돌려 두기 때문이다(TestStore 소스 기준).
칸이 많아지면 문서처럼 store.exhaustivity = .off로 "결국 popFrom이 왔다"만 확인해도 된다. @Observable 라우터였다면 router.path 배열을 비교할 수는 있어도, 각 화면 모델이 무엇을 했는지와 pop될 때 작업이 멈췄는지는 따로 장치를 달아야 한다.
import Testing
@MainActor
@Test
func favoriteRacePrediction() async {
let clock = TestClock()
let store = TestStore(
initialState: MoviesFeature.State(
path: StackState([.movie(MovieFeature.State(movie: Movie(id: 1, title: "인셉션")))])
)
) {
MoviesFeature()
} withDependencies: {
$0.continuousClock = clock
}
store.exhaustivity = .off
await store.send(\.path[id: 0].movie.favoriteButtonTapped)
await store.send(\.path.popFrom, 0) // 저장 전에 뒤로
await clock.advance(by: .seconds(1))
}✅ 복습 · 5분
비동기 결과를 스택의 특정 칸에 반영할 때 인덱스 대신 StackElementID를 쓰는 이유는?
path[id:]가 nil을 준다. .element(id:action:)가 id를 들고 오므로 그대로 이펙트에 넘기면 된다.path.pop(from: id)와 path.pop(to: id)는 무엇이 다른가? 자식의 dismiss()는 어느 쪽과 같은 결과인가?
pop(from:)은 그 칸까지 포함해 위를 전부 내리고, pop(to:)는 그 칸은 남기고 위만 내린다. 자식의 dismiss()는 .popFrom(id: 자기 id)를 보내므로 자기 칸부터 위를 내리는 pop(from:)과 같은 결과다.🔮 위 코드 예측 — favoriteSaved는 도착하나?
forEach가 pop할 때 취소하는 것은 그 칸의 자식이 시작한 이펙트뿐이다. 그래서 1초 뒤 .favoriteSaved(id: 0)가 온다. 하지만 id 0 칸은 이미 없으니 state.path[id: 0]?.modify(\.movie) { … }는 옵셔널 체이닝에서 멈추고 상태는 빈 스택 그대로다. 경고도 없다. exhaustive로 쓰면 await store.receive(\.favoriteSaved)를 상태 변화 없이 받아야 통과한다. 저장을 자식(MovieFeature)의 이펙트로 옮기면 pop과 함께 자동 취소되어 이 액션 자체가 오지 않는다.🔁 1부 복습: 뒤로 버튼을 길게 눌러 세 칸을 한 번에 내리면 스토어는 어떤 액션을 몇 번 받나?
popFrom 한 번이다. 스토어 기반 NavigationStack은 SwiftUI가 path에 쓴 새 배열의 개수를 이전과 비교하고, 줄었으면 .popFrom(id: ids[새 개수])를 보낸다. popFrom은 그 id부터 위를 전부 내리는 액션이라 한 번으로 충분하다.🛠 미니 과제 (선택 · 10분)
회원 가입 마법사를 스택으로 만든다. 단계 화면은 "다음"과 "처음부터"만 안다.
@Reducer
struct StepFeature {
@ObservableState
struct State: Equatable { var number: Int }
enum Action {
case nextButtonTapped
case restartButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { _, _ in .none } // 루트가 듣는다
}
}- 루트
WizardFeature에@Reducer enum Path { case step(StepFeature) }와StackState를 붙인다. - n단계의
nextButtonTapped를 받으면 n+1단계를 push한다.restartButtonTapped를 받으면 루트까지 모두 pop한다. - TestStore로 "[1단계]에서 다음 → id 1에 2단계 → 2단계에서 처음부터 → 빈 스택"을 확인한다.
확인 방법: 테스트 통과. 뷰를 붙인다면 NavigationStack(path: $store.scope(\.path, action: \.path)) 하나면 된다.
정답 보기
import Testing
@Reducer
struct WizardFeature {
@Reducer
enum Path {
case step(StepFeature)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case path(StackActionOf<Path>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .path(.element(id: id, action: .step(.nextButtonTapped))):
guard let current = state.path[id: id, case: \.step] else { return .none }
state.path.append(.step(StepFeature.State(number: current.number + 1)))
return .none
case .path(.element(id: _, action: .step(.restartButtonTapped))):
state.path.removeAll()
return .none
case .path:
return .none
}
}
.forEach(\.path, action: \.path)
}
}
extension WizardFeature.Path.State: Equatable {
// StepFeature.State가 Equatable이다.
}
@MainActor
@Test
func wizard() async {
let store = TestStore(
initialState: WizardFeature.State(path: StackState([.step(StepFeature.State(number: 1))]))
) {
WizardFeature()
}
await store.send(\.path[id: 0].step.nextButtonTapped) {
$0.path[id: 1] = .step(StepFeature.State(number: 2))
}
await store.send(\.path[id: 1].step.restartButtonTapped) {
$0.path = StackState()
}
}