카테고리 없음

Now in Android - Jetpack Navigation 2가 적용된 구조 분석

interfacer_han 2026. 8. 3. 14:52

#1 개요

#1-1 분석 대상

 

Migrate codebase to Navigation 3 by dturner · Pull Request #1902 · android/nowinandroid

[WIP] This PR migrates the codebase to using Jetpack Navigation 3. More details to follow...

github.com

Now in Android의 1902번 PR인, "Migrate codebase to Navigation 3"이다. 이 PR이 merge되기 전까지, Now in Android는 Jetpack Navigation 2를 사용했다. 이를 Jetpack Navigation 3로 마이그레이션한 것이다.
 
그 중에서도, Navigation 3로 전환하면서 도입한 feature/*/api, feature/*/impl 모듈 분리 구조를 분석한다.
 

#1-2 기준

  • 변경 전 기준 커밋
    • d438a00
  • 구조 도입 커밋
    • 068d8cc - Split feature modules into api and impl modules
  • 분석 대상
    • Now in Android - 1902번 PR 
    • Now in Android - 2009번 Discussion
    • Now in Android - 2024번 PR
    • Navigation 3 공식 문서

 

#1-3 목표

최종 목표는 아래의 질문에 답할 수 있는 상태다.

  1. Now in Android가 Navigation 3로 전환하면서, 각 feature를 api와 impl로 분리한 이유는 무엇인가?
  2. 이 변경의 Trade-Off는 무엇인가?
  3. 이 변경에 대해 주관적인 평가를 내린다면?

 

#1-4 본 게시글에서 할 것

먼저, 1902번 PR의 첫 커밋이 들어오기 직전의 커밋인 d438a00의 네비게이션 구조를 분석해본다.
 

#2 의존성 그래프

feature 모듈은 단일 모듈이 아니다. 가독성을 위해 단순화하여 그린 그래프이기 때문이다. 실제로는,

  • :feature:foryou 모듈 (맨 앞 ":"은 편의상 생략하여 표기하는 경우가 많다)
  • :feature:bookmarks 모듈
  • :feature:interests 모듈
  • ...

로 나뉘어있다. 안드로이드에서의 '모듈'은 독립적으로 컴파일이 가능한 단위다. 하지만, "feature 모듈"도 그럴까? 아니다. "feature"는 실제로 컴파일되지 않는다. '진짜 모듈'을 같은 경로에 묶는 컨테이너의 역할이다. 이른바, '논리적 모듈'인 셈이다. '진짜 모듈'은 위에 있는 foryou, bookmarks, interests, ...다. 따라서 ForYouNavigation의 소속도 feature가 아니라 feature:foryou다.
 

#3 app 모듈

#3-1 MainActivity.kt

package com.google.samples.apps.nowinandroid

...

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        setContent {
            val appState = rememberNiaAppState(
                ...
            )

            ...

            CompositionLocalProvider(
                ...
            ) {
                NiaTheme(
                    ...
                ) {
                    NiaApp(appState)
                }
            }
        }
    }

    ...
}

앱의 진입점이다. NiaAppState 객체를 만들고 NiaApp()을 호출한다.
 

#3-2 NiaApp.kt

package com.google.samples.apps.nowinandroid.ui

...
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
...

@Composable
fun NiaApp(
    appState: NiaAppState,
    ...
) {
    ...

    NiaBackground(modifier = modifier) {
        NiaGradientBackground(
            ...
        ) {
            ...

            NiaApp(
                appState = appState,
                ...
            )
        }
    }
}

@Composable
@OptIn(...)
internal fun NiaApp(
    appState: NiaAppState,
    ...
) {
    val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
        .collectAsStateWithLifecycle()
    val currentDestination = appState.currentDestination

    ...

    NiaNavigationSuiteScaffold(
        navigationSuiteItems = {
            appState.topLevelDestinations.forEach { destination ->
                ...
                val selected = currentDestination
                    .isRouteInHierarchy(destination.baseRoute)
                item(
                    selected = selected,
                    onClick = { appState.navigateToTopLevelDestination(destination) },
                    icon = {
                        ...
                    },
                    ...
                )
            }
        },
        ...
    ) {
        Scaffold(
            ...
        ) { ...
            Column(
                ...
            ) {
                val destination = appState.currentTopLevelDestination
                ...

                if (destination != null) {
                    shouldShowTopAppBar = true
                    NiaTopAppBar(
                        ...
                    )
                }

                Box(
                    ...
                ) {
                    NiaNavHost(
                        appState = appState,
                        ...
                    )
                }

                ...
            }
        }
    }
}

