아티클 목록으로 가기

Compose Navigation Graph: Android Studio에서 앱 전체 흐름 시각화하기

skydovesJaewoong Eum (skydoves)||16분 소요

Compose Navigation Graph: Android Studio에서 앱 전체 흐름 시각화하기

Jetpack Compose의 내비게이션(navigation)은 명령형 코드(imperative code)입니다. NavKey는 한 파일에서 선언되고, entry<Route> { Screen() } 람다(lambda)는 또 다른 파일에서 이를 컴포저블(composable)에 연결하며, 실제 화면 전환(transition)은 여러 모듈에 흩어진 클릭 핸들러 속에 파묻힌 backStack.add(...) 호출입니다. Navigation 3는 이 모든 것을 타입 안전(type-safe)하게 만들었지만, 눈에 보이게 만들지는 못했습니다. 앱의 전체 모습을 한눈에 살펴보거나, "여기에서 어디로 갈 수 있는가?" 또는 "이번 리팩터링이 흐름을 바꾸었는가?"라는 질문에 답할 수 있는 단일한 장소는 여전히 없습니다. Compose Navigation Graph는 그 모습을 빌드 타임에 정적으로 재구성하여, Android Studio 안에서 상호작용 가능한 지도로 그려 냅니다.

Compose Navigation Graph는 서로 맞물려 동작하는 네 가지 요소로 이루어진 툴킷(toolkit)입니다. 어노테이션(annotation) 모음, KSP 프로세서, Gradle 플러그인, 그리고 Android Studio와 IntelliJ IDEA를 위한 IntelliJ 플러그인이 그것입니다. 화면에 어노테이션을 달고 Gradle 태스크 하나를 실행하면, 내비게이션 전체를 담은 지도가 도구 창에 나타납니다. 모든 목적지(destination)는 렌더링된 @Preview 썸네일(thumbnail)로 나타나고, 타입이 지정된 인자(typed argument)가 빠짐없이 표시되며, 모든 화면 전환은 따라가 볼 수 있는 화살표로 그려집니다. 이 툴킷은 Navigation 3, Navigation 2, 그 밖의 모든 Compose 내비게이션 라이브러리는 물론 평범한 Activity에서도 동작하며, 에뮬레이터도 연결된 기기도 없이(device-free) 모든 화면을 렌더링합니다.

이 글에서는 이 플러그인을 처음부터 끝까지 살펴봅니다. Gradle 플러그인과 IDE 플러그인을 설치하고, @NavDestination, @NavEdge, @NavPreview, @NavGraphRoot로 화면에 어노테이션을 달고, 그래프를 생성하고 읽고, 캔버스(canvas)에서 곧바로 새로운 전환을 작성하고, 프리뷰 갤러리(preview gallery)를 둘러보고, 그래프를 HTML이나 PNG로 내보내고, 커밋된 .nav 베이스라인(baseline)으로 풀 리퀘스트(pull request)를 검증하는 과정까지, 그리고 이 모든 것이 멀티 모듈(multi-module)과 Kotlin Multiplatform 프로젝트에서 어떻게 확장되는지를 다룹니다.

Compose Navigation Graph plugin

근본적인 문제: 눈에 보이지 않는 내비게이션

전형적인 Navigation 3 설정을 살펴보겠습니다. 라우트(route)를 선언하고, NavDisplay 안에서 이를 컴포저블에 연결한 다음, 백 스택(back stack)을 변경하여 화면을 이동합니다.

@Serializable
data object Feed : NavKey

@Serializable
data class Profile(val userId: String) : NavKey

NavDisplay(backStack = backStack) {
    entry<Feed> { FeedScreen(onOpenProfile = { backStack.add(Profile(it)) }) }
    entry<Profile> { ProfileScreen(it.userId) }
}

FeedFeedScreen에 이어 주는 연결은 entry<Feed> { … } 람다 안에 들어 있습니다. Feed에서 Profile로 가는 전환은 onOpenProfile 콜백 안에 backStack.add(...) 호출의 형태로 존재합니다. 둘 다 함수 본문(function body)이며, KSP 같은 정적 프로세서는 함수 본문을 읽을 수 없습니다. 내비게이션 그래프를 볼 수 있는 기본 수단이 없는 이유가 바로 여기에 있습니다. 그래프를 정의하는 정보가 런타임에만 존재하는 명령형 코드 속에 갇혀 있기 때문입니다.

