SwiftUI Study – NavigationStack에서 안정적인 경로(Path) 관리로 예기치 않은 화면 이동·중복 Push 문제 방지하기
Dev Study/SwiftUI 2025. 12. 9. 10:47반응형
SwiftUI Study – NavigationStack에서 안정적인 경로(Path) 관리로 예기치 않은 화면 이동·중복 Push 문제 방지하기
1. 왜 중요한가 (문제 배경)
iOS 16 이후 NavigationStack + NavigationPath 조합은
SwiftUI 네비게이션의 표준 패턴이 되었지만 다음과 같은 문제가 자주 발생합니다.
- 빠르게 탭하면 같은 화면이 두 번 Push됨
- 비동기 로직 응답이 늦게 와서 이전 화면에서 예상치 못한 Push 발생
- Path를 배열처럼 다루다 보면 pop 흐름이 꼬임
- 여러 View가 Path를 동시에 수정하며 경합 상태(race condition) 발생
- 딥링크/푸시 알림으로 진입 시 초기 경로 구성이 불안정
즉, NavigationStack은 강력하지만
“경로를 언제, 어디서, 누가 업데이트하느냐”가 매우 중요하다.
확실한 규칙을 세우지 않으면
네비게이션 버그는 재현도 어렵고 디버깅도 힘들어진다.
2. 잘못된 패턴 예시
❌ 예시 1: 여러 View에서 직접 Path를 수정하는 구조
struct WrongRoot: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
FirstView(path: $path) // ❌ 하위 뷰에 그대로 바인딩 전달
}
}
}
struct FirstView: View {
@Binding var path: NavigationPath
var body: some View {
Button("다음") {
path.append(Screen.detail) // ❌ 하위가 네비게이션 제어를 직접 수행
}
}
}
문제점
- 모든 하위 View가 네비게이션을 마음대로 변경
- 전역 상태처럼 변함 → 화면 이동 흐름이 복잡해짐
- “누가 push 했는지” 추적 불가
❌ 예시 2: 비동기 응답에서 직접 push
Button("조회") {
Task {
let result = await api.fetchUser()
path.append(Screen.profile(result)) // ❌ 응답이 늦게 와도 push 발생
}
}
문제점
- 유저가 이미 이전 화면으로 돌아간 상태에서도 push 발생
- “사라진 화면에서 push” → 이상한 구조
- race condition으로 네비게이션 스택이 꼬임
❌ 예시 3: Path 배열을 직접 pop/push 하며 상태를 뒤섞는 패턴
path.removeLast() // ❌ 실수로 과도하게 pop될 수 있음
path.append(.detail(id)) // ❌ 화면 중복 push 가능
문제점
- 명령형 방식으로 조작 → SwiftUI 철학과 충돌
- Path는 “네비게이션 상태”인데 배열처럼 다루면 위험
- stacked navigation에서 재현 어려운 버그 다수 발생
3. 올바른 패턴 예시
✅ 예시 1: “네비게이션 전담 ViewModel”로 단일 진입점을 만든다
@MainActor
final class NavigationCoordinator: ObservableObject {
@Published var path = NavigationPath()
func goToDetail(id: Int) {
path.append(Screen.detail(id))
}
func goToProfile(_ user: User) {
path.append(Screen.profile(user))
}
func back() {
path.removeLast()
}
}
View에서 사용:
struct RootView: View {
@StateObject private var nav = NavigationCoordinator()
var body: some View {
NavigationStack(path: $nav.path) {
HomeView()
.environmentObject(nav) // ✔ 경로는 Coordinator만 변경
}
}
}
장점
- 네비게이션 변화의 “유일한 출처(single source of truth)” 확보
- 어디서 push/pop 되는지 추적 가능
- 하위 View는 이동 이벤트만 전달
✅ 예시 2: 하위 View는 이벤트만 전달하고 네비게이션은 Coordinator가 수행
struct HomeView: View {
@EnvironmentObject var nav: NavigationCoordinator
var body: some View {
Button("상세 보기") {
nav.goToDetail(id: 10) // ✔ 하위는 이동 "요청"만
}
}
}
장점
- 단방향 데이터 흐름 유지
- 이동 로직이 View에 섞이지 않음
- Coordinator가 모든 화면 이동을 통제
✅ 예시 3: 비동기 응답 기반 이동은 화면이 살아있을 때만 처리
struct SearchView: View {
@EnvironmentObject var nav: NavigationCoordinator
@State private var alive = true
var body: some View {
Button("조회") {
Task {
let result = try? await api.fetch()
guard alive, let result else { return }
nav.goToProfile(result) // ✔ 뷰가 살아있을 때만 이동
}
}
.onDisappear {
alive = false // ✔ 사라진 화면에서는 push 금지
}
}
}
장점
- 뒤로가기 이후 늦은 push 방지
- race condition 제거
✅ 예시 4: 깊은 링크(Deep Link)도 Coordinator가 일관되게 처리
func applyDeepLink(_ link: DeepLink) {
path = NavigationPath() // 초기화
switch link {
case .home:
break
case let .profile(id):
path.append(Screen.profile(id))
case let .detail(id):
path.append(Screen.detail(id))
}
}
장점
- 한 곳에서 Deep Link 전체를 관리
- 재현 가능한 네비게이션 흐름 확보
4. 실전 적용 팁
✔ 팁 1 – NavigationPath 수정은 Coordinator에서만
여러 View가 path를 건드리면 끝없는 네비게이션 버그가 생김.
✔ 팁 2 – 비동기 응답 기반 push는 “화면 생존 여부 체크” 필수
뒤로가면 즉시 alive = false 처리.
✔ 팁 3 – push/pop은 명령이 아니라 “상태 변경”으로 취급
Path는 네비게이션 상태(State)라는 점을 잊지 말기.
✔ 팁 4 – 중복 push 방지 로직 추가 가능
보통 “지금 보여주고 있는 화면과 동일하면 push 금지” 로 구현.
✔ 팁 5 – Coordinator는 View와 독립적이므로 테스트하기도 매우 쉬움
DeepLink, push/pop 흐름 모두 단위 테스트 가능.
5. 정리
- NavigationStack은 강력하지만, Path를 아무 View나 직접 수정하면 불안정해진다.
- 네비게이션 전담 Coordinator를 두면 경로 제어가 일관되고 안정적이다.
- 비동기 응답에 따른 화면 이동은 “뷰 생존 여부”를 확인해 race condition을 제거해야 한다.
- Deep Link, Push 알림, 상태 기반 화면 이동까지 모두 Coordinator에서 처리하면
재현 가능한 네비게이션 구조가 완성된다.
반응형

