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만을 통해 값을 전달하기 때문에 복잡한 객체 전달 시 구조 설계가 필요하다.
'◽️ Programming > SwiftUI' 카테고리의 다른 글
| matchedGeometryEffect를 통한 자연스러운 애니메이션 표현 방식 (1) | 2025.10.21 |
|---|---|
| SwiftUI NavigationStack은 무엇이 달라졌을까 (0) | 2025.03.11 |
| SwiftUI를 활용한 Pagination 구현하기 (1) | 2025.01.04 |
| SwiftUI에서 Charts를 구현해보자 (1) | 2024.11.18 |
| SwiftUI와 UIKit의 다른 생명주기 방식 (0) | 2024.10.22 |