아티클 목록으로 가기

Navigation 3의 내부 동작 원리

skydovesJaewoong Eum (skydoves)||10분 소요

Navigation 3의 내부 동작 원리

Navigation 3는 Jetpack의 Compose 전용 내비게이션을 처음부터 새로 설계한 라이브러리입니다. Navigation 2가 Fragment 기반 내비게이션 모델을 NavController와 XML 그래프 정의를 통해 Compose에 적용한 것과 달리, Navigation 3는 Compose의 기본 요소(primitive) 위에 전적으로 구축되었습니다. 백 스택(back stack)은 SnapshotStateList이고, 각 항목은 불변 data class이며, 렌더링 파이프라인에는 AnimatedContent를 활용한 화면 전환이 적용됩니다. 그 결과, Compose 위에 레이어를 얹은 것이 아니라 Compose 자체에 자연스럽게 녹아든 내비게이션 라이브러리가 탄생했습니다.

이 글에서는 NavBackStack이 스냅샷 시스템(snapshot system)과 어떻게 통합되어 내비게이션을 반응형(reactive)으로 만드는지, NavEntrycontentKey 기반 상태 범위 지정을 통해 각 목적지(destination)별 UI 상태를 보존하는 방식, NavEntryDecorator 패턴이 상태 영속성과 생명주기 관리를 가능하게 하는 구조, rememberDecoratedNavEntries가 애니메이션 진행 중에도 항목 생명주기를 추적하는 메커니즘, 씬 전략(scene strategy)과 AnimatedContent를 활용한 NavDisplay 렌더링 파이프라인, 그리고 예측형 뒤로 가기 제스처(predictive back gesture)가 탐색 가능한 전환(seekable transition)을 구동하는 원리까지 살펴보겠습니다.

핵심 문제: 내비게이션을 Compose 상태로 다루기

Navigation 2는 Fragment 시대의 유산을 그대로 가지고 있었습니다. NavController가 내부 상태를 명령형(imperative)으로 관리하는 방식이었기 때문에, 개발자가 navigate()popBackStack()을 호출하면 컨트롤러가 알아서 어떤 화면을 표시할지 결정했습니다. 이 방식이 동작하기는 했지만, UI가 상태의 함수인 Compose의 선언형(declarative) 모델과 충돌하는 구조적 한계가 있었습니다. 개발자는 Compose의 "상태가 UI를 결정한다"는 패러다임과 Navigation 2의 "메서드를 호출하여 화면을 전환한다"는 패러다임, 이 두 가지 서로 다른 멘탈 모델을 동시에 다루어야 했습니다.

Navigation 3는 백 스택 자체를 Compose 상태로 만듦으로써 이 문제를 근본적으로 해결합니다. NavController가 존재하지 않습니다. 백 스택은 SnapshotStateList이며, 변경(mutation)이 발생하면 리컴포지션(Recomposition)을 자동으로 트리거합니다. 내비게이션이 곧 리스트 연산이 되는 것입니다.

// 내비게이션 (화면 이동)
backStack.add(DetailScreen(itemId = "123"))

// 뒤로 가기 (pop)
backStack.removeLast()

// 교체 (replace)
backStack[backStack.lastIndex] = EditScreen(itemId = "123")

이것이 바로 라이브러리의 모든 설계 결정을 관통하는 핵심 원칙입니다. 백 스택이 Compose 상태 안에 있는 단순한 리스트이기 때문에, 프레임워크가 이를 관찰(observe)하고, 직렬화(serialize)하며, 변경 사항에 자동으로 반응할 수 있습니다. 기존의 명령형 API 호출 없이도 상태 변화만으로 내비게이션 흐름 전체를 제어할 수 있다는 점이 Navigation 3의 가장 큰 차별점입니다.

백 스택의 모든 요소는 NavKey를 구현해야 합니다. NavKey는 프로세스 종료(process death) 시 복구를 위해 kotlinx.serialization과 연결되는 마커 인터페이스(marker interface)입니다.

public interface NavKey

라우트(route)는 일반적으로 NavKey를 구현하는 @Serializable data class 또는 data object로 정의합니다.

@Serializable
data object Home : NavKey

@Serializable
data class Detail(val id: String) : NavKey

NavBackStack 클래스는 SnapshotStateList를 감싸며, MutableList 인터페이스와 StateObject 인터페이스를 모두 이 리스트에 위임(delegate)합니다.

@Serializable(with = NavBackStackSerializer::class)
public class NavBackStack<T : NavKey> public constructor(
    internal val base: SnapshotStateList<T>
) : MutableList<T> by base, StateObject by base, RandomAccess by base {
    public constructor(vararg elements: T) : this(base = mutableStateListOf(*elements))
}

StateObject를 구현한다는 것은 NavBackStack이 Compose의 스냅샷 시스템에 직접 참여한다는 의미입니다. add, remove, set 등 어떤 변경이 발생하든 스냅샷 시스템이 이를 추적하며, 해당 리스트를 읽는 모든 컴포저블(composable)에서 리컴포지션을 트리거합니다. NavBackStack이 내부적으로 mutableStateListOf에 위임하고 있으므로, mutableStateListOf를 반응형으로 만드는 것과 정확히 동일한 메커니즘이 동작하는 것입니다.

구성 변경(configuration change)과 프로세스 종료 시에도 백 스택을 유지하기 위해, rememberNavBackStackrememberSerializableNavBackStackSerializer를 사용합니다. 이 직렬화기(serializer)는 NavKey 하위 타입의 다형적 직렬화(polymorphic serialization)를 처리합니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기