주맨의 개발노트

[Navigation3] sealed NavKey만으로는 부족하다: Reflection 없는 Back Stack 설계 본문

안드로이드

[Navigation3] sealed NavKey만으로는 부족하다: Reflection 없는 Back Stack 설계

JooMan 2026. 9. 24. 21:04

[Navigation3] rememberNavBackStack()은 왜 Reflection을 사용할까?에서는 Android의 기본 rememberNavBackStack()이 Reflection 기반 NavKeySerializer를 사용하는 이유를 살펴봤습니다.

핵심은 열린 다형성인 NavKey 자체가 Reflection을 강제하는 것이 아니었습니다. 기본 API가 별도의 구현체 등록 없이 간단하게 동작할 수 있도록 NavKeySerializer를 선택했고, 이 serializer가 런타임에 실제 클래스와 serializer를 탐색하기 때문에 Reflection이 발생합니다.

그렇다면 앱에서 사용하는 navigation key를 sealed interface로 정의하면 Reflection을 피할 수 있을까요?

AppNavKey.kt Kotlin
@Serializable
sealed interface AppNavKey : NavKey

절반은 맞지만 이것만으로는 충분하지 않습니다. 타입 계층을 닫는 것과 그 타입에 맞는 serializer를 실제 저장 경로에서 사용하는 것은 서로 다른 문제이기 때문입니다.

Reflection을 피하려면 타입 계층을 닫는 것뿐 아니라, 그 계층에 대해 컴파일 타임에 생성된 serializer를 실제 back stack 저장 경로에 연결해야 합니다.

이 글에서는 sealed AppNavKey를 이용해 앱 전용 back stack을 구성하는 방법과, 이 구조가 단순한 성능 최적화를 넘어 navigation 설계에 어떤 이점을 주는지 살펴보겠습니다.

닫힌 Navigation Key 계층: AppNavKey

쇼핑 앱에서 홈, 상품 목록, 상품 상세 destination을 사용한다고 해보겠습니다. 모든 key는 앱 전용 AppNavKey를 구현하도록 제한합니다.

AppNavKey.kt Kotlin
@Serializable
sealed interface AppNavKey : NavKey

@Serializable
data object Home : AppNavKey

@Serializable
data object ProductList : AppNavKey

@Serializable
data class ProductDetail(
    val productId: Long,
) : AppNavKey

AppNavKey는 sealed interface이므로 직접적인 하위 타입의 범위가 제한됩니다. Kotlin Serialization 컴파일러 플러그인은 이 닫힌 계층을 바탕으로 각 구현 클래스의 serializer뿐 아니라, AppNavKey 값이 어떤 하위 타입인지 구분하고 알맞은 serializer로 연결하는 다형성 serializer도 생성할 수 있습니다.

컴파일 타임에 알려진 타입 매핑 Text
AppNavKey
├── Home          → Home.serializer()
├── ProductList   → ProductList.serializer()
└── ProductDetail → ProductDetail.serializer()

열린 NavKey와 달리 AppNavKey용 sealed serializer는 자신이 처리해야 하는 하위 타입과 각 하위 타입의 serializer를 컴파일 타임에 알고 있습니다.

직렬화할 때는 현재 값의 타입에 맞는 serializer를 선택하고, 역직렬화할 때는 저장된 타입 식별자를 이미 알고 있는 하위 타입과 연결할 수 있습니다. 클래스 이름으로 실제 클래스를 찾고, 그 클래스에서 serializer를 다시 탐색할 필요가 없습니다.

sealed 선언만으로 Reflection이 사라지지는 않습니다

가장 주의해야 할 지점은 sealed 계층을 선언하는 것만으로 기본 API의 동작이 달라지지는 않는다는 점입니다. 다음처럼 AppNavKey를 sealed 계층으로 만들었더라도 기본 API를 그대로 사용하면 Reflection 경로가 유지됩니다.

기본 Android 오버로드 사용 Kotlin
val backStack = rememberNavBackStack(Home)

SavedStateConfiguration을 받지 않는 Android용 rememberNavBackStack(vararg elements: NavKey) 오버로드는 반환 타입을 NavBackStack<NavKey>로 만들고, 원소를 처리할 serializer로 NavKeySerializer를 선택합니다.

기본 API의 원소 Serializer Kotlin
NavBackStack<NavKey>

