← TCA 14일 마스터 11 / 14 · 1부

Day 11 · 1부 · 스택 기반 내비게이션 — StackState와 NavigationStack

재귀 경로를 평평한 배열로 풀고, StackState와 전용 NavigationStack 이니셜라이저로 스택의 칸마다 리듀서를 붙인다.
진행률
0 / 0 완료
Day 10의 트리 기반은 "옵셔널 안에 옵셔널"이었다. 영화 앱을 생각해 보자. 영화 → 출연 배우 → 그 배우의 다른 영화 → 또 배우… 사용자가 원하는 만큼 깊이 들어간다. 이걸 옵셔널로 적으면 MovieFeature.State가 PersonFeature.State?를, 그게 다시 MovieFeature.State?를 품는 재귀 타입이 된다. SwiftUI 코스 Day 10에서 본 NavigationStack(path:)는 이 문제를 평평한 배열로 푼다. TCA의 스택 기반 내비게이션은 그 배열을 리듀서 State 안에 두고, 배열에 쌓인 화면마다 리듀서를 붙인다. 1부는 도구 네 개(StackState·StackAction·forEach·NavigationStack(path:root:destination:))로 스택을 세우는 법이다.

1부 목표 · 23분

  • 트리로 표현하기 힘든 재귀 경로를 보고 스택 기반이 필요한 이유를 설명한다.
  • @Reducer enum Path + StackState<Path.State> + StackActionOf<Path> + .forEach(\.path, action: \.path)로 루트 기능에 스택을 붙인다.
  • NavigationStack(path: $store.scope(\.path, action: \.path)) { … } destination: { … }와 switch store.case로 뷰를 연결한다.
  • 화면을 올리는 두 방법(NavigationLink(state:) / 버튼 + 부모가 append)의 모듈 결합 차이를 설명하고 골라 쓴다.

1. 트리에서 스택으로 — 왜 배열인가 🔥 핵심

문서(TCA 문서 · What is navigation?)는 트리 기반의 약점으로 바로 이 영화 앱을 든다. 영화 → 배우 목록 → 배우 → 처음 그 영화. 기능끼리 서로를 품는 재귀 의존이 생기고, Swift 타입으로 적기 어렵다. 배열로 적으면 재귀가 사라진다. 그냥 [.movie, .person, .movie]인 평평한 목록이다.

트리 — 옵셔널 안에 옵셔널 MovieFeature.State person: PersonFeature.State? movie: MovieFeature.State? 또 person? 또 movie? (재귀 타입 · 끝없는 중첩) 스택 — 평평한 배열 path: StackState<Path.State> .movie .person .movie append = push · 뒤에서 제거 = pop 몇 단계든 배열 길이만 늘어난다 대가: [.edit, .detail] 같은 말 안 되는 순서도 타입상 허용된다
트리는 "어디서 어디로 갈 수 있는지"를 타입으로 강제하고, 스택은 그 강제를 풀어 아무 순서나 쌓게 한다. 재귀 경로가 필요하면 스택이다.

SwiftUI에서는 이렇게 했다

SwiftUI 코스 Day 10의 라우터 방식이다. Hashable enum 배열을 path로 두고, navigationDestination(for:)이 값마다 뷰를 고른다.

SwiftUI(@Observable) — enum 배열 path + navigationDestination(for:)
import SwiftUI
import Observation

struct Movie: Identifiable, Hashable {
    let id: Int
    var title: String
}
struct Person: Identifiable, Hashable {
    let id: Int
    var name: String
}

@Observable
final class MoviesRouter {
    enum Route: Hashable {
        case movie(Movie)
        case person(Person)
    }
    var path: [Route] = []
}

struct MoviesRoot: View {
    @Bindable var router: MoviesRouter
    let movies: [Movie]

    var body: some View {
        NavigationStack(path: $router.path) {
            List(movies) { movie in
                NavigationLink(movie.title, value: MoviesRouter.Route.movie(movie))
            }
            .navigationDestination(for: MoviesRouter.Route.self) { route in
                switch route {
                case .movie(let movie):   Text(movie.title)   // 이 화면의 로직(모델)은 따로 만들어 연결해야 한다
                case .person(let person): Text(person.name)
                }
            }
        }
    }
}

