Compose 기본2026년 03월 05일· 9 min read

24. rememberSaveable로 화면 회전에도 살아남는 Compose 상태 설계

rememberSaveable이 회전·프로세스 재생성에서 상태를 복원하는 원리, Slot Table과 SaveableStateRegistry 흐름, 성능 함정과 커스텀 Saver까지 다룬다. 실습 코드 포함.



























24. rememberSaveable로 화면 회전에도 살아남는 Compose 상태 설계

rememberSaveable로 화면 회전에도 살아남는 Compose 상태 설계

Compose를 처음 쓰면 TextField에 입력한 값이 회전 한 번에 초기화되는 장면을 바로 만난다. 로그캣에는 아무 에러도 없고, 버튼 클릭도 정상인데 값만 사라진다. remember로 고쳤다고 생각했는데, 회전하면 또 초기화된다. 이 문제는 ‘리컴포지션’이 아니라 ‘액티비티 재생성’과 ‘상태 저장소’의 문제라서, rememberSaveable이 왜 존재하는지부터 이해해야 한다.





















































핵심 개념

remember는 “같은 Composition 생명주기 안에서” 값을 붙잡는다. 버튼 클릭으로 인한 recomposition에서는 값이 유지되지만, 화면 회전처럼 Activity가 재생성되면 Composition 자체가 새로 만들어져 remember 값은 전부 새로 초기화된다. 초보가 겪는 ‘회전하면 초기화’는 recomposition 문제가 아니라 composition의 루트가 교체되는 문제다.

rememberSaveable은 remember의 저장 위치를 Slot Table만으로 끝내지 않고, Android의 SavedState(대개 Bundle)로도 흘려보낸다. 그래서 동일 화면이 다시 생성될 때, 초기값 대신 “복원된 값”이 들어간다. 여기서 중요한 포인트는 저장/복원이 Compose 자체 기능이 아니라, 호스트(Activity/Fragment)가 제공하는 SavedStateRegistry와 연결된다는 점이다.

용어를 맥락으로 정의한다. 1) Composition: Composable 호출 결과로 UI 트리를 ‘기록’하는 단계이며 Slot Table에 노드/값이 쌓인다. 2) Slot Table: 호출 순서 기반으로 상태를 매핑하는 테이블이며 remember의 저장소 역할을 한다. 3) Recomposition: 읽었던 State가 바뀌었을 때 해당 구간의 Composable 호출을 다시 실행하는 과정이다. 4) SaveableStateRegistry: “저장 가능한 값”을 키로 등록하고, 저장 시 Bundle로 내보내며 복원 시 다시 제공하는 레지스트리다. 5) Saver: 임의 타입을 Bundle에 들어갈 수 있는 형태로 변환/역변환하는 규칙이다.

왜 API가 remember + rememberSaveable로 분리됐을까. 모든 remember를 자동 저장하면 비용이 폭증한다. Bundle 직렬화는 크기 제한(대략 수백 KB~1MB 수준에서 문제를 만드는 경우가 많다)과 타입 제약이 있고, 저장 타이밍도 onSaveInstanceState에 묶인다. 그래서 ‘회전/프로세스 재생성에서 꼭 살아야 하는 값’만 명시적으로 opt-in 하도록 rememberSaveable이 설계된 쪽이 합리적이다.

MainActivity.kt
1package com.example.saveable
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableIntStateOf
13import androidx.compose.runtime.saveable.rememberSaveable
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.unit.dp
17
18class MainActivity : ComponentActivity() {
19    override fun onCreate(savedInstanceState: Bundle?) {
20        super.onCreate(savedInstanceState)
21        setContent { CounterScreen() }
22    }
23}
24
25@Composable
26fun CounterScreen() {
27    var count by rememberSaveable { mutableIntStateOf(0) }
28
29    Column(Modifier.padding(16.dp)) {
30        Text(text = "count=$count")
31        Button(onClick = { count++ }) { Text("+1") }
32    }
33}

이 코드를 실행하고 count를 3까지 올린 뒤 화면을 회전하면, Text가 0으로 돌아가지 않는다. 같은 클릭 동작(리컴포지션)에서는 remember도 충분하지만, 회전(액티비티 재생성)에서는 rememberSaveable만이 복원 경로를 가진다. 이 차이를 눈으로 확인하는 순간부터, 상태를 어디에 저장해야 하는지 기준이 생긴다.

컴포넌트 해부

rememberSaveable 호출은 단순히 “저장되는 remember”가 아니다. 내부적으로는 1) 현재 Composition이 제공하는 SaveableStateRegistry를 찾고 2) 키를 만들고 3) Saver로 변환 가능한지 검사하고 4) 복원 값이 있으면 그것을 우선 적용한다. 파라미터가 많은 이유는 이 과정에서 충돌/직렬화/수명 문제를 사용자가 제어해야 하기 때문이다.

  • inputs: 값이 달라지면 저장 슬롯을 무효화하고 새로 만든다. remember의 key와 같은 역할이다.
  • key: 레지스트리에 등록할 문자열 키다. 기본은 컴포지션 위치 기반으로 생성되며, 리스트/조건 분기에서 안정성을 위해 수동 지정이 필요하다.
  • stateSaver: Saver<T, Any>를 직접 지정한다. 기본은 자동 저장 가능한 타입만 허용한다.
  • init: 초기값 생성 람다다. 복원 값이 없을 때만 실행된다.
  • autoSaver: 기본 Saver다. Boolean/Int/String, Parcelable, Serializable, ArrayList 등 ‘Bundle에 들어갈 수 있는 타입’ 위주로 처리된다.
  • SaveableStateRegistry: CompositionLocal로 제공되며, Android 호스트가 SavedStateRegistry와 연결해준다.
  • canBeSaved: 레지스트리가 해당 값을 저장 가능한지 검사한다. 실패하면 IllegalArgumentException이 터진다.
  • restore: 레지스트리가 제공하는 복원 값을 Saver로 역변환한다.
  • registerProvider: 저장 시점에 값을 꺼내갈 provider를 등록한다.
  • unregister: Composition에서 해당 상태가 빠질 때 provider 등록을 해제한다.
  • rememberedValue: Slot Table에 잡혀 있는 현재 값이다. 회전 전에는 여기서 읽고, 회전 후에는 restore 경로로 채워진다.