Compose Navigation Graph는 이를 명시적이고 리팩터링에도 안전한 어노테이션으로 해결합니다. 라우트와 컴포저블의 연결, 그리고 전환을 프로세서가 읽을 수 있는 어노테이션으로 한 번만 선언해 두면, 툴킷이 이를 바탕으로 전체 그래프를 재구성합니다. 어노테이션은 시각화를 위해 내비게이션을 묘사할 뿐입니다. 앱이 실제로 화면을 이동하는 방식은 전혀 바꾸지 않으므로, 내비게이션 코드는 이전과 똑같이 작성하시면 됩니다.

Gradle 플러그인 설치하기

화면이 들어 있는 모듈, 즉 :app이나 목적지를 선언하는 각 기능 모듈의 build.gradle.kts에서 KSP와 함께 플러그인을 적용합니다.

plugins {
    id("com.google.devtools.ksp") version "<matching your Kotlin version>"
    id("com.github.skydoves.navgraph") version "0.1.1"
}

설정은 이것이 전부입니다. KSP는 플러그인이 대신 적용해 줄 수 없는 단 하나의 요소인데, 그 버전이 사용하시는 Kotlin 버전에 묶여 있기 때문입니다. 그래서 com.google.devtools.ksp는 직접 명시적으로 적용합니다.

navgraph { } 블록으로 동작을 설정할 수 있으며, 모든 옵션에는 합리적인 기본값이 마련되어 있습니다.

navgraph {
    renderThumbnails.set(true)  // 기기 없이 생성하는 Layoutlib 썸네일 (기본값 true)
    variant.set("demoDebug")  // 특정 플레이버(flavor) 고정, 비워 두면 디버그 KSP 변형을 자동 감지
    failOnNavChange.set(true)  // 그래프가 어긋나면 navCheck가 빌드를 실패시킴 (기본값 true)
    galleryEnabled.set(true)  // 프리뷰 갤러리 파이프라인 (기본값 true)
}

IDE 플러그인 설치하기

IDE 플러그인은 Gradle 쪽에서 만들어 낸 그래프를 그립니다. JetBrains Marketplace에서 설치하시면 됩니다. Android Studio(또는 IntelliJ IDEA)를 열고, Settings > Plugins > Marketplace로 이동하여, Compose Navigation Graph를 검색한 다음 Install을 클릭하세요.

Install from the JetBrains Marketplace

설치가 끝나면 View > Tool Windows > NavGraph Graph를 엽니다. Graph, Previews, Author 탭이 보인다면 플러그인이 준비된 것입니다. 도구 창은 기본적으로 IDE 오른쪽에 고정됩니다.

이 모든 과정을 직접 손으로 설정하고 싶지 않으시다면, 저장소에는 LLM 코딩 에이전트를 위해 작성된 plugin-agent-guides.md 파일이 함께 들어 있습니다. 이 파일을 Claude Code, Cursor, Gemini CLI에 그대로 붙여 넣으면, 에이전트가 Gradle 플러그인을 적용하고, 화면에 어노테이션을 달고, 첫 그래프까지 생성해 줍니다. 이 글의 나머지 부분에서는 동일한 단계를 직접 수동으로 진행합니다.

화면에 어노테이션 달기

그래프는 네 가지 어노테이션으로부터 재구성됩니다. 모듈 안에 선언된 Navigation 3 NavKey 구현체는 자동으로 인식되어, 별도의 어노테이션 없이도 노드(node)가 됩니다. 그 외의 모든 것은 명시적으로 선언합니다. 최소한, 목적지를 렌더링하는 컴포저블에는 @NavDestination(route)를 붙이고, @NavPreview(route)@Preview를 연결하여 노드에 썸네일이 달리도록 합니다.

@NavGraphRoot
@NavDestination(route = Feed::class)
@NavEdge(to = Profile::class, label = "open profile")
@Composable
fun FeedScreen() { /* … */ }

@NavPreview(route = Feed::class, primary = true)
@Preview
@Composable
fun FeedScreenPreview() {
    FeedScreen()
}