...

NiaAppState를 매개변수로 받는 NiaNavHost를 호출한다.
 
navController.navigateToForYou(...) 부분에서 당황했다. 갑자기 ForYou라는 도메인적 고유명사가 포함된 멤버 함수가 튀어나온다고...? 다시 보니 저건 확장함수였다!
 
즉, navController.navigateToForYou(topLevelNavOptions)는 실제론, 

navigateToForYou(
    navController,
    topLevelNavOptions,
)

이런 코드인 것이다.
 

#3-3 NiaAppState.kt

package com.google.samples.apps.nowinandroid.ui

...

@Composable
fun rememberNiaAppState(
    ...
    navController: NavHostController = rememberNavController(),
): NiaAppState {
    NavigationTrackingSideEffect(navController)
    return remember(
        navController,
        ...
    ) {
        NiaAppState(
            navController = navController,
            ...
        )
    }
}

@Stable
class NiaAppState(
    val navController: NavHostController,
    ...
) {
    private val previousDestination = mutableStateOf<NavDestination?>(null)

    /* [1] 현재 화면 확인
     * navController의 현재 백 스택 항목을 관찰하고,
     * 해당 항목이 가리키는 destination을 반환한다.
     */
    val currentDestination: NavDestination?
        @Composable get() {
            val currentEntry = navController.currentBackStackEntryFlow
                .collectAsState(initial = null)

            return currentEntry.value?.destination.also { destination ->
                if (destination != null) {
                    previousDestination.value = destination
                }
            } ?: previousDestination.value
        }

    /* [1] 현재 화면 확인
     * 현재 destination이 ForYou, Bookmarks, Interests 중
     * 어떤 최상위 화면에 해당하는지 판별한다.
     */
    val currentTopLevelDestination: TopLevelDestination?
        @Composable get() {
            return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
                currentDestination?.hasRoute(
                    route = topLevelDestination.route,
                ) == true
            }
        }

    /* [2] 화면 이동
     * 전달받은 최상위 destination에 따라
     * 동일한 navController를 사용해 화면을 이동한다.
     */
    fun navigateToTopLevelDestination(
        topLevelDestination: TopLevelDestination,
    ) {
        val topLevelNavOptions = navOptions {

            /* [3] 백 스택 관리
             * 최상위 탭을 전환할 때 시작 destination까지 되돌려,
             * 탭을 누를 때마다 백 스택이 계속 쌓이는 것을 막는다.
             */
            popUpTo(
                navController.graph.findStartDestination().id,
            ) {
                /* [4] 상태 저장
                 * 백 스택에서 제거되는 destination의 UI 상태를 저장한다.
                 */
                saveState = true
            }

            /* [3] 백 스택 관리
             * 현재 화면과 같은 destination을 다시 선택해도
             * 동일한 destination을 백 스택에 중복 추가하지 않는다.
             */
            launchSingleTop = true

            /* [4] 상태 복원
             * 이전에 방문했던 최상위 destination으로 돌아갈 때
             * 저장해 둔 UI 상태와 백 스택 상태를 복원한다.
             */
            restoreState = true
        }

        /* [2] 화면 이동
         * 각 feature 모듈이 정의한 navigation 확장 함수를 호출하지만,
         * 실제 이동과 백 스택 관리는 모두 같은 navController가 수행한다.
         */
        when (topLevelDestination) {
            FOR_YOU ->
                navController.navigateToForYou(topLevelNavOptions)

            BOOKMARKS ->
                navController.navigateToBookmarks(topLevelNavOptions)

            INTERESTS ->
                navController.navigateToInterests(
                    topicId = null,
                    navOptions = topLevelNavOptions,
                )
        }
    }

    /* [2] 화면 이동
     * 현재 백 스택 위에 Search destination을 추가한다.
     * Search 화면은 TopLevelDestination(최상위 목적지)가 아닌, 일시적으로 진입하는 보조(서브) 화면이다.
     * (여담으로, TopLevelDestination은 enum class로 프로젝트 내에 선언(고정)되어있다.)
     * 따라서, FOR_YOU, BOOKMARKS, INTERESTS처럼 기존 화면 정보를 저장 및 복원하며 전환하지 않고
     * 그저 현재 백스택 위에 Search만 추가해주는 식으로 작동하는 것이다. 
     */
    fun navigateToSearch() {
        navController.navigateToSearch()
    }

    ...
}