Surface 계층 관점에서 보면 rememberSaveable은 UI를 그리는 컴포넌트가 아니라 ‘상태 저장 어댑터’다. 그래서 어디에 두느냐가 중요하다. 화면 루트에 두면 화면 전체가 하나의 저장 단위가 되고, Row 안의 작은 컴포넌트에 두면 그 컴포넌트가 사라질 때 저장도 같이 끊긴다. 실무에서 ‘탭 전환하면 값이 날아감’ 같은 이슈는 이 배치 문제에서 자주 나온다.

Content 계층 관점에서는 rememberSaveable 값이 읽히는 위치가 recomposition 범위를 결정한다. count를 Text와 Button에서 모두 읽으면 둘 다 invalidation 대상이 된다. 반대로 Text만 읽고 Button은 onClick만 가진다면, 클릭 시 Text 쪽이 주로 다시 호출된다. Slot Table은 “호출 순서”로 상태를 매핑하니, 조건문으로 Composable 호출 순서를 흔들면 저장 키도 흔들릴 수 있다.

처음에 나도 rememberSaveable을 TextField 안쪽에 박아두고 ‘왜 화면 이동 후 돌아오면 초기화되지?’를 3시간 붙잡은 적이 있다. Navigation으로 화면이 pop/push 될 때 Composable이 Composition에서 제거되면 provider 등록이 해제되고, 저장은 ‘호스트가 저장 이벤트를 발생시킬 때’만 일어난다. 즉, 화면 이동은 onSaveInstanceState가 아니어서 저장이 보장되지 않는다. 이때는 ViewModel/SavedStateHandle과 책임을 나눠야 한다.

RememberDraftState.kt
1package com.example.saveable
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Stable
5import androidx.compose.runtime.getValue
6import androidx.compose.runtime.mutableStateOf
7import androidx.compose.runtime.remember
8import androidx.compose.runtime.saveable.Saver
9import androidx.compose.runtime.saveable.rememberSaveable
10import androidx.compose.runtime.setValue
11
12@Stable
13class UiDraftState(initialText: String) {
14    var text by mutableStateOf(initialText)
15}
16
17private val UiDraftStateSaver: Saver<UiDraftState, String> = Saver(
18    save = { it.text },
19    restore = { UiDraftState(it) }
20)
21
22@Composable
23fun rememberDraftState(key: String): UiDraftState {
24    // key를 직접 지정하면 조건 분기/리스트 이동에서 복원 대상이 안정된다.
25    return rememberSaveable(key = key, stateSaver = UiDraftStateSaver) {
26        UiDraftState(initialText = "")
27    }
28}

이 재구성 코드는 rememberSaveable의 핵심인 Saver를 노출한다. UiDraftState는 Bundle에 바로 넣을 수 없으니, String으로 저장하고 다시 객체로 복원한다. 실행 중에는 객체가 계속 유지되지만, 회전 후에는 restore가 호출되며 새 인스턴스가 만들어진다. 그래서 @Stable로 ‘변경 추적은 내부 state로 한다’는 의도를 런타임에 전달하는 편이 낫다.

DraftActivity.kt
1package com.example.saveable
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.OutlinedTextField
13import androidx.compose.material3.Surface
14import androidx.compose.material3.Text
15import androidx.compose.runtime.Composable
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.unit.dp
18
19class DraftActivity : ComponentActivity() {
20    override fun onCreate(savedInstanceState: Bundle?) {
21        super.onCreate(savedInstanceState)
22        setContent { MaterialTheme { Surface { DraftScreen() } } }
23    }
24}
25
26@Composable
27fun DraftScreen() {
28    val draft = rememberDraftState(key = "draft:compose")
29
30    Column(Modifier.padding(16.dp)) {
31        Text("회전 후에도 입력이 남는다")
32        Spacer(Modifier.height(12.dp))
33        OutlinedTextField(
34            value = draft.text,
35            onValueChange = { draft.text = it },
36            modifier = Modifier.fillMaxWidth(),
37            label = { Text("메모") }
38        )
39    }
40}

이 코드를 실행하면 텍스트를 입력한 뒤 회전해도 입력이 복원된다. 중요한 관찰 포인트는 복원 시점이다. 회전 직후 첫 composition에서 init 람다가 실행되지 않고 restore 경로가 먼저 적용된다. 그래서 초기값을 비싼 연산(디스크 읽기 등)으로 만들면, 복원과의 우선순위가 꼬여 UI가 튀는 문제가 생긴다.

내부 동작 원리

Compose의 실행은 Composition → Layout → Drawing 순서로 흐른다. rememberSaveable은 Composition 단계에서만 관여한다. Layout/Draw는 이미 계산된 상태 값을 읽어 측정/배치/그리기를 할 뿐이고, 저장/복원은 ‘상태가 어떤 값이냐’를 결정하는 단계에서 끝난다.

컴파일러 관점에서 Composable 함수는 (단순화하면) composer와 changed 플래그를 받는 형태로 변환된다. rememberSaveable 호출 지점은 슬롯 인덱스가 부여되고, 런타임은 그 슬롯에 값을 저장한다. 회전 전에는 Slot Table에서 값을 찾고, 회전 후 첫 composition에서는 SaveableStateRegistry가 제공한 복원 값을 그 슬롯의 초기값으로 채운다.

