LODY/정리

TCA 실전 / 화면 전환: iOS 15 대응부터 iOS 16 Stack까지

화면 전환: iOS 15 대응부터 iOS 16 Stack까지

🟣 화면 전환을 상태로 다룬다는 것

화면이 하나일 때는 1장의 단방향 흐름으로 충분하다. 문제는 화면이 여러 개가 되면서 시작된다. 목록에서 상세로 들어가고, 거기서 편집 시트를 띄우고, 저장하면 두 단계를 한 번에 닫는다. 이런 전환을 뷰 코드에서 명령형으로 밀고 당기면, 지금 어느 화면이 떠 있는지가 다시 여기저기 흩어진다. 1장에서 겨우 좁혀 놓은 추적 가능성이 화면 전환에서 무너지는 것이다.

TCA의 접근은 일관된다. 어떤 화면이 떠 있는지도 State로 표현하고, 화면을 여닫는 것도 Action으로 다룬다. 상세 화면이 떠 있다는 사실이 부모 State의 값 하나로 존재하고, 그 값이 있으면 화면이 뜨고 없으면 닫힌다. 전환이 상태에서 파생되니, "지금 왜 이 화면이 떠 있는가"를 State만 보고 답할 수 있다.

다만 이걸 실제로 구현하는 도구는 지원 OS 버전에 따라 갈렸다. 이 장은 그 갈림길과 각 방식을 정리한다.


🟣 iOS 16이라는 경계선

TCA의 스택 기반 네비게이션은 SwiftUI의 NavigationStack 위에 서 있는데, NavigationStackiOS 16부터만 쓸 수 있다. 당시 서비스는 iOS 15 이하도 지원해야 했기 때문에, 최신 API 하나로 통일할 수가 없었다.

여러 화면 전환 필요iOS 15 이하LinkNavigatoriOS 16 이상NavigationStack하위 호환최신 API

iOS 15 이하에서는 오픈소스 LinkNavigator를 썼다. 이 라이브러리는 내부적으로 UINavigationController를 쓰는 코디네이터 방식이라 낮은 버전에서도 동작하고, 경로를 문자열 기반으로 관리해 딥링크를 붙이기 편했다. 화면을 미리 등록해 두고 라우터로 밀고 닫는 형태다.

// LinkNavigator: 경로 기반 코디네이터로 화면을 민다
navigator.next(paths: ["item-detail"], items: ["id": "42"], isAnimated: true)
navigator.back(isAnimated: true)

TCA의 단방향 흐름과 완전히 같은 결은 아니었다. 화면 전환만 별도 라우터에 맡기고 각 화면 내부는 TCA로 구성하는 절충이었다. 최신 API를 못 쓰는 제약 아래에서 하위 호환과 딥링크를 우선한 선택이었고, iOS 16 대응이 가능해지면서 신규 화면부터 순차적으로 TCA 네이티브 네비게이션으로 옮겨 갔다.


🟣 트리 기반: 하나의 자식을 띄운다

iOS 16 이상에서 쓰는 TCA 네비게이션은 두 갈래다. 먼저 트리 기반은 시트, 팝오버, 단일 상세처럼 "자식 화면 하나가 떠 있거나 없거나"를 다룬다. 부모 State에 자식 State를 옵셔널로 두는 것이 출발점이다. 값이 있으면 화면이 떠 있는 것이고, nil이면 닫힌 것이다.

struct ListFeature: ReducerProtocol {
    struct State: Equatable {
        var items: [Item] = []
        @PresentationState var detail: DetailFeature.State?
    }
    enum Action: Equatable {
        case itemTapped(Item)
        case detail(PresentationAction<DetailFeature.Action>)
    }
 
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case let .itemTapped(item):
                state.detail = DetailFeature.State(item: item)  // 화면 뜸
                return .none
            case .detail:
                return .none
            }
        }
        .ifLet(\.$detail, action: /Action.detail) {
            DetailFeature()
        }
    }
}

@PresentationState로 감싼 옵셔널 자식 State에, ifLet 연산자로 자식 Reducer를 붙인다. ifLet은 자식 State가 존재하는 동안에만 자식 Reducer를 돌리고, nil이 되면 자식의 진행 중인 Effect를 자동으로 정리해 준다. 뷰 쪽에서는 이 옵셔널 State에 시트나 NavigationLinkStore를 연결해, State가 채워지면 화면이 뜨고 비워지면 닫히게 묶는다.

