
Navigation3의 Stable version 이 출시가 된 시점에서, Navigation3에서는 Argument를 전달하는 방식이 어떻게 달라졌는지, 기존Navigation2와 큰 차이점이 있는지 확인해보도록 하겠다.
Navigation3가 나오게 된 배경과 기존의 Navigation과의 차이는 이미 잘 정리된 글들이 있어 링크로 대체하도록 하겠다.
Jetpack Navigation3 is Stable
Navigation3 공식 문서
Jetpack Navigation3를 배워보자
Stable 된 Navigation3 사용해보기
이후 Navigation3 를 Nav3 로 줄여 언급
Nav3의 Argument 전달 방식을 이해하려면 먼저 직렬화의 개념과 각 방식의 차이를 알아야 한다.
Nav3는 KMP 지원을 위해 Kotlinx @Serializable을 채택했다. 따라서 개발자는 더 이상 Parcelable 구현을 신경 쓰지 않아도 된다.
기존에 Nav2는 Bundle 타입으로 Argument 전달했기에, 여러 단점들이 존재하였다.
하지만 Nav3는
skydoves님의 pokedex-compose 레포의 코드를 가져와 설명하도록 하겠다.
Nav2
@HiltViewModel
class DetailsViewModel @Inject constructor(
detailsRepository: DetailsRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel() {
internal val uiState: ViewModelStateFlow<DetailsUiState> =
viewModelStateFlow(DetailsUiState.Loading)
// savedStateHandle를 통해 argument로 전달받은 pokemon을 조회
val pokemon = savedStateHandle.getStateFlow<Pokemon?>("pokemon", null)
val pokemonInfo: StateFlow<PokemonInfo?> =
pokemon.filterNotNull().flatMapLatest { pokemon ->
detailsRepository.fetchPokemonInfo(
name = pokemon.nameField.replaceFirstChar { it.lowercase() },
onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
}
Nav2를 사용할 때는 뷰모델에 savedStateHandle을 생성자 주입, savedStateHandle내에서 전달받은 argument를 꺼내 쓰는 방식으로 주로 구현했었다.
Nav3
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
@Assisted private val pokemon: Pokemon,
private val detailsRepository: DetailsRepository,
) : BaseViewModel() {
@AssistedFactory
interface Factory {
fun create(pokemon: Pokemon): DetailsViewModel
}
internal val uiState: ViewModelStateFlow<DetailsUiState> =
viewModelStateFlow(DetailsUiState.Loading)
val pokemonInfo: StateFlow<PokemonInfo?> = flow {
detailsRepository.fetchPokemonInfo(
// 클래스의 생성자인 pokemon에 접근
name = pokemon.nameField.replaceFirstChar { it.lowercase() },
onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
).collect { emit(it) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
}
Nav3 방식에서는 전달받는 argument를 직접 뷰모델에 생성자로 주입하는 식으로 구현이 변경되었다!
Hilt 2.49 버전 부터 지원하는 어노테이션으로, 어노테이션을 통해 이 클래스는 생성자에 런타임에 받을 파라미터가 있다는 것을 안내(표시)한다.
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
@Assisted private val pokemon: Pokemon, // ← 런타임 파라미터
private val detailsRepository: DetailsRepository, // ← 컴파일타임 DI
) : BaseViewModel() {
@AssistedFactory
interface Factory {
fun create(pokemon: Pokemon): DetailsViewModel
}
}
Hilt 는 컴파일 타임에 의존성 그래프를 생성하지만, Navigation Argument는 런타임에 결정된다.
1MB 제한은 여전히 존재
Nav3가 아무리 발전된 형태여도 Android 시스템 레벨의 Binder IPC 1MB 제한은 피할 수 없다.
Nav3 라이브러리를 사용할 때 어떻게 argument를 넘기면 되는지, 기존 Nav2와 달라진 차이점과 장점들에 대해 확인해볼 수 있었다.
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
@Assisted private val pokemon: Pokemon,
private val detailsRepository: DetailsRepository,
) : BaseViewModel() {
@AssistedFactory
interface Factory {
fun create(pokemon: Pokemon): DetailsViewModel
}
// ...
}
위에서도 언급하였지만, Nav3 가 적용되면서 pokdex-compose 레포의 DetailsViewModel의 변경된 코드인데 Circuit의 Presenter 구조와 매우 유사해졌음을 확인할 수 있었다. 흥미롭...
class OnboardingPresenter @AssistedInject constructor(
@Assisted private val navigator: Navigator,
private val repository: UserRepository,
private val analyticsHelper: AnalyticsHelper,
) : Presenter<OnboardingUiState> {
@CircuitInject(OnboardingScreen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(navigator: Navigator): OnboardingPresenter
}
// ...
}
@Parcelize
data class BookDetailScreen(
val userBookId: String,
val isbn13: String,
) : ReedScreen(name = ScreenNames.BOOK_DETAIL)
@AssistedInject
class BookDetailPresenter(
@Assisted private val screen: BookDetailScreen,
@Assisted private val navigator: Navigator,
private val bookRepository: BookRepository,
private val recordRepository: RecordRepository,
private val analyticsHelper: AnalyticsHelper,
) : Presenter<BookDetailUiState> {
@CircuitInject(BookDetailScreen::class, AppScope::class)
@AssistedFactory
fun interface Factory {
fun create(screen: BookDetailScreen, navigator: Navigator): BookDetailPresenter
}
// bookRepository.getBookDetail(screen.isbn13)
}
Circuit + Hilt가 적용된 Presenter 의 코드
결국 좋은 패턴들은 수렴한다는 것을 알 수 있었다. 수렴 진화ㄷㄷ
Circuit 공부하느라 계속 봐왔지만 아직도 낯선 문법...
SAM(Single Absract Method) Interface라고도 하며, 기본 interface와 차이점이 있다면 추상 메서드가 딱 1개인 인턴페이스 이다.
fun interface로 선언하면 람다로 인스터스 생성이 가능하다.
// 일반 interface
interface Factory {
fun create(navigator: Navigator): Presenter
}
// 사용 - 익명 객체 필요
val factory = object : Factory {
override fun create(navigator: Navigator): Presenter {
return MyPresenter(navigator)
}
}
// fun interface (SAM)
fun interface Factory {
fun create(navigator: Navigator): Presenter
}
// 사용 - 람다로 간단히!
val factory = Factory { navigator ->
MyPresenter(navigator)
}
fun interface 는 당연히 interface로 변환하여 사용이 가능하며, "메서드가 단 하나"라는 의도를 명확히 전달할 때 + 람다 변환 허용을 위해 사용한다고 이해하면 될 듯 하다.
// interface - 이것도 동작함
@AssistedFactory
interface Factory {
fun create(navigator: Navigator): Presenter
}
// fun interface - 더 명시적
@AssistedFactory
fun interface Factory {
fun create(navigator: Navigator): Presenter
}
SAM 변환 하면 가장 익숙한 코드를 다들 떠올릴 것이다.
// Before (익명 객체)
button.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View) {
// ...
}
})
// After (람다) - Android Studio가 노란 전구로 제안 💡
button.setOnClickListener { v ->
// ...
}
근데 한가지 주의할 것은 Java Interface는 Kotlin에서 자동으로 SAM 변환을 허용하지만, Kotlin interface는 그렇지 않다.
Kotlin이 명시성을 중요시 하는 것 같다. =.=;a
명시적으로 SAM 임을 선언해야(fun interface) 변환된다는 것을 유의하자.
// Kotlin interface는 기본적으로 SAM 변환 안 됨
interface MyCallback {
fun onResult(data: String)
}
// ❌ 컴파일 에러
doSomething { data -> }
// ✅ 명시적으로 fun interface 선언해야 함
fun interface MyCallback {
fun onResult(data: String)
}
// 이제 됨
doSomething { data -> }
reference)
https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html
https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html
https://haeti.palms.blog/navigation-3
https://www.youtube.com/watch?v=j8SZsutnl38
https://github.com/skydoves/pokedex-compose
https://github.com/doveletter/dove-letter?tab=readme-ov-file
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation3/?hl=ko
https://developer.android.com/kotlin/parcelize?hl=ko#setup_parcelize_for_kotlin_multiplatform
오늘도 좋은 글 감사합니다^^
요즘 지훈님 글 보면서 Circuit 도 아장아장 걸어보고 있습니다 😊
혹시 Circuit 이랑 Nav3(혹은 외부 서드파티 네비게이션 라이브러리) 랑 같이 사용할 수 있는 좋은 방법이 있을까용 .. ?!
아무래도 Circuit 은 자체 네비게이션과 같이 묶여있는 개념인것 같아서 분리좀 시켜보고 싶어서요ㅎㅎ
혹쉬 비슷한 경험 있으시면 노하우 부탁드리겠습니다 : )