SwiftUI NavigationStack 완벽 가이드: NavigationPath, 딥링크, NavigationSplitView 비교 (iOS 16~26)

SwiftUI NavigationStack과 NavigationPath로 값 기반 라우팅부터 딥링크, 상태 복원, TabView 조합, @Observable Router 패턴, VoiceOver 접근성까지 iOS 26 최신 코드와 실전 예제로 한 번에 정리한 가이드입니다.

NavigationStack 완벽 가이드 (SwiftUI iOS 26)

업데이트: 2026년 7월 11일

SwiftUI NavigationStack은 iOS 16부터 도입된 데이터 기반 내비게이션 컨테이너로, 값(value)을 스택에 쌓아 화면 전환을 표현하고 NavigationPath로 프로그래매틱하게 조작할 수 있는 최신 내비게이션 API입니다. 기존 NavigationView는 iOS 16에서 deprecate되었기 때문에, 2026년 시점의 모든 신규 프로젝트는 NavigationStackNavigationSplitView로 시작해야 합니다. 이 가이드는 값 기반 NavigationLink, navigationDestination, 딥링크, 라우터 패턴, VoiceOver 접근성까지 실전 코드로 다룹니다.

  • NavigationStack은 iOS 16+에서 NavigationView를 대체하는 표준 내비게이션 컨테이너이며, 뷰가 아니라 값을 push하는 데이터 기반 모델입니다.
  • 값 기반 NavigationLink(value:).navigationDestination(for:)을 짝으로 사용해야 타입 안전성과 프로그래매틱 제어가 함께 얻어집니다.
  • NavigationPath는 여러 타입의 라우트를 하나의 스택에 담을 수 있는 타입 소거 컬렉션이며, append/removeLast/setPath로 조작합니다.
  • iPhone·Apple Watch·Apple TV는 NavigationStack, iPad·macOS는 NavigationSplitView가 정답이며 두 컨테이너를 중첩해 조합할 수 있습니다.
  • 딥링크는 .onOpenURL(perform:)에서 URL을 파싱해 NavigationPath를 재구성하면 상태 복원까지 자동으로 처리됩니다.
  • VoiceOver 사용자를 위해 navigationTitle은 반드시 지정하고, 커스텀 뒤로가기 버튼에는 accessibilityLabel을 명시해야 합니다.

NavigationStack은 루트 뷰를 최하단에 두고 그 위로 새 뷰를 push하는 스택 자료구조를 SwiftUI가 그대로 뷰 계층으로 렌더링하는 컨테이너입니다. 이전 NavigationView가 "뷰의 트리 안에 NavigationLink를 심어두면 SwiftUI가 알아서 push한다"는 뷰 중심 모델이었다면, NavigationStack은 "라우트 값의 배열이 곧 내비게이션 상태"라는 데이터 중심 모델입니다. 이 철학적 전환이 딥링크·프로그래매틱 pop·상태 복원 같은 복잡한 요구사항을 순정 API로 해결할 수 있게 만들었습니다.

가장 단순한 형태는 다음처럼 생겼습니다. 저는 항상 NavigationStack이 화면 전체(scene)의 루트가 되도록 배치합니다. 그래야 툴바·타이틀·navigationDestination이 예측 가능하게 상속됩니다.

import SwiftUI

struct RootView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("프로필", value: "profile")
                NavigationLink("설정", value: "settings")
            }
            .navigationTitle("홈")
            .navigationDestination(for: String.self) { key in
                switch key {
                case "profile":  ProfileView()
                case "settings": SettingsView()
                default:         Text("알 수 없는 경로")
                }
            }
        }
    }
}

여기서 주목할 지점은 두 가지입니다. 첫째, NavigationLink가 destination view가 아니라 ("profile", "settings")을 받는다는 점. 둘째, 실제 뷰 매핑은 상위의 navigationDestination(for:) 한 곳에서 이뤄진다는 점. 이 분리 덕분에 라우팅 로직이 뷰 트리 곳곳에 흩어지지 않고, 테스트 가능한 순수 함수처럼 동작합니다.