NavBackStackSerializer(
    elementSerializer = NavKeySerializer(),
)
Step 1 · 실제 값 Home : AppNavKey를 기본 API에 전달합니다.
↓
Step 2 · 타입 경계 확장 기본 오버로드는 NavBackStack<NavKey>를 반환합니다.
↓
Step 3 · Serializer 선택 sealed serializer가 아니라 NavKeySerializer가 원소를 처리합니다.
↓
Step 4 · Reflection 유지 런타임 클래스와 serializer를 탐색하는 경로가 그대로 사용됩니다.

함수에 전달한 실제 값이 Home : AppNavKey인지와 관계없이 기본 API의 serializer 선택은 바뀌지 않습니다.

중요한 것은 객체의 실제 타입이 sealed 계층에 속한다는 사실이 아니라, 어떤 serializer를 실제 저장과 복원에 사용하느냐입니다.

생성된 Serializer를 Back Stack 저장 경로에 연결하기

Reflection 경로를 벗어나려면 NavBackStack의 원소 타입을 AppNavKey로 유지하고, 해당 타입에 대해 생성된 serializer를 rememberSerializable()에 전달해야 합니다. 저장 경로 전체가 앱의 닫힌 타입을 기준으로 이어지게 만드는 것입니다.

rememberAppNavBackStack.kt Kotlin
@Composable
fun rememberAppNavBackStack(
    vararg elements: AppNavKey,
): NavBackStack<AppNavKey> {
    return rememberSerializable(
        serializer = serializer<NavBackStack<AppNavKey>>(),
    ) {
        NavBackStack(*elements)
    }
}

사용하는 쪽에서는 기본 API와 거의 같은 형태로 호출할 수 있습니다. 차이는 back stack의 원소 타입이 넓은 NavKey가 아니라 앱이 정의한 AppNavKey로 유지된다는 점입니다.

App.kt Kotlin
@Composable
fun App() {
    val backStack = rememberAppNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        onBack = {
            if (backStack.size > 1) {
                backStack.removeLastOrNull()
            }
        },
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(
                    onOpenProducts = {
                        backStack.add(ProductList)
                    },
                )
            }

            entry<ProductList> {
                ProductListScreen(
                    onProductClick = { productId ->
                        backStack.add(ProductDetail(productId))
                    },
                )
            }

            entry<ProductDetail> { key ->
                ProductDetailScreen(productId = key.productId)
            }
        },
    )
}

serializer<NavBackStack<AppNavKey>>()의 의미

핵심은 serializer<NavBackStack<AppNavKey>>()입니다. 이 호출은 런타임에 하위 클래스를 조사해 새로운 serializer를 만드는 것이 아닙니다. Kotlin Serialization 컴파일러 플러그인이 미리 생성한 타입 정보를 이용해 NavBackStack<AppNavKey>를 처리할 serializer를 가져옵니다.

NavBackStack은 내부 원소 처리를 AppNavKey의 serializer에 위임하고, AppNavKey의 sealed serializer는 컴파일 타임에 알고 있는 하위 타입의 serializer를 선택합니다.

Step 1 · Back Stack 타입 NavBackStack<AppNavKey>
↓
Step 2 · Back Stack Serializer 생성된 serializer가 목록 구조의 저장과 복원을 담당합니다.
↓
Step 3 · Sealed Serializer AppNavKey의 닫힌 타입 정보를 기준으로 원소 serializer를 선택합니다.
↓
Step 4 · 구체 타입 Serializer Home, ProductList, ProductDetail serializer로 연결됩니다.

따라서 기본 NavKeySerializer가 수행했던 클래스 이름 기반의 실제 클래스 탐색과, 그 클래스에 해당하는 serializer의 런타임 탐색이 필요하지 않습니다.

Reflection을 피하는 조건은 닫힌 AppNavKey 계층과 AppNavKey를 기준으로 생성된 serializer 사용의 조합입니다.

기본 Reflection 경로와 앱 전용 Sealed 경로

두 방식의 차이는 navigation key가 sealed인지 아닌지만 비교해서는 잘 보이지 않습니다. back stack의 원소 타입이 무엇으로 유지되고, 실제로 어떤 serializer가 연결되는지를 끝까지 따라가야 합니다.

기본 Android Reflection 경로

