일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- 360도 이미지 뷰어
- 스켈레톤 통합
- panorama view
- 앱 성능 개선
- data driven construct
- 라이브러리 없이
- requirenativecomponent
- 360도 뷰어
- react
- 뷰 생명주기
- React-Native
- Android
- 리액트 네이티브
- @sendable
- launch screen
- completion handler
- ios
- ssot
- 뷰 정체성
- React Native
- 파노라마 뷰
- react-native-fast-image
- 명시적 정체성
- native
- 네이티브
- 리액트
- 360도 이미지
- presentationbackgroundinteraction
- SwiftUI
- 구조적 정체성
- Today
- Total
Neoself의 기술 블로그
[SwiftUI] Navigation Router 패턴: 복잡한 네비게이션 플로우 관리하기 본문
최근 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. 스와이핑을 통한 뒤로가기 제어 로직
구현 과정을 다루겠습니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
[SwiftUI] 단일 TextField로 여러 정보 연속적으로 입력하기 (0) | 2025.06.18 |
---|---|
[SwiftUI] 화면별 제스처를 통한 뒤로가기 활성화 여부 제어하기 (1) | 2025.06.17 |
[SwiftUI] 바텀시트 외부영역의 어두워짐 효과 제거하기 (0) | 2025.06.04 |
Swift Closure 정리 (0) | 2025.04.07 |
Kingfisher에서 Swift 동시성 모델을 도입한 NeoImage 라이브러리 구현기 (0) | 2025.03.16 |