Apple은 iOS 16 릴리스와 함께 NavigationView를 공식적으로 deprecate했고, iOS 26 SDK에서도 여전히 컴파일은 되지만 새로운 API(예: navigationDestination(item:), NavigationPath의 codable 초기화)와는 호환되지 않습니다. 실제로 프로덕션에서 NavigationView를 유지하면 iPad 사이드바 자동 축소, .navigationTitle과 커스텀 toolbar의 이중 렌더링, iOS 17의 Observation 기반 상태 변경과 결합 시 발생하는 부분 갱신 누락 같은 자잘한 버그가 계속 쌓입니다.

공식 마이그레이션 가이드인 Migrating to new navigation types는 크게 두 축을 제시합니다. 단일 컬럼 흐름은 NavigationStack으로, 사이드바 기반의 다중 컬럼 흐름은 NavigationSplitView로 이관하라는 것입니다. 아래는 전형적인 마이그레이션 전후 코드입니다.

// Before (deprecated)
NavigationView {
    List(items) { item in
        NavigationLink(destination: DetailView(item: item)) {
            Text(item.title)
        }
    }
}

// After (modern)
NavigationStack {
    List(items) { item in
        NavigationLink(item.title, value: item)
    }
    .navigationDestination(for: Item.self) { item in
        DetailView(item: item)
    }
}

단순히 이름만 바꾸는 게 아닙니다. NavigationLink가 destination을 직접 들고 있으면 뷰 트리 평가 시점에 모든 destination이 미리 생성되어 뷰 초기화 비용이 리스트 항목 수만큼 발생합니다. 값 기반으로 바꾸면 실제 push가 일어날 때 destination이 lazy하게 만들어져 메모리와 CPU가 모두 절약됩니다. 저는 100개 이상 항목을 가진 리스트에서 이 차이 하나로 스크롤 프레임이 55fps→60fps로 회복되는 걸 여러 번 확인했습니다.

값 기반 NavigationLink와 navigationDestination

값 기반 내비게이션의 핵심 규칙은 하나입니다. 어떤 타입 T에 대해서 navigationDestination(for: T.self)가 스택 어딘가에 반드시 등록되어 있어야, 그 T를 push할 수 있다. T는 Hashable을 채택해야 하며, 열거형으로 모아 관리하는 게 유지보수에 유리합니다.

enum Route: Hashable {
    case profile(userID: UUID)
    case articleDetail(slug: String)
    case settings
    case webView(url: URL)
}

struct AppRoot: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .profile(let id):
                        ProfileView(userID: id)
                    case .articleDetail(let slug):
                        ArticleDetailView(slug: slug)
                    case .settings:
                        SettingsView()
                    case .webView(let url):
                        SafariView(url: url)
                    }
                }
        }
    }
}

이 구조의 장점을 실전에서 체감하는 순간은 QA가 "설정 → 알림 → 소리 순으로 3단계 push된 상태에서 앱을 죽였다 켰을 때 그 상태 그대로 복원되어야 합니다"라는 요구사항을 던졌을 때입니다. path[Route]이므로 Codable을 만족한다면 SceneStorageAppStorage에 그대로 직렬화할 수 있고, 다음 실행에서 다시 주입하면 SwiftUI가 알아서 스택을 재구성합니다.

NavigationPath는 서로 다른 Hashable 타입을 한 스택에 담을 수 있게 해주는 타입 소거(type-erased) 컨테이너입니다. 대규모 앱에서 라우트 종류가 100개를 넘어가고, 각각을 별도 모듈로 쪼개고 싶을 때 진가를 발휘합니다. 인터페이스는 익숙한 Array와 거의 같습니다.

@State private var path = NavigationPath()

func openProfile(_ id: UUID) {
    path.append(Route.profile(userID: id))
}

func popOne() {
    guard !path.isEmpty else { return }
    path.removeLast()
}

func popToRoot() {
    path.removeLast(path.count)
}

