이 글은 누구를 위한 것인가
- 문자열 기반 라우팅의 오타와 타입 불일치에 지친 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) 검증을 설정하면 크롬에서 링크 클릭 시 앱이 바로 열린다.