Slot Table 매핑이 ‘호출 순서 기반’인 이유가 여기서 체감된다. if/for로 Composable 호출 순서가 바뀌면, 같은 rememberSaveable이라도 다른 슬롯으로 간주돼 복원 값이 엉뚱한 곳에 들어갈 수 있다. 그래서 리스트 아이템에서 key를 명시하거나, LazyColumn에서는 key 파라미터를 주는 습관이 필요하다.

Recomposition 트리거는 State 읽기 추적에서 시작한다. mutableStateOf/derivedStateOf 같은 Snapshot State는 읽을 때 현재 RecomposeScope에 ‘의존성’을 등록한다. 값이 바뀌면 해당 scope를 invalidation 큐에 넣고, 다음 프레임에서 그 scope만 다시 실행한다. rememberSaveable은 State 저장 방식일 뿐, invalidation 메커니즘은 remember와 동일하게 Snapshot 시스템을 탄다.

왜 비교가 필요한가. 컴파일러는 changed 플래그로 파라미터 변경 여부를 전달하고, 런타임은 canSkip을 판단해 그룹을 통째로 건너뛴다. rememberSaveable의 init 람다는 ‘첫 composition + 복원 값 없음’에서만 호출돼야 하므로, 런타임은 슬롯에 값이 있는지/복원 값이 있는지로 분기한다. 이 분기 자체가 skip 최적화와 결합돼 있다.

Modifier 체이닝은 Layout/Draw/PointerInput/Semantics 같은 노드들을 선형으로 연결한다. 상태 저장과 직접 관련은 없어 보이지만, rememberSaveable로 유지되는 값이 Semantics(예: contentDescription)로 노출되면 접근성 트리도 그 값 변화에 반응한다. ‘저장되는 상태’는 UI 렌더링뿐 아니라 입력/접근성/테스트에도 영향을 준다.

한 문단 요약: rememberSaveable은 Slot Table에만 두지 않고 SaveableStateRegistry에도 값을 등록해, Activity 재생성 시 Bundle에서 복원된 값을 첫 composition의 초기값으로 주입한다. recomposition 최적화·호출 순서·키 안정성이 복원 정확도를 좌우한다.

RecomposeTrace.kt
1package com.example.saveable
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.SideEffect
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.saveable.rememberSaveable
13import androidx.compose.runtime.setValue
14
15@Composable
16fun RecomposeTrace() {
17    var a by remember { mutableIntStateOf(0) }
18    var b by rememberSaveable { mutableIntStateOf(0) }
19
20    SideEffect {
21        Log.d("Trace", "Composed: a=$a, b=$b")
22    }
23
24    Column {
25        Text("a(remember)=$a")
26        Text("b(saveable)=$b")
27        Button(onClick = { a++ }) { Text("inc a") }
28        Button(onClick = { b++ }) { Text("inc b") }
29    }
30}

이 코드를 실행하면 버튼을 누를 때마다 Logcat에 Composed 로그가 찍힌다. a를 올리든 b를 올리든 recomposition은 동일하게 일어난다. 차이는 회전에서만 드러난다. 회전 후 a는 0으로 돌아가고 b는 유지된다. SideEffect는 composition이 실제로 끝난 직후에 실행되므로, “몇 번 다시 그려졌는지”를 눈으로 확인하기에 적당하다.

SaveableSemanticsRow.kt
1package com.example.saveable
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.semantics.contentDescription
11import androidx.compose.runtime.semantics.semantics
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun SaveableSemanticsRow(
17    label: String,
18    onClick: () -> Unit
19) {
20    val interaction = remember { MutableInteractionSource() }
21
22    Row(
23        Modifier
24            .semantics { contentDescription = "row:$label" }
25            .clickable(
26                interactionSource = interaction,
27                indication = null,
28                onClick = onClick
29            )
30            .padding(12.dp)
31    ) {
32        Text(text = label)
33    }
34}

여기서 관찰 포인트는 Modifier 순서다. semantics가 clickable보다 앞에 있으면, Semantics 노드는 클릭 가능 여부/라벨을 포함한 트리를 구성한 뒤 클릭 노드가 붙는다. label이 rememberSaveable로 유지되는 값이라면, 회전 후에도 contentDescription이 동일하게 복원돼 UI 테스트에서 안정적인 노드 탐색이 가능해진다. 상태 저장은 접근성/테스트 안정성으로도 이어진다.

실습하기

실습 목표는 세 가지다. (1) remember와 rememberSaveable의 차이를 회전으로 확인한다. (2) TextField 입력이 유지되는지 확인한다. (3) 저장 불가능한 타입을 Saver로 저장 가능하게 바꾼다. 각 단계는 복사-붙여넣기 후 바로 실행되는 형태로 구성한다.

app/build.gradle
1plugins {
2    id 'com.android.application'
3    id 'org.jetbrains.kotlin.android'
4}
5
6android {
7    namespace 'com.example.saveable'
8    compileSdk 34
9
10    defaultConfig {
11        applicationId "com.example.saveable"
12        minSdk 24
13        targetSdk 34
14        versionCode 1
15        versionName "1.0"
16    }
17
18    buildFeatures { compose true }
19    composeOptions { kotlinCompilerExtensionVersion '1.5.15' }
20}
21
22dependencies {
23    implementation platform('androidx.compose:compose-bom:2024.10.00')
24    implementation 'androidx.activity:activity-compose:1.9.3'
25    implementation 'androidx.compose.material3:material3'
26}

의존성은 Activity-Compose와 Material3만으로 충분하다. Kotlin 컴파일러 확장 버전과 Compose BOM이 맞지 않으면 빌드 단계에서 ‘Compiler version mismatch’가 난다. 이 에러를 한 번 겪고 나면, 상태 저장이 아니라 빌드 설정에서 시간을 잃는다. BOM을 쓰면 라이브러리 버전 정합성 문제를 많이 줄인다.

