[Android] Now In Android 에서의 Navigation3

KSK·2025년 12월 11일

Navigation3

목록 보기
2/2

개요

Navigation 3 실제 활용을 어떻게 하면 되는지 사례를 통해 살펴봅니다. 단순히 라이브러리 사용법을 넘어, 멀티 백 스택과 모듈화가 적용된 대규모 앱에서의 적용 방식을 중점적으로 다룹니다.

지난 글 요약

지난 글에서는 Nav3의 핵심적인 패러다임 변화를 살펴보았습니다. 가장 큰 변화는 NavGraph와 NavHost의 제약이 사라지고, 백 스택을 개발자가 직접 리스트(List)로 관리한다는 점입니다.

  • NavKey: 단순한 화면 식별자이자 데이터 객체 (기존 Route/Bundle 대체)
  • NavEntry: 화면 스택의 최소 단위. Decorator를 통해 ViewModel 등의 기능을 선택적으로 주입.
  • NavDisplay: 단순 렌더러. 백 스택 리스트를 관찰하여 UI를 그림.

결국 "네비게이션 == 리스트 조작"이라는 공식이 성립되며, 이를 통해 유연한 구조 설계가 가능해졌습니다.

NIA(Now In Android)에서는?

신기술을 도입할 때 가장 좋은 교과서는 구글의 공식 샘플입니다. 그중 가장 대표적인 Now In Android(NIA) 프로젝트는 이미 Navigation3를 전면적으로 도입했습니다.

NIA는 멀티 모듈 구조와 바텀 네비게이션(멀티 백 스택)을 채택하고 있습니다. Nav3가 이런 복잡한 요구사항을 어떻게 해결했는지 분석해 보겠습니다.

그 전에 NIA의 모듈 구조를 짚고 넘어가겠습니다.

보시다시피 멀티 모듈 구조 입니다.

일반적으로 domain, data 레이어에 속하는 컴포넌트들은 core 에 속해있습니다.
그리고 화면 단위별로 feature 모듈로 분리되어 있습니다.
app 모듈에는 MainActivity 등 앱 작동에 필요한 핵심 코드들이 속해있습니다.

저희는 app 모듈과 각 feature 모듈, :core:navigation 모듈에 속한 내비게이션 관련 코드들을 살펴보겠습니다.

:core:navigation 분석

Nav3는 단순히 List 하나만 제공합니다. 하지만 바텀 탭이 있는 앱은 탭별로 히스토리가 따로 관리(멀티 백 스택)되어야 합니다. 이를 위해 NIA 팀은 NavigationState라는 커스텀 클래스를 정의했습니다.

1. NavigationState


/**
 * State holder for navigation state.
 *
 * @param startKey - the starting navigation key. The user will exit the app through this key.
 * @param topLevelStack - the top level back stack. It holds only top level keys.
 * @param subStacks - the back stacks for each top level key
 */
class NavigationState(
    val startKey: NavKey,
    val topLevelStack: NavBackStack<NavKey>,
    val subStacks: Map<NavKey, NavBackStack<NavKey>>,
) {
	// 현재 보고 있는 탭이 무엇인지 계산 (탭 스택의 맨 마지막 녀석)
    val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }

    // 전체 탭 목록 (Map의 키들)
    val topLevelKeys
        get() = subStacks.keys

    // 현재 활성화된 탭의 '내부 스택'을 가져옴
    // 예: 현재 'Saved' 탭이라면, Saved 탭 전용 리스트를 반환
    @get:VisibleForTesting
    val currentSubStack: NavBackStack<NavKey>
        get() = subStacks[currentTopLevelKey]
            ?: error("Sub stack for $currentTopLevelKey does not exist")

    // 최종적으로 사용자가 보고 있는 화면 (현재 서브 스택의 맨 위)
    @get:VisibleForTesting
    val currentKey: NavKey by derivedStateOf { currentSubStack.last() }
}
  • 파라미터 topLevelStacksubStacks 를 보면 유추할 수 있듯이, 이 클래스의 핵심은 '멀티 백스택' 관리입니다.
  • 최상위 화면(내비게이션바의 탭)들이 리스트로 화면 목록이 쌓이거나 삭제되는 최상위 백스택인 topLevelStack
  • 각 최상위 화면의 하위 화면들의 목록을 관리하는 맵인 subStacks 입니다. map의 key는 각 최상위화면(탭)의 NavKey가 됩니다.
  • 이는 Nav3가 '자유로운 리스트 관리'를 허용했기에 가능한 구조입니다.