각 어노테이션은 그래프에서 저마다 하나의 역할을 맡습니다.

  • @NavDestination\(route = ...\): 라우트를 렌더링하는 컴포저블을 선언합니다. 라우트 클래스는 그래프의 노드가 되고, 그 직렬화 가능한 프로퍼티는 노드의 타입 인자가 되며, 노드를 더블 클릭하면 이 컴포저블로 이동합니다.
  • @NavEdge\(to = ..., from = ..., label = ...\): 두 라우트 사이의 전환을 선언하며, 화살표로 그려집니다. 반복 적용이 가능하므로, 한 화면이 여러 개의 나가는 전환을 선언할 수 있습니다. from을 생략하면 어노테이션이 붙은 선언으로부터 추론되고, label은 화살표에 이름을 붙입니다. 가령 그 전환을 일으키는 버튼의 이름을 붙일 수 있습니다.
  • @NavGraphRoot: 흐름이 시작되는 시작 목적지를 표시합니다. IDE는 이를 별표로 강조합니다.
  • @NavPreview\(route = ..., primary = ...\): @Preview 컴포저블을 라우트에 연결하여, 렌더링된 썸네일이 노드 위에 나타나도록 합니다. 한 라우트에 여러 프리뷰가 있을 때는 primary = true가 그래프에 표시될 프리뷰를 고릅니다.

라우트는 어떤 클래스든 될 수 있습니다. NavKey를 구현할 필요가 없으므로, 기존 Navigation 2 앱이나 @NavEdge가 참조하는 평범한 Activity까지도 리팩터링 없이 그래프에 모습을 드러냅니다.

노드의 타입 인자는 라우트 클래스에서 가져오며, kotlinx.serialization이 직렬화할 때와 똑같은 방식으로 추출됩니다. 즉, 주 생성자(primary constructor)의 프로퍼티가 순서대로 들어가고, 기본값이 있는 매개변수는 선택적(optional)으로 표시되며, @Transient 프로퍼티는 제외되고, @SerialName으로 바꾼 이름은 그대로 반영됩니다.

@Serializable
data class Profile(
    val userId: String,
    val tab: Tab = Tab.Posts,
) : NavKey

이 라우트는 Profile 노드에 두 개의 인자 행을 만들어 냅니다. 하나는 String 타입의 userId이고, 다른 하나는 열거형(enum) 타입 Tabtab입니다. tab은 기본값이 있으므로 선택적으로 표시되고, userId는 필수입니다. 각 인자는 노드 위에 UML 방식으로 그려지므로, 모든 목적지가 어떤 데이터를 기대하는지 한눈에 파악하실 수 있습니다.

그래프 생성하기

플러그인을 적용하고 화면을 하나 이상 어노테이션으로 표시했다면, 살펴보고 싶은 모듈에서 진입점 태스크를 실행합니다.

./gradlew :app:generateNavGraph

이 태스크는 KSP를 실행하여 각 모듈의 노드, 타입 인자, @NavEdge 전환을 추출한 뒤, 모든 @NavPreview 화면을 PNG 썸네일로 렌더링합니다. 렌더링은 기기 없이 이루어집니다. Android Studio가 @Preview를 그릴 때 쓰는 것과 동일한 엔진인 Layoutlib을 사용하며, Layoutlib이 처리하지 못하는 프리뷰에는 Robolectric 폴백(fallback)을 적용합니다. 에뮬레이터도 기기도 관여하지 않습니다. 마지막으로, 모듈이 의존하는 그래프들을 하나로 합쳐 결합된 그래프를 만들어 내므로, 모든 기능 모듈에 의존하는 최상위 :app은 앱 전체의 그래프를 얻게 됩니다. 그리고 그 결과를 build/navgraph/nav-graph.json에 기록하고, 썸네일은 build/navgraph/thumbs/ 아래에 저장합니다.

처음 한 번 이후로는 이 태스크를 직접 실행할 일이 거의 없습니다. 도구 창의 새로 고침 버튼이 generateNavGraph를 대신 실행하고 결과를 다시 불러오므로, IDE 안에 머무르신 채로 작업하실 수 있습니다.

그래프 읽기

View > Tool Windows > NavGraph Graph를 열면, Graph 탭이 앱의 흐름을 네이티브 방식의 상호작용 가능한 캔버스로 그려 냅니다. 각 노드는 하나의 목적지이며, 위쪽에는 렌더링된 썸네일이, 아래쪽에는 타입 인자가 나열됩니다. 곡선 화살표가 전환을 나타내고, 시작 목적지는 강조 테두리와 별표로 표시됩니다.

The NavGraph Graph tool window

