|

rememberSaveable과 SavedStateHandle 차이: 상태를 어디에 둘까

rememberSaveable과 SavedStateHandle 차이를 설명하는 안드로이드 상태 관리 대표 이미지
작은 UI 상태, 화면 상태, process death 복원 키를 나눠 보면 판단이 쉬워진다

rememberSaveable과 SavedStateHandle 차이는 API 기능 비교만으로는 잘 안 잡힙니다. Compose를 쓰다 보면 둘 다 상태를 살려주는 도구처럼 보여서, 무엇을 composable 안에 두고 무엇을 ViewModel에 둘지 자꾸 헷갈리게 됩니다.

이 글에서는 작은 UI 상태, 화면 전체 상태, process death 뒤에도 다시 필요한 복원 키를 나눠서 보겠습니다. 이 기준이 잡히면 rememberSaveable이 충분한 경우와 ViewModel + SavedStateHandle이 필요한 경우를 훨씬 덜 흔들리고 고를 수 있습니다.


rememberSaveable과 SavedStateHandle 차이, 먼저 한 줄로 정리

작고 UI 자체에 가까운 상태라면 rememberSaveable이 잘 맞고, 화면 전체 의미를 설명하는 상태라면 ViewModel이 더 자연스럽습니다. 그리고 process death 뒤에도 다시 화면을 세워야 하는 최소 출발점이 있다면 SavedStateHandle을 함께 검토하면 됩니다.

핵심은 어느 API가 더 강하냐가 아니라, 이 상태가 어디까지 살아남아야 하느냐입니다.


먼저 recomposition, configuration change, process death를 나눠야 한다

1) recomposition

  • 상태가 바뀌어서 composable 일부를 다시 읽는 상황
  • 같은 composition 안에서 일어난다
  • 이 범위라면 remember만으로도 충분한 경우가 많다

2) configuration change

  • 회전, 다크 모드 변경, 언어 변경 등으로 Activity가 다시 만들어질 수 있다
  • 이 경계를 넘겨야 하는 작은 UI 상태라면 rememberSaveable이 잘 맞는다
  • 방금 보던 UI 맥락을 이어 주는 값이 대표 후보다

3) process death

  • 백그라운드에 있던 앱의 프로세스가 시스템에 의해 정리될 수 있다
  • 나중에 다시 돌아왔을 때 화면의 출발점을 복원해야 할 수 있다
  • 여기서는 전체 데이터보다 최소 복원 키가 더 중요하다

이 세 가지를 섞어 생각하면 판단이 계속 꼬입니다. 회전만 넘기면 되는 상태인지, process death 뒤에도 같은 조건으로 다시 보여줘야 하는 상태인지 먼저 나눠야 합니다.


rememberSaveable이 충분한 상태

rememberSaveable이 가장 자연스러운 상태는 보통 작고, UI 자체에 가깝고, 복원 비용이 낮은 값입니다. TextField 입력값, 선택된 탭 인덱스, 펼침 여부, 필터 패널 열림 여부가 대표적입니다.

@Composable
fun FilterSection() {
    var expanded by rememberSaveable { mutableStateOf(false) }
    var query by rememberSaveable { mutableStateOf("") }

    Column {
        Button(onClick = { expanded = !expanded }) {
            Text(if (expanded) "필터 닫기" else "필터 열기")
        }

        if (expanded) {
            OutlinedTextField(
                value = query,
                onValueChange = { query = it },
                label = { Text("검색어") }
            )
        }
    }
}

이 코드에서 expanded와 query는 composable 바깥의 복잡한 화면 로직을 설명하지 않습니다. 반대로 회전 뒤에도 사용자가 보던 맥락이 이어지면 좋다는 기대는 분명합니다. 이럴 때는 rememberSaveable이 가장 단순하고 읽기 쉽습니다.


ViewModel로 올려야 하는 화면 상태는 무엇이 다를까

아래 질문 중 하나라도 예라면 ViewModel 쪽이 더 자연스러운 경우가 많습니다. 이 값이 화면 전체가 무엇을 보여줘야 하는지 결정하는가, 여러 composable에서 함께 읽히는가, 로딩과 성공과 실패 흐름과 연결되는가, Repository 결과와 함께 움직이는가를 먼저 보세요.

예를 들어 검색 화면의 결과 목록, 로딩 상태, 에러 메시지, 현재 정렬 기준은 화면 전체 의미를 설명합니다. 이런 값은 rememberSaveable로 조각조각 붙잡기보다 ViewModel 안의 uiState로 묶는 편이 더 자연스럽습니다.