...

NiaAppState의 생성과 동시에 NavHostController 객체 navController도 만들어진다. NavHostController는 '목적지'(전환할 화면)와 목적지의 이동 기록인 백스택을 관리한다. NavHostController.navigate(A)를 호출하면 A가 백스택의 맨 위에 추가된다. NavHost는 이를 감지하여 화면을 전환한다. NavHost는 바로 아래에서 다룬다.
 

#3-4 NiaNavHost.kt

package com.google.samples.apps.nowinandroid.navigation

...
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
...

@Composable
fun NiaNavHost(
    appState: NiaAppState,
    ...
) {
    val navController = appState.navController
    NavHost(
        navController = navController,
        startDestination = ForYouBaseRoute,
        ...
    ) {
        forYouSection(
            onTopicClick = navController::navigateToTopic,
        ) {
            topicScreen(
                ...
                onBackClick = navController::popBackStack,
                onTopicClick = navController::navigateToTopic,
            )
        }
        bookmarksScreen(
            onTopicClick = navController::navigateToInterests,
            onShowSnackbar = onShowSnackbar,
        )
        searchScreen(
            onBackClick = navController::popBackStack,
            onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
            onTopicClick = navController::navigateToInterests,
        )
        interestsListDetailScreen()
    }
}

NavHost 중괄호 안의 함수들은 화면을 전부 표시하는걸 의미하는 게 아니다. 화면을 표시하기 위한 목적지들을 그래프 형식으로 등록하는 것이다 (네비게이션 그래프).
 

@Serializable
object Profile // 목적지의 ID로 쓰이는 객체
@Serializable
object FriendsList // 목적지의 ID로 쓰이는 객체

val navController = rememberNavController()

NavHost(navController = navController, startDestination = Profile) {
    composable<Profile> { ProfileScreen( /* ... */ ) }
    composable<FriendsList> { FriendsListScreen( /* ... */ ) }
    // Add more destinations similarly.
}

공식 문서에 있는 NavHost의 예시 코드다. Now in Android의 NiaNavHost.kt 속 코드와는 좀 다르게 생겼는데, Now in Anroid는 목적지에서 "composable<Profile> { ProfileScreen( /* ... */ ) }" 부분을 감싼 커스텀 함수를 정의하고, 그걸 불러다 쓰는 방식이기 때문이다. 자기 일은 자기가 챙기는 SRP 원칙을 준수했다고 볼 수도 있고, ForYou 화면에 직접 의존없이 함수(추상화)를 거친다는 점에서 DIP 원칙을 준수했다고도 볼 수 있겠다.
 
NavHost에 NavHostController 객체를 '넣어 놓으면', NavHostController의 값에 따라 NavHost가 선언적으로 반응한다. 즉 화면이 전환된다는 말이다. 이제 NavHostController.navigate() 한 줄만으로 화면 전환이 가능해진다.
 

#4 feature 모듈

#2에서 말한 바와 같이, "feature 모듈"이라는 표현은 경로를 명시하기 위한 논리적인 의미의 모듈이지, "물리적 모듈 (= 컴파일의 단위)"를 말하는 것이 아니다. feature 모듈 내에는 여러 모듈이 있지만, "foryou 모듈"을 대표삼아 분석한다. "foryou 모듈"은 그 속에 또 다른 모듈이 없는 말단 모듈이기에 "물리적 모듈"이기도 하다.
 