캔버스는 한 번에 한 모듈씩이 아니라 앱 전체를 보여 줍니다. Gradle 플러그인이 각 모듈의 그래프를 추출하여 병합하므로, :feature-feed에 선언된 엣지(edge)가 :feature-profile의 화면을 가리키면, 런타임에서 일어나는 그대로 모듈 경계를 가로질러 그려집니다. 저장소에 앱이 둘 이상 있다면, 각 앱이 선택 가능한 별도의 스코프(scope)로 로드되며, 도구 창은 마지막으로 고르신 앱을 기억합니다.

세 가지 상호작용은 그래프를 단순히 바라보기만 하는 대상이 아니라, 코드베이스를 누비는 수단으로 바꿔 줍니다.

  • 노드를 더블 클릭하면 그 소스, 즉 @NavDestination(route)가 붙은 컴포저블로 이동합니다. 지도에서 관심 있는 화면을 찾아 더블 클릭하면 곧바로 코드에 도착합니다.
  • 드래그로 이동하고 휠로 확대·축소하거나, 구석에 있는 확대·축소 버튼을 사용합니다. 그래프는 처음 로드될 때 뷰포트(viewport)에 자동으로 맞춰집니다.
  • 툴바의 Device 콤보 박스는 모든 썸네일을 선택한 기기의 화면 비율(aspect ratio)에 맞춰 다시 잡아 줍니다. Pixel, Galaxy, iPhone, iPad 등을 고를 수 있습니다. 기본값인 Auto는 각 썸네일을 렌더링된 프리뷰 본래 크기 그대로 유지하며, 여기에서 고르신 값이 내보내기의 기본 프레이밍이 됩니다.

캔버스에서 전환 작성하기

그래프는 읽기 전용이 아닙니다. 캔버스에서 직접 전환을 작성하면, 플러그인이 어노테이션을 대신 써 넣습니다. 노드 위에 마우스를 올리면 오른쪽 가장자리에 커넥터 핸들(connector handle)이 나타나는데, 이를 눌러 다른 노드 위로 끌어다 놓으면 됩니다. 플러그인은 PSI를 통해 알맞은 @NavEdge(to = ...)를 소스의 Kotlin 코드에 삽입하므로, 그 결과는 관용적이고 올바른 위치에 놓인 소스 코드가 되어 다른 변경 사항과 똑같이 리뷰하실 수 있습니다. 그런 다음 플러그인이 그래프를 새로 고치고 새 화살표를 그립니다.

Authoring a transition from the canvas

목적지를 추가하는 것도 같은 방식으로 동작합니다. 플러그인이 새 라우트 클래스와 그에 딸린 @NavDestination 화면 스텁(stub)을 스캐폴딩(scaffold)하면, 노드가 캔버스에 나타납니다. 같은 편집 작업을 마우스 오른쪽 버튼 메뉴에서도 할 수 있습니다.

The right click context menu

  • **Add Transition from Here…**는 모든 화면을 나열하므로, 드래그하지 않고도 검색 가능한 목록에서 대상을 고를 수 있습니다.
  • **Add Destination…**는 이름을 물어본 뒤, 완전히 새로운 라우트 클래스와 어노테이션이 붙은 화면을 스캐폴딩합니다.
  • **Wire This Up…**은 고아 노드(orphan node), 즉 참조되고는 있지만 아직 @NavDestination이 없는 라우트에 나타나며, 그 화면 컴포저블을 스캐폴딩합니다.
  • Go to Destination은 더블 클릭과 마찬가지로 노드의 소스로 이동합니다.

이로써 지도와 코드 사이의 고리가 완성됩니다. 흐름을 시각적으로 스케치하더라도, 어노테이션은 여전히 유일한 진실 공급원(single source of truth)으로 남습니다.

프리뷰 갤러리

Previews 탭은 그래프를 위해 어노테이션을 단 화면뿐만 아니라, 프로젝트 안의 모든 @Preview를 모듈과 패키지별로 묶어 렌더링합니다. 이 탭은 디자인 시스템을 살아 있는 형태로 한눈에 보여 줍니다. 모든 화면과 컴포넌트를 한눈에 훑어볼 수 있고, 아무 프리뷰나 더블 클릭하면 그 소스로 이동합니다.

The preview gallery

몇 가지는 자동으로 처리됩니다. 멀티프리뷰 메타 어노테이션(multipreview meta annotation)이 펼쳐지므로, 커스텀 @ThemePreviews가 붙은 컴포저블은 그것이 선언하는 내부 @Preview 하나당 한 번씩 나타납니다. @PreviewParameter 공급자도 그대로 반영되어, 샘플 데이터로 구동되는 프리뷰는 그 데이터로 렌더링됩니다. 그리고 모든 모듈이 빠짐없이 기여하여, 모듈마다 섹션 헤더를 단 하나의 뷰로 병합됩니다.

