안드로이드 아키텍처 학습 노트 목차

상태 관리 — 상태 호이스팅·SavedStateHandle·일회성 이벤트

상태를 "어디에 두느냐"가 모든 걸 가른다

[02 MVVM]에서 상태는 위에서 아래로 흐른다고 했다. 이 장은 그 상태를 정확히 어디에 두고, 화면이 죽었다 살아나도 어떻게 지키며, 토스트 같은 일회성 이벤트는 어떻게 다룰지의 실무다. 핵심 한 줄 — 상태를 어느 자리에·어느 수명으로 두느냐가 재사용성·테스트·생존성을 결정한다. 같은 화면도 상태를 잘못된 자리에 두면 회전에 날아가고, 재사용이 막히고, 테스트가 안 된다. (출처: Android — State and Jetpack Compose / Where to hoist state.)

상태 호이스팅 — 왜 상태를 끌어올리나

Compose에서 **상태 호이스팅(state hoisting)**은 컴포저블이 자기 상태를 직접 들지 않고, 호출자(상위)로 끌어올리는 패턴이다. 왜 이렇게 할까? 상태를 품은(stateful) 컴포저블세 가지가 막힌다(1) 재사용 불가(상태가 안에 박혀 다른 초기값으로 못 쓴다), (2) 제어 불가(바깥에서 값을 바꿀 수 없다), (3) 테스트·프리뷰 불가(특정 상태를 주입할 수 없다).

// ❌ 상태를 품은(stateful) — count가 안에 갇혀 재사용·제어·테스트가 막힘
@Composable fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("$count") }
}

// ✅ 호이스팅(stateless) — 상태는 위에서 받고(↓), 변경은 콜백으로 올림(↑)
@Composable fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("$count") }     // 어떤 count든 그릴 수 있고, 테스트 가능
}
다이어그램 로딩 중…

stateless로 만들면 — 같은 컴포저블을 어떤 count로도 그릴 수 있고(재사용), 바깥에서 값을 통제하며(제어), 프리뷰에 임의 값을 넣어 본다(테스트). 이것이 [02]의 단방향 데이터 흐름(상태↓·이벤트↑)을 컴포저블 단위로 적용한 것이다.

그럼 어디까지 끌어올릴까? 원칙은 — 그 상태를 읽거나 바꿔야 하는 가장 낮은 공통 조상까지다. 너무 위로 올리면 불필요하게 많은 게 재구성되고, 너무 아래면 공유가 안 된다. 화면 전체에 걸친 상태(목록·로딩)는 ViewModel까지, *한 컴포넌트 안에서만 의미 있는 순수 UI 상태(스크롤 위치·펼침 여부)는 remember*에 둔다.

상태의 두 종류 — UI 요소 상태 vs 화면 상태

상태를 둘로 구분하면 어디에 둘지가 명확해진다.

  • UI 요소 상태스크롤 위치·텍스트 필드 입력·펼침 여부순수하게 UI에만 관계된 것. 비즈니스 로직이 필요 없다 → remember/rememberSaveable이나 상태 홀더 클래스에 둔다.
  • 화면 상태(screen state)화면이 보여줄 데이터(목록·로딩·에러). 비즈니스 로직으로 만들어지는 것ViewModel이 보유한다([05]).

rememberSaveable·SavedStateHandle — 두 종류의 죽음에서 살아남기

[01 생명주기]에서 봤듯 화면은 두 가지 방식으로 죽는다, 그리고 각각 다른 생존 도구가 필요하다.

  • 구성 변경(회전) — Activity는 재생성되지만 ViewModel은 살아남는다. 그래서 ViewModel 안의 상태는 회전에 안전하다. Compose의 지역 상태는 ViewModel 밖이라 회전에 날아가니 — rememberSaveable(Bundle에 저장)로 회전 생존시킨다(예: 펼침 상태).
  • 프로세스 죽음(process death) — 메모리 부족으로 OS가 앱 프로세스를 통째로 죽이면 ViewModel도 사라진다. 사용자가 돌아오면 화면을 다시 그려야 하는데 ViewModel이 비어 있다. 이때 *꼭 복원할 작은 상태(검색어·선택한 id)*는 **SavedStateHandle**에 둔다 — OS가 프로세스를 죽이기 전 저장한 Bundle에 묶여 복원된다.
class SearchViewModel(
    private val savedStateHandle: SavedStateHandle   // ViewModel에 주입됨
) : ViewModel() {
    // 프로세스 죽음에도 살아남는 검색어 (StateFlow로 노출)
    val query: StateFlow<String> = savedStateHandle.getStateFlow("query", "")
    fun onQueryChange(q: String) { savedStateHandle["query"] = q }
}

무엇을 SavedState에 담나 — 복원에 꼭 필요한 작은 식별자·입력만이다(검색어·선택 id·스크롤 위치). SavedState는 *Bundle(직렬화 가능, 용량 작음)*이라 큰 데이터(목록 수천 건)를 넣으면 안 된다 — 큰 데이터는 DB(SSOT)에서 그 식별자로 다시 로드한다. "작은 핵심만 저장하고 나머지는 재구성"이 원칙이다.

일회성 이벤트 — 상태로 표현하지 마라

스낵바·토스트·화면 이동한 번만 일어나야 할 이벤트다. 그런데 이걸 상태(StateFlow)로 두면 함정에 빠진다 — StateFlow는 현재값을 들고 있다가 구독자에게 다시 흘리므로, 화면 회전 후 재구독하면 마지막 이벤트가 또 방출되어 토스트가 다시 뜨거나 화면 이동이 두 번 일어난다. 두 가지 해법이 있다 — (1) 이벤트를 상태에 담되 "소비됨"을 표시(소비 후 null로 비움)하거나, (2) SharedFlow한 번만 소비되게 한다([08 Flow]).

// 권장 패턴 중 하나 — 이벤트를 상태에 담고 소비를 표시
data class UiState(val userMessage: String? = null)
fun onMessageShown() { _state.update { it.copy(userMessage = null) } }   // 표시 후 비워 재방출 방지

정리 — 상태를 올바른 자리·올바른 수명에

상태 관리의 핵심 — 상태 호이스팅으로 컴포저블을 stateless하게(상태↓·콜백↑, 재사용·테스트·제어를 살림, 가장 낮은 공통 조상까지), 순수 UI 상태는 remember/rememberSaveable·화면 상태는 ViewModel, 두 죽음을 구분해 회전엔 ViewModel/rememberSaveable·프로세스 죽음엔 SavedStateHandle(작은 핵심만, 큰 건 DB 재로드), 일회성 이벤트는 상태로 표현하지 말고 소비를 표시. 상태를 올바른 자리·올바른 수명에 두는 것이 버그 없는 UI의 토대다.

한 줄 요약 — 상태 호이스팅=stateless 컴포저블(상태↓·콜백↑→재사용/제어/테스트, 가장 낮은 공통 조상까지). 종류: UI 요소 상태(remember/rememberSaveable·회전 생존) vs 화면 상태(ViewModel). 생존: 회전=ViewModel/rememberSaveable, 프로세스 죽음=SavedStateHandle(작은 식별자만·큰 건 DB 재로드). 일회성 이벤트는 소비 표시나 SharedFlow로 재방출 방지.

(출처: Android — State hoisting / UI state production / SavedStateHandle / Now in Android.)

멀티 모듈화 — 빌드 속도·경계·재사용