SwiftUI NavigationStack 면접 질문: 2026 내비게이션 패턴
NavigationStack, NavigationPath, 그리고 최신 SwiftUI 내비게이션 패턴에 관한 핵심 질문으로 iOS 면접을 준비합니다.

내비게이션은 모든 iOS 애플리케이션의 기본 기둥을 구성합니다. iOS 16부터 NavigationStack이 NavigationView를 대체하며, 내비게이션 스택에 대한 완전한 프로그래밍 제어를 제공합니다. 채용 담당자는 기술 면접에서 이러한 개념의 숙련도를 정기적으로 평가합니다.
각 질문은 실제 기술 면접 형식을 그대로 재현하며, 자세한 답변과 동작하는 코드를 함께 제공합니다. 개념은 기초부터 고급까지 단계적으로 진행됩니다.
NavigationStack의 기초
질문 1: NavigationView와 NavigationStack의 차이점은 무엇입니까?
NavigationView(iOS 16부터 더 이상 권장되지 않음)는 중첩된 NavigationLink를 기반으로 하는 암시적 내비게이션을 만들었습니다. NavigationStack은 명시적이며 프로그래밍 방식으로 수정 가능한 내비게이션 스택을 갖춘 선언적 접근 방식을 도입합니다.
// ❌ Old pattern with NavigationView (deprecated)
struct OldNavigation: View {
var body: some View {
NavigationView {
NavigationLink("Details", destination: DetailView())
}
}
}
// ✅ New pattern with NavigationStack
struct ModernNavigation: View {
// Navigation stack is explicit and controllable
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
// NavigationLink with typed value
NavigationLink("User 1", value: User(id: 1, name: "Alice"))
NavigationLink("User 2", value: User(id: 2, name: "Bob"))
}
// Destination defined by value type
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
}
}
}주된 장점은 링크 선언과 목적지를 분리할 수 있다는 점에 있으며, 이를 통해 중앙 집중적이고 테스트 가능한 내비게이션이 가능해집니다.
질문 2: NavigationPath는 어떻게 작동합니까?
NavigationPath는 내비게이션 값을 저장하는 타입 소거 컨테이너입니다. 화면의 정확한 타입을 알지 못해도 스택을 조작할 수 있도록 해주며, 동시에 컴파일 타임의 타입 안전성을 유지합니다.
struct ContentView: View {
// NavigationPath can contain different Hashable types
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 20) {
Button("View user profile") {
// Adds a User to the stack
path.append(User(id: 1, name: "Alice"))
}
Button("View settings") {
// Adds a Settings enum to the stack
path.append(SettingsRoute.notifications)
}
Button("Back to root") {
// Clears the entire stack
path.removeLast(path.count)
}
}
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
.navigationDestination(for: SettingsRoute.self) { route in
SettingsView(route: route)
}
}
}
}
// Types must be Hashable
struct User: Hashable {
let id: Int
let name: String
}
enum SettingsRoute: Hashable {
case notifications
case privacy
case account
}NavigationPath는 내부적으로 타입 소거를 사용하지만, navigationDestination을 통해 컴파일 타임에 타입을 검증합니다. 대응되는 목적지가 없는 값은 런타임에 조용한 오류를 일으킵니다.
질문 3: 완전한 프로그래밍 방식의 내비게이션은 어떻게 구현합니까?
프로그래밍 방식의 내비게이션은 사용자의 직접적인 상호작용 없이도 코드의 어느 지점에서나 스택을 제어할 수 있게 해줍니다. 딥 링크, 인증 후 리다이렉션, 다단계 흐름에는 필수적입니다.
// Centralized router to manage navigation
@Observable
class NavigationRouter {
var path = NavigationPath()
// Navigate to a specific screen
func navigateTo(_ destination: AppRoute) {
path.append(destination)
}
// Go back one level
func goBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
// Return to root
func popToRoot() {
path.removeLast(path.count)
}
// Navigate to a complete stack (deep link)
func navigateToPath(_ routes: [AppRoute]) {
popToRoot()
for route in routes {
path.append(route)
}
}
}
// Enum defining all app routes
enum AppRoute: Hashable {
case userList
case userDetail(userId: Int)
case userEdit(userId: Int)
case settings
case settingsDetail(SettingsSection)
}
enum SettingsSection: String, Hashable {
case notifications, privacy, account
}
// Usage in main view
struct MainView: View {
@State private var router = NavigationRouter()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destinationView(for: route)
}
}
.environment(router)
}
@ViewBuilder
private func destinationView(for route: AppRoute) -> some View {
switch route {
case .userList:
UserListView()
case .userDetail(let userId):
UserDetailView(userId: userId)
case .userEdit(let userId):
UserEditView(userId: userId)
case .settings:
SettingsView()
case .settingsDetail(let section):
SettingsDetailView(section: section)
}
}
}이 패턴은 모든 내비게이션 로직을 중앙으로 모아 단위 테스트와 유지 보수를 더 쉽게 만듭니다.
고급 내비게이션 패턴
질문 4: NavigationStack으로 딥 링크는 어떻게 구현합니까?
딥 링크는 외부 URL에서 특정 화면으로 애플리케이션을 직접 열 수 있게 해줍니다. NavigationStack을 사용하면 스택을 프로그래밍 방식으로 재구성할 수 있습니다.
@Observable
class DeepLinkHandler {
var router: NavigationRouter
init(router: NavigationRouter) {
self.router = router
}
// Parse a URL and navigate to destination
func handle(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host else {
return
}
// Build navigation stack according to URL
let routes = parseRoutes(host: host, path: components.path, queryItems: components.queryItems)
router.navigateToPath(routes)
}
private func parseRoutes(host: String, path: String, queryItems: [URLQueryItem]?) -> [AppRoute] {
// myapp://users/42/edit → [.userList, .userDetail(42), .userEdit(42)]
switch host {
case "users":
return parseUserPath(path)
case "settings":
return parseSettingsPath(path)
default:
return []
}
}
private func parseUserPath(_ path: String) -> [AppRoute] {
let segments = path.split(separator: "/").map(String.init)
var routes: [AppRoute] = [.userList]
if let userIdString = segments.first, let userId = Int(userIdString) {
routes.append(.userDetail(userId: userId))
// /users/42/edit
if segments.count > 1 && segments[1] == "edit" {
routes.append(.userEdit(userId: userId))
}
}
return routes
}
private func parseSettingsPath(_ path: String) -> [AppRoute] {
var routes: [AppRoute] = [.settings]
let segments = path.split(separator: "/").map(String.init)
if let sectionString = segments.first,
let section = SettingsSection(rawValue: sectionString) {
routes.append(.settingsDetail(section))
}
return routes
}
}
// In the main App
@main
struct MyApp: App {
@State private var router = NavigationRouter()
@State private var deepLinkHandler: DeepLinkHandler?
var body: some Scene {
WindowGroup {
MainView()
.environment(router)
.onOpenURL { url in
// Handle deep links
deepLinkHandler?.handle(url: url)
}
.onAppear {
deepLinkHandler = DeepLinkHandler(router: router)
}
}
}
}질문 5: 내비게이션 상태를 어떻게 영구 저장하고 복원합니까?
내비게이션 상태의 영구 저장은 앱이 다시 시작된 후에도 사용자의 위치를 복원할 수 있게 해줍니다. NavigationPath는 직렬화를 위해 Codable을 지원합니다.
// Extension to make NavigationPath persistable
extension NavigationPath {
// Encode path to Data
func encoded() -> Data? {
guard let representation = self.codable else { return nil }
return try? JSONEncoder().encode(representation)
}
// Decode from Data
static func decoded(from data: Data) -> NavigationPath? {
guard let representation = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else {
return nil
}
return NavigationPath(representation)
}
}
// Router with persistence
@Observable
class PersistentNavigationRouter {
var path: NavigationPath {
didSet {
saveState()
}
}
private let storageKey = "navigation_path"
init() {
// Restore state at startup
if let data = UserDefaults.standard.data(forKey: storageKey),
let restored = NavigationPath.decoded(from: data) {
self.path = restored
} else {
self.path = NavigationPath()
}
}
private func saveState() {
if let data = path.encoded() {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
func clearPersistedState() {
UserDefaults.standard.removeObject(forKey: storageKey)
}
}NavigationPath.codable이 동작하려면 경로에 추가되는 모든 타입이 Hashable 외에도 Codable이어야 합니다. 그렇지 않으면 codable 프로퍼티는 nil을 반환합니다.
iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
질문 6: 인증 흐름과 함께 내비게이션을 어떻게 관리합니까?
인증 흐름은 종종 로그인 후 보호된 화면으로 리다이렉션하거나, 로그아웃 후 로그인 화면으로 돌아가는 동작을 필요로 합니다. 다음 패턴은 이러한 전환을 깔끔하게 처리합니다.
enum AuthState {
case unauthenticated
case authenticated(User)
}
@Observable
class AuthManager {
var state: AuthState = .unauthenticated
var pendingDeepLink: URL?
func login(email: String, password: String) async throws {
// Simulated authentication
let user = try await AuthService.shared.login(email: email, password: password)
state = .authenticated(user)
}
func logout() {
state = .unauthenticated
}
}
struct RootView: View {
@State private var authManager = AuthManager()
@State private var router = NavigationRouter()
var body: some View {
Group {
switch authManager.state {
case .unauthenticated:
// Separate navigation stack for auth
AuthNavigationStack(authManager: authManager)
case .authenticated:
// Main app stack
MainNavigationStack(router: router, authManager: authManager)
}
}
.onChange(of: authManager.state) { oldState, newState in
handleAuthStateChange(from: oldState, to: newState)
}
}
private func handleAuthStateChange(from oldState: AuthState, to newState: AuthState) {
switch (oldState, newState) {
case (.unauthenticated, .authenticated):
// Login successful: process pending deep link
if let pendingURL = authManager.pendingDeepLink {
DeepLinkHandler(router: router).handle(url: pendingURL)
authManager.pendingDeepLink = nil
}
case (.authenticated, .unauthenticated):
// Logout: reset navigation
router.popToRoot()
default:
break
}
}
}
struct AuthNavigationStack: View {
let authManager: AuthManager
@State private var authPath = NavigationPath()
var body: some View {
NavigationStack(path: $authPath) {
LoginView(authManager: authManager)
.navigationDestination(for: AuthRoute.self) { route in
switch route {
case .register:
RegisterView(authManager: authManager)
case .forgotPassword:
ForgotPasswordView()
}
}
}
}
}
enum AuthRoute: Hashable {
case register
case forgotPassword
}질문 7: NavigationStack으로 모달 내비게이션은 어떻게 구현합니까?
모달과 시트는 자체적인 내비게이션 컨텍스트가 필요합니다. NavigationStack을 모달 표시와 결합하려면 분리된 상태 관리가 요구됩니다.
struct ParentView: View {
@State private var mainPath = NavigationPath()
@State private var showSettings = false
@State private var showUserProfile: User?
var body: some View {
NavigationStack(path: $mainPath) {
ContentView()
.toolbar {
Button("Settings") {
showSettings = true
}
}
.navigationDestination(for: MainRoute.self) { route in
MainRouteView(route: route)
}
}
// Sheet with its own NavigationStack
.sheet(isPresented: $showSettings) {
SettingsSheet()
}
// Conditional sheet based on item
.sheet(item: $showUserProfile) { user in
UserProfileSheet(user: user)
}
}
}
// Each sheet has its own NavigationStack
struct SettingsSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var settingsPath = NavigationPath()
var body: some View {
NavigationStack(path: $settingsPath) {
SettingsListView()
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
.navigationDestination(for: SettingsSection.self) { section in
SettingsDetailView(section: section)
}
}
}
}
// Extension to make User identifiable for sheet(item:)
extension User: Identifiable {}상태 관리와 테스트 가능성
질문 8: 내비게이션의 단위 테스트는 어떻게 작성합니까?
테스트 가능성은 NavigationStack의 중요한 장점입니다. 라우터를 분리하면 단위 테스트가 UI 없이 내비게이션 로직을 검증할 수 있습니다.
// Protocol to abstract the router
protocol NavigationRouterProtocol {
var pathCount: Int { get }
func navigateTo(_ destination: AppRoute)
func goBack()
func popToRoot()
}
// Concrete implementation
@Observable
class AppNavigationRouter: NavigationRouterProtocol {
var path = NavigationPath()
var pathCount: Int {
path.count
}
func navigateTo(_ destination: AppRoute) {
path.append(destination)
}
func goBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
}
// Unit tests
import XCTest
final class NavigationRouterTests: XCTestCase {
var router: AppNavigationRouter!
override func setUp() {
router = AppNavigationRouter()
}
func testNavigateToAddsToPath() {
// Given
XCTAssertEqual(router.pathCount, 0)
// When
router.navigateTo(.userDetail(userId: 42))
// Then
XCTAssertEqual(router.pathCount, 1)
}
func testGoBackRemovesLastItem() {
// Given
router.navigateTo(.userList)
router.navigateTo(.userDetail(userId: 1))
XCTAssertEqual(router.pathCount, 2)
// When
router.goBack()
// Then
XCTAssertEqual(router.pathCount, 1)
}
func testPopToRootClearsPath() {
// Given
router.navigateTo(.userList)
router.navigateTo(.userDetail(userId: 1))
router.navigateTo(.userEdit(userId: 1))
XCTAssertEqual(router.pathCount, 3)
// When
router.popToRoot()
// Then
XCTAssertEqual(router.pathCount, 0)
}
func testGoBackOnEmptyPathDoesNothing() {
// Given
XCTAssertEqual(router.pathCount, 0)
// When
router.goBack()
// Then
XCTAssertEqual(router.pathCount, 0) // No crash
}
}질문 9: 여러 탭이 있는 복잡한 내비게이션 상태는 어떻게 관리합니까?
TabView를 갖춘 애플리케이션은 탭마다 별도의 내비게이션 상태가 필요합니다. 각 탭은 자신만의 독립적인 스택을 유지합니다.
// Navigation state for each tab
@Observable
class TabNavigationState {
var homePath = NavigationPath()
var searchPath = NavigationPath()
var profilePath = NavigationPath()
var selectedTab: Tab = .home
enum Tab: Hashable {
case home, search, profile
}
func resetCurrentTab() {
switch selectedTab {
case .home:
homePath.removeLast(homePath.count)
case .search:
searchPath.removeLast(searchPath.count)
case .profile:
profilePath.removeLast(profilePath.count)
}
}
func resetAllTabs() {
homePath.removeLast(homePath.count)
searchPath.removeLast(searchPath.count)
profilePath.removeLast(profilePath.count)
}
}
struct TabRootView: View {
@State private var tabState = TabNavigationState()
var body: some View {
TabView(selection: $tabState.selectedTab) {
// Home tab
NavigationStack(path: $tabState.homePath) {
HomeView()
.navigationDestination(for: HomeRoute.self) { route in
HomeRouteView(route: route)
}
}
.tabItem { Label("Home", systemImage: "house") }
.tag(TabNavigationState.Tab.home)
// Search tab
NavigationStack(path: $tabState.searchPath) {
SearchView()
.navigationDestination(for: SearchRoute.self) { route in
SearchRouteView(route: route)
}
}
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(TabNavigationState.Tab.search)
// Profile tab
NavigationStack(path: $tabState.profilePath) {
ProfileView()
.navigationDestination(for: ProfileRoute.self) { route in
ProfileRouteView(route: route)
}
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(TabNavigationState.Tab.profile)
}
.environment(tabState)
}
}질문 10: NavigationStack의 성능 문제는 어떻게 피합니까?
큰 내비게이션 스택이나 복잡한 목적지는 성능에 영향을 줄 수 있습니다. 여러 기법이 렌더링과 메모리 사용을 최적화합니다.
struct OptimizedNavigationStack: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
LazyContentView()
// ✅ Lazy-loaded destinations
.navigationDestination(for: HeavyRoute.self) { route in
// View only created on navigation
HeavyDetailView(route: route)
}
}
}
}
// ✅ View with lazy content loading
struct LazyContentView: View {
@State private var items: [Item] = []
var body: some View {
// LazyVStack only creates visible views
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
NavigationLink(value: HeavyRoute.detail(item.id)) {
ItemRow(item: item)
}
}
}
}
.task {
items = await loadItems()
}
}
}
// ✅ Detail with progressive loading
struct HeavyDetailView: View {
let route: HeavyRoute
@State private var data: DetailData?
var body: some View {
Group {
if let data {
DetailContent(data: data)
} else {
ProgressView()
}
}
.task {
// Load data only when view appears
data = await loadDetailData(for: route)
}
}
}
// ❌ Avoid: eager creation of heavy views
struct BadNavigationStack: View {
let allItems: [Item]
var body: some View {
NavigationStack {
List(allItems) { item in
// Creates all destinations immediately
NavigationLink {
HeavyDetailView(item: item) // ❌ Created upfront
} label: {
ItemRow(item: item)
}
}
}
}
}인라인 목적지를 가진 NavigationLink 대신 항상 navigationDestination(for:)를 사용해야 합니다. 전자의 패턴은 내비게이션 시점에만 목적지 뷰를 로드합니다.
결론
NavigationStack은 완전한 프로그래밍 제어를 제공함으로써 SwiftUI의 내비게이션 관리를 변화시킵니다. 기본 내비게이션부터 딥 링크와 상태 영구 저장까지 이러한 패턴을 숙달하는 것은 면접에서 경험 많은 iOS 개발자를 돋보이게 합니다.
점검 체크리스트
- ✅
NavigationView와NavigationStack의 차이를 이해합니다 - ✅ 프로그래밍 방식의 내비게이션을 위해
NavigationPath를 활용할 수 있습니다 - ✅ 내비게이션을 위한 중앙 집중식 라우터를 구현합니다
- ✅ 스택 재구성을 통해 딥 링크를 처리합니다
- ✅
Codable로 내비게이션 상태를 영구 저장하고 복원합니다 - ✅ 인증 흐름을 메인 내비게이션과 분리합니다
- ✅ 모달과
NavigationStack을 올바르게 결합합니다 - ✅ 내비게이션 로직에 대한 단위 테스트를 작성합니다
- ✅
TabView로 다중 탭 내비게이션을 관리합니다 - ✅ 큰 내비게이션 스택의 성능을 최적화합니다
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

2026년 MapKit SwiftUI 면접: 어노테이션, 오버레이, 지오로케이션
iOS 면접을 위한 SwiftUI와 MapKit 마스터하기: 사용자 정의 어노테이션, 오버레이, 지오로케이션, 장소 검색, Maps 통합 패턴.

StoreKit 2 인터뷰: 구독 관리 및 영수증 검증
StoreKit 2, 구독 관리, 영수증 검증, 인앱 구매 구현에 관한 iOS 인터뷰 질문을 실용적인 Swift 코드 예제와 함께 마스터하십시오.

Vision Framework와 CoreML: 온디바이스 ML iOS 면접 질문
Vision Framework와 CoreML의 핵심 면접 질문으로 iOS 면접을 준비할 수 있습니다. 이미지 인식, 객체 감지, 온디바이스 ML을 다룹니다.