주맨의 개발노트

[Navigation3] rememberNavBackStack()은 왜 Reflection을 사용할까? 본문

안드로이드

[Navigation3] rememberNavBackStack()은 왜 Reflection을 사용할까?

JooMan 2026. 9. 23. 23:11

Navigation 3에서 저장 가능한 back stack을 만들 때는 보통 rememberNavBackStack()을 사용합니다.

NavigationRoot.kt Kotlin
val backStack = rememberNavBackStack(Home)

이 API는 Compose 계층에서 back stack을 기억할 뿐 아니라 Configuration Change와 시스템에 의한 Process Death 이후에도 navigation state를 복원할 수 있도록 돕습니다.

사용하는 입장에서는 간단하지만, SavedStateConfiguration을 받지 않는 Android용 rememberNavBackStack(vararg elements: NavKey) 오버로드는 내부적으로 Reflection 기반 serializer를 사용합니다. 그렇다면 @Serializable로 serializer를 이미 생성했는데도 왜 Reflection이 필요할까요?

이 글에서는 rememberNavBackStack()의 serializer 연결 구조를 따라가며, 직렬화와 역직렬화에서 무엇을 동적으로 찾는지 살펴보겠습니다.

핵심은 NavBackStack의 각 원소를 처리할 serializer를 어떻게 결정하느냐에 있습니다.

rememberNavBackStack()의 Serializer 연결 구조

Android에서 제공하는 기본 rememberNavBackStack()은 개념적으로 다음과 같은 구조를 가집니다. 여기서 Home, ProductDetail 같은 back stack의 각 항목을 원소라고 부르겠습니다.

rememberNavBackStack 개념 구조 Kotlin
@Composable
fun rememberNavBackStack(
    vararg elements: NavKey,
): NavBackStack<NavKey> {
    return rememberSerializable(
        serializer = NavBackStackSerializer(
            elementSerializer = NavKeySerializer(),
        ),
    ) {
        NavBackStack(*elements)
    }
}
구성 요소 역할
rememberSerializable() 값을 Compose에서 기억하고 Saved State를 통해 저장·복원합니다.
NavBackStackSerializer back stack의 목록 구조를 직렬화합니다.
NavKeySerializer 목록에 들어 있는 각각의 NavKey 원소를 직렬화합니다.

NavBackStackSerializer는 원소의 구체적인 타입을 직접 판단하지 않습니다. 생성할 때 전달받은 elementSerializer에 각 원소의 처리를 위임합니다. configuration을 받지 않는 Android용 오버로드에서 그 역할을 맡는 것이 NavKeySerializer()입니다.

Step 1 · 저장과 복원 rememberSerializable이 back stack 값을 기억합니다.
↓
Step 2 · 목록 구조 NavBackStackSerializer가 stack의 목록 구조를 처리합니다.
↓
Step 3 · 원소 Serializer 선택 NavKeySerializer가 Home, ProductDetail 같은 구체 원소를 처리합니다.

따라서 rememberNavBackStack()에서 Reflection이 발생하는 직접적인 이유는 Saved State를 사용해서도, serializer를 사용해서도 아닙니다.

기본 API가 각 원소를 처리하기 위해 Reflection 기반의 NavKeySerializer를 선택했기 때문입니다.

@Serializable이 생성하는 개별 Serializer

다음과 같은 navigation key가 있다고 해보겠습니다. Kotlin Serialization 컴파일러 플러그인은 @Serializable이 선언된 각 클래스의 serializer를 컴파일 타임에 생성합니다.

Destination.kt Kotlin
@Serializable
data object Home : NavKey

@Serializable
data class ProductDetail(
    val productId: Long,
) : NavKey
컴파일 타임에 생성된 Serializer 접근 Kotlin
Home.serializer()
ProductDetail.serializer()

그러나 개별 serializer가 존재한다는 사실과 NavKey 타입의 값에 어떤 serializer를 사용해야 하는지 결정하는 문제는 다릅니다. Home에는 Home.serializer()를, ProductDetail에는 ProductDetail.serializer()를 연결하는 구현 타입과 serializer의 매핑 정보가 필요합니다.

다형성 매핑 정보 Text
NavKey
├── Home          → Home.serializer()
└── ProductDetail → ProductDetail.serializer()