path는 값만 담는다. 영화 화면이 출연진을 불러오는 로직, 배우를 탭했을 때 다음 화면을 push하는 로직은 각 화면이 알아서 모델을 만들고 라우터를 찾아 호출해야 한다. TCA의 StackState는 path의 각 칸에 그 화면의 State 전체를 담고, 각 칸마다 리듀서를 돌린다. 루트는 스택 안에서 일어나는 모든 액션을 본다.

SwiftUI [Route] / NavigationPathTCA StackState<Path.State>
칸에 담기는 것화면을 고를 Hashable 값화면 기능의 State 전체(Hashable 불필요)
각 화면의 로직화면마다 모델을 따로 연결forEach가 칸마다 자식 리듀서 실행
스택 안 일을 루트가 보기델리게이트·클로저를 손으로.path(.element(id:action:))로 전부 보인다
순회·중간 삽입[Route]는 가능 · NavigationPath는 불가가능(RandomAccessCollection·RangeReplaceableCollection)
pop될 때 작업 정리직접자식 이펙트 자동 취소

2. 도메인 연결 — Path · StackState · StackAction · forEach 🔥 핵심

순서는 트리 기반과 같다. 먼저 스택에 올라갈 자식 기능들을 평범하게 만든다. 자식은 자기가 스택에 있는지 모른다. 배우를 탭하면 액션만 보낸다. 다음 화면을 push하는 건 루트의 일이다.

✅ 스택에 올라갈 자식 기능 두 개 — 서로를 모른다
@Reducer
struct MovieFeature {
  @ObservableState
  struct State: Equatable {
    let movie: Movie
    var cast: [Person] = [Person(id: 1, name: "배우 A"), Person(id: 2, name: "배우 B")]
  }
  enum Action {
    case personTapped(Person)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .personTapped:
        return .none            // push하지 않는다 — 루트가 듣고 path에 추가한다
      }
    }
  }
}

@Reducer
struct PersonFeature {
  @ObservableState
  struct State: Equatable {
    let person: Person
    var filmography: [Movie] = [Movie(id: 10, title: "영화 X"), Movie(id: 11, title: "영화 Y")]
  }
  enum Action {
    case movieTapped(Movie)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .movieTapped:
        return .none
      }
    }
  }
}

루트에는 네 가지를 붙인다(TCA 문서 · Stack-based navigation). ① 스택에 올라갈 수 있는 화면 목록 @Reducer enum Path(Day 10의 Destination과 구조가 똑같다), ② State에 StackState<Path.State>, ③ Action에 StackActionOf<Path>, ④ body 끝에 .forEach(\.path, action: \.path).

✅ 루트 기능 — 스택을 소유하고, 스택 안 액션을 듣고 push한다
@Reducer
struct MoviesFeature {
  @Reducer
  enum Path {                                          // ① 스택에 올라갈 수 있는 화면들
    case movie(MovieFeature)
    case person(PersonFeature)
  }

  @ObservableState
  struct State: Equatable {
    var movies: [Movie] = [Movie(id: 1, title: "인셉션"), Movie(id: 2, title: "인터스텔라")]
    var path = StackState<Path.State>()                // ② 빈 배열 = 루트 화면만
  }
  enum Action {
    case path(StackActionOf<Path>)                     // ③ push · popFrom · element
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .path(.element(id: _, action: .movie(.personTapped(person)))):
        state.path.append(.person(PersonFeature.State(person: person)))   // push
        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
      }
    }
    .forEach(\.path, action: \.path)                   // ④ 클로저 생략 가능(enum Path)
  }
}
extension MoviesFeature.Path.State: Equatable {
  // 부모 State가 Equatable이면 필요하다.
  // 각 칸(MovieFeature.State · PersonFeature.State)과 그 안의 Movie · Person 값이 모두 Equatable이다.
}

StackActionOf<Path>는 StackAction<Path.State, Path.Action>의 줄임말이다. 트리의 PresentationAction이 제네릭 하나(Action)인 것과 달리 StackAction은 State·Action 둘 다 받는다. 스택을 조작하는 가장 기본적인 세 가지 사건을 담기 때문이다.

