Navigation3 Argument 전달 방식의 변화(vs Navigation2)

easyhooon·2025년 11월 24일

Navigation

목록 보기
7/7
post-thumbnail

서두

Navigation3의 Stable Version 이 출시가 된 시점에서, Navigation3에서 Argument를 전달하는 방식이 어떻게 달라졌는지, 기존 Navigation2와 큰 차이점이 있는지 확인해보도록 하겠다.

Navigation3가 나오게 된 배경과 구현 방법에 대한 설명은 이미 잘 정리된 글들이 있어 링크로 대체하도록 하겠다.
Jetpack Navigation3 is Stable
Navigation3 공식 문서
Jetpack Navigation3를 배워보자
Stable 된 Navigation3 사용해보기
...그외 Philip Lackner, Stevdza-San 유튜브 영상들

이후 Navigation3 를 Nav3 로 줄여 언급

본론

Java Serializable vs Parcelable vs Kotlinx Serializable

Nav3의 Argument 전달 방식을 이해하려면 먼저 직렬화의 개념과 각 방식의 차이를 알아야 한다.

Java Serializable

  • Java의 표준 직렬화 방식으로 JVM 전용
  • 리플렉션을 기반으로 동작하여 런타임에 클래스 구조를 분석

Parcelable

Kotlinx Serializable

  • Kotlin 전용 직렬화 방식으로 컴파일 타임에 serializer를 생성하여 성능 향상
  • Json, ProtoBuf등 다양한 포멧을 지원하며 모든 KMP 플랫폼들에서 동일하게 동작

Nav3는 KMP 지원을 위해 Kotlinx @Serializable을 채택했다. 따라서 개발자는 더 이상 Parcelable 구현을 신경 쓰지 않아도 된다.

그래서 Nav3 도 Bundle 형식으로 데이터 전달하나?

기존에 Nav2는 Bundle 타입으로 Argument 전달했기에, 여러 단점들이 존재하였다.

문자열 기반 방식을 사용할 시절의 단점은...할말하않

Bundle이 지원하는 타입만 전달이 가능하기에 Primitive Type이 아닌 Custom Data Class, List 타입의 경우 @Parcelable 어노테이션을 붙혀주는 것외에 아래와 같이 NavType 구현을 위한 보일러 플레이트 코드를 작성해줘야 한다.

sealed interface Route {
    @Serializable
    data object Home : Route

    @Serializable
    data class Detail(
        val lectureName: String,
        val studentGradeList: List<Int>,
        val lecture: Lecture,
        val studentList: List<Student>,
    ) : Route {
        companion object {
            val typeMap = mapOf(
                typeOf<Lecture>() to LectureType,
                typeOf<List<Student>>() to StudentListType,
            )
        }
    }
}

val LectureType = object : NavType<Lecture>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Lecture? {
        return bundle.getString(key)?.let { Json.decodeFromString(it) }
    }

    override fun parseValue(value: String): Lecture {
        return Json.decodeFromString(value)
    }

    override fun put(bundle: Bundle, key: String, value: Lecture) {
        bundle.putString(key, Json.encodeToString(Lecture.serializer(), value))
    }

    override fun serializeAsValue(value: Lecture): String {
        return Json.encodeToString(Lecture.serializer(), value)
    }
}

val StudentListType = object : NavType<List<Student>>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): List<Student>? {
        return bundle.getStringArray(key)?.map { Json.decodeFromString<Student>(it) }
    }

    override fun parseValue(value: String): List<Student> {
        return Json.decodeFromString(ListSerializer(Student.serializer()), value)
    }

    override fun put(bundle: Bundle, key: String, value: List<Student>) {
        val serializedList = value.map { Json.encodeToString(Student.serializer(), it) }.toTypedArray()
        bundle.putStringArray(key, serializedList)
    }

    override fun serializeAsValue(value: List<Student>): String {
        return Json.encodeToString(ListSerializer(Student.serializer()), value)
    }
}

savedStateHandle를 통한 간접적인 접근 방식(뎁스가 추가)도 단점이라면 단점으로 뽑을 수 있겠다.

하지만 Nav3는 전달할 argument 객체를 Bundle이 아닌, SnapshotStateList 기반의 NavBackStack(메모리)에 직접 저장하는 방식을 택하였기 때문에, 위의 단점들을 모두 해결하였다!

메모리에 저장하는 방식이면 Process Death 대처는?

backStack을 관리하기 위해 사용하는 rememberNavBack의 구현체를 확인해보면

val backStack = rememberNavBackStack(PokedexScreen.Home)

@Composable
public fun rememberNavBackStack(vararg elements: NavKey): NavBackStack<NavKey> {
    return rememberSerializable(
        serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
    ) {
        NavBackStack(*elements)
    }
}

@Composable
public fun <T : Any> rememberSerializable(
    vararg inputs: Any?,
    serializer: KSerializer<T>,
    configuration: SavedStateConfiguration = DEFAULT,
    init: () -> T,
): T {
    val saver = serializableSaver(serializer, configuration)
    @Suppress("DEPRECATION")
    return rememberSaveable(*inputs, saver = saver, key = null, init = init)
}