NavKey는 일반 인터페이스입니다. 새로운 구현체가 다른 파일이나 모듈에서 추가될 수 있으므로 컴파일러가 전체 구현체 목록을 닫힌 집합으로 확정할 수 없습니다.

기본 rememberNavBackStack()은 개발자에게 이 구현체 목록을 등록하라고 요구하지 않습니다. 대신 현재 객체의 실제 클래스와 그 클래스의 serializer를 런타임에 찾습니다.

Encoding: 런타임 Serializer 탐색

직렬화 시점에는 실제 객체를 이미 가지고 있습니다. 따라서 문자열 클래스 이름으로 실제 클래스를 새로 찾을 필요는 없습니다. 현재 객체에서 런타임 클래스가 ProductDetail이라는 것을 확인할 수 있습니다.

직렬화할 NavKey Kotlin
val key: NavKey = ProductDetail(productId = 42)

NavKeySerializer는 현재 객체의 실제 클래스 정보를 기준으로 전체 클래스 이름과 런타임 serializer를 구합니다.

Encoding 핵심 동작 Kotlin
val className = value::class.java.name
val serializer = value::class.serializer()
Step 1 · 실제 타입 확인 객체에서 ProductDetail이라는 런타임 클래스를 확인합니다.
↓
Step 2 · Serializer 탐색 그 런타임 클래스에 해당하는 serializer를 찾습니다.
↓
Step 3 · 저장 전체 클래스 이름은 type으로, 객체 데이터는 value로 저장합니다.
개념적인 저장 결과 Text
type = "com.example.ProductDetail"
value.productId = 42

이 표현은 이해를 위한 예시이며 실제 저장 결과가 JSON 문자열이라는 뜻은 아닙니다. Saved State는 저장 가능한 값들을 담는 구조화된 컨테이너입니다.

직렬화 과정에서 Reflection이 필요한 핵심 이유는 이미 가진 객체의 클래스를 새로 찾기 위해서가 아닙니다. 그 런타임 클래스에 해당하는 serializer를 찾기 위해서입니다.

Decoding: 클래스와 Serializer 탐색

역직렬화 시점에는 상황이 다릅니다. 프로세스와 Activity가 재생성될 때는 원래의 ProductDetail 객체가 메모리에 존재하지 않습니다. Saved State에는 앞서 저장한 클래스 이름과 상태 값만 남아 있습니다.

Decoding 핵심 동작 Kotlin
val serializer = Class.forName(className)
    .kotlin
    .serializer()
Step 1 · type 읽기 저장된 "com.example.ProductDetail"을 읽습니다.
↓
Step 2 · Class.forName() 문자열을 실제 ProductDetail 클래스와 연결합니다.
↓
Step 3 · Serializer 탐색 찾은 클래스의 serializer를 구합니다.
↓
Step 4 · 객체 복원 value를 읽어 ProductDetail(productId = 42)를 만듭니다.

직렬화와 역직렬화의 차이를 정리하면 다음과 같습니다.

과정 실제 클래스 처리 Serializer 처리
직렬화 가지고 있는 객체에서 런타임 클래스 확인 해당 클래스의 serializer 탐색
역직렬화 Class.forName()으로 실제 클래스 탐색 찾은 클래스의 serializer 탐색

역직렬화에서는 실제 객체가 없기 때문에 저장된 클래스 이름을 실제 클래스로 연결하는 과정까지 필요합니다.

열린 다형성과 Reflection은 같은 문제가 아닙니다

여기까지 보면 NavKey가 열린 인터페이스이기 때문에 Reflection이 발생한다고 생각하기 쉽습니다. 하지만 열린 다형성 자체가 Reflection을 강제하는 것은 아닙니다.

열린 인터페이스를 유지하더라도 구현 타입과 serializer의 관계를 SerializersModule에 직접 등록할 수 있습니다.

NavigationSerialization.kt Kotlin
val module = SerializersModule {
    polymorphic(NavKey::class) {
        subclass(Home::class, Home.serializer())
        subclass(
            ProductDetail::class,
            ProductDetail.serializer(),
        )
    }
}

이 모듈은 객체를 직접 직렬화하지 않습니다. Home을 만나면 Home.serializer()를, ProductDetail을 만나면 ProductDetail.serializer()를 사용하라고 알려주는 등록 목록입니다.

기본 Android 오버로드

구현체 등록을 요구하지 않는 대신 NavKeySerializer가 런타임에 클래스와 serializer를 찾습니다.