StackAction 케이스뜻 / 누가 보내나SwiftUI에서 대응하는 것
.push(id:state:)새 화면을 올린다. NavigationLink(state:)를 탭하면 보내진다NavigationLink(value:) 탭 → path에 값 추가
.popFrom(id:)그 id부터 위를 전부 내린다. 뒤로 버튼·스와이프·dismiss()뒤로 버튼 → path에서 제거
.element(id:action:)스택의 id번 화면 안에서 액션이 일어났다대응 없음 — 루트가 자식 일을 보려면 직접 연결

forEach가 지키는 순서

forEach 소스 주석(StackReducer.swift)은 Day 10의 ifLet과 같은 원칙을 스택에 적용한다. element는 자식 먼저, popFrom은 부모 먼저(내리기 직전 자식 상태를 마지막으로 볼 기회), push는 칸을 추가한 뒤 부모(방금 올린 상태를 부모가 더 손볼 기회). 그리고 칸이 스택에서 빠지면 그 칸의 이펙트를 자동 취소하고, 자식에게 DismissEffect를 준다.

.push(id:state:) ① path에 칸 추가 ② 부모 Reduce .element(id:action:) ① id번 칸의 자식 리듀서 Path.body가 케이스로 분기 ② 부모 Reduce 엿듣고 append 등 .popFrom(id:) ① 부모 Reduce 내리기 직전 상태를 봄 ② id부터 위쪽 제거 그 칸들의 이펙트 취소 셋 다 한 곳(.forEach(\.path, action: \.path))에서 처리된다
StackAction 세 케이스는 각각 정해진 순서로 처리된다. 부모가 자식 상태를 "놓치는" 순간이 없도록 설계된 순서다.
❌ Path.State에 Equatable을 안 붙임 — 컴파일 에러
// MoviesFeature 안
  @ObservableState
  struct State: Equatable {                  // 🛑 path가 Equatable이 아니라서 합성 실패
    var path = StackState<Path.State>()
  }
// extension MoviesFeature.Path.State: Equatable {}  ← 빠짐

StackState는 요소가 Equatable일 때만 Equatable이다. Path.State는 매크로가 만든 enum이라 선언부에 적을 수 없으니 extension 한 줄로 붙인다. SyncUps 튜토리얼도 이 지점에서 "Important"로 짚는다(AppFeature.swift의 extension AppFeature.Path.State: Equatable {}).

3. 뷰 연결 — NavigationStack(path:root:destination:)

TCA는 NavigationStack에 전용 이니셜라이저를 추가한다. 인자는 세 개다. ① StackState·StackAction에 맞춘 스토어 바인딩, ② 항상 바닥에 있는 루트 뷰, ③ 스택의 각 칸에 대한 뷰. ③의 클로저는 Path 도메인 스토어를 받고, store.case로 switch하면 케이스별 자식 스토어가 나온다.

✅ 자식 화면 뷰 — 버튼은 액션만 보낸다
struct MovieView: View {
  let store: StoreOf<MovieFeature>
  var body: some View {
    List(store.cast) { person in
      Button(person.name) { store.send(.personTapped(person)) }
    }
    .navigationTitle(store.movie.title)
  }
}

struct PersonView: View {
  let store: StoreOf<PersonFeature>
  var body: some View {
    List(store.filmography) { movie in
      Button(movie.title) { store.send(.movieTapped(movie)) }
    }
    .navigationTitle(store.person.name)
  }
}
✅ 루트 뷰 — 스택 바인딩 + 루트 + 칸별 목적지
struct MoviesView: View {
  @Bindable var store: StoreOf<MoviesFeature>

  var body: some View {
    NavigationStack(path: $store.scope(\.path, action: \.path)) {
      // 루트: 항상 바닥에 있다
      List(store.movies) { movie in
        NavigationLink(
          movie.title,
          state: MoviesFeature.Path.State.movie(MovieFeature.State(movie: movie))
        )
      }
      .navigationTitle("영화")
    } destination: { store in
      // 칸마다: Path.State의 케이스로 분기
      switch store.case {
      case let .movie(store):
        MovieView(store: store)
      case let .person(store):
        PersonView(store: store)
      }
    }
  }
}

