Navigation-Compose는 오랫동안 androidx.navigation 패키지에 속한 안드로이드 전용 라이브러리였습니다. NavController가 내부적으로 Activity의 SavedStateHandle과 안드로이드 Lifecycle에 의존했기 때문에, iOS 타겟까지 화면 전환 로직을 공유하려면 Voyager 같은 서드파티 라이브러리를 쓰는 수밖에 없었습니다. Navigation Compose가 Kotlin Multiplatform 공통 타겟을 지원하기 시작하면서, commonMain에서도 안드로이드에서 쓰던 것과 같은 NavHost/NavController API를 그대로 쓸 수 있게 됐습니다.
왜 필요한가
서드파티 내비게이션 라이브러리를 도입하면 API 자체를 새로 배워야 합니다. 화면을 Screen 인터페이스로 감싸고, 별도의 Navigator 객체로 전환을 관리하는 식입니다. 안드로이드에서 NavHost/composable 패턴에 익숙한 팀이라면 이 학습 곡선이 불필요한 비용입니다. Navigation Compose가 공통 타겟을 지원하면, 안드로이드 화면 전환 코드를 거의 그대로 commonMain으로 옮길 수 있고, kotlinx.serialization 기반 타입 세이프 라우트도 함께 씁니다. 라우트를 문자열 경로("detail/{itemId}")로 짜지 않고 @Serializable 클래스로 정의하기 때문에, 인자 이름을 오타 내거나 타입을 잘못 넘기는 실수가 컴파일 타임에 걸러집니다.
핵심 개념
commonMain에 의존성을 추가합니다.
// composeApp/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
}
}
}
plugins 블록에 kotlin("plugin.serialization")도 함께 추가해야 라우트 클래스에 @Serializable을 붙일 수 있습니다.
라우트는 화면마다 하나씩, 데이터 클래스나 object로 정의합니다. 인자가 없는 화면은 object, 인자가 있는 화면은 data class를 씁니다.
// commonMain/nav/Routes.kt
import kotlinx.serialization.Serializable
@Serializable
object ItemList
@Serializable
data class ItemDetail(val itemId: String)
실전 예시: NavHost 구성과 타입 세이프 네비게이션
NavHost에 각 라우트를 composable<T> 제네릭 함수로 등록합니다. 화면으로 이동할 때는 문자열 경로 대신 라우트 인스턴스를 그대로 navigate()에 넘깁니다.
// commonMain/nav/AppNavHost.kt
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
@Composable
fun AppNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = ItemList) {
composable<ItemList> {
ItemListScreen(
onItemClick = { id -> navController.navigate(ItemDetail(itemId = id)) },
)
}
composable<ItemDetail> { backStackEntry ->
val detail: ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(
itemId = detail.itemId,
onBack = { navController.popBackStack() },
)
}
}
}
backStackEntry.toRoute<ItemDetail>()이 라우트 인스턴스를 역직렬화해서 돌려주기 때문에, NavBackStackEntry.arguments?.getString("itemId") 같은 문자열 키 조회가 사라집니다. 라우트에 필드를 하나 추가하거나 이름을 바꾸면 이걸 참조하는 모든 곳에서 컴파일 에러가 나서, 리팩터링할 때 빠뜨린 곳을 IDE가 바로 잡아줍니다.
장단점 정리
장점
- 안드로이드에서 써온
NavHost/NavController/composableAPI를 그대로commonMain으로 옮길 수 있어서, 화면 전환만을 위한 새 API를 배울 필요가 없음 - 타입 세이프 라우트 덕분에 인자 이름·타입 불일치가 런타임이 아니라 컴파일 타임에 드러남
- Jetpack 공식 계보라 안드로이드 쪽 문서와 예제를 그대로 참고할 수 있음
단점
- iOS의 스와이프 백 제스처나 딥링크 처리는 안드로이드만큼 자료가 많지 않아서, 플랫폼별로 직접 연결해야 하는 부분이 남아 있음
- 멀티플랫폼 지원이 상대적으로 최근에 들어온 기능이라, 마이너 버전을 올릴 때 API가 바뀌는 경우가 Voyager 같은 서드파티 라이브러리보다 잦은 편
- 백스택 상태 복원(
rememberSaveable연동)을 플랫폼별로 검증해봐야, 안드로이드에서 익숙했던 동작을 iOS에서도 동일하게 기대할 수 있음
안드로이드 팀이 이미 NavHost 패턴에 익숙하고, 화면 전환 로직을 그대로 재사용하고 싶다면 서드파티 라이브러리보다 이쪽이 학습 비용이 낮습니다. 반대로 화면 전환에 애니메이션 트랜지션이나 중첩 내비게이션 같은 세밀한 커스터마이징이 많이 필요하다면, 아직은 그런 기능이 먼저 성숙한 서드파티 라이브러리 쪽이 더 유리할 수 있습니다.