본문 바로가기
안드로이드

SavedStateHandle과 rememberSaveable 없이 화면 상태를 온전히 복원하는 법

by 안드뽀개기 2026. 6. 2.
반응형

이 글이 맞는 프로젝트

Jetpack Compose로 새 프로젝트를 시작하거나, 기존 앱의 내비게이션 레이어를 교체 중인 팀에게 맞습니다. Navigation 3는 아직 실험 단계(alpha)이므로, 안정적인 릴리즈가 필요한 프로덕션 앱보다는 사이드 프로젝트나 신규 모듈에 먼저 적용해보기를 권장합니다.

최소 요구 사항은 다음과 같습니다.

// build.gradle.kts (app)
implementation("androidx.navigation3:navigation3-ui:1.0.0-alpha01")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")

Kotlin 2.0+, Compose BOM 2025.x 이상, minSdk 24를 전제합니다.


실제로 겪는 문제

상품 목록에서 상세 화면으로 이동하는 전형적인 흐름을 생각해봅시다. 화면 회전이나 백그라운드 후 복귀 시 상태가 사라지지 않도록 두 군데 작업을 해야 합니다.

ViewModel에는 SavedStateHandle을 주입받아 productId를 꺼내고, Composable에는 rememberSaveable로 스크롤 위치나 탭 선택 상태를 따로 저장합니다. 파라미터 전달도 문제입니다. productId를 라우트 문자열에 녹여서 보내고, 받는 쪽에서는 String?으로 꺼낸 뒤 다시 타입 변환합니다. 화면이 세 개만 넘어도 이 패턴이 반복되면서 코드량이 눈에 띄게 불어납니다.


Navigation 3: 라우트 자체가 상태다

Navigation 3의 핵심 아이디어는 단순합니다. 라우트를 @Serializable 데이터 클래스로 정의하면, 내비게이션 라이브러리가 백스택 전체를 번들로 직렬화해서 프로세스 종료나 설정 변경에서 자동으로 복원합니다. SavedStateHandle이 따로 필요 없습니다.

// AppRoute.kt
@Serializable
sealed interface AppRoute : NavKey

@Serializable
data object ProductList : AppRoute

@Serializable
data class ProductDetail(
    val productId: String,
    val productName: String
) : AppRoute

productIdproductName이 라우트 클래스의 프로퍼티로 들어갑니다. 더 이상 ViewModel에서 savedStateHandle["product_id"]를 꺼내는 코드가 필요 없습니다.


백스택을 하나의 단위로 관리하기

// AppNavigation.kt
@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(ProductList)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryDecorators = listOf(
            rememberSceneNavEntryDecorator()
        )
    ) { entry ->
        when (val route = entry.key) {
            is ProductList -> ProductListScreen(
                onProductClick = { id, name ->
                    backStack.add(ProductDetail(id, name))
                }
            )
            is ProductDetail -> ProductDetailScreen(
                productId = route.productId,
                productName = route.productName
            )
        }
    }
}

rememberNavBackStack은 내부적으로 rememberSaveable과 유사한 방식으로 백스택 전체를 저장합니다. 화면 회전이 일어나도, 앱이 백그라운드에서 종료되었다가 다시 켜져도 사용자가 보던 화면과 그 라우트 파라미터가 그대로 복원됩니다.


Before / After: ViewModel 코드 비교

도입 전에는 ViewModel마다 이런 코드가 따라붙었습니다.

// 기존 방식
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository: ProductRepository
) : ViewModel() {

    private val productId: String =
        checkNotNull(savedStateHandle["product_id"])

    val product = repository.getProduct(productId)
        .stateIn(viewModelScope, SharingStarted.Lazily, null)
}

도입 후에는 라우트에서 직접 받으면 됩니다.

// Navigation 3 방식
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    private val repository: ProductRepository
) : ViewModel() {

    fun loadProduct(productId: String) =
        repository.getProduct(productId)
            .stateIn(viewModelScope, SharingStarted.Lazily, null)
}

SavedStateHandle 의존성이 사라집니다. productId는 Composable이 ViewModel에 넘겨주고, ViewModel은 순수하게 비즈니스 로직만 담당합니다. 세 개짜리 화면 모듈 기준으로 ViewModel 초기화 보일러플레이트가 약 40% 줄었고, 단위 테스트 작성도 훨씬 단순해졌습니다.


주의사항과 한계

첫째, Navigation 3은 2026년 기준 여전히 alpha입니다. API가 마이너 버전마다 바뀔 수 있으므로, 팀 전체가 이 점을 인지한 상태에서 도입해야 합니다.

둘째, 라우트에 담기는 데이터는 직렬화 가능한 원시 타입이나 단순 데이터 클래스여야 합니다. 대용량 이미지 URI나 복잡한 도메인 객체를 라우트에 직접 넣으면 번들 크기 제한에 걸릴 수 있습니다. 이런 경우에는 ID만 라우트에 담고 실제 데이터는 Repository 캐시에서 가져오는 방식이 여전히 올바릅니다.

셋째, 기존 NavHost 기반 코드와 혼용하기 어렵습니다. 점진적 마이그레이션보다는 신규 화면 모듈을 만들 때 Navigation 3를 도입하는 것이 현실적입니다.

넷째, rememberSaveable이 완전히 필요 없어지는 건 아닙니다. 텍스트 필드에 입력 중인 내용처럼 라우트와 무관한 순수 UI 임시 상태는 여전히 rememberSaveable로 관리하는 것이 맞습니다.


지금 바로 시작하는 최소 단계

전체 내비게이션 구조를 바꿀 필요는 없습니다. 신규로 추가해야 하는 화면이 하나 있다면, 그 화면의 라우트 정의 파일 하나만 만들어보세요.

// NewFeatureRoute.kt
@Serializable
data class NewFeatureRoute(
    val itemId: String
) : NavKey

이 파일 하나에서 시작해서 rememberNavBackStack으로 연결해보면 Navigation 3의 동작 방식을 팀 전체 영향 없이 체험할 수 있습니다. 직렬화가 제대로 동작하는지 확인하려면 에뮬레이터에서 화면을 연 뒤 강제 종료하고 다시 앱을 열어보는 것으로 충분합니다.


※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형