2. rememberNavigationState

이 함수는 Composable 함수 내부에서 NavigationState 객체를 생성하고, 화면 회전(Config Change)이나 재생성 시에도 유지되도록 합니다.

@Composable
fun rememberNavigationState(
    startKey: NavKey,       // 앱의 시작점 (예: ForYou 탭)
    topLevelKeys: Set<NavKey>, // 바텀 탭으로 쓸 최상위 키들 (ForYou, Saved, Interests)
): NavigationState {
    // 1. 탭의 이동 경로를 기억하는 스택
    // 예: [ForYou] -> [Saved] -> [Interests] 순서로 탭을 눌렀다면 그 순서가 저장됨
    val topLevelStack = rememberNavBackStack(startKey)

    // 2. 각 탭마다 독립적인 스택을 미리 만들어둠 (Map 구조)
    // 예: { ForYou: [List, Detail], Saved: [List], Interests: [List] }
    val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }

    // 3. NavigationState 객체 생성 및 remember
    return remember(startKey, topLevelKeys) {
        NavigationState(
            startKey = startKey,
            topLevelStack = topLevelStack,
            subStacks = subStacks,
        )
    }
}
  • topLevelStack을 rememberNavBackStack 를 통해 생성합니다. 이처럼 개발자가 직접 백스택을 생성하고 관리하는 것은 Nav3의 대표적인 특징입니다.

  • 각 탭별 하위 백스택을 subStacks로 생성합니다.


3. NavigationState.toEntries

이 함수가 기술적으로 가장 중요합니다.

NavigationState는 우리가 만든 커스텀 객체이므로, 화면을 그리는 NavDisplay는 이를 이해하지 못합니다.
따라서 우리의 커스텀 상태를 Nav3가 이해할 수 있는 List<NavEntry> 형태로 변환해주는 어댑터가 필요합니다.

@Composable
fun NavigationState.toEntries(
    entryProvider: (NavKey) -> NavEntry<NavKey>, // "어떤 Key가 오면 어떤 UI를 그릴지" 매핑 정보
): SnapshotStateList<NavEntry<NavKey>> {
    
    // 1. 모든 서브 스택(각 탭의 스택들)을 순회하면서 '기능'을 덧붙임 (Decorate)
    val decoratedEntries = subStacks.mapValues { (_, stack) ->
        val decorators = listOf(
            // 화면이 회전되어도 상태를 기억하게 해줌
            rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
            // 각 화면마다 ViewModel을 가질 수 있게 해줌 (이게 없으면 ViewModel 못 씀)
            rememberViewModelStoreNavEntryDecorator<NavKey>(),
        )
        
        // 깡통 스택(stack)에 데코레이터와 UI매핑(entryProvider)을 결합하여
        // "기능이 탑재된 NavEntry 리스트"를 만듦
        rememberDecoratedNavEntries(
            backStack = stack,
            entryDecorators = decorators,
            entryProvider = entryProvider,
        )
    }

    // 2. 현재 활성화된 탭들의 스택만 모아서(flatMap) 하나의 리스트로 반환
    // NavDisplay는 결국 1차원 리스트만 보여주기 때문
    return topLevelStack
        .flatMap { decoratedEntries[it] ?: emptyList() }
        .toMutableStateList()
}
  • NavigationState.subStacks 을 순회하면서 데코레이터를 붙입니다. 이전 글에서도 살펴봤는데요. Nav3의 NavEntry 모든 기능을 다 가지고 있다기보단, 필수적인 최소 정보(NavKey와 key에 맞는 컴포저블 정보)만을 가지고 있는 상태입니다. 부가적인 기능은 개발자가 데코레이터로 추가합니다.

  • 여기선 ViewModelStore(뷰모델 저장소)와 SaveableStateHolder(상태 저장소) 기능을 수동으로 주입하고 있습니다. Nav2에선 backStackEntry가 기본적으로 이 모든 기능을 가지고 있었습니다.