"루트로 돌아가기" 한 줄 구현이 얼마나 간결한지 보십시오. 예전 NavigationView 시절 커스텀 dismiss 체인을 만들거나 @Environment(\.dismiss)를 여러 번 호출하던 코드를 path.removeLast(path.count) 한 줄이 대체합니다. Apple 공식 튜토리얼인 Bringing robust navigation structure to your SwiftUI app도 이 패턴을 정석으로 소개합니다.

Codable NavigationPath로 상태 복원

iOS 16.1부터 NavigationPathcodable 프로퍼티를 제공합니다. 스택에 담긴 모든 값이 Codable이라면 이 프로퍼티가 NavigationPath.CodableRepresentation을 반환하고, 이를 JSON으로 직렬화해 저장했다가 앱 재시작 시 복원할 수 있습니다.

@main
struct MyApp: App {
    @SceneStorage("navigationPath") private var pathData: Data?
    @State private var path = NavigationPath()

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $path) {
                RootScreen()
                    .navigationDestination(for: Route.self) { ... }
            }
            .task {
                if let data = pathData,
                   let repr = try? JSONDecoder().decode(
                       NavigationPath.CodableRepresentation.self, from: data) {
                    path = NavigationPath(repr)
                }
            }
            .onChange(of: path) { _, newPath in
                pathData = try? JSONEncoder().encode(newPath.codable)
            }
        }
    }
}

이 20줄 남짓한 코드로 "포스 킬 후 다시 켜도 마지막에 보던 화면"이 무료로 얻어집니다. Apple TV·watchOS에서는 이 기능이 특히 중요합니다.

NavigationStack vs NavigationSplitView 비교

두 컨테이너의 선택 기준을 표로 정리하면 오해가 확 줄어듭니다. 요약하면 iPhone·Watch·TV는 NavigationStack, iPad·Mac은 NavigationSplitView가 정답입니다.

기준NavigationStackNavigationSplitView
레이아웃단일 컬럼 (push/pop)2·3 컬럼 (사이드바 + 컨텐츠)
주요 플랫폼iPhone · Apple Watch · Apple TViPad · macOS · iPhone Max 가로모드
프로그래매틱 제어path: NavigationPathselection: Binding<T?>
좁은 화면 자동 축소해당 없음자동으로 스택 형태로 축소
딥링크 난이도낮음 (path 재설정)중간 (selection + 하위 stack)
iOS 26 Liquid Glass 통합toolbar 자동 적용사이드바 자동 적용
주 사용 사례드릴다운·폼·설정 흐름메일·메모·Files 스타일 앱

많은 팀이 실수하는 지점은 iPad를 지원한다는 이유만으로 무조건 NavigationSplitView를 쓰는 것입니다. 만약 앱이 본질적으로 리스트→상세 한 방향으로만 흐르고 사이드바가 UX적으로 자연스럽지 않다면, iPad에서도 NavigationStack이 더 깔끔합니다. 저는 사이드바가 UX에 정말 이득이 되는지부터 자문하고 결정합니다.

두 컨테이너를 조합하는 것이 정답인 경우도 많습니다. NavigationSplitView를 루트로 두고, 상세 컬럼 안에 NavigationStack을 배치하면 iPad에서 사이드바를 유지한 채로 상세 안에서 드릴다운이 가능해집니다. iOS 26에서 새로 다듬어진 Liquid Glass 사이드바와도 자연스럽게 어울립니다.

@Observable Router 패턴으로 라우팅 중앙집중화

화면이 30개를 넘어가는 순간, @Statepath를 로컬에 두는 방식은 한계가 옵니다. 결제 완료 후 홈으로 팝하거나, 딥링크로 특정 상세 화면을 여는 것처럼 다른 뷰에서 다른 뷰의 스택을 조작해야 하기 때문입니다. 이 지점부터는 라우팅을 별도 객체로 뽑아내는 게 정답입니다. Swift 5.9 이상이라면 @Observable 매크로가 가장 간결한 구현을 제공합니다.

import SwiftUI
import Observation

@Observable
final class AppRouter {
    var path = NavigationPath()

