Neoself의 기술 블로그

[SwiftUI] Navigation Router 패턴: 복잡한 네비게이션 플로우 관리하기 본문

개발지식 정리/Swift

[SwiftUI] Navigation Router 패턴: 복잡한 네비게이션 플로우 관리하기

Neoself 2025. 6. 11. 19:31

최근 SwiftUI 프로젝트에서 복잡한 네비게이션 플로우를 구현하면서 Navigation Router 패턴을 도입하게 되었습니다. 기존의 NavigationView/NavigationStack 기반 접근법으로는 다단계 회원가입 플로우와 같은 복잡한 네비게이션을 관리하기 어려웠기 때문입니다.

 

*해당 네비게이션 방식은 iOS 16버전부터 지원되는 방식입니다. 때문에, 최소버전이 15이하일 경우에는 도움이 되지 않을 수 있습니다!

 

0. 기존 방식의 문제점

1. NavigationStack 중첩 문제

처음에는 각 View에서 독립적으로 NavigationStack을 관리하고자 했습니다.

struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {  // 첫 번째 NavigationStack
                OnboardingView()
            }
        }
    }
}

// CreateAccountView.swift 
struct CreateAccountView: View {
    @StateObject private var viewModel = CreateAccountViewModel()
    
    var body: some View {
        NavigationStack(path: $viewModel.navigationPath) {  // 두 번째 NavigationStack (중첩!)
            // ... view content
        }
    }
}

 

문제점

- NavigationStack이 중첩되어 네비게이션이 예측 불가능하게 동작

- 각 View가 자체 네비게이션 상태를 관리하여 일관성 부족

- 뒤로가기 제스처와 네비게이션 바 버튼이 올바르게 작동하지 않음

 

2. 상태 관리의 복잡성

// CreateAccountViewModel.swift - 기존 방식
@MainActor
final class CreateAccountViewModel: ObservableObject {
    @Published var navigationPath = NavigationPath()  // 분산된 네비게이션 상태
    @Published var currentStepIndex: Int = 0
    
    func proceedToNextStep() {
        // 어떤 화면으로 이동할지 복잡한 로직
        switch currentStepIndex {
        case 0: navigationPath.append("accountForm")
        case 1: navigationPath.append("endCreateAccount")
        // ...
        }
    }
}

 

문제점

- 각 ViewModel이 네비게이션 로직을 중복 구현

- 화면 간 데이터 전달이 복잡

- 테스트하기 어려운 구조

 

1. Navigation Router 패턴 선정

해당 패턴을 택하게 된 이유는 아래와 같이 정리해볼 수 있었습니다.

1. 단일 책임 원칙: 네비게이션 관리만을 담당하는 Router 클래스

2. 타입 안전성: Enum 기반 Route 정의로 컴파일 타임 오류 검출

3. 중앙화: 앱 전체의 네비게이션 플로우를 한 곳에서 관리

4. 확장성: 새로운 화면 추가 시 최소한의 코드 변경

 

특히, 현재 프로젝트의 경우 화면마다 선택적으로 적용되어야 하는 하기 커스텀 뷰 수정자들이 존재했었습니다.

ExampleView()
    .isTabHidden(_ isHidden: Bool) // 하단 네비게이션 탭 표시여부 제어
    .isBackSwipeDisabled(_ isDisabled: Bool) // 스와이핑을 통한 화면 뒤로가기 제어

 

때문에, 모든 화면들에 대한 뷰 수정자 적용 여부를 한곳에서 확인 및 수정할 수 있게 된다는 점이 제게 가장 큰 장점으로 다가왔습니다.

2. Navigation Router 패턴 도

1. Route 프로토콜 정

참고한 블로그의 경우 프로젝트 내부 모든 화면을 단일 Route로 관리하고 있었지만, 저희 앱에서는 네비게이션 스택이 완전히 분리되어야 하는 2개의 플로우가 존재했기 때문에, 2개 이상의 Route 및 Router 인스턴스가 필요했습니다. 때문에, 모든 Route가 공통적으로 필요로 하는 요구사항을 Protocol로 정의해 Route 구현 간 중복코드를 제거하고, Route 추가에 드는 리소스를 절감하고자 했습니다.

 

여기서 Route를 열거형화하면서, 필수정보인 id 속성뿐만 아니라 화면마다 매핑되는 메타데이터를 최대한 정의하여 타입 안정성 및 유지보수성을 높이고자 했습니다.

// Route.swift
import Foundation

protocol Route: Hashable, CaseIterable {
    var id: String { get } // NavigationPath에서 사용되는 키값
    var analyticsName: String { get } // GoogleFirebase Analytics 로깅에 사용되는 이름
    var disableSwipeBack: Bool { get } // 스와이핑을 통한 뒤로가기 지원 여부
    var hidesTabBar: Bool { get } // 커스텀 하단 네비게이션 바 표시 여부
}

// extension으로 Hashable 프로토콜의 필수구현사항을 구현하여 중복 코드를 최소화합니다.
extension Route {
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.id == rhs.id
    }
}

 

CaseIterable: allCases 프로퍼티를 자동으로 제공받을 수 있는 프로토콜
Hashable: NavigationPath에서 Route를 키로 사용하기 위해 필수로 채택되어야 하는 프로토콜

 

2. 도메인별 Route 구현

이후, 각 도메인 별로 Route를 정의하였습니다.

// AuthRoute.swift
import Foundation

enum AuthRoute: Route {
    case createAccount
    case accountForm
    case endCreateAccount
    
    var id: String {
        switch self {
        case .createAccount: return "createAccount"
        case .accountForm: return "accountForm"
        case .endCreateAccount: return "endCreateAccount"
        }
    }
    