상태(Data)가 있다면 이를 조작할 기능이 필요합니다.
Navigator 클래스는 탭 전환, 중복 방지, 뒤로 가기 등의 비즈니스 로직을 담당합니다.

Nav3는 push나 pop 같은 단순한 도구만 제공하므로, "탭을 눌렀을 때 어떻게 동작할지", "뒤로가기를 눌렀을 때 탭을 건너뛸지 말지" 같은 실제 앱의 이동 규칙을 이 클래스에서 정의하고 있습니다.

class Navigator(val state: NavigationState) {

    fun navigate(key: NavKey) {
		...
    }

    fun goBack() {
        ...
    }

    private fun goToKey(key: NavKey) {
        ...
    }

    private fun goToTopLevel(key: NavKey) {
        ...
    }

    private fun clearSubStack() {
        ...
    }
}

1. navigate(key: NavKey) 함수

fun navigate(key: NavKey) {
    when (key) {
        // Case 1: 이미 보고 있는 탭을 또 눌렀을 때 (Reselection)
        state.currentTopLevelKey -> clearSubStack()
        
        // Case 2: 다른 탭(Top Level)을 눌렀을 때 (Tab Switching)
        in state.topLevelKeys -> goToTopLevel(key)
        
        // Case 3: 일반 화면(Detail 등)으로 이동할 때 (Push)
        else -> goToKey(key)
    }
}
  • 가장 중요한 진입점 함수입니다. 들어온 key가 무엇이냐에 따라 3가지 동작으로 분기합니다.

  • Case 1 (Reselection): 사용자가 현재 활성화된 '홈' 탭 아이콘을 또 누르면, 보통 스크롤이 위로 가거나 초기 화면으로 돌아가죠? clearSubStack()이 그 역할을 합니다. (홈 -> 디테일 -> 더보기 상태에서 '홈' 누르면 -> 홈 초기화면으로 리셋)

  • Case 2 (Tab Switching): 하단 바의 다른 아이콘을 누르면 goToTopLevel로 탭을 교체합니다.

  • Case 3 (Push): 리스트 아이템 클릭 등 일반적인 이동입니다. 현재 탭의 스택 위에 쌓습니다.


2. goBack() 함수
단순히 화면만 끄는 게 아니라, "화면이 없으면 이전 탭으로 이동"하는 로직이 들어있습니다.

fun goBack() {
    when (state.currentKey) {
        // 1. 시작점(홈)이면 더 이상 갈 곳 없음 -> 에러 (또는 앱 종료)
        state.startKey -> error("You cannot go back from the start route")
        
        // 2. 현재 탭의 Root 화면까지 왔다면?
        // -> 이전에 방문했던 '탭'으로 이동
        state.currentTopLevelKey -> {
            state.topLevelStack.removeLastOrNull()
        }
        
        // 3. 탭 내부에 쌓인 화면이 있다면?
        // -> 그냥 화면 하나 끄기 (Pop)
        else -> state.currentSubStack.removeLastOrNull()
    }
}

굉장히 직관적입니다.


3. 내부 조작함수들
이들은 실제로 백스택 리스트를 조작하는 역할입니다. Nav3의 "리스트 조작 == 네비게이션" 철학이 가장 잘 드러나는 곳입니다.

