
SavedStateHandle에 navigation argument를 같이 넣는 이유는 값을 한 번 더 보관하려고가 아닙니다. process death 뒤에도 화면을 다시 어떤 조건으로 세워야 하는지 ViewModel이 잃지 않게 하려는 구조에 더 가깝습니다.
상세 화면의 itemId, 검색 화면의 query와 sort처럼 복원의 출발점이 되는 최소 키만 남기고, 실제 큰 데이터는 Repository에서 다시 읽는 패턴이 가장 자연스럽습니다.
이번 글에서는 configuration change와 process death를 먼저 나눈 뒤, 왜 navigation argument와 SavedStateHandle이 자주 같이 등장하는지 Compose 화면 복원 관점에서 단계적으로 풀어보겠습니다.

왜 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가 책임집니다. 역할이 섞이지 않는다는 점이 가장 큽니다.

큰 객체를 넘기면 안 되는 이유
Compose Navigation 공식 가이드는 복잡한 객체를 route에 직접 실어 나르기보다, 고유 ID 같은 최소 인자만 넘기고 실제 데이터는 data layer에서 읽는 방식을 권장합니다. 이 원칙은 SavedStateHandle에도 그대로 적용됩니다.
- route가 무거워지면 직렬화와 Bundle 부담이 커진다
- 큰 객체를 SavedStateHandle에 다시 넣기 시작하면 stale data 위험이 커진다
- 실제 데이터 소스가 어디인지 흐려지고 복원 구조도 읽기 어려워진다
즉 route와 SavedStateHandle 모두 “본문 데이터 창고”가 아니라 다시 읽기 위한 좌표 보관함으로 보는 편이 안전합니다.

검색 화면에서는
상세 화면은 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 후보
즉 둘은 경쟁 관계라기보다 상태의 책임이 다릅니다.
자주 막히는 지점
- navigation argument와 UI state를 같은 것으로 보는 것
- SavedStateHandle을 작은 DB처럼 쓰는 것
- 큰 DTO를 route와 SavedStateHandle에 같이 실어 나르는 것
- 복원 키와 실제 데이터 책임을 섞는 것
이 패턴이 길게 보면 제일 안정적입니다. 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 문서를 함께 보면 흐름이 더 분명해집니다.