Compose Navigation 3 적용하기

cotton·2026년 8월 3일

Android

목록 보기
4/4

서론

Jetpack Navigation (Nav2) 는 Compose가 생성되기 이전 Fragment 시절부터 있던 네비게이션 구조로 설계되었습니다. Compose 기반으로 사용할 수 있도록 어댑터 형태로 지원했으나, 선언형 UI에 맞지 않은 네비게이션 라이브러리를 Compose에 사용하는 것은 상당히 어긋난 구조에서 개발하는 느낌을 주었습니다.

Google은 Google I/O 2025에서 Compose 기반으로 설계된 내비게이션 라이브러리인 Jetpack Navigation 3(Nav3) 을 출시했습니다. Nav3은 선언적 UI와 Compose의 상태를 기반으로 동작합니다.

Nav2를 사용할 때도 큰 문제 없이 사용할 수 있었겠지만, 현재 Nav2 를 사용하고 있다면 마이그레이션을 고려해야 합니다.

Nav2의 단점

Nav2도 Compose에서 사용할 수 있는 탐색 라이브러리로써는 강력했지만, 여러 단점이 존재했습니다.

XML 기반 동작

Nav2는 문자열 기반의 경로를 강제했습니다. (Type-Safe 이전) 이는 오류 발생 가능성이 높으며 상수화하지 않으면 타입 안정성이 상대적으로 떨어졌습니다.

navController.navigate("profile/${userId}")
composable("profile/{id}") { /* ... */ } 

ViewModel 스코핑의 어려움

여러 화면에 같은 ViewModel을 공유하기 위해서는 복잡한 과정을 거쳐야 했습니다.

val parentEntry = remember { 
		navController.getBackStackEntry("parent_route")
} 
val sharedViewModel  = hiltViewModel(parentEntry)

Multi-Pane 환경에서의 관리의 어려움

태블릿과 같은 넓은 기기에 반응형 레이아웃을 구축하기 위해서는 Nav2 환경에서는 여러 어려움이 있었습니다

여러 개의 NavController를 동시에 컨트롤해야 했으며, 각 화면 간 상태를 동기화해주어야 했습니다. 또한 위 기능들을 원활하게 사용하기 위한 기능을 Nav2에서는 지원하지 않았습니다.

이러한 어려움이 발생하면서 Google은 Nav2를 포기하고 Compose 위에서 돌아가는 네비게이션 솔루션을 구축했고, Navigation 3을 출시했습니다.

Navigation 3 의 기능

Type-Safe Navigation

더 이상 문자열 경로를 사용하지 않습니다. (Navigation 2의 Type-Safe와 동일) Class 기반으로 동작하여, 컴파일 시점에 안전하게 사용할 수 있습니다.

// Nav3: Type-safe with data classes
@Serializable
data class ProfileScene(val userId: String)

navigator.navigate(ProfileScene(userId = "123"))

Scenes API

Scenes API 를 이용하여 태블릿 및 폴더블 기기에서도 여러 동시에 렌더링할 수 있게 해줍니다. 이를 통해 단일 창 레이아웃과 다중 창 레이아웃 간의 전환이 간편해집니다.

val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

NavDisplay(
	backStack = backStack,
	sceneStrategies = listOf(listDetailStrategy),
	entryProvider = entryProvider { 
		entry<List>( 
			metadata = ListDetailScene.listPane(), 
		) {
			ListScreen()
		}
		entry<Detail>(
			metadata = ListDetailScene.detailPane(),
		) {
			DetailScreen()
		}		
	},		
)

직접 관리하는 백스택

Nav3의 가장 중요한 부분 중 하나인, 개발자가 직접 백스택을 소유하고 제어할 수 있습니다.

기존 Nav2에서는 NavController가 백스택을 소유하고 있어 개발자가 직접 제어하지 못하고 NavController의 popBackStack() 과 같은 함수를 호출하여 제어할 수 있었습니다.

navController.popBackStack()