갤러리는 그래프 썸네일과 똑같은, 기기 없는 Layoutlib 파이프라인 위에서 동작합니다. 탭의 새로 고침 버튼이 generatePreviewGallery를 대신 실행해 주며, 명령줄에서 직접 실행할 수도 있습니다.

./gradlew :app:generatePreviewGallery

이 갤러리 태스크들은 요청할 때만 실행됩니다. generateNavGraphcheck의 일부로는 절대 실행되지 않으므로, 직접 요청하지 않는 한 아무 비용도 들지 않습니다.

그래프 내보내기

도구 창에 나타나는 모든 것은 독립적인 산출물(artifact)로 IDE 바깥으로 내보낼 수 있어, 풀 리퀘스트나 디자인 리뷰, 팀 위키에 곧바로 넣을 수 있습니다. 툴바의 Export 동작은 그래프를 캔버스 전체를 담은 한 장의 PNG로 저장하거나, 브라우저에서 이동·확대·필터링할 수 있는 상호작용형 HTML 페이지로 저장합니다. 같은 내보내기 기능을 Gradle 태스크로도 사용할 수 있습니다.

./gradlew :app:exportNavGraphHtml  # 독립 실행형 상호작용 HTML 캔버스
./gradlew :app:exportNavGraphImage  # 그래프 전체를 담은 한 장의 PNG

HTML 내보내기는 그 자체로 완결된(self-contained) 페이지입니다. 흐름 지도를 이동하고 확대하고, 라우트를 필터링하고, 모든 인자와 전환, 소스 위치가 담긴 화면 표를 읽을 수 있습니다.

The interactive HTML export

프리뷰 갤러리도 같은 방식으로, 브라우저에서 둘러볼 수 있는 HTML 갤러리나 한 장의 PNG 컨택트 시트(contact sheet)로 내보낼 수 있습니다.

./gradlew :app:exportPreviewGalleryHtml  # 독립 실행형 HTML 갤러리
./gradlew :app:exportPreviewGalleryImage  # 한 장의 PNG 컨택트 시트

The HTML preview gallery export

풀 리퀘스트에서 내비게이션 검증하기

내비게이션 변경은 그렇지 않으면 리뷰에서 눈에 띄지 않습니다. 새 목적지, 바뀐 인자 타입, 추가되거나 제거된 전환, 이 가운데 무엇도 UI 코드로 가득한 diff 속에서는 두드러지지 않습니다. Compose Navigation Graph는 apiDumpapiCheck의 발상을 빌려 와 내비게이션에 적용합니다. .nav 베이스라인을 커밋해 두면, 이후 git diff가 내비게이션이 어떻게 바뀌었는지를 정확히 보여 줍니다.

두 개의 태스크가 베이스라인을 관리합니다.

./gradlew :app:navDump  # 커밋된 베이스라인을 기록하거나 갱신 (app/nav/app.nav)
./gradlew :app:navCheck  # 베이스라인을 검증, check에 연결되어 어긋나면 실패함

두 태스크 모두 정적으로 추출된 그래프를 직접 읽을 뿐 썸네일은 전혀 렌더링하지 않으므로, 매번 check를 돌릴 때와 모든 CI 빌드에서 실행할 만큼 충분히 빠릅니다. 커밋되는 베이스라인은 의도적으로 사람이 읽기 좋게 만들어져 있어, 풀 리퀘스트 diff가 앱 흐름을 설명하는 글처럼 읽힙니다.

dest Article  args=(id: String, section: Section = …, query: String? = …)
dest Home  start
dest Profile  args=(userId: String)
edge Feed -> Profile
edge Home -> Feed
edge Profile -> Article  "Open article"
edge Settings -> Home  "home"

그래프가 베이스라인과 어긋나면, navCheck는 제거된 항목과 추가된 항목을 diff로 출력하고 빌드를 실패시킵니다. 그래서 변경 사항을 반드시 리뷰하게 되고, navDump로 베이스라인을 의도적으로 갱신하게 됩니다.

navgraph: navigation graph changed — app/nav/app.nav is out of date:

  - edge Profile -> Settings
  + dest Onboarding
  + edge Home -> Onboarding  "first run"