명시적인 다형성 등록

SerializersModule이 구현 타입과 serializer의 매핑을 제공하므로 Reflection 기반 탐색을 피할 수 있습니다.

따라서 원인 관계는 다음과 같이 이해하는 편이 정확합니다.

Step 1 · 열린 다형성 NavKey는 새로운 구현체가 추가될 수 있는 일반 인터페이스입니다.
↓
Step 2 · 등록 생략 Android 기본 API는 구현체 serializer를 별도로 등록하라고 요구하지 않습니다.
↓
Step 3 · NavKeySerializer 선택 누락된 매핑을 대신해 런타임 탐색을 수행합니다.
↓
Step 4 · Reflection 직렬화에서는 serializer를, 역직렬화에서는 클래스와 serializer를 동적으로 찾습니다.

열린 다형성 자체가 Reflection을 발생시키는 것이 아니라, Android 기본 API가 구현체 등록 없이 동작하기 위해 Reflection 기반 NavKeySerializer를 선택한 것입니다.

공식 문서에서 확인해보기

공식 rememberNavBackStack() 문서는 configuration을 받지 않는 Android용 오버로드가 Reflection 기반 serializer로 상태를 저장하고 복원한다고 설명합니다. 이 경로는 Android에서 열린 다형성 subtype을 수동 등록하지 않아도 처리하지만, 다른 플랫폼에서는 configuration 오버로드와 명시적인 subtype 등록이 필요합니다.

NavKeySerializer 문서에서도 구체적인 동작을 확인할 수 있습니다. 직렬화할 때는 실제 NavKey의 전체 클래스 이름과 런타임 serializer로 인코딩한 객체를 저장합니다. 복원할 때는 Class.forName()으로 클래스를 찾고, 해당 클래스의 serializer로 객체를 역직렬화합니다.

따라서 rememberNavBackStack()에서 Reflection이 발생한다는 설명은 열린 다형성에 대한 추측이 아닙니다. Android 기본 오버로드의 serializer 전략과 NavKeySerializer의 공식 동작에서 확인할 수 있는 내용입니다.

정리하며

@Serializable이 선언된 각 NavKey 구현 클래스에는 컴파일 타임에 생성된 serializer가 있습니다. 기본 rememberNavBackStack()에서 부족한 것은 개별 serializer가 아니라, 열린 NavKey와 그 구현체 serializer 사이의 매핑 정보입니다.

Android 기본 API는 개발자가 이 매핑을 등록하지 않아도 간단히 사용할 수 있도록 NavKeySerializer를 제공합니다. 그 편의성을 위해 직렬화할 때는 런타임 serializer를 찾고, 역직렬화할 때는 클래스와 serializer를 함께 찾습니다.

결국 핵심은 한 문장으로 정리할 수 있습니다.

기본 rememberNavBackStack()은 열린 다형성을 간편하게 처리하기 위해 Reflection 기반 NavKeySerializer를 사용합니다.

그렇다면 sealed interface로 구현 타입의 범위를 닫으면 Reflection은 자동으로 사라질까요? 다음 글에서는 sealed 계층만 선언해서는 충분하지 않은 이유와, 생성된 serializer를 실제 back stack 저장 경로에 연결하는 방법을 살펴보겠습니다.

참고 문서

아래 공식 API 문서에서 Android 기본 오버로드의 Reflection 사용과 NavKeySerializer의 encoding·decoding 동작을 확인할 수 있습니다.

핵심 정리
  • @Serializable은 개별 타입의 serializer를 생성합니다. 그 자체로 NavKey 구현체와 serializer의 다형성 매핑까지 제공하는 것은 아닙니다.
  • Reflection의 직접적인 출발점은 NavKeySerializer입니다. Saved State나 직렬화 자체가 Reflection을 강제하는 것은 아닙니다.
  • Encoding에서는 런타임 serializer를 찾습니다. 실제 객체가 있으므로 클래스를 문자열로부터 다시 찾을 필요는 없습니다.
  • Decoding에서는 클래스와 serializer를 모두 찾습니다. Class.forName()으로 저장된 타입 이름을 실제 클래스와 연결합니다.
  • 열린 다형성은 Reflection과 동의어가 아닙니다. SerializersModule로 subtype을 명시적으로 등록하면 런타임 탐색 없이도 다형성을 처리할 수 있습니다.
Comments