Android 개발/Jetpack Compose

Navigation 3로 갈아타 봤습니다 — 화면 백스택을 상태로 직접 들고 있게 된 자리

stackD 2026. 7. 6. 18:00
반응형

 

예전에 멀티 백스택 구현하느라 popUpTo saveState 옵션을 3시간 동안 붙들고 있었던 적이 있어요. Navigation 3 백스택을 처음 본 순간, 그 3시간이 좀 허무해지더라고요.

 

지난달에 사이드 프로젝트 하나를 통째로 Navigation 3 (1.0 stable, 작년 11월 출시) 로 갈아끼워봤습니다. 화면 12개짜리 작은 앱이라 부담이 적었는데요, 갈아끼우면서 느낀 차이를 정리해두려 합니다.

 

Navigation 2 멀티 백스택의 한계 — 백스택을 못 만지는 답답함

내비게이션 2(Nav2)를 쓰면서 가장 답답했던 건 백스택을 직접 만질 수 없다는 점이었습니다. NavController.backQueue 가 외부에서 못 만지게 막혀 있어서, 멀티 백스택을 구현하려면 popUpTo + saveState + restoreState 조합을 외워야 했어요. 이게 한 번에 잡히는 사람이 있을까 싶을 정도로 까다롭습니다.

 

결과 반환도 약점이었어요. savedStateHandle 로 이전 화면에 값을 넘기는 방식인데, 타입 안전성이 없고 동일 화면이 스택에 두 번 올라간 경우엔 어느 인스턴스로 가는지 모호했습니다. 라우트 문자열과 navArgument 보일러플레이트도 매번 똑같은 손가락 운동이었어요. 오타 하나로 런타임 크래시가 떨어지는 구조라, 컴파일러가 잡아주길 바라는 사람한테는 항상 불안한 대목이었습니다.

 

 

Navigation 3 백스택 — SnapshotStateList로 직접 들고 있기

내비게이션 3(Nav3)의 시작점은 한 문장으로 요약됩니다. 내비게이션은 이벤트 발사가 아니라 상태 조작이라는 것. 백스택이 SnapshotStateList<NavKey> 라는 Compose 상태 리스트로 바뀝니다. 개발자가 직접 add, removeLastOrNull 같은 컬렉션 연산으로 화면을 밀고 빼게 됐어요.

 

기본 골격은 이런 식입니다.

 

@Serializable data object Home : NavKey
@Serializable data class ProductDetails(val id: String) : NavKey

val backStack = rememberNavBackStack(Home)
NavDisplay(
    backStack = backStack,
    entryDecorators = listOf(
        rememberSceneSetupNavEntryDecorator(),
        rememberSavedStateNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator()
    ),
    entryProvider = entryProvider {
        entry<Home> { HomeScreen(onOpen = { backStack.add(ProductDetails(it)) }) }
        entry<ProductDetails> { key -> DetailsScreen(key.id) }
    }
)

 

라우트 문자열이 사라지고 @Serializable 데이터 클래스가 그 자리에 들어옵니다. IDE 가 자동완성으로 잡아주고, 인자 타입도 컴파일러가 검증해줘요. 화면별 ViewModel 스코프나 rememberSaveable 도 데코레이터로 명시적으로 끼워 넣어야 동작하는 구조가 됐어요. 무엇이 켜져 있는지 코드만 봐도 보이는 점이 좋습니다.

 

 

멀티 백스택과 딥링크 — 컬렉션 연산으로 푸는 자리

가장 체감이 컸던 건 멀티 백스택이었어요. BottomNav 탭 세 개라면 SnapshotStateList 세 개를 그냥 들고 있으면 됩니다. 활성 탭의 리스트를 NavDisplay 에 꽂아주는 게 끝이에요. Nav2 의 popUpTo(graph.findStartDestination().id) { saveState = true } 같은 주문은 다시 외울 일이 없어집니다.

 

