TCA 공식 문서에 스택 기반 네비게이션 가이드가 있으니 참고하면 좋을 것 같다.
Documentation
pointfreeco.github.io
StackState?
StackState는 TCA에서 네비게이션 스택을 표현하기 위한 상태 타입이다. SwiftUI의 NavigationStack과 함께 사용되어 상태 기반 네비게이션을 구현한다. 쉽게 설명하자면, SwiftUI의 NavigationStack을 상태로 옮겨놓은 것이라고 생각하면 된다.
1. 배열과 유사한 구조
var path: StackState<Path.State> = .init()
state.path.append(.detail(.init())) // push
state.path.removeLast() // pop
state.path.removeAll() // pop to root
state.path.count // 스택 깊이
2. 타입 안전성
@Reducer
public enum Path {
case detail(DetailFeature)
case settings(SettingsFeature)
case profile(ProfileFeature)
}
var path: StackState<Path.State> = .init()
state.path.append(.detail(.init()))
state.path.append(.unknown(.init())) // 정의되지 않은 타입은 컴파일에러 발생
왜 StackState를 사용할까?
struct ContentView: View {
@State private var isDetailActive = false
var body: some View {
NavigationStack {
VStack {
Button("상세로 이동") {
isDetailActive = true
}
}
.navigationDestination(isPresented: $isDetailActive) {
DetailView()
}
}
}
}
기존의 SwiftUI 네비게이션 구조는 View에서 Push와 Pop을 하는, 상태와 네비게이션이 섞이는 구조이다. 이 구조에는 몇 가지 문제가 있다.
첫 번째로, 현재 어떤 화면들이 네비게이션 스택에 쌓여있는지 명확하게 파악하기 어렵다. View 계층 구조를 일일이 확인해야만 현재 상태를 확인할 수 있고, 이는 디버깅을 어렵게 만든다.
@ObservableState
struct State {
var path: StackState<Path.State> = .init()
// path = [.detail1, .detail2, .settings] 이 배열만 보면 현재 네비게이션 상태를 정확히 알 수 있음
}
StackState 기반 네비게이션 구조는 네비게이션을 하나의 명확한 상태로 관리하기 때문에, State 안에 있는 path 배열만 확인하면 현재 어떤 화면들이 어떤 순서로 스택에 쌓여있는지 파악할 수 있다.
@Test
func testNavigation() {
var state = HomeFeature.State()
// Given: 초기 상태
XCTAssertEqual(state.path.count, 0)
// When: 검색 결과 클릭
let action = SearchFeature.Action.searchResultTapped(article)
let effect = SearchFeature().reduce(into: &state, action: action)
// Then: 상세 화면이 스택에 추가됨
XCTAssertEqual(state.path.count, 1)
XCTAssertEqual(state.path[0], .detail)
}
두 번째로, 네비게이션 로직이 View와 강하게 결합되어 있어 UI 없이 독립적인 테스팅이 거의 불가능하다. 하지만 StackState 기반 네비게이션 구조는 독립적인 테스팅이 가능하다. 예를들어, 검색 결과 화면에서 검색 결과를 클릭했을 때 path에 detail이 추가되는가? 를 검증할 수 있다.
StackState를 사용하는 가장 큰 이유는 네비게이션을 예측 가능한 상태로 만들기 위함이다. 상태로 관리되는 네비게이션은 추적할 수 있고, 테스트할 수 있고, 디버깅할 수 있다.
StateState의 전체 구조를 알아보자.
Reducer
@Reducer
public struct HomeFeature {
@ObservableState
public struct State {
var path: StackState<Path.State> = .init() // 스택 컨테이너
var searchText: String = ""
}
public enum Action {
case path(StackActionOf<Path>) // 스택 액션
case searchResultTapped(Article)
}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .searchResultTapped(article):
state.path.append(.detail(.init(article: article)))
return .none
case .path:
return .none
}
}
.forEach(\.path, action: \.path) // 스택 리듀서 연결
}
// 스택에 들어갈 수 있는 화면/패스들 정의
@Reducer(state: .equatable, action: .equatable)
public enum Path {
case detail(DetailFeature)
case edit(EditFeature)
case settings(SettingsFeature)
}
}
StackState는 크게 세 가지 요소로 구성된다. 먼저 State에 StackState 타입의 path 프로퍼티를 선언한다. 이 path가 실제 네비게이션 스택을 나타내는 배열 역할을 한다. 다음으로, Action에 StackActionOf 타입의 path 액션을 정의한다. 이는 스택 내부의 각 화면에서 발생하는 모든 액션을 처리하기 위한 것이다. 마지막으로, @Reducer 매크로로 Path enum을 정의하여 스택에 들어갈 수 있는 화면 타입들을 명시한다.
View
public struct HomeView {
@Bindable public var store: StoreOf<HomeFeature>
public init(store: StoreOf<HomeFeature>) {
self.store = store
}
}
extension HomeView: View {
public var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
VStack {
... Home View UI
}
} destination: { store in
switch store.case {
case let .detail(store):
DetailView(store: store)
case let .edit(store):
EditView(store: store)
case let .settings(store):
SettingsView(store: store)
}
}
}
}
View에서는 NavigationStack을 사용하고, path 파라미터에 store의 path를 바인딩한다. 위의 예제 코드에서 NavigationStack(path: $store.scope(state: \.path, action: \.path))처럼 작성하면 TCA의 path 상태와 SwiftUI의 NavigationStack이 양방향으로 연결 된다. NavigationStack의 desitination 클로저에 Path enum의 각 case별로 switch문을 작성해 해당하는 화면을 반환한다.
+ 최상위 뷰에서만 NavigationStack을 감싸주면 된다.
자주 사용하는 메소드
// 추가 (push)
state.path.append(.detail(.init()))
// 마지막 제거 (pop)
state.path.removeLast()
// n개 제거
state.path.removeLast(2)
// 전체 제거 (pop to root)
state.path.removeAll()
// 특정 인덱스 제거
state.path.remove(at: 1)
// 범위 제거
state.path.removeSubrange(1...3)
배열과 유사한 구조를 가지고 있기 때문에, 배열을 관리하거나 제어하는 메소드를 사용하면 된다.
dismiss?
StackState 기반 네비게이션에서는 @Dependency(\.dismiss)를 사용하지 않는다. 이유는 아래와 같다.
1. dismiss는 SwiftUI의 환경 값을 통해 동작하는 명령형 API이다. 이는 View 레이어에서 직접 네비게이션을 제어한다. TCA의 StackState는 상태가 변경되면 화면이 자동으로 변하는 선언형 방식이다. 이 두 패러다임 사이에서 충돌이 일어날 수 있다.
2. dismiss를 호출하면 SwiftUI가 화면을 닫기는 하지만 이것이 state.path에 반영되지 않을 수 있다. 특히 비동기로 동작하는 Effect와 함께 사용할 때 타이밍 이슈가 생길 수 있다.
3. 부모 Feature가 네비게이션을 제어하는 구조와 맞지 않다. dismiss를 자식에서 직접 호출하면 부모는 화면이 닫혔다는 사실조차 모를 수 있다.
'iOS > TCA' 카테고리의 다른 글
| [TCA] 03. TCA의 바인딩 방식을 알아보자. (0) | 2026.01.13 |
|---|---|
| [TCA] 02. Store를 알아보자. (1) | 2026.01.13 |
| [TCA] 1. The Composable Architecture ? (0) | 2025.10.17 |