NavBackStack<NavKey>와 NavKeySerializer를 사용합니다. 등록 과정은 없지만 런타임에 클래스와 serializer를 탐색합니다.

앱 전용 Sealed 경로

NavBackStack<AppNavKey>와 생성된 sealed serializer를 사용합니다. 이미 알려진 하위 타입의 serializer를 선택합니다.

기본 Android Reflection 경로 Text
rememberNavBackStack(Home)
    ↓
NavBackStack<NavKey>
    ↓
NavBackStackSerializer
    ↓
NavKeySerializer
    ↓
런타임 클래스와 serializer 탐색
앱 전용 Sealed Serializer 경로 Text
rememberAppNavBackStack(Home)
    ↓
NavBackStack<AppNavKey>
    ↓
컴파일 타임에 생성된 serializer
    ↓
AppNavKey sealed serializer
    ↓
이미 알고 있는 하위 타입의 serializer 선택

세부 차이를 표로 정리하면 다음과 같습니다.

구분 기본 rememberNavBackStack() 앱 전용 rememberAppNavBackStack()
back stack 타입 NavBackStack<NavKey> NavBackStack<AppNavKey>
원소 처리 NavKeySerializer 생성된 sealed serializer
구현 타입 결정 런타임 탐색 컴파일 타임에 알려진 하위 타입 활용
Reflection 사용 NavKeySerializer의 Reflection 경로를 사용하지 않음
새로운 key 추가 별도 등록 불필요 sealed 계층에 @Serializable 하위 타입 추가
확장 범위 열린 구조 앱이 정의한 닫힌 구조

Reflection 제거 이상의 설계 이점

Reflection을 피한다는 점은 눈에 띄는 차이지만, 앱 설계에서는 타입 경계를 명확히 한다는 장점이 더 중요할 수 있습니다.

기본 API의 NavBackStack<NavKey>는 NavKey를 구현한 값이라면 앱의 navigation 정책과 관계없는 값도 받을 수 있습니다. 반면 NavBackStack<AppNavKey>는 back stack에 들어갈 수 있는 값의 범위를 앱에서 정의한 navigation key로 제한합니다.

앱 전용 타입 경계 Kotlin
fun navigateTo(
    backStack: NavBackStack<AppNavKey>,
    destination: AppNavKey,
) {
    backStack.add(destination)
}

이 구조가 제공하는 이점을 구현 관점에서 나누어보겠습니다.

1경계

허용되는 navigation key가 타입으로 드러납니다

앱과 무관한 NavKey 구현체가 실수로 back stack에 들어가는 것을 컴파일 단계에서 막을 수 있습니다.

2등록

별도의 subtype 등록 목록을 동기화할 필요가 없습니다

sealed 계층에 @Serializable 하위 타입을 추가하면 생성된 serializer의 닫힌 타입 정보에 포함됩니다.

3분기

when의 exhaustiveness를 활용할 수 있습니다

navigation key를 해석하는 코드가 모든 destination을 처리하는지 컴파일러의 도움을 받을 수 있습니다.

4경로

직렬화 경로가 코드에 명시적으로 드러납니다

어떤 타입 범위와 serializer가 저장과 복원에 사용되는지 추적하기 쉬워집니다.

5플랫폼

Android 전용 Reflection 경로에서 벗어납니다

JVM Reflection에 의존하지 않는 구조는 다른 플랫폼으로 navigation state를 확장할 때 유리합니다.

이 설계는 Reflection 호출 몇 번을 줄이는 최적화라기보다, 앱이 사용할 navigation key의 범위와 저장 계약을 AppNavKey 타입으로 일치시키는 방법에 가깝습니다.

열린 확장이 필요하다면 SerializersModule

sealed 계층이 항상 정답인 것은 아닙니다. 여러 feature module이나 외부 라이브러리에서 새로운 NavKey 구현체를 독립적으로 추가해야 한다면 모든 구현체를 하나의 닫힌 계층으로 관리하기 어려울 수 있습니다.

이 경우에는 열린 다형성을 유지하면서 SerializersModule에 구현 타입과 serializer를 명시적으로 등록할 수 있습니다.

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

val configuration = SavedStateConfiguration {
    serializersModule = module
}

그리고 configuration을 받는 rememberNavBackStack() 오버로드를 사용합니다.

열린 다형성 Back Stack Kotlin
val backStack = rememberNavBackStack(
    configuration = configuration,
    Home,
)

