[Android] Navigation3

KSK·2025년 12월 10일

Navigation3

목록 보기
1/2

개요

기존 Jetpack Navigation은 Compose와 함께 발전해 왔지만, 여전히 보일러플레이트 코드와 복잡한 상태 관리, 그리고 멀티플랫폼 지원에 대한 갈증이 있었습니다. 이번 글에서는 기존 Navigation 방식의 한계를 짚어보고, I/O에서 공개된 Navigation3가 이를 어떻게 해결했는지 알아보겠습니다.

기존 Navigation

우리는 지금까지 NavHost와 NavController를 사용하여 화면 이동을 구현해 왔습니다. 최근 버전(2.8.0+)에서 Type-safety가 도입되긴 했지만, 기본적인 구조는 "그래프 기반"이었습니다.

기존 방식 (Compose)

보통 Serializable 객체나 문자열 경로(Route)를 정의하고, NavHost라는 거대한 컨테이너 안에서 각 컴포저블을 미리 연결하는 방식입니다.


// 1. Route 정의
@Serializable
object Home
@Serializable
data class Detail(val id: Int)

// 2. NavHost 구성 (그래프 정의)
@Composable
fun AppNavigation(navController: NavHostController) {
    NavHost(navController = navController, startDestination = Home) {
        
        composable<Home> {
            HomeScreen(
                onNavigateToDetail = { id -> 
                    navController.navigate(Detail(id)) 
                }
            )
        }
        
        composable<Detail> { backStackEntry ->
            // Route에서 데이터 복원 과정 필요
            val detail: Detail = backStackEntry.toRoute()
            DetailScreen(id = detail.id)
        }
    }
}

문제점

Nav2는 2018년에 도입되어 제 역할을 다해왔지만, Compose 중심의 현대적 앱 개발 환경에서는 몇 가지 명확한 한계가 드러났습니다.

  1. 상태 관리의 이원화 (Two Sources of Truth)
    가장 큰 문제는 백 스택 상태를 간접적으로만 관찰할 수 있다는 점이었습니다. NavController가 관리하는 내부 상태와 개발자가 관리하는 앱 상태(ViewModel 등)가 서로 달라질 위험이 있어, '진실의 원천'이 꼬일 가능성이 있었습니다.

  2. 적응형 레이아웃(Adaptive Layouts)의 어려움
    Nav2의 NavHost는 기본적으로 한 번에 하나의 화면(백스택 최상단에 있는)만 보여주도록 설계되었습니다. 이 때문에 태블릿이나 폴더블 기기에서 '리스트-상세(List-Detail)' 뷰처럼 두 개의 화면을 동시에 띄워야 할 때, NavHost 구조로는 구현이 매우 까다로웠습니다.

  3. 그 외 구조적 한계

  • NavHost의 비대화: 앱이 커질수록 NavHost 내부의 그래프 정의 코드가 지나치게 길어집니다.
  • 프레임워크 의존성: NavController는 안드로이드 프레임워크에 강하게 결합되어 있어, 비즈니스 로직에서의 제어나 테스트가 어렵습니다.
  • KMP 확장성 부족: 안드로이드 Lifecycle 의존성 때문에 iOS나 데스크탑 타겟의 멀티플랫폼 프로젝트와 코드를 공유하기 어렵습니다.

Google I/O 2025에서 발표된 Navigation3의 핵심은 "Navigation as State (상태로서의 네비게이션)"입니다. 복잡한 그래프 개념을 버리고, UI 상태(State) 자체가 네비게이션이 되는 구조로 재탄생했습니다.

변화 포인트

Google은 Nav3를 개발하면서 개발자에게 더 큰 유연성과 제어권(Control)을 부여하기 위해 다음 3가지 핵심 원칙을 세웠습니다.

1. 백 스택의 소유권은 개발자에게 (You own the back stack)
백 스택을 소유하고 제어하는 것은 라이브러리가 아니라 개발자(당신)입니다. 백 스택은 Compose 상태로 뒷받침되는 단순한 리스트일 뿐입니다. 더 이상 NavController라는 블랙박스 안에서 백 스택이 어떻게 꼬이는지 걱정할 필요가 없습니다. 우리가 흔히 쓰는 mutableStateListOf와 똑같습니다. 리스트에 데이터를 넣으면(add) 화면이 뜨고, 빼면(remove) 사라집니다. "네비게이션 == 리스트 조작"이라는 단순 명료한 공식이 성립됩니다.

