IT

TCA Optional State Navigation 패턴 정리 (SwiftUI / Composable Architecture)

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

TCA Optional State Navigation 패턴 정리 (SwiftUI / Composable Architecture)

TCA(Composable Architecture)에서 화면 이동을 구현할 때 항상 NavigationStack처럼 여러 화면을 push해야 하는 것은 아니다.

실제 앱에서는 다음처럼 “한 번에 하나의 목적지”만 열리는 화면도 많다.

  • sheet
  • fullScreenCover
  • alert
  • confirmationDialog
  • 간단한 drill-down 화면

이런 경우에 잘 맞는 패턴이 바로 Optional State Navigation이다.

TCA에서는 이 방식을 흔히 tree-based navigation이라고도 부른다. 즉, 어떤 목적지 화면이 열려 있으면 state가 존재하고, 닫혀 있으면 nil이 되는 구조로 화면 이동을 모델링한다.

Optional State Navigation이란?

Optional State Navigation은 “목적지 화면이 열려 있는지 여부”를 optional state로 표현하는 방식이다.

예를 들어 프로필 화면에서 편집 시트를 띄운다고 해보자. 이 경우 편집 화면이 열려 있지 않으면 destination state는 nil이고, 열려 있으면 EditFeature.State가 들어간다.

var destination: EditFeature.State?

즉, 화면 이동을 UI 이벤트가 아니라 상태 변화로 보는 것이다.

언제 Optional State Navigation을 쓰면 좋을까

이 패턴은 보통 다음과 같은 경우에 잘 맞는다.

  • sheet 하나를 열고 닫는 경우
  • alert를 보여주는 경우
  • fullScreenCover를 띄우는 경우
  • 한 번에 하나의 하위 화면만 표시되는 경우

반대로 여러 화면이 연속으로 쌓이는 구조라면 StackState 기반 NavigationStack 패턴이 더 잘 맞는다.

기본 아이디어

핵심 구조는 아주 단순하다.

  1. 부모 state에 optional destination state를 둔다
  2. 특정 액션이 오면 destination state를 생성한다
  3. 화면이 닫히면 destination state를 nil로 만든다
  4. 부모 reducer는 ifLet으로 자식 reducer를 연결한다

즉, 화면 이동 자체가 state의 생성과 제거로 표현된다.

가장 기본적인 예제

프로필 화면에서 편집 화면을 sheet로 띄우는 예제로 보자.

import ComposableArchitecture

@Reducer
struct ProfileFeature {

    @Reducer
    struct Destination {
        @ObservableState
        enum State: Equatable {
            case editProfile(EditProfileFeature.State)
        }

        enum Action {
            case editProfile(EditProfileFeature.Action)
        }

        var body: some ReducerOf<Self> {
            Scope(state: \.editProfile, action: \.editProfile) {
                EditProfileFeature()
            }
        }
    }

    @ObservableState
    struct State: Equatable {
        var username: String = "Bella"
        @Presents var destination: Destination.State?
    }

    enum Action {
        case editButtonTapped
        case destination(PresentationAction<Destination.Action>)
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .editButtonTapped:
                state.destination = .editProfile(
                    EditProfileFeature.State(username: state.username)
                )
                return .none

            case .destination:
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination) {
            Destination()
        }
    }
}

이 코드에서 가장 중요한 부분은 두 가지다.

  • @Presents var destination: Destination.State?
  • .ifLet(\.$destination, action: \.destination)

즉, destination이 nil이 아니면 자식 reducer가 살아 있고, nil이면 자식 화면도 사라진다.

@Presents의 역할

최근 TCA에서는 optional navigation을 다룰 때 destination state에 @Presents를 붙이는 패턴을 많이 사용한다.

@Presents var destination: Destination.State?

이 속성은 단순 optional state보다 presentation 흐름과 더 자연스럽게 연결되도록 도와준다.

예를 들어 sheet가 닫힐 때나 child action이 presentation action으로 올라올 때 TCA와 SwiftUI의 presentation 생명주기를 맞추기 쉬워진다.

Destination reducer를 따로 두는 이유

destination 화면이 하나뿐이면 바로 optional state를 둘 수도 있지만, 실무에서는 sheet, alert, fullScreenCover처럼 목적지가 늘어나는 경우가 많다.

그래서 보통 Destination이라는 하위 reducer를 만들고, 그 안에 enum State를 두는 패턴을 많이 쓴다.

@Reducer
struct Destination {
    @ObservableState
    enum State: Equatable {
        case editProfile(EditProfileFeature.State)
        case settings(SettingsFeature.State)
    }

    enum Action {
        case editProfile(EditProfileFeature.Action)
        case settings(SettingsFeature.Action)
    }

    var body: some ReducerOf<Self> {
        Scope(state: \.editProfile, action: \.editProfile) {
            EditProfileFeature()
        }
        Scope(state: \.settings, action: \.settings) {
            SettingsFeature()
        }
    }
}

이 구조를 쓰면 부모 Feature가 여러 목적지를 깔끔하게 관리할 수 있다.

sheet와 연결하는 View 코드

이제 View에서 이 destination state를 sheet와 연결해보자.

import SwiftUI
import ComposableArchitecture

struct ProfileView: View {
    @Bindable var store: StoreOf<ProfileFeature>

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

            Button("프로필 편집") {
                store.send(.editButtonTapped)
            }
        }
        .sheet(
            item: $store.scope(state: \.destination?.editProfile, action: \.destination.editProfile)
        ) { store in
            EditProfileView(store: store)
        }
    }
}