.sheet(store: store.scope(state: \.$detail, action: { .detail($0) })) { detailStore in
    DetailView(store: detailStore)
}

🟣 스택 기반: 화면들을 배열로 쌓는다

목록에서 상세로, 상세에서 또 하위 화면으로 깊게 들어가는 내비게이션 스택은 다르게 모델링한다. 떠 있는 화면 하나가 아니라 쌓여 있는 화면들의 배열이 필요하다. TCA는 이걸 StackState로 준다.

struct RootFeature: ReducerProtocol {
    struct State: Equatable {
        var path = StackState<Path.State>()
    }
    enum Action: Equatable {
        case path(StackAction<Path.State, Path.Action>)
    }
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            // state.path.append(...) 로 밀고, 배열에서 빼면 pop
            return .none
        }
        .forEach(\.path, action: /Action.path) {
            Path()
        }
    }
}
Root 화면path[0]목록path[1]상세path[2]편집StackState = 화면들의 배열appendappendappend

화면을 미는 건 배열에 State를 추가하는 것이고, 뒤로 가는 건 배열 끝을 덜어내는 것이다. forEach가 스택에 쌓인 각 화면의 Reducer를 돌린다. 뷰는 NavigationStackStore로 이 배열을 실제 NavigationStack에 연결한다. 네비게이션 상태 전체가 하나의 배열로 존재하니, 딥링크로 특정 화면 몇 개를 한 번에 쌓거나, 루트까지 한 번에 되돌리는 조작이 배열 연산으로 깔끔해진다.


🟣 자식이 스스로 닫는다: DismissEffect

화면 전환에서 자주 막혔던 지점이 하나 있다. 편집 화면에서 저장 버튼을 누르면 그 화면이 스스로 닫혀야 한다. 그런데 "닫는다"는 건 부모의 State를 바꾸는 일이라, 자식이 부모에게 "나 좀 닫아 줘" Action을 위로 올려보내야 했다. 화면이 깊어질수록 이 위임이 지저분해진다.

TCA는 이걸 의존성으로 푼다. 2장에서 본 @Dependency 자리에 dismiss를 꺼내 쓰면, 자식이 부모를 모른 채 스스로 닫을 수 있다.

struct EditFeature: ReducerProtocol {
    @Dependency(\.dismiss) var dismiss
 
    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        case .saveTapped:
            return .run { _ in
                await dismiss()   // 자식이 자기 화면을 닫는다
            }
        }
    }
}

await dismiss()가 실행되면 이 화면을 띄운 부모의 해당 State가 nil이 되거나 스택에서 빠진다. 자식은 부모의 구조를 전혀 몰라도 되고, 부모는 자식이 어떤 조건에서 닫히는지 일일이 처리하지 않아도 된다. 화면 전환을 상태로 다룬다는 원칙이 여기서 가장 깔끔하게 맞아떨어졌다.


🟣 정리

  • TCA는 어떤 화면이 떠 있는지도 State로, 여닫는 것도 Action으로 다룬다. 전환이 상태에서 파생되니 "왜 이 화면이 떠 있는가"를 State로 답할 수 있다.
  • NavigationStack이 iOS 16부터라, 하위 버전 지원이 필요하면 최신 API 하나로 통일할 수 없다. iOS 15 이하는 오픈소스 LinkNavigator로 하위 호환과 딥링크를 우선했다.
  • 트리 기반은 옵셔널 자식 State와 ifLet으로 화면 하나의 표시 여부를 다룬다. State가 채워지면 뜨고 비워지면 닫힌다.
  • 스택 기반은 StackState 배열로 쌓인 화면들을 표현한다. push는 append, pop은 배열에서 제거이고, 딥링크와 루트 복귀가 배열 연산으로 정리된다.
  • @Dependency(\.dismiss)로 자식이 부모 구조를 모른 채 스스로 닫을 수 있다. 화면이 깊어질 때 위임이 지저분해지는 문제를 없앤다.
  • 면접에서는 "명령형 push 대신 상태 기반 네비게이션이 주는 이점"과 "OS 버전 제약 아래에서의 절충 판단"을 함께 설명할 수 있으면 된다.

화면 사이는 오갈 수 있게 됐다. 남은 실전 도구는 화면 안쪽이다. 목록처럼 같은 화면이 여러 개 반복될 때와, 폼처럼 입력값을 State에 양방향으로 묶을 때를 다음 장에서 정리한다.