@Immutable
data class SearchUiState(
    val query: String = "",
    val sort: SortType = SortType.Recent,
    val isLoading: Boolean = false,
    val items: List<SearchItem> = emptyList(),
    val errorMessage: String? = null,
)

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

    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState

    fun onQueryChanged(query: String) {
        _uiState.value = _uiState.value.copy(query = query)
    }

    fun onSortChanged(sort: SortType) {
        _uiState.value = _uiState.value.copy(sort = sort)
    }

    fun search() {
        val current = _uiState.value
        _uiState.value = current.copy(isLoading = true, errorMessage = null)

        viewModelScope.launch {
            runCatching {
                repository.search(current.query, current.sort)
            }.onSuccess { items ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    items = items,
                )
            }.onFailure { throwable ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    errorMessage = throwable.message,
                )
            }
        }
    }
}

여기서 query와 sort 자체는 작아 보이지만, 이미 화면 전체 의미를 설명하는 uiState의 일부로 쓰이고 있습니다. 즉 작은 값인지 아닌지보다 화면 의미를 같이 움직이느냐가 더 중요합니다.


SavedStateHandle은 process death 뒤에도 다시 필요한 최소 키에 가깝다

검색 화면이 process death 뒤에도 같은 조건으로 다시 보여야 한다면, 정말 다시 필요할 가능성이 큰 것은 결과 목록 전체보다 query와 sort 같은 복원 키입니다. 이때 SavedStateHandle을 붙이면 ViewModel이 화면의 출발점을 다시 잡기 쉬워집니다.

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 _uiState = MutableStateFlow(
        SearchUiState(
            query = savedStateHandle[KEY_QUERY] ?: "",
            sort = savedStateHandle[KEY_SORT] ?: SortType.Recent,
        )
    )
    val uiState: StateFlow<SearchUiState> = _uiState

    fun onQueryChanged(query: String) {
        savedStateHandle[KEY_QUERY] = query
        _uiState.value = _uiState.value.copy(query = query)
    }

    fun onSortChanged(sort: SortType) {
        savedStateHandle[KEY_SORT] = sort
        _uiState.value = _uiState.value.copy(sort = sort)
    }
}

여기서 중요한 것은 저장하는 단위입니다. 검색 결과 목록 전체를 SavedStateHandle에 넣는 것이 아니라, 화면을 다시 어떤 조건으로 세워야 하는지 알려주는 최소 키만 저장하고 실제 데이터는 다시 로드하는 쪽이 더 실용적입니다.

SavedStateHandle은 전체 화면 데이터를 저장하는 곳이 아니라, 화면 재구성의 출발점을 잡는 도구에 가깝습니다.


rememberSaveable과 SavedStateHandle은 같은 화면에서도 같이 쓸 수 있다

  • 검색창 포커스 여부: composable 내부의 일시적 상태
  • 필터 섹션 펼침 여부: rememberSaveable 후보
  • query, sort, 결과 목록, 로딩 상태: ViewModel의 screen state
  • process death 뒤에도 다시 필요한 query, sort: SavedStateHandle 후보

즉 둘은 경쟁 관계가 아닙니다. 같은 화면 안에서도 상태의 성격이 다르면 위치도 달라질 수 있습니다. 문제를 API 중심으로 보면 복잡하지만, 상태 중심으로 보면 오히려 단순해집니다.


네 가지 질문

  1. 이 값은 UI element state인가, screen state인가
  2. 회전만 버티면 되는가, process death 뒤에도 다시 필요할까
  3. 이 값이 화면 전체 흐름과 연결되는가
  4. 이 값만 있으면 실제 데이터는 다시 불러올 수 있는가

대개 이렇게 정리됩니다. 작은 UI element state이고 화면 로직과 약하게 연결되면 rememberSaveable을 먼저 보고, 화면 전체 의미를 설명하면 ViewModel을 먼저 봅니다. process death 뒤에도 출발점이 꼭 필요하면 SavedStateHandle을 추가로 검토하고, 앱을 완전히 종료해도 남아야 하는 business state라면 Room, DataStore, 서버 저장까지 함께 봐야 합니다.


마무리

rememberSaveable과 SavedStateHandle 차이는 둘 중 뭐가 더 좋은가의 문제가 아닙니다. 무엇을 composable에 남겨도 되고 무엇을 화면 상태로 끌어올려야 하며 무엇만 process death 대비 키로 저장할지를 나누는 문제에 가깝습니다.

작고 UI 자체에 가까운 상태라면 rememberSaveable로 충분한 경우가 많습니다. 반대로 화면 전체 의미를 설명하는 상태는 ViewModel 쪽이 더 자연스럽고, process death 뒤에도 꼭 필요한 최소 출발점은 SavedStateHandle이 잘 맞습니다.

안드로이드 상태 관리 감각을 더 이어서 잡고 싶다면 remember와 rememberSaveable 차이 글, SavedStateHandle은 언제 써야 할까, 화면 상태는 어디에 두는 게 맞을까도 함께 보면 흐름이 더 선명해집니다.

공식 기준은 State and Jetpack Compose, Save UI state in Compose, Saved State module for ViewModel, Saving UI States 문서를 함께 보면 더 분명해집니다.

함께보면 좋은 글