#Preview {
  MoviesView(store: Store(initialState: MoviesFeature.State()) { MoviesFeature() })
}

switch store.case는 @Reducer enum Path가 주는 기능이다. 새 목적지 케이스를 추가하면 이 switch가 컴파일 에러를 내서 뷰를 빠뜨릴 수 없다. SwiftUI의 navigationDestination(for:) 안 switch route와 같은 모양인데, 넘어오는 것이 값이 아니라 그 칸 전용 스토어다.

SwiftUI 쪽 사건이 액션이 되는 경로

이 이니셜라이저 안(NavigationStack+Observation.swift)을 보면, SwiftUI가 path 바인딩에 새 배열을 쓸 때 개수를 비교한다. 늘었으면 .push(id:state:), 줄었으면 .popFrom(id: ids[새 개수])를 보낸다. 뒤로 버튼을 길게 눌러 세 칸을 한 번에 내려도 popFrom 한 번이다. 그 id부터 위를 모두 내리는 액션이기 때문이다.

SwiftUI (뷰) TCA (리듀서) NavigationLink(state:) 탭 path 개수 증가 뒤로 버튼 · 스와이프 path 개수 감소 .path(.push(id:state:)) .path(.popFrom(id:)) forEach StackState 변경 반대 방향: StackState가 바뀌면(append·popLast…) SwiftUI가 그 배열대로 화면을 다시 쌓는다
사용자의 push·pop도, 리듀서의 append·pop도 결국 StackState 하나를 바꾼다. 진실은 한 곳이다.

화면을 올리는 두 방법

NavigationLink(state:)
위 루트 뷰의 방식이다. 탭하면 .push가 간다. 코드가 제일 짧다. 대신 링크를 가진 뷰가 MoviesFeature.Path.State, 즉 모든 목적지 기능을 알아야 한다. 영화 화면 안에 이 링크를 쓰면 영화 모듈이 배우 모듈까지 빌드해야 한다.
버튼 + 부모가 append
MovieView의 방식이다. 자식은 .personTapped만 보내고, 루트가 .path(.element(…))를 듣고 append한다. 자식 모듈은 다른 화면을 전혀 모른다. 코드가 조금 늘어난다.

문서의 표현으로는 전자가 "by far the simplest", 후자가 모듈화에 유리하다. 케이스 스터디(04-NavigationStack.swift)의 Screen B가 후자("Decoupled navigation")를 보여 준다. 루트 목록처럼 이미 모든 것을 아는 곳에서는 링크, 기능 모듈 안에서는 버튼+액션이 무난하다.

❌ Path.State로 감싸지 않은 state를 링크에 넘김 — 컴파일은 되지만 push되지 않는다
// MovieView 안
NavigationLink("배우 A", state: PersonFeature.State(person: Person(id: 1, name: "배우 A")))
// 스택 요소 타입은 MoviesFeature.Path.State인데 PersonFeature.State를 넘겼다

NavigationLink(state:)는 제네릭이라 아무 타입이나 받는다. 대신 DEBUG 빌드에서 링크가 나타날 때 스택의 요소 타입과 비교해 "A navigation link … is not presentable"이라는 런타임 경고를 띄운다(소스 기준). 스토어 기반 NavigationStack 밖에 링크가 있을 때도 같은 경고가 난다. 링크에는 항상 MoviesFeature.Path.State.person(…)처럼 Path.State의 케이스를 넘긴다.

📝 루트 화면은 스택에 없다

SyncUps의 AppFeature는 목록 화면(SyncUpsList)을 path에 넣지 않고 Scope로 붙인다. 튜토리얼 설명대로 루트는 "항상 바닥에 있고 닫히지 않는" 화면이라서다. NavigationStack의 첫 번째 클로저에 store.scope(\.syncUpsList, action: \.syncUpsList)로 만든 스토어를 넘긴다.