#4-1 ForYouNavigation.kt

package com.google.samples.apps.nowinandroid.feature.foryou.navigation

import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
...
import androidx.navigation.compose.navigation
...
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouScreen
import kotlinx.serialization.Serializable

@Serializable data object ForYouRoute // route to ForYou screen

@Serializable data object ForYouBaseRoute // route to base navigation graph

// (1)
fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions)

// (2)
fun NavGraphBuilder.forYouSection(
    onTopicClick: (String) -> Unit,
    topicDestination: NavGraphBuilder.() -> Unit,
) {
    navigation<ForYouBaseRoute>(startDestination = ForYouRoute) {
        composable<ForYouRoute>(
            deepLinks = listOf(
                navDeepLink {
                    /* Deep Link 부분은 본 게시글의 주제가 아니므로 무시한다. 
                     * 그래도 간단히 살펴보자면, "외부 링크나 알림이 이 URI 패턴과 일치하면 Navigation이 ForYouRoute를 목적지로 찾아 이동"시키는 코드라고 한다.
                     */
                    uriPattern = DEEP_LINK_URI_PATTERN
                },
            ),
        ) {
            ForYouScreen(onTopicClick)
        }
        topicDestination() // = composable<TopicRoute> {...}
    }
}

(1) fun NavController.navigateToForYou()
#3-2에서 봤던, 확장함수가 선언된 부분이다. 이 모듈의 진입점인 셈이다.

 

(2) fun NavGraphBuilder.forYouSection()

뭔가 이상하다. 최외각 함수가 composable()이 아니라 navigation()이다. 이는 중첩 그래프를 의미한다. 이미 NavHost의 람다 부분에는 navigation()이 하나 숨겨져 있었다. Navigation 그래프의 모양은 트리(Tree)다. 트리의 Root 부분을 선언하는게 바로 navigation()인 것이다.

Root NavGraph           ← 기본 NavGraph (숨겨져 있어서 안 보임)
├── ForYouBaseRoute     ← 중첩 NavGraph
│   ├── ForYouRoute     ← destination
│   └── TopicRoute      ← destination
├── BookmarksRoute      ← destination
├── SearchRoute         ← destination
└── InterestsRoute      ← destination

만약 navigation()를 쓸 수 없다면, 깊이(depth)가 최대 1인 트리 밖에는 그릴 수 없을 것이다.
 

#4-2 ForYouScreen.kt

package com.google.samples.apps.nowinandroid.feature.foryou

...

@Composable
internal fun ForYouScreen(
    onTopicClick: (String) -> Unit,
    ...
) {
    ...

    ForYouScreen(
        ...
        onTopicClick = onTopicClick,
        ...
    )
}

@Composable
internal fun ForYouScreen(
    ...
    onTopicClick: (String) -> Unit,
    ...
) {
    ...

    Box(
        ...
    ) {
        LazyVerticalStaggeredGrid(
            ...
        ) {
            ...

            newsFeed(
                ...
                onTopicClick = onTopicClick,
            )

            ...
        }
        ...
}

...

이 파일에 네비게이션 관련 코드는 없다. 간접적으로는, 저 onTopicClick 람다 함수를 들 수 있겠다. 저 함수는 NiaNavHost에서 navController::navigateToTopic로 정의했었다. 즉, 화면 전환을 유도하는 함수다.
 

#5 요약

  1. NavHost에 그릴 화면 정보를 등록하고, NavHostController에 반응하도록 만들어둔다.
  2. NavHostController를 매개변수로 받아 생성되는 NiaAppState 클래스를 선언한다.
  3. NiaAppState 클래스에 NavHostController의 메소드를 편하게 쓰기 위한 Wrapper 메소드들을 짜둔다.
  4. NiaAppState 객체를 런타임 중 계속 유지되도록 잘 관리하며, 필요할 때마다 화면 전환을 위한 Wrapper 메소드를 호출한다.