Compose로 화면 전환을 짜본 사람이라면 NavHost와 NavController 조합이 묘하게 불편했던 기억이 있을 것이다. 백스택은 내부에서만 관리되고, 인자는 "detail/{id}" 같은 문자열 라우트로 넘기다 오타 하나에 런타임 크래시가 난다. Compose는 상태를 선언적으로 다루라고 하면서 정작 네비게이션만 블랙박스로 남아있던 셈이다. Navigation 3는 이 어긋남을 정면으로 손댄 라이브러리다.
문제는 백스택을 누가 쥐고 있느냐였다
Navigation 2는 원래 XML 시대에 설계된 구조를 Compose 위에 얹은 것이라 태생적인 한계가 있었다. NavController가 백스택을 캡슐화해버리니 특정 화면 두 개를 건너뛰거나 순서를 바꾸는 것 같은 조작이 까다로웠고, 문자열 라우트는 컴파일 타임에 잡히지 않는 오류를 만들었다. Navigation 3의 답은 간단하다. 백스택을 라이브러리가 감추지 말고 개발자에게 그냥 넘겨주자는 것이다.
실제로 Nav3의 백스택은 SnapshotStateList다. 즉 Compose의 상태 관리 모델과 완전히 같은 방식으로 동작하고, add·removeAt 같은 리스트 연산만으로 화면 전환이 끝난다.
코드로 보는 핵심 구조
라우트는 NavKey를 구현하는 데이터 클래스로 정의한다. 문자열 파싱이 아니라 Kotlin Serialization을 쓰기 때문에 인자 타입이 컴파일 타임에 검증된다.
@Serializable
data object Home : NavKey
@Serializable
data class Detail(val destinationId: String) : NavKey
백스택과 렌더링은 이렇게 연결한다.
@Composable
fun TravelApp() {
val backStack = rememberNavBackStack(Home)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Home> {
HomeScreen(
onDestinationClick = { id -> backStack.add(Detail(id)) }
)
}
entry<Detail> { key ->
DetailScreen(
id = key.destinationId,
onBack = { backStack.removeLastOrNull() }
)
}
}
)
}
entryProvider는 라우트 타입과 화면을 매핑하는 DSL이다. 새 화면을 추가할 때 라우트 클래스 하나와 entry 블록 하나만 늘리면 되고, 백스택 조작은 어디서든 backStack.add(...), backStack.removeLastOrNull()처럼 리스트 API 그대로 쓰면 된다. 특정 화면까지 한 번에 돌아가고 싶다면 backStack.removeAll { it !is Home } 같은 코드도 자연스럽게 짤 수 있다. NavController에서는 popBackStack 옵션을 조합해야 했던 일이 그냥 컬렉션 연산이 된다.
화면 간 결과 전달, 문자열 대신 콜백으로
Nav2에서 흔히 겪는 불편 중 하나가 화면 A에서 연 화면 B의 결과를 다시 A로 돌려받는 방법이었다. SavedStateHandle에 값을 넣고 NavController의 currentBackStackEntry를 관찰하는 식으로 우회해야 했는데, 백스택을 직접 들고 있는 Nav3에서는 이 과정이 훨씬 직관적이다. 백스택에 들어가는 라우트 자체에 콜백을 담을 수는 없지만, 상위 컴포저블이 백스택과 상태를 함께 소유하고 있으니 결과 값을 일반 상태 변수로 끌어올리면 된다.
@Composable
fun TravelApp() {
val backStack = rememberNavBackStack(Home)
var selectedFilter by remember { mutableStateOf<TripFilter?>(null) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Home> {
HomeScreen(appliedFilter = selectedFilter)
}
entry<FilterSheet> {
FilterScreen(
onApply = { filter ->
selectedFilter = filter
backStack.removeLastOrNull()
}
)
}
}
)
}
라우트를 문자열로 파싱해 값을 실어 나르던 방식과 달리, 그냥 클로저로 콜백을 넘기는 코드라 테스트하기도 쉽다. 백스택 자체가 평범한 리스트이므로 단위 테스트에서도 rememberNavBackStack 없이 mutableStateListOf(Home)을 직접 만들어 화면 전환 로직만 따로 검증할 수 있다.
셋업과 버전 제약
gradle/libs.versions.toml에 버전을 등록한다.
[versions]
nav3Core = "1.1.2"
kotlinxSerializationCore = "1.11.0"
[libraries]
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3Core" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
app/build.gradle.kts에는 kotlin.serialization 플러그인이 반드시 있어야 한다. 라우트 클래스를 직렬화하는 데 이 플러그인이 쓰이기 때문에 빠뜨리면 @Serializable 어노테이션이 있어도 컴파일이 깨진다.
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.kotlinx.serialization.core)
}
백스택을 화면 회전이나 프로세스 종료 후에도 유지하려면 rememberNavBackStack 대신 저장 가능한 형태로 감싸거나 상위에서 rememberSaveable을 조합해야 한다. 이 부분은 Nav2의 SavedStateHandle만큼 자동은 아니므로 마이그레이션할 때 놓치기 쉬운 지점이다.
언제 맞고 언제 아닌가
신규 프로젝트나 Compose 단일 화면 구조로만 이뤄진 앱이라면 Nav3의 이점이 바로 체감된다. 백스택을 직접 들여다보고 테스트할 수 있다는 것 자체가 디버깅 시간을 줄여준다. 반대로 이미 Nav2 기반으로 딥링크, Fragment 상호운용, SafeArgs까지 얽혀 있는 대형 앱이라면 얘기가 다르다. 문자열 라우트와 NavController에 의존하는 코드가 많을수록 전환 비용이 만만치 않다.
또 하나 감안할 점은 성숙도다. 1.1.2라는 버전 자체는 안정권에 가깝지만 Nav2만큼 오랜 기간 검증된 것은 아니다. 딥링크 처리나 애니메이션 커스터마이징처럼 세부 API는 아직 손볼 여지가 있으니, 신규 화면 하나에 먼저 적용해보고 점진적으로 넓히는 쪽이 안전하다.
※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.
'안드로이드' 카테고리의 다른 글
| lateinit, 정말 안전한가 (0) | 2026.07.14 |
|---|---|
| Compose에서 state를 어디서 읽느냐가 성능을 가른다 (0) | 2026.07.13 |
| Room은 그대로 두고 동기화만 얹는다 — SyncForge의 아웃박스 설계 (0) | 2026.07.06 |
| dependencies { } 는 왜 함수처럼 안 생겼는데 함수일까 (0) | 2026.07.03 |
| 백만 줄 스레드 코드를 코루틴으로 바꾸되, 호출부는 한 줄도 건드리지 않는 법 (0) | 2026.06.30 |