1단계: 회전으로 차이 확인

화면에 remember 카운터와 rememberSaveable 카운터 두 개를 동시에 띄운다. 버튼을 각각 5번씩 눌러 수치를 만든 뒤 회전한다. 화면에서 기대하는 장면은 remember 쪽만 0으로 돌아가고 saveable 쪽은 유지되는 것이다.

이 단계에서 확인하는 포인트는 ‘리컴포지션은 둘 다 동일하게 발생한다’는 사실이다. 클릭할 때마다 UI는 다시 호출되지만, 저장 위치가 다르기 때문에 재생성에서만 결과가 갈린다.

PracticeStep1.kt
1package com.example.saveable
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.padding
10import androidx.compose.material3.Button
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Surface
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.mutableIntStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.saveable.rememberSaveable
19import androidx.compose.runtime.setValue
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.tooling.preview.Preview
22import androidx.compose.ui.unit.dp
23
24class PracticeActivity : ComponentActivity() {
25    override fun onCreate(savedInstanceState: Bundle?) {
26        super.onCreate(savedInstanceState)
27        setContent {
28            MaterialTheme {
29                Surface { PracticeStep1() }
30            }
31        }
32    }
33}
34
35@Composable
36fun PracticeStep1() {
37    var r by remember { mutableIntStateOf(0) }
38    var s by rememberSaveable { mutableIntStateOf(0) }
39
40    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
41        Text("remember=$r")
42        Text("rememberSaveable=$s")
43        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
44            Button(onClick = { r++ }) { Text("r++") }
45            Button(onClick = { s++ }) { Text("s++") }
46        }
47    }
48}
49
50@Preview
51@Composable
52private fun PreviewPracticeStep1() {
53    MaterialTheme { Surface { PracticeStep1() } }
54}

회전 후 rememberSaveable이 유지되는 이유는, Activity가 onSaveInstanceState에서 SavedStateRegistry에 저장을 요청하고, Compose가 등록해 둔 provider가 현재 값을 Bundle로 내보내기 때문이다. 반대로 remember는 Slot Table에만 있어서, Activity 재생성과 함께 Slot Table이 새로 만들어지는 순간 값이 사라진다.

2단계: TextField 입력 유지 + 키 안정성

TextField는 초보가 가장 빨리 부딪히는 케이스다. 입력 중 회전하면 ‘사용자 데이터가 날아간다’는 체감이 강하다. rememberSaveable로 value를 잡고, key를 명시해 조건 분기에서 슬롯이 흔들릴 때도 복원이 안정적인지 확인한다.

실행 후 텍스트를 입력하고, 토글을 켜서 UI 분기를 바꾼 뒤 회전한다. 기대하는 장면은 토글 상태가 어떻든 입력 문자열이 유지되는 것이다. 만약 key를 안 주고 분기 구조가 바뀌면, 복원 값이 다른 슬롯으로 들어가 ‘엉뚱한 필드에 값이 나타나는’ 형태로도 터진다.

PracticeStep2.kt
1package com.example.saveable
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.OutlinedTextField
13import androidx.compose.material3.Surface
14import androidx.compose.material3.Switch
15import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.getValue
18import androidx.compose.runtime.mutableStateOf
19import androidx.compose.runtime.saveable.rememberSaveable
20import androidx.compose.runtime.setValue
21import androidx.compose.ui.Modifier
22import androidx.compose.ui.tooling.preview.Preview
23import androidx.compose.ui.unit.dp
24
25class Practice2Activity : ComponentActivity() {
26    override fun onCreate(savedInstanceState: Bundle?) {
27        super.onCreate(savedInstanceState)
28        setContent { MaterialTheme { Surface { PracticeStep2() } } }
29    }
30}
31
32@Composable
33fun PracticeStep2() {
34    var advanced by rememberSaveable(key = "toggle") { mutableStateOf(false) }
35    var text by rememberSaveable(key = "memo") { mutableStateOf("") }
36
37    Column(Modifier.padding(16.dp)) {
38        Text("advanced=$advanced")
39        Switch(checked = advanced, onCheckedChange = { advanced = it })
40        Spacer(Modifier.height(12.dp))
41
42        if (advanced) {
43            Text("고급 모드")
44        } else {
45            Text("기본 모드")
46        }
47
48        OutlinedTextField(
49            value = text,
50            onValueChange = { text = it },
51            modifier = Modifier.fillMaxWidth(),
52            label = { Text("입력") }
53        )
54    }
55}
56
57@Preview
58@Composable
59private fun PreviewPracticeStep2() {
60    MaterialTheme { Surface { PracticeStep2() } }
61}

key를 명시하면 “컴포지션 위치 기반 키”에 덜 의존하게 된다. 조건 분기에서 그룹 구조가 바뀌면 슬롯 인덱스가 이동할 수 있는데, key가 있으면 레지스트리 저장/복원 매칭이 더 안정적이다. 특히 여러 TextField가 있는 폼 화면에서, 한 필드 값이 다른 필드로 복원되는 사고를 막는 데 도움이 된다.

3단계: 저장 불가능한 타입을 Saver로 저장

실무에서는 String/Int만 저장하는 화면이 거의 없다. 예를 들어 ‘필터 상태’ 같은 data class를 저장하고 싶어진다. 이때 자동 저장이 안 되면 런타임에서 IllegalArgumentException이 터진다. 이 단계는 그 예외를 재현하고, Saver로 해결하는 흐름을 손에 익히는 목적이다.

실행 후 필터 토글을 바꾸고 회전한다. 기대하는 장면은 data class 기반 상태가 그대로 복원되는 것이다. 동시에 Saver가 save/restore를 언제 타는지(회전 전 저장, 회전 후 첫 composition 복원)도 관찰한다.

