IT

TCA NavigationStack 구조 구현 방법 (SwiftUI / Composable Architecture)

초코모찌롤 2026. 3. 15. 13:19
반응형

TCA NavigationStack 구조 구현 방법 (SwiftUI / Composable Architecture)

SwiftUI에서 화면이 하나둘 늘어나기 시작하면 네비게이션 구조도 빠르게 복잡해진다. 특히 목록 화면에서 상세 화면으로 이동하고, 상세 화면에서 다시 편집 화면으로 들어가고, 특정 액션에 따라 다음 화면을 push해야 하는 흐름이 생기면 단순한 NavigationLink만으로는 관리가 어려워질 수 있다.

이럴 때 TCA에서는 stack-based navigation 구조를 사용해 네비게이션 스택 자체를 상태로 관리할 수 있다. 이 방식은 화면 이동이 상태 변화로 표현되기 때문에 테스트하기 쉽고, 흐름을 추적하기도 훨씬 편하다.

이 글에서는 TCA에서 NavigationStack 구조를 어떻게 구현하는지 기본 개념부터 실제 코드 예시까지 정리한다.

TCA에서 NavigationStack을 어떻게 관리하나

TCA의 스택 기반 네비게이션은 보통 다음 구조로 만든다.

  • 부모 Feature가 StackState를 가진다.
  • 화면별 상태를 Path reducer로 묶는다.
  • 부모 Action에 StackAction<Path.State, Path.Action>를 둔다.
  • 특정 액션이 발생하면 state.path.append(...)로 다음 화면을 push한다.

TCA 문서에서는 스택 기반 네비게이션을 컬렉션 기반 상태로 모델링하는 방식으로 설명하고 있으며, StackState는 SwiftUI의 NavigationPath와 비슷하지만 타입이 유지된다는 점이 특징이다.

기본 구조 만들기

먼저 루트 Feature가 있고, 이 루트에서 상세 화면과 편집 화면으로 이동한다고 가정해보자.

import ComposableArchitecture

@Reducer
struct RootFeature {

    @Reducer
    enum Path {
        case detail(DetailFeature)
        case edit(EditFeature)
    }

    @ObservableState
    struct State: Equatable {
        var items: [String] = ["Apple", "Banana", "Orange"]
        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(DetailFeature.State(title: item)))
                return .none

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

이 구조에서 핵심은 State 안의 path다. 이 값이 현재 push된 화면들의 스택을 나타낸다.

Path reducer의 역할

Path는 push될 수 있는 화면들을 한곳에 모아두는 reducer다. 예를 들어 상세 화면과 편집 화면이 있다면 둘 다 Path에 case로 등록한다.

@Reducer
enum Path {
    case detail(DetailFeature)
    case edit(EditFeature)
}

이렇게 해두면 루트 Feature는 현재 스택에 어떤 화면이 쌓일 수 있는지 명확하게 표현할 수 있다. TCA 문서의 stack-based navigation 예시도 같은 방식으로 Path reducer를 정의한 뒤, 부모 상태에서 StackState를 보관하는 구조를 보여준다.

상세 화면 Feature 예시

이제 상세 화면에서 편집 화면으로 이동할 수 있게 만들어보자.

import ComposableArchitecture

@Reducer
struct DetailFeature {

    @ObservableState
    struct State: Equatable {
        var title: String
    }

    enum Action {
        case editButtonTapped
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .editButtonTapped:
                return .none
            }
        }
    }
}

편집 화면 Feature도 간단히 만들어둘 수 있다.

import ComposableArchitecture

@Reducer
struct EditFeature {

    @ObservableState
    struct State: Equatable {
        var text: String = ""
    }

    enum Action {
        case textChanged(String)
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .textChanged(text):
                state.text = text
                return .none
            }
        }
    }
}

부모 reducer에서 자식 액션을 받아 다음 화면 push하기

상세 화면의 버튼 탭을 부모가 받아서 편집 화면을 push하는 방식이 TCA에서 자주 쓰인다.

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
        case let .itemTapped(item):
            state.path.append(.detail(DetailFeature.State(title: item)))
            return .none

        case let .path(.element(id: id, action: .detail(.editButtonTapped))):
            guard let detailState = state.path[id: id, case: \.detail] else { return .none }
            state.path.append(.edit(EditFeature.State(text: detailState.title)))
            return .none

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

이 패턴이 좋은 이유는 화면 이동 로직이 부모 reducer에 모여서 전체 흐름을 한눈에 보기 쉽다는 점이다. TCA의 StackState 타입은 특정 id와 case를 기준으로 스택 안의 상태에 접근할 수 있는 도구도 제공한다.

SwiftUI View에서 NavigationStack 연결하기

최신 TCA에서는 observation 도구를 사용하는 경우 기본 SwiftUI NavigationStack에 store를 직접 바인딩하는 방식으로 구성할 수 있다. 마이그레이션 문서에 따르면 예전의 NavigationStackStore는 더 이상 꼭 필요하지 않다.