Nav3의 경우 백스택 자체를 State 로 처리, 화면에서 가져와 소유할 수 있고, 화면 내에서 리스트를 변경하듯이 직접 제어할 수 있습니다.

val backStack = rememberNavBackStack(HomeRoute)

이외에 예측 가능한 뒤로 가기 애니메이션을 지원, 내장형 적응형 레이아웃 지원 등 Compose에 적합한 Navigation 기능을 제공합니다.

Nav3의 장점

Navigation3은 눈에 띄는 여러 개선점을 제공합니다.

  • Nav2 기반 Compose 탐색보다 보일러플레이트 코드가 감소합니다.
  • 상태 기반으로 동작하여 화면 스코핑을 이전보다 편리하게 할 수 있습니다.
  • Android Studio에서 Nav3 기반의 IDE 기능들을 지원합니다.
  • 이전보다 Navigation 이동에 대해서 테스트하기 쉽습니다.

동작 방식

흔히 말하는 ‘탐색’ 이란, 앱 내에서 화면을 이동하는 방식을 의미하며 유저의 Click 등을 통해 UI와 상호작용하는 과정에서, 앱이 새로운 화면을 띄우거나, 이전 화면으로 이동하는 등의 형태로 응답하는 것을 의미합니다.

탐색 상태 모델링

이 과정을 모델링하기 가장 좋은 방법은 콘텐츠 스택을 이용하는 것입니다. 사용자가 새로운 콘텐츠로 이동하면 스택 최상단에 해당 화면이 Push 됩니다. (Tap on B → B pushed onto stack) 해당 화면에서 뒤로 가기 버튼 등을 통해 이전 화면으로 이동한다면, 스택에서 B 화면이 Pop 되고, 이전 콘텐츠(A 화면) 으로 이동합니다. (Go Back → B popped off stack)

탐색 용어로, 해당 스택을 ‘이전 화면으로 돌아갈 수 있는 콘텐츠’ 를 나타내는 스택이기 때문에, Back Stack이라고 부릅니다.

백스택 만들기

Nav2 에서는 백스택에 실제 콘텐츠를 보유하고 있는 NavBackStackEntry가 들어갔습니다. 하지만 Navigation 3에서는 역할을 분리하여 콘텐츠 정보를 가진 NavBackStackEntry가 아닌 Key 가 들어갑니다. 이 때 키는 어떤 유형이든 될 수 있지만 일반적으로 ‘직렬화 가능한 데이터 클래스’를 기반으로 동작합니다.

@Serializable
data object ProductList
@Serializable
data class ProductDetail(val id: String)

@Composable
fun MyApp() {

    // 백스택을 생성합니다. 앱을 시작할 화면의 Key를 기반으로 시작합니다.
    val backStack = remember { mutableStateListOf<Any>(ProductList) }

    // UI 변경 사항을 반영할 수 있도록 백스택을 NavDisplay 에 제공합니다.
    // 키를 백스택에 push하면, Navigation은 backstack을 기반으로 화면을 변경합니다.
    backStack.add(ProductDetail(id = "ABC"))

    // 마찬가지로 키를 제거하면, Navigation은 변경된 backstack 상태를 기반으로 화면을 변경합니다.
    backStack.removeLastOrNull()
}

콘텐츠 그리기

Key 기반으로 백스택이 동작한다면 콘텐츠는 어디에서 처리될까요? 콘텐츠는 Composable 함수가 포함된 NavEntry를 이용해 목적지 역할을 합니다.

NavEntry에는 콘텐츠 정보인 여러 메타데이터가 포함됩니다. Key 뿐만 아니라 실제로 화면에 그려야 할 Composable 함수와 이외 메타데이터를 가지고 있습니다.

NavEntry를 직접 Key를 가져와서 하나하나 매핑해줄 수도 있겠지만, key를 화면으로 변환하는 함수를 Nav 3에서 지원해줍니다. EntryProvider 를 이용하여 NavKey를 매핑해주는 하나의 매핑 테이블 역할을 합니다.