goToKey() 는 중복을 방지하며 다른 화면으로 이동합니다.

private fun goToKey(key: NavKey) {
    state.currentSubStack.apply {
        remove(key) // 이미 스택에 있으면 제거하고
        add(key)    // 맨 위에 다시 추가 (Reorder)
    }
}
  • A -> B -> C 상태에서 다시 B로 이동하면, A -> B -> C -> B가 되는 게 아니라 A -> C -> B가 되거나 순서를 조정하여 같은 화면이 중복으로 쌓이는 것을 방지합니다. (SingleTop 동작과 유사)

goToTopLevel(key) 는 탭의 히스토리를 관리합니다.

private fun goToTopLevel(key: NavKey) {
    state.topLevelStack.apply {
        if (key == state.startKey) {
            clear() // 홈(StartKey)으로 가면 탭 히스토리 싹 날림 (초기화)
        } else {
            remove(key) // 다른 탭은 순서만 맨 위로
        }
        add(key)
    }
}
  • 홈으로 돌아가면 이전 탭 방문 기록을 지워서 백 스택이 너무 깊어지는 것을 방지합니다.

clearSubStack() 은 현재 탭의 서브 백스택을 초기화합니다.

private fun clearSubStack() {
    state.currentSubStack.run {
        // 0번(Root) 빼고 다 지워라
        if (size > 1) subList(1, size).clear()
    }
}
  • [홈 메인 -> 디테일 -> 댓글] 상태에서 탭을 다시 눌렀을 때 [홈 메인]만 남기고 싹 정리해주는 기능입니다.

결론적으로 Navigator 클래스는 Nav3가 제공하지 않는 "앱 특화 이동 규칙"을 정의한 것입니다.

  • 3단 분기 Maps: 탭 재선택(Reset), 탭 이동(Switch), 화면 이동(Push)을 구분하여 처리합니다.

  • 똑똑한 goBack: 화면 스택이 비면 이전 탭으로 이동하는 탭 간 히스토리를 지원합니다.

  • 리스트 조작: remove, add, subList().clear() 같은 표준 리스트 함수를 사용하여 복잡한 네비게이션 동작(SingleTop, PopUpTo)을 직관적으로 구현했습니다.



feature 모듈

다음읜 feature 모듈 별로 Navigation 3 에 필요한 코드들을 어떻게 구현했는지 살펴보겠습니다.


@Serializable
object ForYouNavKey : NavKey


fun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {
    // 1. 등록: "ForYouNavKey라는 키가 들어오면..."
    entry<ForYouNavKey> {
        // 2. 연결: "ForYouScreen을 그려라."
        ForYouScreen(
            // 3. 주입: "화면 이동이 필요하면 navigator의 이 함수를 써라."
            onTopicClick = navigator::navigateToTopic,
        )
    }
}
  • NavKey 인터페이스를 상속받아 Serializable한 ForYouNavKey 를 만듭니다.
  • NavKey 인터페이스의 주석에도 잘 나와있지만, NavKey를 구현하는 클래스/오브젝트는 필수적으로 @Serializable 어노테이션을 붙여야합니다.
  • EntryProviderScope의 확장함수를 정의합니다. 이 함수는 entryProvider { .. } 라는 DSL 내부에서 사용하기 위한 함수입니다.
  • 내부에선 ForYouNavKey와 ForYouScreen 을 1대1로 매칭합니다.
  • 의존성 분리: App 모듈은 ForYouScreen 라는 컴포저블의 존재나 파라미터를 알 필요가 없습니다. 단지 forYouEntry(navigator) 한 줄만 호출하면 됩니다.
  • Nav2에선 여기에 ForYouScreen 로 이동하는 함수와, ForYouScreen 를 NavGraph에 등록하는 함수가 필요했습니다. Nav3에선 더 간단해졌습니다.

app 모듈

마지막으로 이 모든 부품을 조립하는 App 모듈입니다.

