Jetpack Compose Navigation: 타입 세이프 라우팅과 딥링크 전략

모바일 개발

Jetpack ComposeNavigation딥링크Android타입 세이프

이 글은 누구를 위한 것인가

  • 문자열 기반 라우팅의 오타와 타입 불일치에 지친 Android 개발자
  • Compose Navigation에서 딥링크를 처음 설정하는 팀
  • 복잡한 앱 구조를 중첩 NavGraph로 정리하고 싶은 개발자

들어가며

기존 Compose Navigation은 "product/{productId}"처럼 문자열로 라우트를 정의해서 오타와 타입 불일치가 런타임 오류로 이어졌다. Navigation 2.8 (Compose 1.7+)에서 Kotlin Serialization 기반 타입 세이프 라우팅이 안정화됐다.

이 글은 bluefoxdev.kr의 Android 네비게이션 아키텍처 가이드 를 참고하고, 타입 세이프 딥링크 구현 관점에서 확장하여 작성했습니다.


1. 타입 세이프 라우팅 설정

// build.gradle.kts
// implementation("androidx.navigation:navigation-compose:2.8.0")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
// id("org.jetbrains.kotlin.plugin.serialization")

import kotlinx.serialization.Serializable

// 라우트 정의 — 데이터 클래스로 인자 포함
@Serializable
object HomeRoute

@Serializable
data class ProductDetailRoute(val productId: String)

@Serializable
data class OrderRoute(
    val orderId: String,
    val showReview: Boolean = false  // 선택적 인자
)

@Serializable
object CheckoutRoute

@Serializable
object ProfileRoute

2. NavGraph 구성

import androidx.navigation.compose.*
import androidx.navigation.NavHostController

@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = HomeRoute
    ) {
        composable<HomeRoute> {
            HomeScreen(
                onProductClick = { productId ->
                    navController.navigate(ProductDetailRoute(productId))
                }
            )
        }
        
        composable<ProductDetailRoute> { backStackEntry ->
            val route = backStackEntry.toRoute<ProductDetailRoute>()
            
            ProductDetailScreen(
                productId = route.productId,
                onAddToCart = { navController.navigate(CheckoutRoute) },
                onBack = { navController.popBackStack() }
            )
        }
        
        composable<OrderRoute> { backStackEntry ->
            val route = backStackEntry.toRoute<OrderRoute>()
            
            OrderScreen(
                orderId = route.orderId,
                showReviewPrompt = route.showReview,
            )
        }
        
        // 중첩 NavGraph — 기능별 분리
        navigation<CheckoutRoute>(startDestination = CartRoute) {
            composable<CartRoute> { CartScreen(navController) }
            composable<PaymentRoute> { PaymentScreen(navController) }
            composable<OrderCompleteRoute> { OrderCompleteScreen(navController) }
        }
    }
}

3. 딥링크 설정

// AndroidManifest.xml
/*
<activity android:name=".MainActivity">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="myapp.com" />
    </intent-filter>
    <!-- 커스텀 스킴 -->
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" />
    </intent-filter>
</activity>
*/

// 딥링크 포함 NavGraph 설정
composable<ProductDetailRoute>(
    deepLinks = listOf(
        // https://myapp.com/products/{productId}
        navDeepLink<ProductDetailRoute>(
            basePath = "https://myapp.com/products"
        ),
        // myapp://products/{productId}
        navDeepLink<ProductDetailRoute>(
            basePath = "myapp://products"
        )
    )
) { backStackEntry ->
    val route = backStackEntry.toRoute<ProductDetailRoute>()
    ProductDetailScreen(productId = route.productId)
}

composable<OrderRoute>(
    deepLinks = listOf(
        // https://myapp.com/orders/{orderId}?showReview={showReview}
        navDeepLink<OrderRoute>(
            basePath = "https://myapp.com/orders"
        )
    )
) { backStackEntry ->
    val route = backStackEntry.toRoute<OrderRoute>()
    OrderScreen(orderId = route.orderId, showReviewPrompt = route.showReview)
}

4. 바텀 네비게이션 통합

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination

    Scaffold(
        bottomBar = {
            // 특정 화면에서만 바텀 바 표시
            val showBottomBar = currentRoute?.hierarchy?.any { dest ->
                dest.hasRoute<HomeRoute>() || dest.hasRoute<ProfileRoute>()
            } == true

            if (showBottomBar) {
                NavigationBar {
                    val items = listOf(
                        BottomNavItem("홈", Icons.Default.Home, HomeRoute),
                        BottomNavItem("마이", Icons.Default.Person, ProfileRoute),
                    )

                    items.forEach { item ->
                        val selected = currentRoute?.hierarchy?.any { dest ->
                            dest.hasRoute(item.route::class)
                        } == true

                        NavigationBarItem(
                            selected = selected,
                            onClick = {
                                navController.navigate(item.route) {
                                    // 백스택에 중복 쌓이지 않도록
                                    popUpTo(navController.graph.findStartDestination().id) {
                                        saveState = true
                                    }
                                    launchSingleTop = true
                                    restoreState = true
                                }
                            },
                            icon = { Icon(item.icon, contentDescription = item.label) },
                            label = { Text(item.label) }
                        )
                    }
                }
            }
        }
    ) { paddingValues ->
        AppNavHost(
            navController = navController,
            modifier = Modifier.padding(paddingValues)
        )
    }
}

data class BottomNavItem<T : Any>(
    val label: String,
    val icon: ImageVector,
    val route: T
)

5. 전환 애니메이션

import androidx.navigation.compose.composable

// 화면 전환 애니메이션 (슬라이드)
composable<ProductDetailRoute>(
    enterTransition = {
        slideIntoContainer(
            towards = AnimatedContentTransitionScope.SlideDirection.Start,
            animationSpec = tween(300)
        )
    },
    exitTransition = {
        slideOutOfContainer(
            towards = AnimatedContentTransitionScope.SlideDirection.Start,
            animationSpec = tween(300)
        )
    },
    popEnterTransition = {
        slideIntoContainer(
            towards = AnimatedContentTransitionScope.SlideDirection.End,
            animationSpec = tween(300)
        )
    },
    popExitTransition = {
        slideOutOfContainer(
            towards = AnimatedContentTransitionScope.SlideDirection.End,
            animationSpec = tween(300)
        )
    }
) { backStackEntry ->
    val route = backStackEntry.toRoute<ProductDetailRoute>()
    ProductDetailScreen(productId = route.productId)
}

마무리

타입 세이프 라우팅은 문자열 기반 라우팅의 런타임 오류를 컴파일 타임 오류로 바꿔준다. Kotlin Serialization 기반이므로 딥링크 파라미터도 자동으로 직렬화/역직렬화된다.

딥링크는 푸시 알림, 마케팅 캠페인, 공유 링크와 함께 사용하면 앱 진입점을 다양화할 수 있다. App Links(HTTPS) 검증을 설정하면 크롬에서 링크 클릭 시 앱이 바로 열린다.