반응형

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에서 처리하면
    재현 가능한 네비게이션 구조가 완성된다.
반응형
Posted by 까칠코더
,