🔮 스택에 영화 한 칸이 있는 상태에서 그 영화 화면의 배우를 탭했다. 이 send 뒤 path는 몇 칸이 되고, 루트 Reduce가 받은 액션은 무엇인가?
import Testing

@MainActor
@Test
func pushPrediction() async {
  let store = TestStore(
    initialState: MoviesFeature.State(
      path: StackState([.movie(MovieFeature.State(movie: Movie(id: 1, title: "인셉션")))])
    )
  ) {
    MoviesFeature()
  }
  store.exhaustivity = .off
  await store.send(\.path[id: 0].movie.personTapped, Person(id: 1, name: "배우 A"))
  #expect(store.state.path.count == 2)
}

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
루트 기능에 스택을 붙이려면 어디에 무엇을 추가하나? (네 가지)
① 중첩 @Reducer enum Path { case …(Feature) } ② State에 var path = StackState<Path.State>() ③ Action에 case path(StackActionOf<Path>) ④ body 끝에 .forEach(\.path, action: \.path). State가 Equatable이면 extension …Path.State: Equatable {}도 붙인다. 뷰는 NavigationStack(path: $store.scope(\.path, action: \.path)) { 루트 } destination: { switch store.case … }.
쉽게 말하면 "갈 수 있는 방 목록"(Path), "지금 쌓인 방들"(StackState), "방에서 생긴 일"(StackAction), "방마다 관리인 배치"(forEach) 네 가지를 준비한다.
화면 안에서 NavigationLink(state:) 대신 버튼 + 액션으로 push하는 이유는?
NavigationLink(state:)는 Path.State를 만들어야 하므로 그 뷰가 스택의 모든 목적지 기능을 알아야(빌드해야) 한다. 버튼이 자기 액션만 보내고 루트가 .path(.element(id:action:))를 듣고 append하면, 자식 모듈은 다른 화면과 완전히 분리된다.
쉽게 말하면 손님이 직접 다음 방 문을 여는 대신 "저 이 배우 궁금해요"라고 말만 하면, 안내원(루트)이 알아서 방을 열어 준다. 손님은 건물 구조를 몰라도 된다.
🔮 위 코드 예측 — path는 몇 칸, 루트는 어떤 액션을?
스토어가 받은 액션은 .path(.element(id: 0, action: .movie(.personTapped(배우 A))))다. forEach는 id 0 칸의 자식(MovieFeature)을 먼저 돌리고(자식은 .none), 이어서 루트 Reduce가 같은 액션을 받는다. 루트의 첫 번째 case에 걸려 .person(PersonFeature.State(person: 배우 A))를 append한다. 그래서 path는 [.movie(인셉션), .person(배우 A)] 2칸이고 #expect가 통과한다. 새 칸의 id가 무엇인지는 2부에서 다룬다.
쉽게 말하면 영화 방에서 "배우 A!"라고 외치면, 방 안 사람이 먼저 듣고 그다음 안내원이 듣고 배우 방을 하나 더 쌓는다.
🔁 Day 10 복습: 트리 기반 ifLet과 스택 기반 forEach의 공통점 세 가지는?
① 자식 액션은 자식 먼저·부모 나중, 닫기(.dismiss / .popFrom)는 부모 먼저로 순서를 강제한다. ② 자식 상태가 사라지면(nil / 스택에서 제거) 자식 이펙트를 자동 취소한다. ③ 자식에게 @Dependency(\.dismiss)를 주입해 스스로 닫게 한다. 목적지 enum(Destination / Path)도 같은 @Reducer enum이다.
쉽게 말하면 방 하나짜리 관리인(ifLet)과 여러 층 관리인(forEach)은 일하는 규칙이 같다. 담당하는 방 개수만 다르다.
➡️ 2부 예고

2부는 스택을 다루는 법이다. 인덱스 대신 StackElementID를 쓰는 이유, popLast·pop(from:)·pop(to:)·자식의 dismiss, 딥링크로 여러 칸을 한 번에 쌓기, 트리와 스택을 고르는 기준, 그리고 \.path[id: 0]로 쓰는 스택 테스트를 다룬다.

출처 · 참고자료