내부적으로 rememberSaveable을 사용하기 때문에 Process가 종료되어도 복원이 가능하다.

Composable Argument 처리 방식 변화

Nav3-recipes 레포의 코드를 통해 설명하도록 하겠다.

@Serializable
private data object RouteA  // NavKey 불필요
@Serializable
private data class RouteB(val id: String)

class BasicDslActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            NavHost(
                navController = navController,
                startDestination = RouteA
            ) {
                composable<RouteA> {
                    ContentGreen("Welcome to Nav2") {
                        Button(onClick = {
                            navController.navigate(RouteB("123"))
                        }) {
                            Text("Click to navigate")
                        }
                    }
                }
                composable<RouteB> { backStackEntry ->
                    val route = backStackEntry.toRoute<RouteB>() // 변환 필요
                    ContentBlue("Route id: ${route.id}")
                }
            }
        }
    }
}

런타임에 람다 파라미터인 backStackEntry를 toRoute() 함수를 통해 역직렬화(Bundle -> data class)하여 전달받은 id를 사용하였다.

@Serializable
private data object RouteA : NavKey

@Serializable
private data class RouteB(val id: String) : NavKey

class BasicDslActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)
        setContent {
            val backStack = rememberNavBackStack(RouteA)

            NavDisplay(
                backStack = backStack,
                onBack = { backStack.removeLastOrNull() },
                entryProvider = entryProvider {
                    entry<RouteA> {
                        ContentGreen("Welcome to Nav3") {
                            Button(onClick = {
                                backStack.add(RouteB("123"))
                            }) {
                                Text("Click to navigate")
                            }
                        }
                    }
                    entry<RouteB> { key -> // 이미 RouteB 타입
                        ContentBlue("Route id: ${key.id} ") 
                    }
                }
            )
        }
    }
}

SnapshotStateList를 통해 메모리에 직접 저장하기 때문에, 이미 객체 상태로 존재하여 변환 과정 없이 전달 받은 id를 바로 사용할 수 있다.

ViewModel Argument 처리 방식 변화

skydoves님의 pokedex-compose 레포의 코드를 통해 설명하도록 하겠다.

주고 받는 Argument

import androidx.compose.runtime.Immutable
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Immutable
@Parcelize
@Serializable
data class Pokemon(
  var page: Int = 0,
  @SerialName(value = "name")
  val nameField: String,
  @SerialName(value = "url") val url: String,
) : Parcelable {

  val name: String
    get() = nameField.replaceFirstChar { it.uppercase() }

  val imageUrl: String
    inline get() {
      val index = url.split("/".toRegex()).dropLast(1).last()
      return "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/" +
        "pokemon/other/official-artwork/$index.png"
    }
}
@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를 꺼내 쓰는 방식으로 주로 구현했었다.

하지만 이 방식은 Type-Safe Compose Navigation이 등장하기 전 까진, 문자열 기반의 key를 통해 value를 조회하는 방식이었기 때문에, 휴먼 에러가 발생할 수 있고 null 값에 대한 대응을 해줘야 했다.

// Before
private val name: String =
        requireNotNull(savedStateHandle.get<String>(LECTURE_NAME)) { "lectureName is required." }

// After
private val name: String = savedStateHandle.toRoute<Detail>().lectureName

관련한 더 자세한 내용은 해당 글에서 확인할 수 있다.

주고 받는 Argument

import androidx.compose.runtime.Immutable
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Immutable
// @Parcelize 어노테이션 제거, Parcelable Interface 구현 필요 X
@Serializable
data class Pokemon(
  var page: Int = 0,
  @SerialName(value = "name")
  val nameField: String,
  @SerialName(value = "url") val url: String,
) {

  val name: String
    get() = nameField.replaceFirstChar { it.uppercase() }

  val imageUrl: String
    inline get() {
      val index = url.split("/".toRegex()).dropLast(1).last()
      return "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/" +
        "pokemon/other/official-artwork/$index.png"
    }
}
@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를 직접 뷰모델에 생성자로 주입하는 식으로 구현이 변경되었다!

@AssistedInject?

Hilt 2.49 버전 부터 지원하는 어노테이션으로, 어노테이션을 통해 이 클래스는 생성자에 런타임에 받을 파라미터가 있다는 것을 안내(표시)한다.

@Inject만 선언하면 Hilt가 모든 파라미터를 컴파일타임에 주입해야 하기 때문에, 런타임에 결정되는 값(navigigation arguments)들은 전달이 불가능하다.

이때 @AssistedInject 어노테이션을 사용하면 일부는 Hilt가, 일부는 개발자가 런타임에 전달할 수 있기 때문에 더 유연한 조합이 가능하다.

Factory가 필요한 이유

@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
    @Assisted private val pokemon: Pokemon,      // ← 런타임 파라미터(개발자가 전달)
    private val detailsRepository: DetailsRepository,  // ← 컴파일 타임(Hilt가 주입) 
) : BaseViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(pokemon: Pokemon): DetailsViewModel
    }
}