PracticeStep3.kt
1package com.example.saveable
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.Button
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.mutableStateOf
15import androidx.compose.runtime.saveable.Saver
16import androidx.compose.runtime.saveable.rememberSaveable
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.tooling.preview.Preview
20import androidx.compose.ui.unit.dp
21
22data class FilterState(
23    val onlyFavorites: Boolean,
24    val minStars: Int
25)
26
27private val FilterStateSaver: Saver<FilterState, String> = Saver(
28    save = { state -> "${state.onlyFavorites},${state.minStars}" },
29    restore = { raw ->
30        val parts = raw.split(",")
31        FilterState(
32            onlyFavorites = parts.getOrNull(0)?.toBooleanStrictOrNull() ?: false,
33            minStars = parts.getOrNull(1)?.toIntOrNull() ?: 0
34        )
35    }
36)
37
38class Practice3Activity : ComponentActivity() {
39    override fun onCreate(savedInstanceState: Bundle?) {
40        super.onCreate(savedInstanceState)
41        setContent { MaterialTheme { Surface { PracticeStep3() } } }
42    }
43}
44
45@Composable
46fun PracticeStep3() {
47    var filter by rememberSaveable(stateSaver = FilterStateSaver) {
48        mutableStateOf(FilterState(onlyFavorites = false, minStars = 3))
49    }
50
51    Column(Modifier.padding(16.dp)) {
52        Text("filter=$filter")
53        Button(onClick = {
54            filter = filter.copy(onlyFavorites = !filter.onlyFavorites)
55        }) {
56            Text("toggle favorites")
57        }
58        Button(onClick = {
59            filter = filter.copy(minStars = (filter.minStars + 1) % 6)
60        }) {
61            Text("minStars +1")
62        }
63    }
64}
65
66@Preview
67@Composable
68private fun PreviewPracticeStep3() {
69    MaterialTheme { Surface { PracticeStep3() } }
70}

여기서 save 타입을 String으로 둔 이유는 Bundle 호환성을 확실히 하기 위해서다. Parcelable로도 가능하지만, Parcelable은 필드 추가/삭제에 따른 버전 호환 이슈가 쉽게 난다. String 포맷도 마찬가지로 깨질 수 있으니, 실제 서비스에서는 버전 필드를 넣거나 JSON + 버전 전략을 쓰기도 한다. 핵심은 Saver가 ‘저장 가능한 형태’로 변환하는 경계라는 점이다.

심화: Advanced 버전 만들기

rememberSaveable을 제대로 쓰기 시작하면, 두 갈래로 확장된다. (1) UI 상태를 화면 단위로 저장해 폼/검색/스크롤을 복원한다. (2) 저장하면 안 되는 값(대용량, 보안, 단명 캐시)은 아예 registry로 보내지 않고 remember나 ViewModel로 분리한다. 심화에서는 ‘버튼 컴포넌트’에 이 판단을 녹여낸다.

사례 1: 로딩/디바운스/롱프레스가 있는 버튼 상태

로딩 중 중복 클릭 방지는 UI 컴포넌트에서 자주 요구된다. 여기서 로딩 플래그를 rememberSaveable로 저장하면 회전 후에도 로딩이 남아 UX가 안정적일 수 있다. 반대로 네트워크 요청 자체는 회전으로 재시작될 수 있으니, 로딩 상태만 저장한다고 해서 요청이 이어지는 것은 아니다. 저장 범위를 명확히 해야 한다.

디바운스는 저장할 필요가 없다. 마지막 클릭 시간은 회전으로 리셋돼도 문제 없는 경우가 대부분이고, Bundle 저장은 시간값까지 쌓으면 크기와 복잡도가 증가한다. 그래서 디바운스는 remember로 두고, 사용자에게 의미 있는 상태(로딩/토글)는 rememberSaveable로 둔다.

AdvancedButton.kt
1package com.example.saveable
2
3import android.os.SystemClock
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.size
9import androidx.compose.material3.CircularProgressIndicator
10import androidx.compose.material3.Icon
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Text
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.mutableLongStateOf
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.saveable.rememberSaveable
19import androidx.compose.runtime.setValue
20import androidx.compose.ui.Alignment
21import androidx.compose.ui.Modifier
22import androidx.compose.ui.semantics.contentDescription
23import androidx.compose.ui.semantics.semantics
24import androidx.compose.ui.unit.dp
25
26@Composable
27fun AdvancedButton(
28    label: String,
29    icon: (@Composable () -> Unit)? = null,
30    accessibilityLabel: String = label,
31    debounceMs: Long = 600L,
32    onClick: () -> Unit,
33    onLongClick: (() -> Unit)? = null
34) {
35    var loading by rememberSaveable(key = "loading:$label") { mutableStateOf(false) }
36    var lastClickAt by remember { mutableLongStateOf(0L) }
37
38    Row(
39        modifier = Modifier
40            .semantics { contentDescription = accessibilityLabel }
41            .combinedClickable(
42                enabled = !loading,
43                onClick = {
44                    val now = SystemClock.elapsedRealtime()
45                    if (now - lastClickAt < debounceMs) return@combinedClickable
46                    lastClickAt = now
47
48                    loading = true
49                    onClick()
50                },
51                onLongClick = onLongClick
52            )
53            .padding(horizontal = 14.dp, vertical = 10.dp),
54        verticalAlignment = Alignment.CenterVertically
55    ) {
56        if (loading) {
57            CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
58            Spacer(Modifier.size(10.dp))
59        } else if (icon != null) {
60            icon()
61            Spacer(Modifier.size(10.dp))
62        }
63        Text(text = label, style = MaterialTheme.typography.labelLarge)
64    }
65}