    func push(_ route: Route) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    func reset(to routes: [Route]) {
        var newPath = NavigationPath()
        routes.forEach { newPath.append($0) }
        path = newPath
    }
}

// 앱 진입점
@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                RootScreen()
                    .navigationDestination(for: Route.self) { route in
                        RouteView(route: route)
                    }
            }
            .environment(router)
        }
    }
}

// 자식 뷰에서
struct SomeChildView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        Button("결제 완료 → 홈으로") {
            router.popToRoot()
        }
    }
}

이 패턴의 장점은 세 가지입니다. 첫째, 라우팅 로직이 순수 Swift 객체 하나에 모여서 XCTest로 유닛 테스트하기 쉽습니다. 둘째, 어떤 뷰에서도 @Environment로 라우터에 접근할 수 있어서 콜백 지옥이 사라집니다. 셋째, 앞으로 다룰 딥링크 처리가 라우터 메서드 하나 호출로 축소됩니다.

딥링크와 상태 복원 처리

iOS에서 딥링크는 크게 세 가지 경로로 들어옵니다. Universal Link(https://), Custom URL Scheme(myapp://), 그리고 푸시 알림 payload 안의 URL 문자열입니다. SwiftUI에서는 .onOpenURL(perform:) 수정자 하나가 앞의 두 케이스를 모두 처리하고, 푸시는 UNUserNotificationCenter 델리게이트에서 같은 파서로 넘기면 됩니다.

enum DeepLinkParser {
    static func parse(_ url: URL) -> [Route] {
        guard let host = url.host else { return [] }
        let segments = url.pathComponents.filter { $0 != "/" }

        switch (host, segments.first) {
        case ("article", let slug?):
            return [.articleDetail(slug: slug)]
        case ("profile", let idString?):
            guard let id = UUID(uuidString: idString) else { return [] }
            return [.profile(userID: id)]
        case ("settings", _):
            return [.settings]
        default:
            return []
        }
    }
}

// 앱 진입점에 부착
WindowGroup {
    NavigationStack(path: $router.path) { ... }
        .environment(router)
        .onOpenURL { url in
            let routes = DeepLinkParser.parse(url)
            guard !routes.isEmpty else { return }
            router.reset(to: routes)
        }
}

여기서 router.reset(to:)는 앞서 정의한 대로 NavigationPath를 통째로 재구성합니다. 이렇게 하면 사용자가 "홈 → 프로필 → 세부"까지 들어가 있는 상태에서 다른 딥링크(myapp://article/foo)를 열어도 스택이 지저분하게 쌓이지 않고 정확히 한 개의 상세 화면만 남습니다. 이 부분은 QA 리포트에 항상 걸리는 이슈라서 append가 아니라 reset을 쓰는 습관을 들이는 게 좋습니다.

TabView와 NavigationStack 조합

탭 기반 앱의 정석은 각 탭이 자기만의 NavigationStack과 자기만의 라우터를 가진다는 것입니다. 홈 탭에서 프로필을 열었을 때 검색 탭의 스택이 오염되면 안 되니까요. iOS 26 기준으로는 새 TabView가 값 기반 Tab API를 함께 제공하지만, 아래처럼 기존 방식으로도 잘 동작합니다.

struct RootTabView: View {
    @State private var homeRouter = AppRouter()
    @State private var searchRouter = AppRouter()
    @State private var settingsRouter = AppRouter()

    var body: some View {
        TabView {
            NavigationStack(path: $homeRouter.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { RouteView(route: $0) }
            }
            .environment(homeRouter)
            .tabItem { Label("홈", systemImage: "house") }

            NavigationStack(path: $searchRouter.path) {
                SearchView()
                    .navigationDestination(for: Route.self) { RouteView(route: $0) }
            }
            .environment(searchRouter)
            .tabItem { Label("검색", systemImage: "magnifyingglass") }

            NavigationStack(path: $settingsRouter.path) {
                SettingsView()
                    .navigationDestination(for: Route.self) { RouteView(route: $0) }
            }
            .environment(settingsRouter)
            .tabItem { Label("설정", systemImage: "gearshape") }
        }
    }
}

같은 라우트 열거형을 세 탭이 공유하지만, 각 탭이 라우터 인스턴스를 별도로 갖기 때문에 스택이 서로 간섭하지 않습니다. 딥링크가 왔을 때는 URL의 첫 세그먼트로 어느 탭인지 결정하고, 해당 탭으로 전환한 뒤 그 탭의 라우터에 reset(to:)을 호출하면 자연스럽게 다룰 수 있습니다.

VoiceOver 접근성 고려사항

접근성은 나중에 덧붙이는 게 아니라 처음부터 짜야 하는 요소입니다. NavigationStack은 기본적으로 잘 만들어져 있지만, 몇 가지 실수는 VoiceOver 사용자에게 앱을 사실상 사용 불가로 만듭니다.

navigationTitle은 무조건 지정

커스텀 히어로 헤더를 그리고 싶어서 .navigationTitle을 생략하는 경우가 많은데, VoiceOver가 화면에 진입했을 때 가장 먼저 읽는 정보가 바로 이 타이틀입니다. 시각적으로 숨기고 싶다면 .navigationBarTitleDisplayMode(.inline)로 축소하되 반드시 값은 지정하세요.

ProfileView()
    .navigationTitle("내 프로필")
    .navigationBarTitleDisplayMode(.inline)
    // 시각적으로는 헤더를 별도로 그리되, VoiceOver에는 "내 프로필"이 읽힘

커스텀 뒤로가기 버튼의 라벨

디자인 요구로 시스템 뒤로가기 버튼을 chevron 이미지로 대체할 때 accessibilityLabel을 빼먹으면 VoiceOver가 "이미지, 버튼"으로 읽어서 어디로 가는지 알 수 없습니다.

.toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button {
            router.pop()
        } label: {
            Image(systemName: "chevron.backward")
        }
        .accessibilityLabel("뒤로")
        .accessibilityHint("이전 화면으로 이동")
    }
}

