| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 명령형 ui
- 선언형ui
- 양과 늑대
- 명령형ui
- JCF
- 선언형 ui
- State
- 무선 페어링
- clean coder
- 2022 kakao blind
- Java
- producestate
- NavHost
- gradle jdk
- bottomscaffold
- ktor-auth
- 안드로이드
- apollo3
- rememberupdatedstate
- 2022 KAKAO BLIND RECRUITMENT
- derivedstateof
- 자바
- android
- 2989번
- snapshotflow
- compose
- genarics
- mutableStateOf
- remembercoroutinescope
- mutablestate
- Today
- Total
버미
[안드로이드] Compose Navigation 3 개괄적 이해 본문
기존에 사용하던 Navigation 2(NavHost + NavController + NavGraph)에 익숙한 입장에서, 2025년 11월에 1.0 안정 버전이 출시된 Jetpack Navigation 3(이하 Nav3)는 어떤 점이 달라졌고, 왜 새로 설계됐는지, 그리고 실제로 어떻게 사용하는지 살펴보고자 한다.
Nav3가 등장한 이유
Nav2는 2018년에 설계된 라이브러리다. Compose가 세상에 나오기도 전이다. 그래서 Nav2는 본질적으로 명령형(imperative) 패러다임을 따른다.
navController.navigate("home")
navController.popBackStack()
위 코드는 "컨트롤러에게 명령을 내리면 → 내부 백스택이 바뀌고 → UI가 갱신된다"는 흐름이다.
문제는 Compose가 모든 영역에서 상태 기반(state-driven) 모델을 채택하고 있다는 점이다. mutableStateOf, remember, collectAsState 등 Compose의 모든 도구는 "상태가 바뀌면 UI가 다시 그려진다"는 단방향 흐름을 따른다.
결국 Compose 앱 안에서 내비게이션만 다른 패러다임으로 동작하는 구조였고, 이것은 다음과 같은 실질적 문제를 만들었다.
- 백스택이 라이브러리 내부에 숨겨져 있어서 직접 조작이 어려움
- 인자 전달이 라우트 문자열 파싱 기반이라 타입 안전성이 약함
- "한 화면에 한 destination" 가정 때문에 태블릿/폴더블의 list-detail 같은 적응형 레이아웃 구현이 어색함
- ViewModel 스코핑이 라이브러리 내부에 숨어 있어 디버깅이 어려움
- 테스트하려면 인스트루먼트 테스트가 필요
Nav3는 이 문제들을 해결하기 위해 백스택을 개발자에게 완전히 노출시키는 방향으로 다시 설계됐다.
Nav2와 Nav3의 개념 대응
기존 Nav2의 요소를 그대로 매핑해보면 다음과 같다.
| Nav3 | Nav2 | |
| NavHost | NavDisplay | 화면을 그려주는 컨테이너 Composable |
| NavController | backStack (리스트 그 자체) | 화면 이동 / 백스택 조작 |
| NavGraph | entryProvider | 키 → 화면 매핑 정의 |
| 라우트 문자열 "product/{id}" | NavKey를 구현한 데이터 클래스 | 화면 식별자 + 인자 |
핵심은 NavController라는 별도의 객체가 사라지고, 백스택 리스트 자체가 곧 내비게이션 상태가 됐다는 점이다.
Nav3의 구성 요소
본격적으로 살펴보기 전에, Nav3에서 등장하는 핵심 요소들을 먼저 정리하면 다음과 같다.
| 요소 | 타입 / 형태 | 역할 | Nav2 |
| NavKey | 인터페이스 (데이터 클래스로 구현) | 화면 식별, 인자 | 라우트 문자열 "product/{id}" |
| backStack | SnapshotStateList<NavKey> | 현재 쌓여 있는 화면들의 리스트(상태) | NavController 내부 백스택 |
| NavDisplay | Composable | 백스택을 관찰해 마지막 항목을 그리는 컨테이너 | NavHost |
| entryProvider | 람다 (DSL) | 키를 통한 화면 매핑 | NavGraph |
NavKey
화면을 식별하는 키다. 라우트 문자열 대신 데이터 클래스로 정의한다.
@Serializable
data object Home : NavKey
@Serializable
data class ProductDetail(val productId: String) : NavKey
@Serializable
data class Checkout(
val items: List<CartItem>,
val couponCode: String?
) : NavKey
- NavKey 인터페이스 구현 → 라이브러리에 "이 키는 저장 가능하다"고 알림
- @Serializable → 프로세스 죽음(process death) 후에도 복원 가능
문자열 라우트 시절에는 arguments?.getString("productId")처럼 꺼내야 했던 인자가, Nav3에서는 그냥 key.productId로 접근된다. 컴파일러가 타입을 검증해주고, IDE의 리팩토링 기능도 완벽하게 동작한다.
backStack
현재 쌓여 있는 화면들의 리스트다. 정확히는 SnapshotStateList<NavKey>다.
val backStack = rememberNavBackStack(Home)
rememberNavBackStack은 구성 변경과 프로세스 죽음을 가로질러 백스택을 유지해주는 편의 함수다. 초기값으로 넣은 Home이 Nav2의 startDestination 역할을 한다.
이 리스트는 개발자가 직접 소유하고 직접 조작한다.
backStack.add(ProductDetail("abc")) // navigate
backStack.removeLastOrNull() // popBackStack
backStack.clear() // 백스택 비우기
NavDisplay
백스택을 관찰하다가 마지막 항목을 그려주는 Composable이다. Nav2의 NavHost에 해당한다.
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
// ...
}
)
onBack은 시스템 백버튼을 눌렀을 때 실행되는 람다다. 보통 마지막 항목을 제거한다.
entryProvider
키 → 화면 매핑을 정의한다. Nav2의 NavGraph에 해당한다.
entryProvider = entryProvider {
entry<Home> {
HomeScreen()
}
entry<ProductDetail> { key ->
ProductDetailScreen(productId = key.productId)
}
}
entry<T> { ... } DSL은 내부적으로 when (key) { is T -> ... } 패턴매칭을 깔끔하게 표현한 것이다. 람다의 key 파라미터는 자동으로 해당 타입으로 추론된다.
기본 사용 예제
위 요소들을 모두 합친 최소한의 코드는 다음과 같다.
@Serializable
data object Home : NavKey
@Serializable
data class ProductDetail(val productId: String) : NavKey
@Composable
fun App() {
val backStack = rememberNavBackStack(Home)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Home> {
HomeScreen(
onProductClick = { id ->
backStack.add(ProductDetail(id))
}
)
}
entry<ProductDetail> { key ->
ProductDetailScreen(
productId = key.productId,
onBack = { backStack.removeLastOrNull() }
)
}
}
)
}
흐름은 다음과 같다.
- 사용자가 HomeScreen에서 상품을 탭
- backStack.add(ProductDetail("abc")) 실행
- backStack은 SnapshotStateList이므로 변경을 Compose가 감지
- NavDisplay가 리컴포지션되면서 리스트의 마지막 항목을 확인
- entryProvider에서 ProductDetail 타입에 매칭되는 람다 실행
- ProductDetailScreen("abc")가 화면에 렌더링됨
별도의 컨트롤러도, 그래프 탐색도 없다. 상태 변경 → UI 갱신이라는 Compose의 기본 패턴 그대로다.
백스택 조작 — popUpTo, launchSingleTop은 어떻게?
Nav2의 DSL(popUpTo, inclusive, launchSingleTop 등)이 모두 사라졌다. 대신 백스택이 리스트이므로 Kotlin Collection API로 직접 조작한다.
popUpTo 패턴
fun SnapshotStateList<NavKey>.popUpTo(
key: NavKey,
inclusive: Boolean = false
) {
val index = indexOfFirst { it == key }
if (index < 0) return
val targetSize = if (inclusive) index else index + 1
while (size > targetSize) removeLastOrNull()
}
// 사용
backStack.popUpTo(Login, inclusive = true)
backStack.add(Home)
launchSingleTop 패턴
fun SnapshotStateList<NavKey>.navigateSingleTop(key: NavKey) {
if (lastOrNull() != key) add(key)
}
로그아웃 등 백스택 초기화
backStack.clear()
backStack.add(Login)
특정 화면으로 돌아가기
fun SnapshotStateList<NavKey>.popBackTo(key: NavKey) {
val index = indexOfFirst { it == key }
if (index < 0) return
while (size > index + 1) removeLastOrNull()
}
Nav2의 DSL로 표현 불가능했던 케이스
// 백스택에서 모든 ProductDetail 화면 제거
backStack.removeAll { it is ProductDetail }
// 특정 조건을 만족하는 가장 최근 화면까지 돌아가기
val targetIndex = backStack.indexOfLast { it is Home || it is Login }
if (targetIndex >= 0) {
while (backStack.size > targetIndex + 1) backStack.removeLastOrNull()
}
Nav2 시절에는 Google이 제공하는 DSL의 조합으로 표현 가능한 흐름만 만들 수 있었지만, Nav3는 자료구조를 직접 다루기 때문에 표현 가능한 케이스의 폭이 훨씬 넓다.
EntryDecorator — 횡단 관심사 처리
각 NavEntry에 공통 동작을 추가하는 메커니즘이다. Nav2에서 라이브러리가 알아서 처리해주던 상태 보존, ViewModel 스코핑 같은 동작을 Nav3는 명시적으로 데코레이터로 부착한다.
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
// rememberSaveable 동작 보장 (configuration change, process death 대응)
rememberSaveableStateHolderNavEntryDecorator(),
// ViewModel을 NavEntry 생명주기에 스코프
rememberViewModelStoreNavEntryDecorator()
),
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider { /* ... */ }
)
rememberViewModelStoreNavEntryDecorator()를 추가하면 ViewModel이 Activity가 아니라 해당 화면 단위로 스코프된다. Nav2에서 backstack entry 단위로 ViewModel을 묶던 것과 비슷하지만, 동작이 코드에 드러난다는 점이 중요하다.
적응형 레이아웃 — Scenes
Nav2의 가장 큰 한계 중 하나가 "한 번에 한 화면"이라는 가정이었다. 태블릿에서 list-detail을 동시에 보여주려면 별도의 SlidingPaneLayout이나 Adaptive 라이브러리를 끌어와야 했다.
Nav3는 백스택이 그냥 리스트이므로 마지막 두 개를 동시에 그린다 같은 동작이 자연스럽게 가능하다. 이를 위해 sceneStrategy 파라미터를 제공한다.
NavDisplay(
backStack = backStack,
sceneStrategy = ListDetailSceneStrategy(),
// ...
)
SinglePaneSceneStrategy(기본값), ListDetailSceneStrategy 등의 전략을 선택할 수 있고, 직접 구현도 가능하다.
폴더블/태블릿/Compose Multiplatform을 고려해야 하는 시대에 결정적인 차이다.
결과 전달은 어떻게?
Nav2에서 가장 어색했던 부분 중 하나가 previousBackStackEntry.savedStateHandle.set(...) 패턴으로 이전 화면에 결과를 넘기는 것이었다.
Nav3에서는 공유 상태로 해결한다.
보통 공유 ViewModel을 쓰거나, 단순한 경우 콜백을 활용한다.
class CheckoutFlowViewModel : ViewModel() {
var selectedAddress by mutableStateOf<Address?>(null)
}
entry<AddressList> {
val vm: CheckoutFlowViewModel = hiltViewModel(/* 공유 스코프 */)
AddressListScreen(
onAddressSelected = { addr ->
vm.selectedAddress = addr
backStack.removeLastOrNull()
}
)
}
entry<Checkout> {
val vm: CheckoutFlowViewModel = hiltViewModel(/* 같은 스코프 */)
CheckoutScreen(address = vm.selectedAddress)
}
내비게이션이 상태로 표현되니, 화면 간 데이터 공유도 일반적인 상태 공유 패턴 그대로 해결할 수 있다.
정리
Nav2가 "컨트롤러가 그래프를 따라 화면을 옮긴다"였다면, Nav3는 "백스택은 내가 들고 있는 리스트고, NavDisplay는 그 리스트를 관찰해 마지막 항목을 그린다" 가 전부다.
| 패러다임 | 명령형(Nav2) | 상태 기반(Nav3) |
| 백스택 소유 | 라이브러리 내부 | 개발자 |
| 화면 식별 | 라우트 문자열 | 데이터 클래스 |
| 이동 명령 | navigate("route") | backStack.add(Route) |
| 뒤로가기 | popBackStack() | backStack.removeLastOrNull() |
| 인자 전달 | 문자열 파싱 | 프로퍼티 접근 |
| 적응형 UI | 별도 라이브러리 필요 | SceneStrategy 기본 지원 |
| 테스트 | 인스트루먼트 테스트 | 유닛 테스트 가능 |
Compose의 나머지 모든 부분이 상태 기반으로 동작하는데, 내비게이션만 명령형이었던 일관성의 균열이 Nav3에 와서야 비로소 메워졌다고 볼 수 있다.
기존 Nav2를 사용 중인 프로젝트라면 당장 마이그레이션할 필요는 없지만, 신규 Compose 프로젝트나 적응형 UI / Compose Multiplatform을 염두에 둔 프로젝트라면 Nav3로 시작하는 것을 고려해볼 만하다.
- Reference
'안드로이드' 카테고리의 다른 글
| [안드로이드] API 36 버전 업데이트 정리 (0) | 2026.05.01 |
|---|---|
| [안드로이드] 웹뷰와 커스텀 탭 (0) | 2026.03.23 |
| [안드로이드] MVI에서 Event 처리: Channel vs SharedFlow (0) | 2025.12.22 |
| [안드로이드] JDK, JRE, JVM 그리고 Gradle JDK (0) | 2025.10.09 |
| [안드로이드] 안드로이드 스튜디오 무선 연결이 안될 때 (0) | 2025.10.02 |