런타임에 전달받은 navigation args를 전달받아 hiltViewModel을 생성하기 위해선 factory가 필요하기에 factory의 interface를 뷰모델 내에 정의해줘야 한다.

// 사용 - Factory를 통해 런타임 값 전달
@Composable
fun PokedexDetails(
  sharedTransitionScope: SharedTransitionScope,
  animatedContentScope: AnimatedContentScope,
  pokemon: Pokemon,
  detailsViewModel: DetailsViewModel = hiltViewModel(
    key = pokemon.name,
    creationCallback = { factory: DetailsViewModel.Factory ->
      factory.create(pokemon) // 여기서 pokemon 전달
    },
  ),
) { // ... }

이때 @AssistedFactory 어노테이션을 통해 Hilt에게 런타임 파라미터 전달을 위한 팩토리 구현체를 생성하도록 지시할 수 있다.

실제로 ksp를 통해 생성되는 Factory 코드

// feature/details/build/generated/ksp/debug/java/.../DetailesViewModel_Factory_Impl
@DaggerGenerated
@Generated(
    value = "dagger.internal.codegen.ComponentProcessor",
    comments = "https://dagger.dev"
)
public final class DetailsViewModel_Factory_Impl implements DetailsViewModel.Factory {
  private final DetailsViewModel_Factory delegateFactory;

  DetailsViewModel_Factory_Impl(DetailsViewModel_Factory delegateFactory) {
    this.delegateFactory = delegateFactory;
  }

  @Override
  public DetailsViewModel create(Pokemon pokemon) {
    return delegateFactory.get(pokemon);
  }

  public static Provider<DetailsViewModel.Factory> create(
      DetailsViewModel_Factory delegateFactory) {
    return InstanceFactory.create(new DetailsViewModel_Factory_Impl(delegateFactory));
  }

  public static dagger.internal.Provider<DetailsViewModel.Factory> createFactoryProvider(
      DetailsViewModel_Factory delegateFactory) {
    return InstanceFactory.create(new DetailsViewModel_Factory_Impl(delegateFactory));
  }
}

한계

Nav3가 아무리 Nav2에 비해 발전된 형태라 하리라도, Android 시스템 레벨의 Binder IPC 1MB 제한은 피할 수 없다.

Android Binder IPC

Android에서 앱 프로세스와 System Server 간 통신은 Binder 메커니즘을 사용한다.

이 Binder Transaction Buffer는 프로세스당 1MB로 고정되어 있으며, 해당 프로세스의 모든, 동시에 진행 중인 트랜잭션들이 이 버퍼를 공유한다.

버퍼에 담긴 데이터 총합이 1MB를 초과하면TransactionTooLargeException 이 발생한다.

왜 Binder IPC 방식을 사용하게 되었느지는 아래 블로그글을 확인해보면 좋을 듯 하다.
[Android] IPC , RPC ,Binder 알아보기

위에서 언급했듯, 일반적인 상황에선 화면 전환시 SnapshotStateList에 객체를 직접 저장하지만, Process Death 복원을 위해서는 결국 시스템에 상태를 저장해줄 필요가 있다. 이때 @Serizalizable 객체를 SavedState 포맷(Bundle)으로 저장하는데, 이 과정에서 Binder IPC를 거치게 되므로 1MB 제한이 적용된다.

따라서 1MB 초과의 큰 데이터는 Room, DataStore등에 저장하고 데이터의 고유 id만 전달하는 방식을 권장하는 것은 Nav3로 넘어와서도 유효하며, 이는 Nav3 뿐만 아니라 Circuit, Voyager, Decompose 등 모든 Navigation 라이브러리에 공통으로 적용된다.

결과

Nav3 라이브러리를 사용할 때 어떻게 argument를 넘기면 되는지, 기존 Nav2와 달라진 차이점과 장점들에 대해 확인해볼 수 있었다.

P.S

@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 의 코드

결국 좋은 패턴들은 수렴한다는 것을 알 수 있었다. 수렴 진화ㄷㄷ

fun interface?

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
https://developer.android.com/jetpack/androidx/releases/navigation3?hl=ko
https://github.com/android/nav3-recipes/issues/53

profile
실력은 고통의 총합이다. Android Developer

4개의 댓글

comment-user-thumbnail
2025년 11월 26일

오늘도 좋은 글 감사합니다^^

요즘 지훈님 글 보면서 Circuit 도 아장아장 걸어보고 있습니다 😊
혹시 Circuit 이랑 Nav3(혹은 외부 서드파티 네비게이션 라이브러리) 랑 같이 사용할 수 있는 좋은 방법이 있을까용 .. ?!

아무래도 Circuit 은 자체 네비게이션과 같이 묶여있는 개념인것 같아서 분리좀 시켜보고 싶어서요ㅎㅎ
혹쉬 비슷한 경험 있으시면 노하우 부탁드리겠습니다 : )

3개의 답글