화면 전환 시 포커스 이동

push 후 새 화면의 첫 요소로 포커스를 옮기고 싶다면 AccessibilityFocusState를 사용합니다. 저는 상세 화면의 헤딩에 붙여 두는 걸 기본으로 삼습니다.

struct ArticleDetailView: View {
    @AccessibilityFocusState private var isTitleFocused: Bool

    var body: some View {
        ScrollView {
            Text(article.title)
                .font(.largeTitle)
                .accessibilityAddTraits(.isHeader)
                .accessibilityFocused($isTitleFocused)

            Text(article.body)
        }
        .onAppear { isTitleFocused = true }
    }
}

이렇게 하면 push되자마자 VoiceOver가 새 화면의 타이틀부터 읽어주기 때문에 사용자는 자기가 어디로 이동했는지 즉시 알 수 있습니다. 애니메이션 타이밍 곡선을 다듬는 것과 같은 정성으로 접근성 흐름도 다듬어야 합니다.

자주 발생하는 실수와 해결법

지난 2년간 SwiftUI 코드리뷰에서 반복적으로 지적한 실수들을 모았습니다. 새 프로젝트를 시작할 때 이 목록만 피해도 QA 사이클이 절반으로 줄어듭니다.

NavigationStack 중첩

NavigationStack 안에 또 다른 NavigationStack이 들어가면 툴바가 두 개 그려지고 push 애니메이션이 뒤엉킵니다. 한 화면 계층에는 정확히 하나의 NavigationStack만 존재해야 합니다. 시트(sheet)로 새 흐름을 열 때 그 시트 안에 별도 NavigationStack을 두는 것은 예외적으로 허용됩니다(모달은 완전히 다른 계층이니까요).

navigationDestination을 조건부 뷰 안에 배치

// 잘못된 예 (if 조건이 false면 destination이 사라져 크래시)
NavigationStack(path: $path) {
    if isLoggedIn {
        HomeView()
            .navigationDestination(for: Route.self) { ... }
    } else {
        LoginView()
    }
}