EntryProvider - 직접 매핑하기

entryProvider = { key ->
    when (key) {
        is ProductList -> NavEntry(key) { Text("Product List") }
        is ProductDetail -> NavEntry(
            key,
            metadata = mapOf("extraDataKey" to "extraDataValue")
        ) { Text("Product ${key.id} ") }

        else -> {
            NavEntry(Unit) { Text(text = "Invalid Key: $it") }
        }
    }
}

EntryProvider Scope에서는 Key를 전달하여, 해당 값을 조건문을 이용하여 NavEntry를 지정합니다.

EntryProvier - DSL 이용하기

entryProvider = entryProvider {
    entry<ProductList> { Text("Product List") }
    entry<ProductDetail>(
        metadata = mapOf("extraDataKey" to "extraDataValue")
    ) { key -> Text("Product ${key.id} ") }
}

entryProvider DSL을 이용해 더 간단하게 작성할 수 있습니다.

entry<*> 는 유형과 컴포저플 콘텐츠로 NavEntry를 정의하며, metadata 매개변수롤 가질 수 있습니다

백스택 표시

백스택은 앱의 네비게이션 상태를 가지고 있습니다. 백스택이 변경될 때마다 앱 UI는 변경된 백스택에 따라 화면을 변경해야 합니다. Nav3의 NavDisplay는 백스택을 옵저빙하고, 그에 따라서 UI를 업데이트합니다.

NavDisplay는 아래 매개변수를 가지고 있어야 합니다.

  • backStack : SnapshotStateList<T> 유형이어야 하며, T는 NavKey여야 합니다. 변경될 때 NavDisplay의 리컴포지션을 트리거 해야 화면이 변경되므로, 관찰 가능한 List 형태여야 합니다. 보통 rememberNavBackStack() 를 이용하여 초기화, 사용합니다.
  • entryProvider : NavEntry 객체를 반환하는 entryProvider가 필요합니다.
  • onBack (Optional) : 원하는 경우 onBack에 람다를 제공하여 사용자가 뒤로 가기 액션을 했을 때 트리거됩니다. 뒤로 가기 행동을 커스텀할 수 있습니다.
data object Home
data class Product(val id: String)

@Composable
fun NavExample() {

    val backStack = remember { mutableStateListOf<Any>(Home) }

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = { key ->
            when (key) {
                is Home -> NavEntry(key) {
                    ContentGreen("Welcome to Nav3") {
                        Button(onClick = {
                            backStack.add(Product("123"))
                        }) {
                            Text("Click to navigate")
                        }
                    }
                }

                is Product -> NavEntry(key) {
                    ContentBlue("Product ${key.id} ")
                }

                else -> NavEntry(Unit) { Text("Unknown route") }
            }
        }
    )
}

정리

  1. 백스택이 변경됩니다. 유저의 액션에 의해 Key가 추가되거나 사라집니다.
  2. NavDisplay는 백스택을 옵저빙합니다. 기본적으로는 최상단 백스택 항목을 단일 창 레이아웃에 보여줍니다 ( Multi Pane 환경에서는 다르게 동작합니다.)
    1. 백 스택의 최상단 키가 변경되면 NavDisplay는 해당 값을 기반으로 EntryProvider에게 콘텐츠(NavEntry) 를 요청합니다.
  3. Entry Provider가 콘텐츠를 제공합니다. Key를 기반으로 확인하여, 키에 해당하는 NavEntry를 제공합니다.
  4. 콘텐츠가 화면에 표시됩니다. NavDisplay는 NavEntry를 수신하고 콘텐츠를 화면에 표시합니다.

참고

https://developer.android.com/guide/navigation/navigation-3?hl=ko

https://medium.com/@kemal_codes/jetpack-navigation-3-why-a-new-navigation-system-for-compose-13a05bd38ac7

profile
안드로이드 개발자

0개의 댓글