대신 딥링크는 손해를 봅니다. 프레임워크가 URI 를 자동으로 풀어 백스택을 구성해주지 않아요. Intent.data 를 직접 파싱해서 List<NavKey> 를 만들고 backStack.addAll(...) 로 주입하는 코드를 손으로 짜야 합니다. 처음엔 "어 이걸 내가 다 짜?" 싶은데, 막상 써보면 분기 규칙을 한 곳에서 읽을 수 있어서 디버깅이 오히려 편하기도 합니다.

 

개인적으로는 작은 앱에서 Nav2 의 마법 같은 처리보다 이쪽이 훨씬 마음 편하더라고요. 어디서 무슨 일이 일어나는지 코드로 다 보이니까요. 다만 딥링크 규칙이 10개 넘어가는 큰 앱이면 얘기가 좀 달라질 것 같아요.

 

 

Navigation 3 마이그레이션 함정 세 가지 — 미리 알아두기

갈아타기 전에 발등 찍힐 만한 지점들이 있습니다.

 

  1. NavKey@Serializable 누락 — 컴파일 에러 없이 통과되는데, 프로세스가 죽었다 살아날 때 백스택이 조용히 초기화됩니다. 디버그 빌드에선 잡기 어려운 종류의 버그예요.
  2. 공유 ViewModel 부재 — Nav2 의 그래프 스코프 ViewModel 이 사라졌습니다. 여러 화면이 같은 ViewModel 을 봐야 하면 상태 호이스팅으로 위에서 들고 있다가 내려주거나, 커스텀 NavEntryDecorator 를 직접 짜야 합니다.
  3. 결과 반환 방식 변경 — savedStateHandle 로 결과 넘기던 패턴이 없습니다. 상위 컴포저블에 콜백을 내려주고, 자식이 그 콜백을 호출해 상태를 바꾸는 방향으로 다시 짜야 해요.

 

세 번째가 특히 손이 많이 갑니다. 다이얼로그에서 선택값 받아오는 식의 흔한 흐름조차 구조를 다시 짜야 했어요. 처음엔 후퇴처럼 느껴졌는데, 결국 단방향 데이터 흐름이라 추적은 더 쉬워졌습니다.

 

Navigation 3 도입 기준 — 지금 갈아탈까 말까

Nav2 가 deprecated 된 게 아니라서, 굳이 서둘러 갈아탈 의무는 없습니다. 제 기준은 이렇게 잡고 있어요.

 

  • 화면 15개 이하, 100% Compose 로 구성된 신규 프로젝트라면 검토해볼 만합니다.
  • 프래그먼트(Fragment)가 한 줄이라도 섞여 있는 하이브리드 앱이면 Nav2 유지가 안전하다고 봅니다.
  • 딥링크 규칙이 복잡하거나 그래프 스코프 ViewModel 의존도가 높으면 다음 알파에서 추가될 신규 API 가 익을 때까지 관망하는 게 합리적이라고 봅니다.

 

학습 비용도 정직하게 봐야 합니다. Nav2 의 DSL 을 외우는 시간이 아니라, Compose 상태 관리 원리를 깊이 이해해야 한다는 쪽으로 학습 곡선이 옮겨갔어요. SnapshotStateList 가 컴포지션과 어떻게 엮이는지 감이 있으면 며칠이면 익숙해지지만, 그쪽이 약하면 오히려 Nav2 보다 헷갈릴 수 있습니다.

 

정리하며 남은 자리

3시간 붙들고 있던 popUpTo saveStatebackStack.add(...) 한 줄로 끝나는 걸 보고 한참을 쳐다봤어요. 프레임워크가 떠안고 있던 복잡도를 개발자에게 돌려준 셈이라, 잃은 것도 분명히 있습니다. 딥링크와 결과 반환은 손으로 짜야 하는 자리가 됐지요.

 

이번 글에선 백스택 구조와 마이그레이션 함정까지만 풀었는데요, 공유 ViewModel 을 흉내내는 커스텀 NavEntryDecorator 패턴은 다음 글에서 코드와 함께 더 깊이 풀어볼 생각입니다. 사이드 프로젝트에 직접 붙여본 데코레이터 두 개가 있어서, 그 쪽 대목에서 다시 만나뵙겠습니다.

 

반응형
LIST