2. 개발자를 방해하지 않는 구조 (Get out of your way)
기존에는 라이브러리가 정해준 틀을 벗어나기가 매우 힘들었습니다. 하지만 Nav3는 투명합니다. 기본 제공 기능이 마음에 안 들면, 라이브러리 내부 로직을 뜯어고칠 필요 없이 제공된 하위 API를 조합해 나만의 네비게이션 로직을 짤 수 있습니다.

3. 조립 가능한 빌딩 블록 (Pick your building blocks)
모든 기능을 라이브러리 안에 때려 박는 대신, Nav3는 더 복잡한 기능을 만들기 위해 조합할 수 있는 작은 컴포넌트(Building blocks)들을 제공합니다. 또한 일반적인 네비게이션 문제를 해결하기 위해 이 블록들을 어떻게 조합하는지 보여주는 '레시피 북(Recipes book)'도 함께 제공합니다.

핵심 기능

1. 애니메이션

참고 이미지 gif

화면 전환 시 사용할 수 있는 기본 애니메이션이 내장되어 있으며, 여기에는 예측형 뒤로가기 지원도 포함됩니다. 또한 앱 전체 레벨 혹은 개별 화면 레벨에서 애니메이션을 오버라이딩할 수 있는 유연한 API를 제공합니다.

덕분에 화면마다 다른 전환 효과를 주고 싶을 때도 훨씬 직관적인 코드로 구현이 가능해졌습니다.

2. 적응형 레이아웃 (Adaptive layouts)
Scenes라고 불리는 매우 자유롭게 커스텀이 가능한 레이아웃 API를 통해 하나의 레이아웃 안에 여러 개의 화면을 동시에 렌더링할 수 있습니다

앞서 언급한 적응형 레이아웃 문제점의 해결책입니다. Scenes API를 사용하면 NavHost의 제약 없이, 태블릿에서는 화면 두 개를 나란히 보여주고 폰에서는 하나만 보여주는 반응형 UI를 손쉽게 구현할 수 있습니다.

3. 상태 스코핑 (State scoping)
백 스택에 있는 화면의 수명에 맞춰 State의 범위를 지정할 수 있습니다.
화면이 백 스택에서 사라지면 관련된 상태나 ViewModel도 자동으로 정리됩니다. 메모리 누수를 방지하고 리소스 관리를 효율적으로 할 수 있게 도와줍니다.

주요 개념

Nav3를 이해하기 위한 핵심 개념 3가지(NavKey, NavEntry, NavDisplay)를 살펴보겠습니다.

NavKey는 화면을 식별하고 데이터를 전달하는 객체입니다.
Nav2의 Route와 비슷해 보이지만, 그래프 없이 동작한다는 점이 결정적으로 다릅니다.

  • 데이터 전달 방식
// Nav 2
// [1. Route 정의]
@Serializable
data class ProfileRoute(val userId: Int, val name: String)

// [2. 연결] NavHost 안에서 그래프 정의
NavHost(navController = navController, startDestination = HomeRoute) {
    
    // 여기가 "Graph" 영역
    composable<ProfileRoute> { backStackEntry ->
        // [3. 데이터 추출] 시스템이 내부적으로 파싱한 걸 다시 꺼냄 (Serialization 동작)
        val route: ProfileRoute = backStackEntry.toRoute()
        
        ProfileScreen(
            id = route.userId, 
            name = route.name
        )
    }
}

// [4. 이동]
navController.navigate(ProfileRoute(100, "Minsu"))

Nav2에선 backStackEntry 에서 ProfileRoute 를 찾아 복원 후(toRoute) 데이터를 가져옵니다.


// Nav 3
// [1. 정의] NavKey 인터페이스 구현
@Serializable
data class ProfileKey(val userId: Int, val name: String) : NavKey

// [2. 연결] EntryProvider에서 매핑 (Graph 아님)
val entryProvider = entryProvider {
    
    // [3. 데이터 추출] key 에서 파라미터를 가져옴
    entry<ProfileKey> { key ->
        ProfileScreen(
            id = key.userId, 
            name = key.name
        )
    }
}

// [4. 이동]
navigator.navigate(ProfileKey(100, "Minsu"))

Nav3 에선 backStackEntry를 찾아 변환하는 과정 없이 매개변수로 넘어온 NavKey에서 바로 데이터를 가져옵니다.


  • Navigaton Graph