    var analyticsName: String {
        switch self {
        case .createAccount: return "create_account_start_screen"
        case .accountForm: return "create_account_form_screen"
        case .endCreateAccount: return "account_creation_complete_screen"
        }
    }
    
    var disableSwipeBack: Bool {
        switch self {
        case .accountForm: return true  // 단계별 진행이므로 스와이프 뒤로가기 비활성화
        default: return false
        }
    }
    
    var hidesTabBar: Bool {
        return false  // 인증 플로우에서는 탭바가 없음
    }
}

 

 

 

3. Router 클래스 구현

이후 각 도메인마다 화면 전환 로직을 담당하는 Router 클래스를 정의하였습니다.

// AuthRouter.swift
import SwiftUI
import Combine

@MainActor
final class AuthRouter: ObservableObject {
    @Published var path = NavigationPath()
    
    func push(to route: AuthRoute) {
        path.append(route)
    }
    
    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }
    
    func reset() {
        path = NavigationPath()
    }
    
    func popTo(count: Int) {
        guard count > 0, count <= path.count else { return }
        path.removeLast(count)
    }
    
    func replace(with route: AuthRoute) {
        path = NavigationPath()
        path.append(route)
    }
}

 

4. NavigationStack 래퍼 구현

마지막으로 navigationDestination에 대한 처리로직을 구현하고, 화면별로 매핑되는 뷰 수정자 적용여부를 관리하기 위한 래퍼 구조체를 새로 생성하였습니다.

// AuthNavigationStack.swift
import SwiftUI

struct AuthNavigationStack: View {
    @StateObject private var authRouter = AuthRouter()
    
    var body: some View {
        NavigationStack(path: $authRouter.path) {
            OnboardingView()
                .navigationDestination(for: AuthRoute.self) { route in
                    destinationView(for: route)
                        .swipeBackDisabled(route.disableSwipeBack)
                        .onAppear {
                            // 분석 이벤트 로깅
                            print("📊 Auth Analytics: \(route.analyticsName)")
                        }
                }
        }
        .environmentObject(authRouter)
    }
    
    @ViewBuilder
    private func destinationView(for route: AuthRoute) -> some View {
        switch route {
        case .createAccount:
            CreateAccountView()
        case .accountForm:
            AccountFormView()
        case .endCreateAccount:
            EndCreateAccountView()
        }
    }
}

 

보시는 것과 같이 레퍼 구조체 내부에서 Route 열거형에 정의된 모든 화면에 접근이 가능하기 때문에, 위 코드처럼 swipeBackDisabled 뷰 수정자를 동적으로 삽입할 수 있습니다. 또한 화면전환마다 onAppear 수정자가 호출되기에, 이벤트 로깅을 수행하기에도 적합합니다.

 

이때, navigationDestination 뷰 수정자의 클로저 내부 구현체를 별도 메서드로 분리하였는데요. 이는 전환될 수 있는 화면 개수가 많아질 경우, 코드 가독성이 저하되는 것을 방지하기 위함입니다. 

 

5. 메인 앱 구조 통

이처럼 도메인별로 Router를 분리하였다면, 앱 최상단인 App 구조체에서 이를 분기해 네비게이션 구현이 가능합니다.

이는 각 플로우가 독립적인 NavigationStack을 보유하게 됨에 따라, 화면 간 의존성이 저하되어 유지보수가 용이해집니다.

// ExampleApp.swift
@main
struct ExampleApp: App {
    @StateObject private var authManager = AuthManager.shared
    
    var body: some Scene {
        WindowGroup {
            if authManager.isLoggedIn {
                MainNavigationStack()  // 로그인 후 메인 플로우
            } else {
                AuthNavigationStack()  // 인증 플로우
            }
        }
    }
}

 

 

6. Router 사용

그러면 실제 화면 전환은 어디서 호출되어야 할까요?

MVVM 아키텍처를 기준으로 ViewModel과 View 두가지 선택지가 크게 있을 것입니다.

저는 ViewModel은 순수 비즈니스로직만을 담당해야하기 때문에, View파일에서 해당 네비게이션 로직을 호출하도록 설계했습니다.

// OnboardingView.swift
struct OnboardingView: View {
    @StateObject private var viewModel = OnboardingViewModel()
    @EnvironmentObject private var authRouter: AuthRouter // Router 환경객체 구독
    
    var body: some View {
        VStack(spacing: 0) {
            // ... UI 컴포넌트들

            Button(action:{
                authRouter.push(to: .createAccount)  // 간단한 네비게이션
            }) {
                Text("회원가입 화면으로 전환하기")
            }
        }
    }
}

// CreateAccountView.swift
struct CreateAccountView: View {
    @EnvironmentObject private var authRouter: AuthRouter
    
    var body: some View {
        VStack {
            Text("본인인증이 필요합니다")

            Spacer()

            Button("시작하기") {
                authRouter.push(to: .accountForm)  // 타입 안전한 네비게이션
            }
        }   
    }
}

 

 

3. 결론

Navigation Router 패턴을 도입한 결과 아래 장점을 체감할 수 있었습니다.

1. 코드 품질 향상: 네비게이션 로직의 중앙화로 유지보수성 증대

2. 개발 생산성 향상: 타입 안전성으로 런타임 오류 감소

3. 사용자 경험 개선: 일관된 네비게이션 동작과 제스처 제어

4. 확장성 확보: 새로운 화면 추가 시 최소한의 코드 변경

 

다음 글에는 이 패턴을 기반으로 하는

1. 하단 네비게이션 탭 표시 제어 로직

2. 스와이핑을 통한 뒤로가기 제어 로직

구현 과정을 다루겠습니다.