Run :app:navDump to update the baseline, then review the diff.

두 가지 설정으로 이를 실제 팀 환경에 맞게 조정할 수 있습니다. 하나는 CI에서는 엄격하게, 로컬에서는 경고만 내도록 하는 것이고, 다른 하나는 모듈별로 점진적으로 도입하는 것입니다.

navgraph {
    failOnNavChange.set(System.getenv("CI") == "true")  // CI에서는 실패, 로컬에서는 경고
    allowMissingBaseline.set(true)  // 아직 베이스라인이 없는 모듈은 건너뜀
}

멀티 모듈과 Kotlin Multiplatform

이 플러그인은 실제 프로젝트 구조를 염두에 두고 만들어졌습니다. 멀티 모듈 앱에서는 :app 모듈 자체에 어노테이션이 없더라도, 각 모듈의 그래프가 독립적으로 추출되어 하나의 캔버스로 병합됩니다. 그래서 모듈 경계를 가로지르는 엣지까지 포함하여 앱 전체를 보실 수 있습니다.

Compose Navigation Graph는 Kotlin Multiplatform에서도 별도 설정 없이 동작합니다. 어노테이션은 commonMain에 자리하며 Android, JVM, iOS, JS, wasmJs용으로 배포되고, Gradle 플러그인은 모듈 구조를 감지하여 알맞은 KSP 패스를 자동으로 연결합니다. Android 타깃이 있는 KMP 모듈에서는 Android 컴파일에서 그래프를 추출하며, 사용하는 앱의 병합된 Compose 리소스를 재활용하여 모든 화면이 여전히 썸네일을 얻습니다. Android가 없는 KMP 모듈에서는 썸네일이 없는, 구조만 담은 그래프, 즉 노드와 타입 인자, 전환만 추출됩니다.

저장소는 이를 실제 앱으로 보여 줍니다. samples/sample-kotlinconf 모듈은 JetBrains의 Compose Multiplatform KotlinConf 앱에 플러그인을 적용하여 완전한 그래프를 그려 냅니다. 화면 26개, 전환 36개에 모든 썸네일이 빠짐없이 포함되어 있습니다.

그래프는 여러분의 내비게이션 라이브러리를 결코 읽지 않습니다. 그래프가 읽는 것은 네 가지 어노테이션이며, 라우트란 그 어노테이션이 가리키는 클래스일 뿐입니다. Navigation 3의 NavKey 타입은 자동으로 인식되는 유일한 경우인데, 프로세서가 NavKey 마커(marker)를 알고 있기 때문입니다. 그 외의 모든 클래스는 NavKey 구현 여부와 무관하게, route, from, to 인자에 이름이 적히는 것만으로 노드가 됩니다. 바로 이 단 하나의 규칙 덕분에, 하나의 그래프가 Navigation 3, 평범한 Activity, Navigation 2, 그리고 Voyager나 Decompose 같은 라이브러리를 동시에 아우를 수 있습니다.

Navigation 3는 선언할 것이 가장 적은 경우입니다. data objectdata class 구현체를 가진 @Serializable sealed interface : NavKey는 자동으로 노드 집합이 되며, 이들이 NavKey 타입이기 때문에 각 구현체의 직렬화 가능한 프로퍼티가 노드의 타입 인자로 추출됩니다.

@Serializable
sealed interface AppKey : NavKey

@NavGraphRoot
@Serializable
data object Home : AppKey

@Serializable
data class Profile(val userId: String) : AppKey

HomeProfile은 선언하는 순간 캔버스에 올라오고, Profile에는 userId: String 인자 행이 표시됩니다. 노드에 더블 클릭 대상을 부여하려면 여전히 @NavDestination을, 전환을 그리려면 @NavEdge를, 썸네일을 붙이려면 @NavPreview를 추가해야 하지만, 노드 자체는 거저 생깁니다.

평범한 Activity는 아무것도 구현하지 않고도 같은 그래프에 합류합니다. 샘플 앱에서 ProfileScreen은 또 다른 ComponentActivity로 이어지며, 단 하나의 @NavEdge가 경계를 넘는 그 이동을 그려 냅니다.

@NavEdge(to = Article::class, label = "Test Label")
@NavEdge(to = Settings::class)
@NavEdge(to = ProfileDetailActivity::class, label = "View Detail")
@NavDestination(route = Profile::class)
@Composable
fun ProfileScreen(userId: String, /* … */) { /* … */ }