NiaAppState

NiaAppState는 앱의 전반적인 상태를 관리하기 위한 클래스입니다.

@Stable
class NiaAppState(
    val navigationState: NavigationState,
    coroutineScope: CoroutineScope,
    networkMonitor: NetworkMonitor,
    userNewsResourceRepository: UserNewsResourceRepository,
    timeZoneMonitor: TimeZoneMonitor,
) {
    ...
}

내부에 navigationState를 파라미터로 받고 있습니다.

  • 기존 Nav2에서는 NavController가 백 스택을 독점 관리했지만, 여기서는 NiaAppState가 NavigationState를 멤버 변수로 들고 있습니다.
  • 즉, 네비게이션 상태가 네트워크 상태 유저데이터 등과 동등한 레벨의 일반적인 앱 상태(State)로 취급됨을 알 수 있습니다.

NiaApp

MainActivity에서 호출하는 앱의 시작점입니다.

internal fun NiaApp(appState: NiaAppState, ...) {
    val navigator = remember { Navigator(appState.navigationState) }

    // ... Scaffold UI ...

    // [Nav3의 핵심: 적응형 레이아웃 전략]
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    // [메뉴판 구성] 각 모듈의 확장 함수 호출
    val entryProvider = entryProvider {
        forYouEntry(navigator)
        bookmarksEntry(navigator)
        // ...
    }

    // [렌더링]
    NavDisplay(
        // 커스텀 상태 -> NavEntry 리스트 변환
        entries = appState.navigationState.toEntries(entryProvider),
        // 적응형 전략 주입
        sceneStrategy = listDetailStrategy,
        onBack = { navigator.goBack() },
    )
}
  • NavigationState가 포함된 AppState를 파라미터로 받습니다.
  • 적응형 레이아웃 구현을 위한 WindowAdaptiveInfo 도 받습니다.
  • 내비게이션 컨트롤러인 Navigator 를 정의합니다.
  • entryProvider 를 통해 "어떤 키(Key)가 오면 어떤 화면(Entry)을 보여줄지" 매핑만 등록합니다.
  • 각 feature 모듈에 EntryProviderScope의 확장함수로 정의한 함수들을 사용합니다.
  • NavDisplay 로 화면을 그립니다.
  • NavigationState에서 확장함수로 만들었던 toEntries 를 통해 NavDisplay의 entries 파라미터에 맞는 형태(List<NavEntry<T>>)로 변환합니다.
  • listDetailStrategy 는 적응형 레이아웃 전략입니다. rememberListDetailSceneStrategy를 사용하여 화면 크기에 따라 리스트-디테일 구조를 자동으로 처리합니다. Nav3의 NavDisplay는 SceneStrategy를 통해 여러 화면을 동시에 렌더링할 수 있습니다.
  • Nav3의 적응형 레이아웃 대응 방법이 잘 드러났습니다.

마치며

Navigation3 라이브러리 활용법을 알아보고자 대표적인 샘플 프로젝트인 NIA 를 살펴보았습니다.

NIA는 Navigator와 NavigationState 같은 커스텀 요소를 직접 설계하여, 멀티 백 스택이나 적응형 UI 같은 복잡한 요구사항을 효울적으로 해결하고 있었습니다.

이는 Nav3가 기존의 경직된 구조를 버리고, 개발자에게 높은 유연성과 제어권을 돌려주었기에 가능한 설계였습니다.

결국 중요한 것은 NIA의 코드를 그대로 복사하는 것이 아니라, Nav3를 이해하고 자신의 프로젝트 상황에 맞게 적용해 나가는 능력일 것입니다.

이 글이 여러분의 프로젝트에 Navigation3를 도입하는 데 도움이 되길 바랍니다.

참고

https://github.com/android/nowinandroid

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

1개의 댓글

comment-user-thumbnail
2026년 2월 10일

덕분에 깊이 이해할 수 있었습니다~

답글 달기