SwiftUI Navigation Router Injection VS NavigationPath

SwiftUI로 프로젝트를 진행하면서 네비게이션 관리가 점점 어려워 진다고 느껴지는 경우가 많다. 어디서 네비게이션을 처리해야할지 항상 고민이었는데 처음 도입하는데 복잡하다고 느낄 수 있지만 팀원의 추천으로 Router를 활용한 방식을 한번 도입해보려고 한다.

 

Router Injection은 네비게이션 로직을 완전히 추상화해 의존성으로 주입하는 패턴이다.

 

일단 기존의 네비게이션을 활용하자면

struct CalendarView: View {
    @State private var events: [Event] = []
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            List(events) { event in
                Button(event.title) {
                    // 문제 1: View가 네비게이션 로직을 직접 처리
                    navigationPath.append(EventDetailDestination(event))
                    
                    // 문제 2: 비즈니스 로직이 View에 섞임
                    if !hasPermission(for: event) {
                        showAlert = true
                        return
                    }
                    
                    // 문제 3: 분석 이벤트도 View에서 처리
                    Analytics.track("event_selected")
                }
            }
        }
    }
}

이렇게 기존의 방식대로 네비게이션을 구현하려면 View에 UI + 네비게이션 + 비즈니스 로직을 모두 처리하게 되면서 테스트가 어려워지고 재사용, 유지보수가 어려운 점이 생긴다.

 

또한, SwiftUI의 강점은 선언형 UI라는 건데 이전 방식의 경우엔 View가 네비게이션까지 직접 하게 되면 그 이점을 살릴 수 없는 것 같은 느낌이었다.

 

이 외에 조금 쓰기 어렵다 라고 느끼게 되는 경우가 많은데 그래서 Router Injection을 적용해 보려고 한다.

 

Router Injection

이 Router Injection은 네비게이션 책임을 별도의 Router 객체로 분리하고, 이를 의존성으로 주입하는 패턴이다.

View -> Coordinator -> Router -> Navigation Implementation

이러한 흐름으로 진행되는데 코드를 통해 더 알아보자

먼저 첫번째로 Router 인터페이스를 정의한다.

import SwiftUI

protocol RouterProtocol {
    func push<T: View>(_ view: T)
    func present<T: View>(_ view: T, style: PresentationStyle)
    func pop()
    func popToRoot()
    func dismiss()
}

enum PresentationStyle {
    case sheet
    case fullScreenCover
    case alert
}

그 다음 Router 구현체를 만들어준다.

class SwiftUIRouter: RouterProtocol, ObservableObject {
    @Published var navigationPath = NavigationPath()
    @Published var presentedSheet: AnyView?
    @Published var isSheetPresented = false
    
    func push<T: View>(_ view: T) {
        let destination = NavigationDestination(view)
        navigationPath.append(destination)
    }
    
    func present<T: View>(_ view: T, style: PresentationStyle) {
        switch style {
        case .sheet:
            presentedSheet = AnyView(view)
            isSheetPresented = true
        case .fullScreenCover:
            // fullScreen 구현
            break
        case .alert:
            // alert 구현
            break
        }
    }
    
    func pop() {
        if !navigationPath.isEmpty {
            navigationPath.removeLast()
        }
    }
}

그 다음엔 Coordinator를 사용해 Router를 사용하는 방식을 만든다

class CalendarCoordinator {
    private let router: RouterProtocol
    private let analyticsService: AnalyticsService
    private let permissionService: PermissionService
    
    init(router: RouterProtocol, 
         analyticsService: AnalyticsService,
         permissionService: PermissionService) {
        self.router = router
        self.analyticsService = analyticsService
        self.permissionService = permissionService
    }
    
    func handleEventSelection(_ event: Event) {
        // 1. 분석 이벤트 전송
        analyticsService.track("event_selected", parameters: [
            "event_id": event.id
        ])
        
        // 2. 권한 검증
        guard permissionService.hasViewPermission(for: event) else {
            showAccessDeniedAlert()
            return
        }
        
        // 3. 네비게이션 (구체적인 방법은 Router가 결정)
        let detailView = EventDetailView(event: event)
        router.push(detailView)
    }
    
    private func showAccessDeniedAlert() {
        let alertView = AccessDeniedAlert {
            self.router.dismiss()
        }
        router.present(alertView, style: .alert)
    }
}

이렇게 구현된 코디네이터를 사용하게 되면 뷰에는 단순하게 UI만 담당할 수 있는 구조로 이뤄진다