그 Activity 노드에 고유한 더블 클릭 대상과 썸네일을 부여하려면, 다른 화면과 똑같이 그 Activity가 품고 있는 컴포저블에 어노테이션을 답니다.

@NavDestination(route = ProfileDetailActivity::class)
@Composable
fun ProfileDetailScreen() { /* … */ }

@NavPreview(route = ProfileDetailActivity::class, primary = true)
@Preview
@Composable
fun ProfileDetailPreview() {
    ProfileDetailScreen()
}

ProfileDetailActivity가 노드인 이유는 NavKey를 구현해서가 아니라, 세 개의 어노테이션이 그것을 이름으로 가리키기 때문입니다. NavKey가 아닌 노드는 타입 인자를 갖지 않는데, 프로세서가 임의의 클래스 필드까지 캐내지는 않기 때문입니다. 하지만 그런 노드도 썸네일을 렌더링하고, 소스로 연결되며, 다른 목적지와 똑같이 지도 위에 자리합니다.

**Navigation 2 라우트, Voyager 화면, Decompose 컴포넌트, 또는 프래그먼트(fragment)**도 같은 문을 통해 연결됩니다. 프로세서는 NavController 그래프나 composable("route") 빌더, 그 어떤 라이브러리의 런타임 연결도 파싱하지 않습니다. 실제 내비게이션 코드 옆에 어노테이션 모델을 프로젝트당 한 번만 선언하고, 이미 라우트로 사용 중인 클래스를 어노테이션으로 가리키기만 하면 됩니다.

@Serializable
data class SearchResults(val query: String)

@NavEdge(to = SearchResults::class, label = "search")
@NavDestination(route = HomeRoute::class)
@Composable
fun HomeScreen() { /* … */ }

여기서 HomeRouteSearchResultsNavKey 타입이 아니라 평범한 Navigation 2 라우트입니다. 이 둘은 search 엣지로 이어진 노드로 나타나고, Navigation 3 화면에 하던 것과 똑같이 @NavPreview로 어느 쪽에든 썸네일을 붙일 수 있으며, 단 하나 얻지 못하는 것은 NavKey 라우트에서만 추출되는 타입 인자 행입니다. 내비게이션 코드는 그대로 둡니다. 즉, navController.navigate(...)를 계속 호출하면 되고, 어노테이션은 그와 나란히 존재하는, 툴킷이 그려 내는 읽기 좋은 설명일 뿐입니다. 한 가지 기억해 두실 점이 있습니다. NavKey를 구현하지 않는 클래스는 어노테이션이 참조할 때만 노드가 되므로, 어노테이션도 참조도 없는 라우트는 저절로 나타나지 않습니다.

혼합 형태의 앱이 하나로 연결된 그림으로 그려지는 이유가 바로 여기에 있습니다. NavKey 목적지, Navigation 2 라우트, Activity가 같은 그래프를 공유하며 서로를 가로지르는 엣지로 이어질 수 있는데, 프로세서에게 이들은 모두 노드가 가리키는 하나의 클래스일 뿐이기 때문입니다.

다음 단계: Compose HotSwan과 실시간 백 스택

지금까지의 모든 것은 빌드 타임에 일어납니다. 그래프는 정적으로 추출되고, 썸네일은 기기 없이 렌더링되며, 노드를 더블 클릭하면 그 소스 코드로 이동합니다. 이 지도는 앱을 묘사하지만, 앱이 실행되는 동안 그 안으로 손을 뻗치지는 못합니다.

그것이 바로 다음으로 눈여겨볼 만한 방향이며, 저자가 만든 Android용 Jetpack Compose 핫 리로드(hot reload) 플러그인인 Compose HotSwan과 자연스럽게 맞물립니다. HotSwan은 실제 기기나 에뮬레이터에서 Compose 코드를 몇 초 만에 핫 리로드합니다. 앱이 내비게이션과 런타임 상태를 그대로 유지한 채 UI 변경이 즉시 반영되며, 재설치도 재시작도 필요 없습니다.

Compose HotSwan hot reloading a running app

