IT

TCA NavigationStackStore 사용 방법 (SwiftUI / Composable Architecture)

초코모찌롤 2026. 3. 15. 14:09
반응형

TCA NavigationStackStore 사용 방법 (SwiftUI / Composable Architecture)

SwiftUI에서 NavigationStack을 사용하면 화면 이동을 비교적 간단하게 구현할 수 있다. 하지만 TCA(Composable Architecture)를 사용하는 경우에는 NavigationStack을 상태 기반(state-driven)으로 관리하는 방식이 필요하다.

이때 사용하는 것이 바로 NavigationStackStore이다.

NavigationStackStore는 SwiftUI NavigationStack과 TCA 상태 시스템을 연결해주는 도구로, StackState와 StackAction을 이용해 navigation 흐름을 관리한다.

왜 NavigationStackStore가 필요한가

SwiftUI 기본 NavigationStack은 보통 다음처럼 사용한다.

NavigationStack {
    List(items) { item in
        NavigationLink(value: item) {
            Text(item.title)
        }
    }
}
.navigationDestination(for: Item.self) { item in
    DetailView(item: item)
}

이 방식은 간단하지만 TCA에서는 문제가 생긴다.

  • navigation 상태가 Store에 없다
  • 화면 이동을 reducer에서 제어할 수 없다
  • 테스트하기 어렵다
  • navigation 흐름이 분산된다

그래서 TCA에서는 navigation 자체도 state로 관리하는 패턴을 사용한다.

NavigationStackStore 핵심 구성 요소

NavigationStackStore를 사용할 때 등장하는 핵심 구성 요소는 다음과 같다.

  • StackState
  • StackAction
  • NavigationStackStore
  • Path reducer

이 네 가지가 함께 navigation 흐름을 구성한다.

기본 구조

먼저 Feature에 navigation 상태를 정의한다.

@Reducer
struct TodoListFeature {

    @Reducer
    enum Path {
        case detail(TodoDetailFeature)
    }

    @ObservableState
    struct State: Equatable {
        var items: [String] = []
        var path = StackState<Path.State>()
    }

    enum Action {
        case itemTapped(String)
        case path(StackAction<Path.State, Path.Action>)
    }

    var body: some ReducerOf<Self> {

        Reduce { state, action in
            switch action {

            case let .itemTapped(item):
                state.path.append(.detail(
                    TodoDetailFeature.State(title: item)
                ))
                return .none

            case .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }
}

여기서 중요한 부분은 StackState이다.

StackState는 NavigationStack의 경로(path)를 TCA state 안에서 관리하는 구조다.

StackState 역할

StackState는 navigation stack의 상태를 저장한다.

var path = StackState<Path.State>()

예를 들어 다음과 같은 화면 구조가 있다고 해보자.

  • TodoList
  • TodoDetail
  • TodoEdit

사용자가 Detail → Edit로 이동하면 StackState 내부는 다음과 같은 구조가 된다.

[DetailState, EditState]

즉 NavigationStack의 stack을 그대로 state로 표현하는 것이다.

Path Reducer 정의

navigation으로 push되는 Feature들을 Path reducer 안에 정의한다.

@Reducer
enum Path {
    case detail(TodoDetailFeature)
    case edit(TodoEditFeature)
}

이 enum은 navigation stack에 들어갈 수 있는 화면들을 의미한다.

NavigationStackStore 사용하기

이제 View에서 NavigationStackStore를 사용한다.

import SwiftUI
import ComposableArchitecture

struct TodoListView: View {

    let store: StoreOf<TodoListFeature>

    var body: some View {

        NavigationStackStore(
            store.scope(state: \.path, action: \.path)
        ) {

            List {
                ForEach(["Apple", "Banana", "Orange"], id: \.self) { item in
                    Button(item) {
                        store.send(.itemTapped(item))
                    }
                }
            }

        } destination: { state in

            switch state {

            case .detail:
                CaseLet(
                    /TodoListFeature.Path.State.detail,
                    action: TodoListFeature.Path.Action.detail,
                    then: TodoDetailView.init
                )
            }
        }
    }
}

NavigationStackStore는 두 부분으로 나뉜다.

  • 루트 화면
  • destination 화면

destination에서는 CaseLet을 사용해 각 Feature를 연결한다.

화면 push하기

TCA에서는 reducer에서 navigation을 발생시킨다.

case let .itemTapped(item):

    state.path.append(
        .detail(
            TodoDetailFeature.State(title: item)
        )
    )

    return .none

append를 하면 NavigationStack에 push된다.

즉 navigation 흐름이 reducer 중심으로 관리된다.

pop 처리

뒤로 가기는 다음처럼 path를 수정하면 된다.

state.path.removeLast()

또는 특정 위치까지 pop할 수도 있다.

state.path.removeAll()

이렇게 navigation 자체가 state이기 때문에 테스트와 디버깅이 훨씬 쉬워진다.

Delegate 패턴과 함께 사용하기

실무에서는 자식 Feature에서 직접 navigation을 하지 않고 delegate 패턴을 통해 부모 reducer에 이벤트를 전달하는 구조를 많이 사용한다.

enum Action {
    case delegate(Delegate)

    enum Delegate {
        case editRequested
    }
}

그리고 부모 reducer에서 navigation을 처리한다.

case .detail(.delegate(.editRequested)):

    state.path.append(
        .edit(EditFeature.State())
    )

    return .none

이 구조는 navigation 흐름을 부모 reducer에서 관리하게 해 준다.

NavigationStackStore 장점

  • navigation 상태를 state로 관리한다
  • reducer에서 navigation 흐름 제어 가능
  • 테스트 가능
  • navigation 로직이 분산되지 않는다
  • DeepLink 처리에도 유리하다

즉 navigation을 UI 로직이 아니라 상태 기반 로직으로 관리할 수 있다.

자주 하는 실수

NavigationLink를 직접 사용하는 경우

TCA에서는 NavigationLink보다 state 기반 navigation을 사용하는 것이 좋다.

path reducer를 정의하지 않는 경우

navigation stack에 들어갈 Feature는 Path reducer 안에 정의해야 한다.

forEach를 빼먹는 경우

StackState를 사용하면 반드시 다음 코드가 필요하다.

.forEach(\.path, action: \.path)

navigation 로직을 View에 두는 경우

navigation은 reducer에서 처리하는 것이 TCA 구조에 맞다.

실무에서 자주 사용하는 구조

MainFeature
 ├── HomeFeature
 │     └── HomeDetailFeature
 │
 ├── RecordFeature
 │     └── RecordEditFeature
 │
 └── SettingsFeature

보통 Root Feature에서 NavigationStackStore를 관리하고 각 Feature가 delegate 이벤트를 통해 navigation을 요청하는 구조를 많이 사용한다.

정리

  • NavigationStackStore는 SwiftUI NavigationStack을 TCA state와 연결하는 도구다.
  • StackState로 navigation stack을 state로 관리한다.
  • navigation push는 reducer에서 path.append로 처리한다.
  • destination 화면은 CaseLet으로 연결한다.
  • navigation 흐름을 reducer 중심으로 관리할 수 있다.

TCA (Composable Architecture) 관련 글

반응형