import SwiftUI
import ComposableArchitecture

struct RootView: View {
    @Bindable var store: StoreOf<RootFeature>

    var body: some View {
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            List(store.items, id: \.self) { item in
                Button(item) {
                    store.send(.itemTapped(item))
                }
            }
            .navigationTitle("Fruits")
        } destination: { store in
            switch store.state {
            case .detail:
                if let store = store.scope(state: \.detail, action: \.detail) {
                    DetailView(store: store)
                }
            case .edit:
                if let store = store.scope(state: \.edit, action: \.edit) {
                    EditView(store: store)
                }
            }
        }
    }
}

이 구조는 TCA 문서의 최신 stack-based navigation 흐름과 맞닿아 있다. 즉, 부모가 path 상태를 들고 있고, SwiftUI NavigationStack이 그 path를 기반으로 화면을 쌓는 구조다.

DetailView 예시

import SwiftUI
import ComposableArchitecture

struct DetailView: View {
    let store: StoreOf<DetailFeature>

    var body: some View {
        VStack(spacing: 16) {
            Text(store.title)
                .font(.title)

            Button("편집 화면 이동") {
                store.send(.editButtonTapped)
            }
        }
        .navigationTitle("Detail")
    }
}

EditView 예시

import SwiftUI
import ComposableArchitecture

struct EditView: View {
    @Bindable var store: StoreOf<EditFeature>

    var body: some View {
        VStack {
            TextField(
                "입력",
                text: Binding(
                    get: { store.text },
                    set: { store.send(.textChanged($0)) }
                )
            )
            .textFieldStyle(.roundedBorder)
            .padding()
        }
        .navigationTitle("Edit")
    }
}

NavigationStackStore는 이제 안 쓰는 걸까

완전히 못 쓰는 것은 아니지만, 최신 TCA 문서에서는 observation 기반 API를 사용할 때 기존의 NavigationStackStore 헬퍼가 더 이상 필요하지 않다고 안내한다. 즉, 최신 프로젝트라면 기본 SwiftUI NavigationStack과 store 바인딩 방식을 우선 고려하는 편이 자연스럽다.

다만 레거시 코드나 이전 버전 예제를 보고 있다면 NavigationStackStore가 등장할 수 있다. 실제 문서에도 1.3 시점에는 NavigationStackStore가 stack-based navigation을 위한 뷰로 소개되어 있다. 그래서 오래된 예제와 최신 예제가 다르게 보여도 이상한 것이 아니다.

실무에서 자주 쓰는 패턴

TCA NavigationStack 구조는 보통 다음과 같은 경우에 많이 사용된다.

  • 목록 → 상세 → 편집 흐름
  • 탭 내부에서 여러 단계 push가 필요한 화면
  • 딥링크로 특정 상세 화면까지 바로 진입해야 하는 경우
  • 부모가 전체 네비게이션 흐름을 관리해야 하는 경우

특히 네비게이션 흐름을 테스트하고 싶을 때 장점이 크다. 화면 이동이 단순 UI 이벤트가 아니라 상태 변화로 표현되기 때문이다.

테스트가 쉬운 이유

예를 들어 어떤 아이템을 탭했을 때 상세 화면이 push되는지, 상세 화면에서 편집 버튼을 눌렀을 때 편집 화면이 append되는지를 reducer 테스트로 검증할 수 있다. 이것이 TCA 방식의 큰 장점이다.

// 예시 개념
// store.send(.itemTapped("Apple")) 이후
// state.path에 .detail(DetailFeature.State(title: "Apple")) 가 추가되는지 확인

구현할 때 자주 헷갈리는 부분

1. path state와 destination 선언이 맞아야 한다

push되는 화면 case와 View의 destination 분기가 맞지 않으면 네비게이션이 정상 동작하지 않는다. 각 Path case마다 대응되는 destination을 정확히 연결하는 것이 중요하다.

2. 자식에서 직접 push하기보다 부모가 관리하는 편이 낫다

상세 화면에서 다음 화면으로 가는 버튼을 눌렀더라도, 실제 push 로직은 부모 reducer가 처리하는 구조가 더 깔끔하다. 그래야 전체 흐름이 한 곳에 모인다.

3. 탭 구조와 섞일 때는 탭별 path를 분리하는 편이 좋다

TabView와 함께 쓸 때는 각 탭이 자기만의 NavigationStack path를 가지도록 설계하면 구조가 더 안정적이다.

정리

  • TCA NavigationStack 구조는 StackStateStackAction으로 만든다.
  • push 가능한 화면들은 Path reducer에 모은다.
  • 부모 상태가 path를 갖고, 화면 이동은 append로 처리한다.
  • 최신 TCA에서는 기본 SwiftUI NavigationStack에 store를 바인딩하는 방식이 자연스럽다.
  • 화면 이동이 상태 변화로 표현되기 때문에 테스트와 추적이 쉬워진다.

TCA (Composable Architecture) 관련 글

반응형