Compose Navigation Graph는 내비게이션의 모습을 알고 있고, HotSwan은 실행 중인 앱으로 향하는 실시간 채널을 유지합니다. 이 둘을 합치면 정적이던 지도가 실행 중인 앱의 백 스택을 조종하는 제어 수단으로 바뀝니다. 이로써 열리는 몇 가지 방향은 다음과 같습니다.

  • 지도를 클릭해 화면으로 점프하기. 그래프에서 노드를 클릭하면, UI를 일일이 탭하며 찾아 들어가는 대신 실행 중인 앱이 그 목적지로 곧장 이동합니다.
  • 클릭 한 번으로 백 스택 복원하기. 관심 있는 백 스택, 가령 인자까지 갖춘 화면 세 개 깊이의 플로우를 저장해 두었다가, 수동으로 이동할 필요 없이 클릭 한 번으로 실행 중인 앱을 바로 그 상태로 되돌립니다.
  • 백 스택을 실시간으로 재배치하기. 지도에서 목적지를 푸시(push)하거나 팝(pop)하거나 순서를 바꾸면 실행 중인 앱이 그대로 따라오므로, 버그 리포트나 특정 상태를 재현하는 일이 손으로 단계를 되짚는 대신 노드를 배치하는 일로 바뀝니다.

이 가운데 무엇도 현재 플러그인에 들어 있지는 않습니다. 그래프는 여전히 빌드 타임 지도이며, 이 런타임 다리는 지금 당장 쓸 수 있는 기능이라기보다 하나의 방향입니다. 하지만 조각들은 이미 맞아떨어집니다. 한쪽에는 시각적이고 타입이 지정된 앱 전체 그래프가 있고, 다른 한쪽에는 실행 중인 앱을 실시간으로 갱신하는 런타임이 있습니다. 지도와 살아 있는 앱 사이의 거리를 좁히는 것, 바로 그곳이 이 프로젝트가 향하는 지점입니다.

한데 모아 사용하기

이 조각들은 내비게이션을 둘러싼 하나의 고리를 이룹니다. 개발하는 동안에는 화면을 만들어 가면서 어노테이션을 달고, NavGraph Graph 도구 창을 열어 둔 채 흐름이 갖춰지는 모습을 지켜봅니다. 노드를 더블 클릭하여 코드를 오가고, 커넥터를 끌어다 캔버스를 벗어나지 않고 전환을 추가합니다. 그림을 공유해야 할 때는 그래프를 HTML이나 PNG로 내보내 디자인 리뷰나 풀 리퀘스트에 첨부합니다. 그리고 시간이 지나도 흐름을 정직하게 유지하려면, .nav 베이스라인을 커밋하고 navCheck가 베이스라인을 갱신하지 않은 채 내비게이션을 바꾼 풀 리퀘스트를 실패시키도록 둡니다. 그러면 모든 목적지와 전환 변경이 의도적으로 리뷰됩니다.

추출이 정적이고 썸네일은 Layoutlib으로 렌더링되므로, 이 모든 과정이 에뮬레이터도 실행 중인 앱도 없이 빌드 타임에 이루어지며, 멀티 모듈 프로젝트와 Kotlin Multiplatform 프로젝트에서 똑같이 동작합니다. 어노테이션은 앱이 화면을 이동하는 방식을 바꾸는 것이 아니라, 앱을 담은 지도입니다. Navigation 3, Navigation 2, Activity 코드는 그대로 둔 채, 그림을 더 풍부하게 만들고 싶은 곳에 어노테이션을 더하시면 됩니다. 여기에 썸네일 하나, 저기에 명시적 엣지 하나를 더하면 지도가 그만큼 채워집니다.

여기서 가벼운 감사의 말씀을 전하고 싶습니다. 이 프로젝트는 무료이자 오픈 소스로, CodeRabbit의 지원을 받아 공개되었습니다. CodeRabbit은 여러분의 워크플로우와 IDE 안에서 직접, 맥락을 이해하며 풀 리퀘스트를 리뷰해 주는 AI 기반 코드 리뷰 플랫폼입니다. 이들의 후원 덕분에 오픈 소스로 공개할 수 있었으며, 그 점에 깊이 감사드립니다.

Compose Navigation Graph는 지금 바로 사용하실 수 있습니다. JetBrains Marketplace에서 IDE 플러그인을 설치하고, com.github.skydoves.navgraph Gradle 플러그인을 적용한 다음, ./gradlew :app:generateNavGraph를 실행하여 첫 그래프를 확인해 보세요. 어노테이션, navgraph { } DSL, .nav 베이스라인, 내보내기, IDE 플러그인까지 모두 다루는 전체 문서는 skydoves.github.io/compose-nav-graph에서 확인하실 수 있습니다.

아티클 목록으로 가기