기존 Nav2에서는 NavHost라는 컨테이너 안에 composable<ProfileRoute> 와 같은 빌더 함수들을 사용하여 모든 경로가 연결된 거대한 그래프를 미리 정의해야 했습니다.

화면을 전환할 때마다 내부적으로 이 그래프를 탐색하여 목적지(Destination)를 찾아내는 방식이었습니다.

반면, Nav3는 미리 연결된 그래프 구조를 만들지 않습니다.
NavKey는 단순한 식별자이자 데이터 객체일 뿐이며, 화면 이동이 요청된 그 순간에 EntryProvider를 통해 NavKey와 1:1로 매핑된 NavEntry(UI)를 즉시 찾아내어 표시합니다.

즉, 그래프를 탐색하는 것이 아니라 "메뉴판(Provider)에서 조회"하는 방식으로 간소화되었습니다.

Nav3에선 NavGraph 클래스와 개념은 아예 삭제되었습니다.
대신 화면(Destinations) 들은 NavBackStack 이라는 리스트에 쌓이거나(push) 사라지면서(pop) 화면 전환이 이루어집니다. 이것이 위에서 mutableStateListOf와 똑같다고 언급한 개념입니다


NavEntry는 Navigation3에서 화면 스택의 최소 단위입니다.

Nav3의 백 스택(NavBackStack)은 사실상 List 형태로 관리되며, NavDisplay는 이 NavEntry 리스트를 받고, NavEntry는 객체의 각 화면의 키와 내용을 매핑합니다.

Nav3의 NavEntry는 가벼운 상태로 시작해(key와 key에 맞는 컴포저블 스크린만 가진 상태) 필요한 기능만 Decorator 패턴으로 주입합니다.

  • ViewModel이 필요하면 ViewModelStore 데코레이터를 추가
  • 화면 회전 시 상태 저장이 필요하면 SaveableStateHolder 데코레이터를 추가

이는 변화포인트에서 언급한 "조립 가능한 빌딩 블록"이라는 Nav3의 철학을 잘 보여줍니다.

// NavEntry에 기능 주입 (Decoration) 예시
val decorators = listOf(
    // 1. 상태 저장 기능 추가
    rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
    // 2. ViewModel 사용 기능 추가
    rememberViewModelStoreNavEntryDecorator<NavKey>()
)

NavDisplay는 기존의 NavHost를 대체하여 실제로 화면을 그리는 렌더러(Renderer) 역할을 합니다.

하지만 역할은 NavHost보다 훨씬 단순하고 명확해졌습니다.
NavHost는 그래프를 정의하고, 백 스택을 관리하고, 화면을 그리는 모든 역할을 담당하는 중앙 집중 관리 시스템이었지만,

NavDisplay는 오직 "관찰하고 그리는 것"에만 집중합니다.
NavDisplay의 동작 방식은 다음과 같습니다.

  1. 관찰: 개발자가 관리하는 백 스택(List)의 상태 변화를 감지합니다.

  2. 조회: 리스트에 새로운 NavKey가 들어오면, 앞서 정의한 EntryProvider를 보고 어떤 UI를 그려야 할지 찾습니다.

  3. 렌더링: 찾아낸 UI(entry 블록 내부의 컴포저블)를 화면에 표시합니다.

@Composable
fun MyApp() {
    // 1. 네비게이터 및 백 스택 생성 
    val navigator = rememberListNavigator<NavKey>(initial = HomeKey)

    // 2. 화면 매핑 정보
    val entryProvider = entryProvider {
        entry<HomeKey> { HomeScreen() }
        entry<ProfileKey> { key -> ProfileScreen(key.user) }
    }

    // 3. 렌더링 (NavHost 대신 사용)
    NavDisplay(
        navigator = navigator,
        entryProvider = entryProvider,
        transition = SlideTransition // 애니메이션 설정 가능
    )
}

마치며

Navigation3는 단순히 기능을 개선한 업데이트가 아니라, "화면 이동은 곧 상태 관리다"라는 본질로 돌아간 패러다임의 전환입니다. KMP 지원과 유연한 아키텍처가 필요한 프로젝트라면 도입을 적극적으로 고려해 볼 만합니다.

참고

https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html

https://developer.android.com/guide/navigation/navigation-3

https://jcodingcraft.tistory.com/21#Nav3%EA%B0%80%20%ED%95%B4%EA%B2%B0%ED%95%9C%20%EB%B0%A9%EC%8B%9D-1

profile
그런게어딨어그냥하는거지

0개의 댓글