이 컴포넌트를 실행하고 클릭하면 로딩 인디케이터가 나타나고, 회전해도 로딩이 유지된다. 다만 onClick에서 네트워크를 시작하고, 완료 시 loading=false로 돌리는 책임은 외부에 있다. UI 컴포넌트가 비동기 완료를 알 수 없기 때문이다. 실무에서는 onClick에서 코루틴을 시작해 delay 후 loading=false로 만드는 식의 데모는 가능하지만, 실제 요청 상태는 ViewModel이 소유하는 편이 맞다.

AdvancedDemo.kt
1package com.example.saveable
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.filled.Send
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Surface
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.LaunchedEffect
14import androidx.compose.runtime.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.getValue
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.tooling.preview.Preview
20import androidx.compose.ui.unit.dp
21import kotlinx.coroutines.delay
22
23class AdvancedDemoActivity : ComponentActivity() {
24    override fun onCreate(savedInstanceState: Bundle?) {
25        super.onCreate(savedInstanceState)
26        setContent { MaterialTheme { Surface { AdvancedDemo() } } }
27    }
28}
29
30@Composable
31fun AdvancedDemo() {
32    var doneText by remember { mutableStateOf("idle") }
33
34    Column(Modifier.padding(16.dp)) {
35        AdvancedButton(
36            label = "Send",
37            icon = { androidx.compose.material3.Icon(Icons.Filled.Send, contentDescription = null) },
38            accessibilityLabel = "send-button",
39            onClick = {
40                doneText = "clicked (finish in 1s)"
41            },
42            onLongClick = {
43                doneText = "long pressed"
44            }
45        )
46
47        LaunchedEffect(doneText) {
48            if (doneText.startsWith("clicked")) {
49                delay(1000)
50                doneText = "done"
51            }
52        }
53
54        androidx.compose.material3.Text("status=$doneText")
55    }
56}
57
58@Preview
59@Composable
60private fun PreviewAdvancedDemo() {
61    MaterialTheme { Surface { AdvancedDemo() } }
62}

여기서는 데모를 위해 LaunchedEffect로 완료를 흉내 냈다. 클릭 직후 회전하면 status는 유지되지만, AdvancedButton 내부 loading은 onClick에서 true로 바뀐 뒤 외부에서 false로 돌려주지 않으면 계속 남는다. 이 ‘남는 로딩’이 심화에서 가장 흔한 설계 실수다. 저장할 상태와 소유할 상태를 섞으면 회전이 버그를 증폭시킨다.

사례 2: 저장 크기/타입 제약과 나의 흑역사

처음에 나도 ‘필터 옵션 전체’를 rememberSaveable로 저장하려고, 서버 응답 리스트(수백 개)를 그대로 넣었다. 특정 기기에서 회전 시 앱이 죽었고, 로그캣에는 TransactionTooLargeException이 찍혔다. 스택트레이스에는 onSaveInstanceState 경로가 보였고, Bundle 크기 제한에 걸린 케이스였다.

교정은 두 단계였다. (1) UI에서 필요한 최소만 저장했다: 선택된 id 목록, 검색어, 정렬 타입 정도만 rememberSaveable로 남겼다. (2) 나머지 큰 데이터는 ViewModel 캐시로 옮겼다. 회전은 프로세스가 살아 있는 동안이라 ViewModel로 충분했고, 프로세스 데스까지 커버가 필요하면 Room/Datastore로 내려야 했다. rememberSaveable은 ‘영속 저장’이 아니라 ‘인스턴스 상태 저장’ 범주라는 사실이 여기서 뼈에 박혔다.

자주 하는 실수

1) remember만 쓰고 회전에서 값이 초기화됨

증상: 카운터/입력값이 클릭 중에는 유지되는데, 회전(또는 다크모드 전환 등 구성 변경) 후 0/빈 문자열로 돌아간다.

원인: remember는 Slot Table에만 저장되고, Activity 재생성으로 Composition 루트가 교체되면 Slot Table도 새로 만들어진다. 저장/복원 경로가 없다.

해결: 회전에도 남아야 하는 값만 rememberSaveable로 올린다. 값이 커지면 Saver로 축약하거나, 화면 상태는 ViewModel로 올리고 rememberSaveable은 입력 중 임시값 정도로 제한한다.

2) 저장 불가능한 타입을 rememberSaveable에 넣고 크래시

증상: 첫 실행은 되는데 회전 순간 또는 백그라운드 후 복귀에서 IllegalArgumentException: "... cannot be saved" 류의 예외가 발생한다.

원인: Bundle에 넣을 수 없는 타입을 자동 Saver가 처리하지 못한다. data class라도 내부에 List<Custom> 같은 비저장 타입이 섞이면 바로 깨진다.

해결: Saver를 제공해 String/Int/Parcelable 같은 저장 가능한 형태로 변환한다. 저장 크기를 줄이기 위해 전체 객체 대신 id/버전만 저장하는 전략을 쓴다.

NonSaveableFix.kt
1package com.example.saveable
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.saveable.Saver
6import androidx.compose.runtime.saveable.rememberSaveable
7
8data class NonSaveable(val bytes: ByteArray)
9
10private val NonSaveableSaver: Saver<NonSaveable, String> = Saver(
11    save = { it.bytes.size.toString() },
12    restore = { sizeText -> NonSaveable(ByteArray(sizeText.toIntOrNull() ?: 0)) }
13)
14
15@Composable
16fun FixedNonSaveable() {
17    val state = rememberSaveable(stateSaver = NonSaveableSaver) {
18        mutableStateOf(NonSaveable(ByteArray(128)))
19    }
20    // size만 저장하는 형태라 실제 bytes는 복원 시 새로 생성된다.
21}

이 예시는 교육용으로 ‘크기만 저장’한다. 실제로 ByteArray 전체를 저장하면 Bundle이 커져서 다른 문제를 만든다. 복원 후 동일성을 보장해야 하는 데이터는 인스턴스 상태 저장이 아니라 영속 저장소로 내려야 한다.

