|

SavedStateHandle 복원 기준 정리

SavedStateHandle에 navigation argument를 연결하는 대표 이미지
복원 키는 크게 저장하는 것이 아니라 다시 읽을 출발점을 남기는 일에 가깝다

SavedStateHandle에 navigation argument를 같이 넣는 이유는 값을 한 번 더 보관하려고가 아닙니다. process death 뒤에도 화면을 다시 어떤 조건으로 세워야 하는지 ViewModel이 잃지 않게 하려는 구조에 더 가깝습니다.

상세 화면의 itemId, 검색 화면의 query와 sort처럼 복원의 출발점이 되는 최소 키만 남기고, 실제 큰 데이터는 Repository에서 다시 읽는 패턴이 가장 자연스럽습니다.

이번 글에서는 configuration change와 process death를 먼저 나눈 뒤, 왜 navigation argument와 SavedStateHandle이 자주 같이 등장하는지 Compose 화면 복원 관점에서 단계적으로 풀어보겠습니다.

SavedStateHandle과 navigation argument 요약 카드
큰 데이터를 저장하는 구조가 아니라 최소 복원 키를 연결하는 구조다

왜 ViewModel만으론 부족할까

ViewModel은 configuration change에는 강하지만 system-initiated process death 대응이 필요하면 SavedStateHandle을 backup으로 검토할 수 있다고 공식 문서가 설명합니다. 즉 회전 대응과 프로세스 복원 대응은 같은 문제가 아닙니다.

  • configuration change: 같은 화면 범위의 ViewModel이 계속 살아남을 수 있다
  • process death: ViewModel 인스턴스 자체가 사라졌다고 보는 편이 맞다
  • 그래서 복원의 출발점이 되는 최소 키를 별도로 잡아둘 필요가 생긴다

여기서 navigation argument와 SavedStateHandle 조합이 등장합니다. 화면 진입에 필요했던 최소 입력을 ViewModel 재생성 후에도 다시 읽을 수 있게 하기 때문입니다.


argument와 잘 맞는 이유

navigation argument는 원래부터 화면 진입에 필요한 최소 입력 역할을 합니다. itemId, userId, orderId 같은 값은 그 자체가 복원의 좌표가 되기 때문에 SavedStateHandle과 결이 잘 맞습니다.

class ProductDetailViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repository: ProductRepository,
) : ViewModel() {

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

    val uiState: StateFlow<ProductDetailUiState> =
        repository.productFlow(itemId)
            .map { product -> ProductDetailUiState(product = product) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = ProductDetailUiState(isLoading = true)
            )
}

이 구조에서는 route도 가볍고, SavedStateHandle도 가볍고, 데이터 최신성은 Repository가 책임집니다. 역할이 섞이지 않는다는 점이 가장 큽니다.

navigation argument와 SavedStateHandle 복원 흐름도
route의 ID가 SavedStateHandle을 거쳐 ViewModel 초기화와 데이터 재로딩으로 이어진다

큰 객체를 넘기면 안 되는 이유

Compose Navigation 공식 가이드는 복잡한 객체를 route에 직접 실어 나르기보다, 고유 ID 같은 최소 인자만 넘기고 실제 데이터는 data layer에서 읽는 방식을 권장합니다. 이 원칙은 SavedStateHandle에도 그대로 적용됩니다.

  1. route가 무거워지면 직렬화와 Bundle 부담이 커진다
  2. 큰 객체를 SavedStateHandle에 다시 넣기 시작하면 stale data 위험이 커진다
  3. 실제 데이터 소스가 어디인지 흐려지고 복원 구조도 읽기 어려워진다

즉 route와 SavedStateHandle 모두 “본문 데이터 창고”가 아니라 다시 읽기 위한 좌표 보관함으로 보는 편이 안전합니다.

ID 전달과 큰 객체 전달 비교 카드
ID 중심 전달은 구조를 가볍게 하고 데이터 최신성 책임도 분명하게 만든다

검색 화면에서는

상세 화면은 itemId가 분명해서 쉽지만, 검색 화면은 query, sort, selectedFilter, 결과 목록, 로딩 여부, 에러 메시지가 섞여 있어 더 헷갈립니다. 이때 SavedStateHandle 1차 후보는 대개 query, sort, selectedFilter처럼 복원의 출발점이 되는 값입니다.

class SearchViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repository: SearchRepository,
) : ViewModel() {

    companion object {
        private const val KEY_QUERY = "query"
        private const val KEY_SORT = "sort"
    }

    private val queryFlow = MutableStateFlow(savedStateHandle[KEY_QUERY] ?: "")
    private val sortFlow = MutableStateFlow(savedStateHandle[KEY_SORT] ?: "recent")

    fun onQueryChanged(newQuery: String) {
        savedStateHandle[KEY_QUERY] = newQuery
        queryFlow.value = newQuery
    }

    fun onSortChanged(newSort: String) {
        savedStateHandle[KEY_SORT] = newSort
        sortFlow.value = newSort
    }
}

반대로 결과 목록 전체나 큰 응답 모델은 보통 다시 만들어질 결과에 가깝습니다. 그래서 저장 우선순위가 낮습니다.


rememberSaveable과 차이

공식 문서는 business logic에 쓰는 state는 ViewModel에 두고 SavedStateHandle로 저장할 수 있으며, UI logic에 가까운 state는 rememberSaveable을 먼저 보라고 설명합니다.

  • TextField 임시 입력, 펼침 여부 같은 composable 내부 UI 요소 → rememberSaveable 후보
  • 화면 진입 ID, 검색 조건, process death 뒤에도 필요한 복원 키 → ViewModel + SavedStateHandle 후보

즉 둘은 경쟁 관계라기보다 상태의 책임이 다릅니다.


자주 막히는 지점

  1. navigation argument와 UI state를 같은 것으로 보는 것
  2. SavedStateHandle을 작은 DB처럼 쓰는 것
  3. 큰 DTO를 route와 SavedStateHandle에 같이 실어 나르는 것
  4. 복원 키와 실제 데이터 책임을 섞는 것

이 패턴이 길게 보면 제일 안정적입니다. route에는 최소 ID만, SavedStateHandle에는 그 ID만, 실제 데이터는 Repository에서 다시 읽습니다.


빠르게 고르는 기준

  • 이 값은 화면 진입을 위한 최소 입력인가
  • process death 뒤에도 다시 필요할까
  • 이 값만 있으면 실제 데이터는 다시 로드할 수 있을까
  • 큰 비즈니스 데이터가 아니라 단순한 복원 키인가

관련해서 같이 보면 좋은 글은 SavedStateHandle과 rememberSaveable 차이, Navigation Component와 FragmentManager 차이, 안드로이드에서 ViewModel이 왜 필요한가입니다.


복원 키만 남긴다는 말의 뜻

SavedStateHandle은 화면의 모든 상태를 넣는 저장소가 아니라, 화면을 다시 세우는 출발점을 남기는 공간으로 보는 편이 더 안전합니다.

그래서 itemId, query, filter처럼 다시 읽을 수 있는 최소 키를 남기고, 실제 큰 데이터는 다시 로드하는 구조가 오래 버팁니다.

마무리

SavedStateHandle에 navigation argument를 같이 넣는 이유는 값을 더 많이 저장하려고가 아니라, ViewModel이 다시 만들어져도 화면 복원의 출발점을 잃지 않게 하려는 데 있습니다.

외부 기준으로는 Saved State module for ViewModel, Save UI state in Compose, Compose Navigation 문서를 함께 보면 흐름이 더 분명해집니다.

함께보면 좋은 글