var body: some View {
    List(store.state.events) { event in
        Button(event.title) {
            // 단순히 Intent만 전송
            store.send(.selectEvent(event))
        }
    }
}
class CalendarStore: ObservableObject {
    @Published var state = CalendarState()
    private let coordinator: CalendarCoordinator
    
    func send(_ intent: CalendarIntent) {
        switch intent {
        case .selectEvent(let event):
            // 비즈니스 로직은 Coordinator에게 위임
            coordinator.handleEventSelection(event)
        }
    }
}

이 구조는 관심사의 분리 원칙을 적용된것으로 각 레이어는 단일 책임 원칙을 지키고 서로 느슨하게 결합되어 있어 교체나 테스트가 용이해진다는 장점이 있다.

 

예를 들어 RouterProtocol을 테스트 Mock 객체로 교체하면 UI 테스트를 하지 않고도 네비게이션 동작을 검증할 수 있다.

 

결과적으로 View는 단순히 UI 테스트로 충분하고 Coordinator만 테스트 해서 비즈니스 흐름을 검증할 수 있다.

 

하지만 내가 느꼈을땐 좋은 방식이라고 생각이 들지만 지금 우리 프로젝트에 이런 방식의 네비게이션 구현이 필요할까? 에 대한 의문이 살짝 들었다. 다소 복잡성이 높아질 수도 있을 것 같다는 느낌..?

 

Screen Enum + NavigationPath

그렇다면 내가 생각했을때 사용하면 괜찮을 것 같은 방식은 바로 Screen을 enum으로 구분하고 NavigationPath를 사용한 네비게이션 방식은 어떤가! 싶다

 

먼저 NavigationPath에 어떤 화면을 push할지 타입 안정성을 높히기 위해 enum으로 화면을 추상화 시킨다

enum Screen: Hashable {
    case eventDetail(Event)
    case settings
    case profile(UserID)
}

이 enum을 NavigationPath에 넣고 꺼내면서 어떤 화면인지 판별하여 대응하는 View를 보여주는 방식이다

 

NavigationPath 내부적으로 Set 또는 Dictionary 처럼 작동하게 되는데 이 경로는 내부적으로 식별이 가능해야하고, 중복을 피해 화면을 식별하기 위해서 비교 연산이 필요하기 때문에 Hashable을 사용한다.

 

그럼 다면 이 Screen이 Hashable이라 NavigationPath에 넣을 수 있고, SwiftUI는 이 enum을 바탕으로 어떤 View를 보여줄 지 선택할 수 있다.

@MainActor
final class NavigationManager: ObservableObject {
    @Published var path = NavigationPath()
    
    func push(_ screen: Screen) {
        path.append(screen)
    }
    
    func pop() {
        path.removeLast()
    }
    
    func popToRoot() {
        path.removeLast(path.count)
    }
}

네비게이션 매니저를 구성해 push, pop, popToRoot 등 네비게이션의 방식을 설정하고

struct AppView: View {
    @StateObject private var navManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navManager.path) {
            HomeView()
                .navigationDestination(for: Screen.self) { screen in
                    switch screen {
                    case .eventDetail(let event):
                        EventDetailView(event: event)
                    case .settings:
                        SettingsView()
                    case .profile(let userId):
                        ProfileView(userId: userId)
                    }
                }
        }
        .environmentObject(navManager)
    }
}

각 뷰에서 NavigationStack을 설정해 path를 넣어주는 방식으로 진행된다.

 

만약 네비게이션 push/pop 가 아니라 모달을 활용한 sheet, fullScreen, alert은 별도로 네비게이션과 분리하여 적용해둔다.

enum OverlayScreen: Identifiable {
    case sheet(Event)
    case fullScreen(SettingsViewModel)
    case alert(String)
    
    var id: String {
        switch self {
        case .sheet(let event): return "sheet_\\(event.id)"
        case .fullScreen: return "full_screen"
        case .alert: return "alert"
        }
    }
}

이런식으로 진행됐을때의 장점과 단점!!

 

✅ 장점

  • 타입 안정성 : Screen enum을 통해 NavigationPath의 타입이 명확해지고, View를 잘못 연결하는 오류를 줄인다.
  • 네비게이션 중앙화 : NavigationManager 하나로 모든 화면 제어
  • 디버깅/로깅 관리 : Screen 값으로 어떤 화면이 언제 push/pop 되었는지 추적 가능

❌ 단점

  • 화면의 수가 많아지면 Screen enum이 엄청 비대해질 수 있다.
  • Screen의 associated value만을 통해 값을 전달하기 때문에 복잡한 객체 전달 시 구조 설계가 필요하다.