3) 조건 분기/리스트에서 키를 안 줘서 값이 엉킴

증상: 두 개의 TextField가 있는 폼에서, 회전 후 A 필드 값이 B 필드로 들어가거나 일부 값이 사라진다. 재현이 간헐적이라 더 괴롭다.

원인: Slot Table은 호출 순서에 의존한다. if 분기나 리스트 아이템 삽입/삭제로 그룹 인덱스가 이동하면, 자동 생성 키가 다른 항목과 충돌하거나 매칭이 바뀐다.

해결: rememberSaveable에 key를 명시하고, LazyColumn/LazyRow에서는 item key를 안정적으로 제공한다. “항목 id”가 키의 기준이 된다.

4) rememberSaveable로 너무 큰 데이터를 저장해서 회전 시 죽음

증상: 회전 또는 백그라운드 후 복귀에서 앱이 즉시 종료되고, 로그캣에 TransactionTooLargeException, Failed to transact, data parcel size 같은 메시지가 보인다.

원인: onSaveInstanceState 경로에서 Bundle이 Binder 트랜잭션 크기 제한을 넘는다. Compose가 아니라 Android 시스템 제약이다.

해결: 저장 데이터의 크기를 줄인다. 선택 상태는 id 목록으로 축약하고, 큰 리스트/이미지/응답 전문은 저장하지 않는다. 필요하면 ViewModel 캐시 + 디스크(Room/Datastore)로 분리한다.

5) 저장할 상태와 소유할 상태를 섞어서 로딩이 영구히 남음

증상: 로딩 중 회전하면 로딩 UI가 계속 남거나, 버튼이 영구 비활성화된다. 네트워크는 이미 끝났는데 UI가 따라오지 못한다.

원인: UI 컴포넌트 내부 rememberSaveable에 로딩을 저장해 놓고, 완료 시점에 false로 되돌리는 단일 진실 공급원(single source of truth)이 없다. 회전 후에는 외부 비동기 작업과 내부 상태가 분리된다.

해결: 로딩은 ViewModel 상태로 끌어올리고(UI는 읽기만), rememberSaveable은 입력 중 임시값처럼 ‘사용자가 만든 값’ 위주로 제한한다. 컴포넌트 내부에 저장 상태를 두면 외부에서 상태를 주입받는 형태로 바꾼다.

성능 최적화 체크리스트

  • 회전/다크모드 전환에서 유지돼야 하는 값만 rememberSaveable로 분리했는가
  • 저장 크기가 큰 리스트/이미지/응답 전문을 rememberSaveable에 넣지 않았는가(수십 KB만 넘어도 의심)
  • 조건 분기(if/when)로 Composable 호출 순서가 변하는 위치의 rememberSaveable에 key를 명시했는가
  • LazyColumn/LazyRow 항목의 상태는 item key와 rememberSaveable key를 동일한 id 기준으로 안정화했는가
  • 저장 불가능 타입(data class 내부의 커스텀 타입 포함)은 Saver로 축약해 Bundle 호환 타입으로 변환했는가
  • Saver의 restore가 실패해도 안전한 기본값으로 복원되도록 파싱 예외를 방어했는가
  • 저장 대상이 보안 민감 정보(토큰/비밀번호)라면 rememberSaveable을 피하고 암호화 저장소로 분리했는가
  • 로딩/요청 상태는 ViewModel 등 단일 진실 공급원에서 소유하고, UI 내부 rememberSaveable과 섞지 않았는가
  • recomposition 범위를 줄이기 위해 rememberSaveable 값을 읽는 위치를 필요한 Composable로 제한했는가
  • Layout Inspector/Composition tracing으로 회전 직후 첫 composition에서 init이 실행되는지 restore가 실행되는지 확인했는가
  • 프로세스 데스까지 요구되는 데이터는 rememberSaveable이 아니라 Room/Datastore로 내려야 한다는 요구사항 합의가 되었는가
  • 테스트(Compose UI test)에서 semantics 라벨이 rememberSaveable 복원 후에도 안정적으로 유지되는지 확인했는가

자주 묻는 질문

rememberSaveable이면 프로세스 데스에서도 항상 복원되나?

항상 복원된다고 가정하면 위험하다. rememberSaveable은 Android 인스턴스 상태 저장(SavedStateRegistry → Bundle)에 기대며, 이 값은 프로세스가 죽었다가 복원될 때도 사용될 수 있지만, 시스템이 저장을 건너뛰거나(Bundle이 너무 크거나, 백그라운드 제한 등) 복원 데이터가 유실되는 경우가 있다. 그래서 “사용자가 다시 들어와도 반드시 남아야 하는 데이터”는 Room/Datastore 같은 영속 저장소가 책임져야 한다. 학습 키워드는 SavedStateRegistry, onSaveInstanceState, process death, persistence layer이며, UI 입력 중 임시값·스크롤 위치 같은 것은 rememberSaveable이 적합하다.

remember와 rememberSaveable의 내부 차이는 Slot Table 관점에서 무엇인가?

둘 다 Composition 단계에서 슬롯을 하나 차지하고, 런타임은 호출 순서 기반으로 그 슬롯을 찾는다. 차이는 슬롯에 값을 채우는 초기 경로다. remember는 “슬롯에 값이 있으면 재사용, 없으면 init 실행”만 가진다. rememberSaveable은 여기에 “SaveableStateRegistry에 복원 값이 있으면 그것을 먼저 사용”이 추가된다. 또한 저장 시점에는 registry에 provider를 등록해 두었다가, 호스트가 저장 이벤트를 발생시키면 provider가 현재 값을 내보낸다. 키 안정성이 없으면 복원 값이 다른 슬롯에 주입될 수 있으니 key 설계가 중요하다. 학습 키워드는 SlotTable, rememberImpl, SaveableStateRegistry, key stability다.