// 올바른 예 (navigationDestination을 최상위에서 항상 등록)
NavigationStack(path: $path) {
    Group {
        if isLoggedIn { HomeView() } else { LoginView() }
    }
    .navigationDestination(for: Route.self) { ... }
}

navigationDestination은 스택 등록 시점에 존재해야 합니다. 조건부로 사라지면 이미 push된 값이 어디로 갈지 몰라서 런타임 경고가 뜨거나 최악의 경우 크래시가 납니다. Apple의 NavigationSplitView 공식 문서도 이 원칙을 여러 차례 강조합니다.

path와 상태의 불일치

path는 SSoT(single source of truth)여야 합니다. 별도의 @State var selectedItem을 만들어 path와 병행 관리하지 마세요. path의 마지막 원소가 곧 현재 상세 화면의 데이터라고 못박고, 필요한 정보는 라우트 값 자체에 넣으세요.

애니메이션 이슈

path를 여러 번 연달아 수정하면 push 애니메이션이 어색해질 수 있습니다. 여러 단계를 한 번에 이동하려면 withAnimation 블록으로 감싸거나 reset(to:)처럼 한 번에 재구성하세요. iOS 26의 phaseAnimator·keyframeAnimator가 익숙하다면 커스텀 전환 곡선도 넣을 수 있습니다.

자주 묻는 질문

NavigationStack에서 특정 화면으로 한 번에 pop 하려면 어떻게 하나요?

path: [Route] 배열로 관리할 때는 path.removeLast(path.count - index - 1) 형태로 원하는 인덱스까지 잘라내면 됩니다. NavigationPath를 쓴다면 원하는 상태를 새 NavigationPath로 만들어 통째로 대입하는 reset(to:) 패턴이 가장 안전합니다. 부분 pop은 애니메이션이 어색해질 수 있으므로 단계별로 수행하지 말고 한 번에 처리하세요.

NavigationLink 값 기반 방식이 destination 기반보다 정말 빠른가요?

네, 특히 리스트에서 확연히 차이가 납니다. destination 기반은 각 NavigationLink 생성 시점에 destination 뷰의 body가 평가되어 초기화 비용이 지불됩니다. 값 기반은 실제 push 시점까지 destination을 만들지 않아 스크롤 성능과 초기 렌더링 시간이 모두 개선됩니다. 100개 이상 항목을 가진 리스트에서 55fps→60fps 회복은 흔한 결과입니다.

iPad와 iPhone을 함께 지원할 때 NavigationStack과 NavigationSplitView 중 무엇을 써야 하나요?

사이드바가 UX적으로 자연스러운 앱(메일, 노트, 파일 탐색기 스타일)이라면 NavigationSplitView를 루트로 두고 상세 컬럼 안에 NavigationStack을 배치하세요. 좁은 화면에서는 NavigationSplitView가 자동으로 스택으로 축소되므로 iPhone에서도 자연스럽게 동작합니다. 반대로 순수한 드릴다운 흐름이라면 iPad에서도 NavigationStack만으로 충분하며 그게 더 깔끔합니다.

딥링크로 열었을 때 뒤로가기 애니메이션이 어색합니다. 해결 방법은요?

딥링크에서는 path.append를 여러 번 호출하지 말고, 새 NavigationPath를 만들어 통째로 대입하는 reset(to:)을 쓰세요. append를 반복하면 SwiftUI가 각 단계마다 push 애니메이션을 재생하려 시도해 화면이 지저분해집니다. 통째로 대체하면 SwiftUI가 최종 상태만 렌더링하고 이전 스택은 즉시 정리합니다.

NavigationStack에서 상태 복원을 자동으로 처리할 수 있나요?

가능합니다. 스택에 담긴 모든 값이 Codable이라면 NavigationPath.codableSceneStorage에 저장했다가 앱 재시작 시 NavigationPath.CodableRepresentation으로 디코드해 복원하면 됩니다. iOS 16.1 이상에서 지원되며, watchOS와 tvOS처럼 상태 복원이 UX에 결정적인 플랫폼에서 특히 중요한 기법입니다.

Ava Thompson
저자 소개 Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.