이 구조에서는 editButtonTapped가 발생하면 destination에 값이 들어가고, 그 결과 sheet가 표시된다. 반대로 sheet가 닫히면 destination이 다시 nil로 정리된다.

자식 Feature 예시

import ComposableArchitecture

@Reducer
struct EditProfileFeature {

    @ObservableState
    struct State: Equatable {
        @BindingState var username: String
    }

    enum Action: BindableAction {
        case binding(BindingAction<State>)
        case saveButtonTapped
    }

    var body: some ReducerOf<Self> {
        BindingReducer()

        Reduce { state, action in
            switch action {
            case .binding:
                return .none

            case .saveButtonTapped:
                return .none
            }
        }
    }
}

자식 Feature는 그냥 자기 화면 상태와 액션에만 집중하면 된다. 시트를 띄우고 닫는 책임은 부모가 가진다.

.ifLet의 역할

optional navigation에서 reducer 연결의 핵심은 .ifLet이다.

.ifLet(\.$destination, action: \.destination) {
    Destination()
}

이 코드는 destination state가 존재할 때만 자식 reducer를 활성화하겠다는 의미다.

즉, child state가 nil이면 child reducer도 동작하지 않는다. 그래서 optional state와 navigation life cycle이 아주 잘 맞아떨어진다.

Alert와 ConfirmationDialog도 같은 패턴이다

Optional State Navigation은 sheet뿐 아니라 alert 처리에도 같은 원리로 적용된다.

@ObservableState
struct State: Equatable {
    @Presents var alert: AlertState<Action.Alert>?
}

enum Action {
    case deleteButtonTapped
    case alert(PresentationAction<Alert>)

    enum Alert {
        case confirmDelete
    }
}
case .deleteButtonTapped:
    state.alert = AlertState {
        TextState("정말 삭제할까요?")
    } actions: {
        ButtonState(role: .destructive, action: .confirmDelete) {
            TextState("삭제")
        }
    }
    return .none

이처럼 alert도 “있으면 보이고, 없으면 닫힌다”는 optional state 구조로 이해할 수 있다.

Optional State Navigation의 장점

  • 화면 표시 여부가 state에 명확하게 드러난다
  • sheet, alert, fullScreenCover와 잘 맞는다
  • 자식 reducer 생명주기를 state와 함께 관리할 수 있다
  • 테스트하기 쉽다
  • 한 번에 하나의 목적지만 다루는 화면에서 구조가 단순하다

특히 modal 계열 UI에는 이 패턴이 정말 잘 맞는다.

StackState와의 차이

둘 다 state-driven navigation이지만 목적이 다르다.

Optional State Navigation

  • 한 번에 하나의 목적지
  • sheet / alert / fullScreenCover에 적합
  • tree-based navigation

StackState Navigation

  • 여러 화면을 push로 쌓는 구조
  • NavigationStack에 적합
  • stack-based navigation

즉, “모달 계열”이면 optional state, “push stack 계열”이면 StackState라고 생각하면 편하다.

실무에서 자주 쓰는 패턴

실제 프로젝트에서는 다음처럼 많이 쓴다.

  • 설정 화면 → 프로필 편집 sheet
  • 목록 화면 → 필터 sheet
  • 상세 화면 → 삭제 확인 alert
  • 로그인 화면 → 약관 fullScreenCover

이런 구조는 StackState로 만들기보다 optional state가 훨씬 자연스럽다.

테스트는 어떻게 하나

Optional State Navigation의 큰 장점 중 하나는 화면 표시도 상태 변화이기 때문에 테스트가 쉽다는 점이다.

import ComposableArchitecture
import XCTest

@MainActor
final class ProfileFeatureTests: XCTestCase {

    func testEditButtonTapped() async {
        let store = TestStore(
            initialState: ProfileFeature.State()
        ) {
            ProfileFeature()
        }

        await store.send(.editButtonTapped) {
            $0.destination = .editProfile(
                EditProfileFeature.State(username: "Bella")
            )
        }
    }
}

즉, 실제로 시트가 떴는지를 UI 테스트로 보지 않아도, destination state가 생성되는지만 보면 핵심 흐름을 검증할 수 있다.

자주 하는 실수

1. sheet 표시 여부를 Bool로만 관리하는 경우

Bool만 두면 화면이 열렸는지는 알 수 있지만, 그 화면이 어떤 상태를 가져야 하는지는 따로 관리해야 해서 구조가 어색해질 수 있다. optional state로 두면 더 자연스럽다.

2. 자식 Feature 상태를 부모와 분리하지 않는 경우

sheet 안에서 필요한 입력값이나 초기 상태를 destination state에 담아야 화면이 독립적인 Feature로 유지된다.

3. destination 로직이 많아졌는데도 하나의 optional만 계속 쓰는 경우

sheet, alert, settings, edit 화면이 섞이기 시작하면 Destination reducer를 따로 두는 편이 훨씬 깔끔하다.

4. Optional State Navigation과 StackState를 섞어서 헷갈리는 경우

모달 계열과 push 계열을 구분해서 설계하는 것이 좋다.

정리

  • TCA Optional State Navigation은 한 번에 하나의 목적지를 optional state로 관리하는 패턴이다.
  • sheet, alert, fullScreenCover 같은 UI와 잘 맞는다.
  • 최근 TCA에서는 @Presents.ifLet 조합이 핵심 구조다.
  • 목적지가 늘어나면 Destination reducer를 두는 방식이 깔끔하다.
  • 화면 표시 여부가 state에 드러나기 때문에 테스트도 쉬워진다.

TCA (Composable Architecture) 관련 글

반응형