rememberSaveable에서 key를 언제 직접 줘야 하나?

조건 분기, 리스트 변경, 재사용 가능한 컴포넌트에서 상태가 꼬일 가능성이 있을 때 직접 key를 주는 편이 낫다. 자동 키는 “컴포지션 위치”를 기반으로 만들어지는데, if로 UI가 삽입/삭제되면 위치가 이동한다. 그 결과 저장/복원 매칭이 바뀌어 다른 필드에 값이 들어가는 현상이 나온다. key는 사용자 의미의 안정적인 식별자(예: itemId, fieldName)로 잡는다. LazyColumn이면 items(key=...)와 rememberSaveable(key=...)를 같은 기준으로 맞추는 것이 실전 처방이다. 학습 키워드는 composition key, movable content, Lazy list key다.

Saver는 왜 필요한가? Parcelable이면 끝 아닌가?

Saver는 ‘저장 가능한 형태’로의 변환 규칙을 UI 레이어에서 선언하는 장치다. Parcelable은 구현 비용이 있고, 필드 변경에 따른 버전 호환 이슈가 있으며, 실수로 큰 그래프를 Parcelable로 직렬화해 Bundle을 비대하게 만들기 쉽다. Saver를 쓰면 “무엇을 저장할지”를 의도적으로 축약할 수 있다. 예를 들어 필터 상태 전체 대신 선택된 id 목록과 정렬 타입만 저장하면 크기가 급격히 줄고, 복원 실패 시 기본값으로 회복도 쉽다. 학습 키워드는 Saver, autoSaver, Bundle constraints, TransactionTooLargeException이며, 실전에서는 save 타입을 String/Int/ArrayList 같은 단순 타입으로 유지하는 전략이 자주 통한다.

rememberSaveable을 남발하면 성능이 나빠지나?

클릭 한 번의 recomposition 비용은 remember와 rememberSaveable이 크게 다르지 않다. 성능 차이는 주로 저장 이벤트(onSaveInstanceState)에서 나타난다. rememberSaveable은 provider를 통해 값을 수집하고 Bundle로 직렬화하는데, 저장 대상이 많거나 크면 그 순간 프레임 드랍이나 예외가 발생한다. 또한 큰 객체를 Saver로 변환하는 과정에서 문자열 생성/리스트 복사 같은 할당이 늘어난다. 측정은 ‘회전 직전/직후’에 이루어져야 하며, Layout Inspector만으로는 저장 비용이 보이지 않는다. 학습 키워드는 systrace, onSaveInstanceState timing, allocation tracking, StrictMode(일부 케이스)다.

Navigation을 쓰면 rememberSaveable만으로 화면 상태 복원이 충분한가?

화면 간 이동은 onSaveInstanceState가 아니다. 즉, 화면을 스택에서 제거했다가 다시 만들 때 rememberSaveable이 반드시 저장되었다고 기대하면 안 된다. Navigation Compose는 BackStackEntry 단위로 SavedStateHandle/SaveableStateHolder 같은 도구를 제공해 “목록 스크롤 위치” 같은 상태를 보존할 수 있지만, 이는 호스트 저장 이벤트와는 다른 레이어다. 실전 처방은 1) 화면 간 유지가 필요한 상태는 ViewModel + SavedStateHandle로 끌어올리고 2) 회전 정도만 커버하면 되는 입력 임시값은 rememberSaveable로 둔다. 학습 키워드는 SaveableStateHolder, BackStackEntry, SavedStateHandle, state hoisting이다.

@Stable/@Immutable은 rememberSaveable과 무슨 관계가 있나?

rememberSaveable 자체는 저장/복원 경로를 제공하지만, recomposition 최적화는 타입 안정성에 크게 좌우된다. 복원 후 새 인스턴스가 만들어지는 타입(예: restore가 객체를 생성)은 참조가 바뀌므로, 그 객체를 파라미터로 받는 하위 Composable이 changed로 판정될 가능성이 높다. 내부에 mutableStateOf를 가진 상태 홀더라면 @Stable로 “프로퍼티 변경이 State로 추적된다”는 계약을 명확히 하고, data class처럼 불변이면 @Immutable로 전달해 스킵 가능성을 높인다. 실전에서는 ‘상태 홀더는 @Stable + 내부 State, UI 모델은 @Immutable’ 패턴이 자주 쓰인다. 학습 키워드는 stability inference, changed flags, skip groups, state holder pattern이다.

관련 글

24. rememberSaveable로 화면 회전에도 살아남는 Compose 상태 설계
Compose 기본2026.03.05

24. rememberSaveable로 화면 회전에도 살아남는 Compose 상태 설계

rememberSaveable이 회전·프로세스 재생성에서 상태를 복원하는 원리, Slot Table과 SaveableStateRegistry 흐름, 성능 함정과 커스텀 Saver까지 다룬다. 실습 코드 포함.



























23. remember·mutableStateOf로 Compose 상태를 시작하는 내부 원리
Compose 기본2026.03.05

23. remember·mutableStateOf로 Compose 상태를 시작하는 내부 원리

remember와 mutableStateOf가 왜 필요한지, Compose Runtime이 상태 읽기/쓰기와 Slot Table, 리컴포지션 범위를 어떻게 결정하는지 내부 동작으로 설명한다. 초보가 겪는 버그를 재현한다. 140~160자 내외 문장 구성.

13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유
Compose 기본2026.03.01

13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유

Jetpack Compose에서 State/MutableState가 왜 필요한지, remember·Slot Table·recomposition 추적이 어떻게 연결되는지 내부 동작 관점에서 설명한다. 실습 코드 포함. 154자 내외로 맞춘 설명 문장이다.