| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- Kotlin
- WebView
- OkHttp Interceptor
- 영어공부
- Kotlin FCM
- 66챌린지
- Android Jetpack
- Android WebView
- Android ViewPager2
- 프로그래머스 알고리즘
- Android Navigation
- 카카오 알고리즘
- coroutine
- 알고리즘 자바
- Android
- android recyclerview
- 습관만들기
- Android 12
- 안드로이드
- 영어독립365
- 안드로이드 갤러리 접근
- scope function
- DataBinding
- 코틀린 코루틴
- MVP Architecture
- 안드로이드 카카오 로그인
- Android 12 대응
- Android Interceptor
- Android ProgressBar
- Java
- Today
- Total
주맨의 개발노트
[Navigation3] rememberNavBackStack()은 왜 Reflection을 사용할까? 본문
Navigation 3에서 저장 가능한 back stack을 만들 때는 보통 rememberNavBackStack()을 사용합니다.
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의 각 항목을 원소라고 부르겠습니다.
@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()입니다.
rememberSerializable이 back stack 값을 기억합니다.
NavBackStackSerializer가 stack의 목록 구조를 처리합니다.
NavKeySerializer가 Home, ProductDetail 같은 구체 원소를 처리합니다.
따라서 rememberNavBackStack()에서 Reflection이 발생하는 직접적인 이유는 Saved State를 사용해서도, serializer를 사용해서도 아닙니다.
기본 API가 각 원소를 처리하기 위해 Reflection 기반의
NavKeySerializer를 선택했기 때문입니다.
@Serializable이 생성하는 개별 Serializer
다음과 같은 navigation key가 있다고 해보겠습니다. Kotlin Serialization 컴파일러 플러그인은 @Serializable이 선언된 각 클래스의 serializer를 컴파일 타임에 생성합니다.
@Serializable
data object Home : NavKey
@Serializable
data class ProductDetail(
val productId: Long,
) : NavKey
Home.serializer()
ProductDetail.serializer()
그러나 개별 serializer가 존재한다는 사실과 NavKey 타입의 값에 어떤 serializer를 사용해야 하는지 결정하는 문제는 다릅니다. Home에는 Home.serializer()를, ProductDetail에는 ProductDetail.serializer()를 연결하는 구현 타입과 serializer의 매핑 정보가 필요합니다.
NavKey
├── Home → Home.serializer()
└── ProductDetail → ProductDetail.serializer()
NavKey는 일반 인터페이스입니다. 새로운 구현체가 다른 파일이나 모듈에서 추가될 수 있으므로 컴파일러가 전체 구현체 목록을 닫힌 집합으로 확정할 수 없습니다.
기본 rememberNavBackStack()은 개발자에게 이 구현체 목록을 등록하라고 요구하지 않습니다. 대신 현재 객체의 실제 클래스와 그 클래스의 serializer를 런타임에 찾습니다.
Encoding: 런타임 Serializer 탐색
직렬화 시점에는 실제 객체를 이미 가지고 있습니다. 따라서 문자열 클래스 이름으로 실제 클래스를 새로 찾을 필요는 없습니다. 현재 객체에서 런타임 클래스가 ProductDetail이라는 것을 확인할 수 있습니다.
val key: NavKey = ProductDetail(productId = 42)
NavKeySerializer는 현재 객체의 실제 클래스 정보를 기준으로 전체 클래스 이름과 런타임 serializer를 구합니다.
val className = value::class.java.name
val serializer = value::class.serializer()
ProductDetail이라는 런타임 클래스를 확인합니다.
type으로, 객체 데이터는 value로 저장합니다.
type = "com.example.ProductDetail"
value.productId = 42
이 표현은 이해를 위한 예시이며 실제 저장 결과가 JSON 문자열이라는 뜻은 아닙니다. Saved State는 저장 가능한 값들을 담는 구조화된 컨테이너입니다.
직렬화 과정에서 Reflection이 필요한 핵심 이유는 이미 가진 객체의 클래스를 새로 찾기 위해서가 아닙니다. 그 런타임 클래스에 해당하는 serializer를 찾기 위해서입니다.
Decoding: 클래스와 Serializer 탐색
역직렬화 시점에는 상황이 다릅니다. 프로세스와 Activity가 재생성될 때는 원래의 ProductDetail 객체가 메모리에 존재하지 않습니다. Saved State에는 앞서 저장한 클래스 이름과 상태 값만 남아 있습니다.
val serializer = Class.forName(className)
.kotlin
.serializer()
"com.example.ProductDetail"을 읽습니다.
ProductDetail 클래스와 연결합니다.
value를 읽어 ProductDetail(productId = 42)를 만듭니다.
직렬화와 역직렬화의 차이를 정리하면 다음과 같습니다.
| 과정 | 실제 클래스 처리 | Serializer 처리 |
|---|---|---|
| 직렬화 | 가지고 있는 객체에서 런타임 클래스 확인 | 해당 클래스의 serializer 탐색 |
| 역직렬화 | Class.forName()으로 실제 클래스 탐색 |
찾은 클래스의 serializer 탐색 |
역직렬화에서는 실제 객체가 없기 때문에 저장된 클래스 이름을 실제 클래스로 연결하는 과정까지 필요합니다.
열린 다형성과 Reflection은 같은 문제가 아닙니다
여기까지 보면 NavKey가 열린 인터페이스이기 때문에 Reflection이 발생한다고 생각하기 쉽습니다. 하지만 열린 다형성 자체가 Reflection을 강제하는 것은 아닙니다.
열린 인터페이스를 유지하더라도 구현 타입과 serializer의 관계를 SerializersModule에 직접 등록할 수 있습니다.
val module = SerializersModule {
polymorphic(NavKey::class) {
subclass(Home::class, Home.serializer())
subclass(
ProductDetail::class,
ProductDetail.serializer(),
)
}
}
이 모듈은 객체를 직접 직렬화하지 않습니다. Home을 만나면 Home.serializer()를, ProductDetail을 만나면 ProductDetail.serializer()를 사용하라고 알려주는 등록 목록입니다.
구현체 등록을 요구하지 않는 대신 NavKeySerializer가 런타임에 클래스와 serializer를 찾습니다.
SerializersModule이 구현 타입과 serializer의 매핑을 제공하므로 Reflection 기반 탐색을 피할 수 있습니다.
따라서 원인 관계는 다음과 같이 이해하는 편이 정확합니다.
NavKey는 새로운 구현체가 추가될 수 있는 일반 인터페이스입니다.
열린 다형성 자체가 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을 명시적으로 등록하면 런타임 탐색 없이도 다형성을 처리할 수 있습니다.
'안드로이드' 카테고리의 다른 글
| [Navigation3] sealed NavKey만으로는 부족하다: Reflection 없는 Back Stack 설계 (0) | 2026.09.24 |
|---|---|
| [Navigation3] NavController 명령에서 back stack 소유로의 전환 (0) | 2026.08.15 |
| [CD] Firebase App Distribution으로 테스트 앱 배포 자동화하기 (0) | 2026.06.16 |
| [Jetpack Compose] val인데 왜 Unstable일까? Compose Stability 이해하기 (0) | 2026.06.10 |
| [Android] MVVM에서 MVI로 - 사용자 액션과 상태 변화를 명시적으로 다루기 (0) | 2026.05.31 |