이 방식에서는 NavKeySerializer가 Reflection으로 구현체를 찾는 대신, SerializersModule에 등록된 구현 타입과 serializer의 매핑을 사용합니다.

다만 새로운 key를 추가할 때마다 등록도 함께 추가해야 합니다. 등록이 빠졌을 때 자동으로 Reflection 방식으로 전환되는 것이 아니라, 직렬화 과정에서 serializer를 찾지 못해 오류가 발생합니다.

앱 구조에 따른 선택 기준은 다음처럼 정리할 수 있습니다.

앱 구조 적합한 방식
navigation key를 하나의 닫힌 계층으로 관리할 수 있음 sealed AppNavKey + 생성된 serializer
여러 모듈이나 외부 구성 요소가 구현 타입을 확장해야 함 열린 NavKey + SerializersModule
Android에서 간단한 기본 구성이 우선임 기본 rememberNavBackStack()

Reflection 제거만을 목표로 방식을 선택하기보다, 앱에서 navigation key를 누가 정의하고 어디까지 확장할 수 있어야 하는지를 먼저 결정하는 편이 좋습니다.

Reflection 제거와 실제 성능

Reflection 기반 탐색에는 직접 생성된 serializer를 호출하는 것보다 추가 비용이 있습니다. 특히 많은 원소를 역직렬화할 때는 클래스와 serializer를 런타임에 찾는 비용이 누적될 수 있습니다.

하지만 일반적인 앱의 back stack은 크기가 제한적이고, 저장과 복원도 매 프레임 반복되는 작업이 아닙니다. 따라서 Reflection을 제거했다는 이유만으로 사용자가 체감할 정도의 navigation 성능 향상이 생긴다고 단정해서는 안 됩니다.

이 방식을 선택하는 더 분명한 이유는 앱 전용 key 타입 유지, 등록 누락 방지, 직렬화 경로의 명시성, 멀티플랫폼 호환성, 닫힌 navigation 계약입니다. 성능은 실제 앱의 back stack 크기와 복원 빈도를 기준으로 측정해야 하는 별도의 문제입니다.

정리하며

sealed AppNavKey를 선언하면 컴파일러가 하위 타입의 범위를 알고, Kotlin Serialization이 닫힌 다형성을 처리하는 serializer를 생성할 수 있습니다. 하지만 기본 rememberNavBackStack()을 그대로 사용하면 내부적으로 여전히 NavKeySerializer가 선택되므로 Reflection 경로는 유지됩니다.

Reflection 없는 앱 전용 back stack을 구성하려면 닫힌 key 계층과 생성된 serializer를 실제 저장 경로에서 함께 사용해야 합니다.

Reflection 없는 저장 경로 Text
sealed AppNavKey
    +
serializer<NavBackStack<AppNavKey>>()
    +
rememberSerializable()

이 구조는 Reflection을 피하기 위한 구현이면서 동시에 앱의 navigation key 범위를 타입으로 제한하는 설계입니다.

sealed interface를 선언하는 것보다 중요한 것은, 그 타입에 대해 생성된 serializer가 실제 저장과 복원 경로에서 사용되도록 연결하는 것입니다.

참고 문서

아래 공식 문서에서 NavBackStack의 closed-polymorphism 예제와 기본 Android 오버로드의 Reflection 동작을 확인할 수 있습니다.

핵심 정리
  • sealed AppNavKey 선언만으로는 부족합니다. 기본 Android 오버로드를 사용하면 여전히 NavKeySerializer의 Reflection 경로를 탑니다.
  • 타입 정보가 저장 경로까지 이어져야 합니다. serializer<NavBackStack<AppNavKey>>()를 rememberSerializable()에 연결해야 합니다.
  • 앱 전용 back stack은 타입 경계를 유지합니다. NavBackStack<AppNavKey>에는 앱이 허용한 navigation key만 들어갈 수 있습니다.
  • 열린 확장이 필요하다면 SerializersModule을 사용합니다. 대신 새로운 subtype을 추가할 때 명시적인 등록도 함께 관리해야 합니다.
  • 핵심 이점은 성능보다 설계 명시성에 가깝습니다. 닫힌 navigation 계약, 직렬화 경로, 멀티플랫폼 확장 가능성을 코드에 드러낼 수 있습니다.
Comments