13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유
Jetpack Compose에서 State/MutableState가 왜 필요한지, remember·Slot Table·recomposition 추적이 어떻게 연결되는지 내부 동작 관점에서 설명한다. 실습 코드 포함. 154자 내외로 맞춘 설명 문장이다.
Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유
버튼을 눌러 count를 올렸는데 텍스트가 안 바뀌거나, 반대로 리스트 전체가 매번 다시 그려져서 스크롤이 버벅이는 상황이 Compose 첫날에 자주 터진다. 원인은 대개 “값은 바뀌었는데 Compose가 그 변화를 ‘관찰’하지 못했거나”, “관찰 범위가 과하게 넓어진 것”이다. State와 MutableState는 단순 변수 래퍼가 아니라, Runtime이 Slot Table과 스냅샷을 통해 ‘어떤 Composable이 어떤 값을 읽었는지’를 기록하기 위한 장치다.
핵심 개념
View 시스템에서는 TextView.setText 같은 ‘명령’을 직접 호출해서 화면을 바꿨다. 값 변경과 화면 갱신이 한 함수 안에서 끝나니, 누락되면 UI가 틀어지고 중복 호출되면 불필요한 invalidate/requestLayout이 쌓였다. Compose는 반대로 “UI는 상태의 함수”라는 모델을 강제한다. 그래서 Runtime이 상태 읽기(read)를 추적하고, 상태 쓰기(write)가 발생하면 해당 읽기 지점만 다시 호출(recompose)하는 구조가 필요해졌다.
State<T>는 읽기 전용 인터페이스처럼 쓰이지만, 핵심은 ‘읽을 때 추적된다’는 점이다. Composable이 state.value를 읽으면, Runtime은 현재 실행 중인 RecomposeScope(정확히는 해당 그룹/슬롯 범위)에 그 state를 구독자로 등록한다. 이후 MutableState<T>.value가 바뀌면, 스냅샷 시스템이 변경을 기록하고, 구독자였던 scope들을 invalid로 표시한다.
MutableState<T>는 쓰기까지 가능한 State다. 중요한 설계 의도는 “값 변경을 감지 가능한 채널로 강제”하는 것이다. 평범한 var는 Runtime 입장에서 변경을 알 길이 없다. MutableState는 내부적으로 SnapshotMutableState를 통해 현재 스냅샷과 정책(StructuralEqualityPolicy 등)에 따라 변경 여부를 판단한다. 값이 ‘같다’고 판단되면 invalidation 자체가 발생하지 않는다.
remember는 Slot Table에 값을 저장하기 위한 키워드다. Composable은 매 recomposition 때 다시 호출되므로, 함수 로컬 변수는 매번 새로 만들어진다. remember가 없으면 MutableState 자체가 매번 새 인스턴스로 교체되고, 이전에 구독하던 state와의 연결이 끊겨서 “클릭했는데 값이 초기화됨” 같은 현상이 나온다. remember는 “이 호출 위치(call site)에 1칸 슬롯을 만든다”는 의미에 가깝다.
Recomposition은 전체 화면을 다시 그리는 작업이 아니다. Runtime은 Slot Table에 ‘이전 composition에서의 호출 트리’를 저장하고, 변경된 scope만 다시 실행해 새로운 슬롯 값으로 갱신한다. 그 다음 Layout/Draw 단계에서 실제 측정/그리기 필요 여부가 결정된다. 그래서 State 설계는 “어디까지 다시 실행될지”를 정하는 문제와 직결된다.
1import androidx.compose.foundation.layout.Column
2import androidx.compose.material3.Button
3import androidx.compose.material3.Text
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.mutableIntStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.ui.tooling.preview.Preview
8
9@Composable
10fun CounterBasic() {
11 val count = remember { mutableIntStateOf(0) }
12 Column {
13 Text(text = "count=${count.intValue}")
14 Button(onClick = { count.intValue++ }) {
15 Text("+1")
16 }
17 }
18}
19
20@Preview
21@Composable
22private fun CounterBasicPreview() {
23 CounterBasic()
24}이 코드를 실행하면 버튼을 누를 때마다 Text의 숫자만 바뀐다. 중요한 관찰 포인트는 두 가지다. (1) Text가 count.intValue를 읽는 순간 구독 관계가 만들어진다. (2) onClick에서 intValue를 증가시키면 스냅샷 쓰기가 발생하고, 그 값을 읽었던 scope가 invalid로 표시돼 다음 프레임에 CounterBasic의 해당 구간이 다시 호출된다. remember가 없으면 클릭할 때마다 state 인스턴스가 교체돼 값이 0으로 되돌아가는 장면을 보게 된다.
컴포넌트 해부
State 자체는 UI가 아니라 데이터 채널이므로, 실제로는 ‘어디에서 읽고 어디에서 쓰는지’가 컴포넌트 설계의 전부다. Counter를 예로 들면, Text는 읽기 지점, Button은 쓰기 지점이다. 이 둘이 같은 Composable 안에 있으면 편하지만, 재사용을 위해서는 읽기/쓰기 책임을 분리(state hoisting)해야 한다.
Compose에서 흔히 쓰는 패턴은 value: T(또는 State<T>)와 onValueChange: (T) -> Unit 조합이다. value만 전달하면 읽기만 가능해서 UI는 ‘표시 전용’이 된다. onValueChange만 전달하면 쓰기만 가능해 표시가 불가능하다. 둘을 함께 두는 이유는 데이터 흐름을 단방향으로 고정해, “어디서 값이 바뀌는지”를 추적 가능하게 만들기 위해서다.
파라미터를 왜 이렇게 쪼개는지 체감되는 순간이 있다. 예전에 나도 Counter를 여러 곳에 붙이면서, 내부에서 remember로 상태를 잡아두면 편하다고 생각했다. 그런데 화면 회전이나 네비게이션 복귀에서 값이 제멋대로 초기화되거나, 테스트에서 상태를 주입하기 어려워졌다. 외부에서 상태를 소유하면(hoist) 이 문제가 한 번에 정리된다.
- count: Int — UI가 표시할 값. 읽기만으로도 recomposition 범위를 통제할 수 있다
- onIncrement: () -> Unit — 버튼 클릭 시 실행할 이벤트. 상태 변경의 ‘출처’를 고정한다
- enabled: Boolean — 비활성화 시 클릭 이벤트 자체를 차단해 불필요한 쓰기를 막는다
- modifier: Modifier — 호출자가 레이아웃/세만틱/입력 처리를 합성할 수 있게 한다
- label: String — 접근성/테스트 태그에 재사용 가능하다
- step: Int — 증가 단위. 파라미터화하지 않으면 내부 상수 변경이 recomposition 원인으로 섞인다
- min/max: Int? — 경계 조건을 UI 밖으로 드러내면 정책 테스트가 쉬워진다
- interactionSource: MutableInteractionSource — press/hover 상태를 외부에서 관찰 가능하게 한다
- contentDescription: String? — TalkBack 라벨을 명시해 semantics 트리를 안정화한다
- onLongPress: (() -> Unit)? — 제스처 확장 포인트. null이면 입력 처리 비용을 줄인다
- colors/shape — Surface 계층(시각)과 Content 계층(텍스트/아이콘)을 분리한다
- textStyle — Typography를 외부에서 주입해 디자인 시스템과 결합한다
Surface 계층은 ‘배경, 그림자, 모양, 클릭 리플, 세만틱 노드’ 같은 컨테이너 책임을 가진다. Content 계층은 ‘무엇을 그릴지’만 담당한다. Compose Material 컴포넌트들이 대부분 containerColor/contentColor를 분리하는 이유가 여기 있다. 컨테이너가 바뀌어도 콘텐츠 측 recomposition을 최소화할 수 있다.
Content slot은 람다로 받는 경우가 많다. 이유는 두 가지다. 첫째, 호출자가 내부에 어떤 UI를 넣을지 결정하게 해서 재사용성을 확보한다. 둘째, Slot Table 관점에서 content 람다는 별도 그룹으로 취급되기 쉬워, 컨테이너 파라미터 변경과 콘텐츠 recomposition을 분리할 수 있다.
파라미터를 안 쓰면 어떤 일이 생기는지도 중요하다. modifier를 내부에서 고정하면 테스트에서 size를 강제하기 어렵고, semantics를 밖에서 추가할 방법이 없다. enabled가 없으면 클릭을 막기 위해 상위에서 if로 컴포넌트를 통째로 제거해야 하고, 그 순간 Slot Table 그룹 구조가 바뀌어 불필요한 이동/재사용 실패가 생긴다.
1import androidx.compose.foundation.background
2import androidx.compose.foundation.clickable
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.remember
9import androidx.compose.foundation.interaction.MutableInteractionSource
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.semantics.contentDescription
12import androidx.compose.ui.semantics.semantics
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun CounterRowSkeleton(
17 count: Int,
18 onIncrement: () -> Unit,
19 modifier: Modifier = Modifier,
20 enabled: Boolean = true,
21 label: String = "counter"
22) {
23 val interaction = remember { MutableInteractionSource() }
24
25 Row(
26 modifier = modifier
27 .semantics { contentDescription = label }
28 .background(MaterialTheme.colorScheme.surfaceVariant)
29 .clickable(
30 enabled = enabled,
31 interactionSource = interaction,
32 indication = null,
33 onClick = onIncrement
34 )
35 .padding(12.dp)
36 ) {
37 Text(text = "count=$count")
38 }
39}이 스켈레톤은 Material Button을 쓰지 않고도 ‘컨테이너와 콘텐츠 분리’를 보여준다. 실행하면 Row 전체가 클릭 영역이 되고, count 텍스트만 바뀐다. 핵심은 count가 Int로 들어오므로 CounterRowSkeleton 내부에는 상태가 없다. 상태 소유는 호출자에게 남겨두고, 이 컴포넌트는 읽기 지점(Text)과 이벤트(onIncrement)만 연결한다.
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.Spacer
3import androidx.compose.foundation.layout.height
4import androidx.compose.material3.Button
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Surface
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.unit.dp
13import androidx.compose.ui.tooling.preview.Preview
14
15@Composable
16fun CounterWithSurface(modifier: Modifier = Modifier) {
17 val count = remember { mutableIntStateOf(0) }
18
19 Surface(
20 modifier = modifier,
21 color = MaterialTheme.colorScheme.surface,
22 tonalElevation = 2.dp
23 ) {
24 Column {
25 CounterRowSkeleton(
26 count = count.intValue,
27 onIncrement = { count.intValue += 1 },
28 label = "tap to increment"
29 )
30 Spacer(Modifier.height(8.dp))
31 Button(onClick = { count.intValue = 0 }) {
32 Text("reset")
33 }
34 }
35 }
36}
37
38@Preview
39@Composable
40private fun CounterWithSurfacePreview() {
41 CounterWithSurface()
42}여기서는 Surface가 ‘그릇’ 역할을 하고, 실제 상태 변경은 count.intValue에만 발생한다. reset 버튼을 누르면 숫자가 0으로 돌아가고, Row를 탭하면 1씩 증가한다. Layout Inspector에서 recomposition counts를 켜면, 클릭 시 Column 전체가 아니라 count를 읽는 구간 중심으로 다시 호출되는 패턴을 확인할 수 있다(구성에 따라 숫자는 달라질 수 있다).
내부 동작 원리
Compose 파이프라인은 Composition → Layout → Drawing 순서로 진행된다. Composition은 “어떤 UI 트리를 만들지”를 결정하고 Slot Table에 호출 순서/파라미터/remember 값 등을 저장한다. Layout은 측정(measure)과 배치(place)이며, Drawing은 실제 캔버스 그리기다. State 변경은 먼저 Composition 단계의 재실행을 유발하고, 그 결과가 레이아웃/드로잉에 영향을 주면 그 단계까지 이어진다.
Compose Compiler는 @Composable 함수를 그대로 호출하지 않는다. 실제로는 숨은 파라미터(Composer, changed 플래그 등)를 붙인 형태로 변환하고, 함수 본문은 ‘그룹 시작/종료’ 호출로 둘러싸인다. remember는 composer.cache 같은 형태로 슬롯에 저장된다. 소스 코드를 길게 복사할 필요는 없고, 중요한 건 “호출 위치가 슬롯 인덱스를 결정한다”는 사실이다.
Slot Table은 선형 배열에 가까운 구조로, 그룹(Composable 호출)과 슬롯(remember 값, 파라미터 메타데이터 등)을 순서대로 저장한다. recomposition 때 Runtime은 같은 호출 순서로 다시 실행하면서, 기존 슬롯을 재사용하거나 필요한 경우 이동/삽입/삭제를 수행한다. 그래서 조건문으로 Composable 호출 구조가 크게 흔들리면 슬롯 이동 비용이 커지고, remember가 엉뚱한 값과 결합되는 버그가 생긴다(키를 쓰는 이유가 여기 있다).
MutableState 쓰기 시점에는 스냅샷 시스템이 관여한다. 쓰기는 즉시 UI를 다시 그리지 않고, 변경을 기록한 뒤 해당 state를 읽었던 scope들을 invalid로 표시한다. 그 다음 프레임에서 recomposer가 invalid scope를 다시 실행한다. 이 구조 덕분에 같은 프레임 안에서 여러 번 값을 바꿔도 최종 상태로 한 번만 반영되는 경우가 많다(상황에 따라 다름).
비교는 두 층에서 일어난다. 첫째, MutableState 자체는 “값이 바뀌었는지”를 정책으로 판단한다(기본은 equals 기반). 둘째, Composable 파라미터 변경은 compiler가 생성한 changed 비트로 빠르게 판단한다. @Stable/@Immutable은 이 두 번째 층의 최적화 힌트다. 안정적이라고 선언된 타입은 내부 필드 변경이 외부 관찰에 영향을 주지 않는다고 가정하거나, equals를 신뢰할 수 있다고 가정해 불필요한 재실행을 줄인다.
Modifier 체이닝은 내부적으로 노드(Modifier.Node) 또는 요소의 연결 리스트에 가깝다. 순서가 곧 의미다. padding 다음 background와 background 다음 padding은 측정 결과가 달라진다. 그리고 clickable/semantics 같은 입력·접근성 노드는 Layout/Draw와 별개로 hit test, semantics 트리 구축에 영향을 준다. State를 어디에서 읽느냐에 따라 “입력 노드만 다시 구성”될 수도, “레이아웃까지 다시 측정”될 수도 있다.
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.SideEffect
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14fun RecomposeTraceDemo() {
15 var count by remember { mutableIntStateOf(0) }
16
17 Column {
18 SideEffect {
19 Log.d("RecomposeTrace", "Column committed. count=$count")
20 }
21
22 Text(text = "count=$count")
23 Button(onClick = { count++ }) { Text("increment") }
24 }
25}
26
27@Preview
28@Composable
29private fun RecomposeTraceDemoPreview() {
30 RecomposeTraceDemo()
31}이 코드를 실행하고 Logcat에서 RecomposeTrace 태그를 보면, 버튼을 누를 때마다 로그가 찍힌다. SideEffect는 composition이 적용(commit)된 뒤 실행되므로, ‘재실행 됐다’가 아니라 ‘UI 변경이 적용됐다’를 확인하는 장치가 된다. 나도 처음엔 println을 Composable 본문에 넣었다가 로그가 과하게 찍혀서 헷갈렸다. SideEffect로 옮기니 “recomposition → apply changes” 흐름이 분리돼 보였다.
1import androidx.compose.foundation.interaction.MutableInteractionSource
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.padding
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.mutableIntStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.semantics.Role
11import androidx.compose.ui.semantics.semantics
12import androidx.compose.ui.semantics.stateDescription
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun StateReadPlacementDemo() {
17 val count = remember { mutableIntStateOf(0) }
18 val interaction = remember { MutableInteractionSource() }
19
20 Column(Modifier.padding(16.dp)) {
21 Text(
22 modifier = Modifier.semantics {
23 stateDescription = "count is ${count.intValue}"
24 },
25 text = "Tap button"
26 )
27 Button(
28 modifier = Modifier.semantics { }
29 ,
30 interactionSource = interaction,
31 onClick = { count.intValue++ }
32 ) {
33 Text(text = "count=${count.intValue}")
34 }
35 }
36}여기서는 count를 Text의 semantics에도 읽는다. 실행 후 TalkBack을 켜거나 Accessibility Scanner로 확인하면, 버튼 라벨뿐 아니라 stateDescription이 함께 바뀐다. 같은 state라도 ‘어디에서 읽느냐’가 구독 범위를 만든다. semantics 블록에서 읽으면 접근성 트리 업데이트가 필요해지고, 버튼 텍스트에서 읽으면 draw 텍스트가 바뀐다. 값 변경 한 번이 어느 서브시스템까지 파급되는지 감각이 생긴다.
실습하기
실습 목표는 세 가지다. (1) remember가 없을 때 상태가 왜 유지되지 않는지 눈으로 확인한다. (2) MutableState 쓰기가 어떤 Composable을 다시 호출하는지 로그로 추적한다. (3) 상태를 hoist해서 재사용 가능한 컴포넌트로 바꾼다. 각 단계는 복사-붙여넣기 후 바로 실행 가능한 단위로 구성한다.
1android {
2 buildFeatures { compose true }
3 composeOptions {
4 kotlinCompilerExtensionVersion = "1.5.15"
5 }
6}
7
8dependencies {
9 implementation(platform("androidx.compose:compose-bom:2024.10.00"))
10 implementation("androidx.compose.material3:material3")
11 implementation("androidx.activity:activity-compose:1.9.3")
12}버전은 프로젝트 상황에 맞게 조정한다. 핵심은 Compose BOM으로 런타임/머티리얼 버전을 맞추고, compiler extension 버전과 Kotlin 버전 호환을 유지하는 것이다. 버전이 안 맞으면 빌드 에러가 먼저 터진다. 내가 3시간 삽질했던 케이스는 compiler extension을 올렸는데 Kotlin 플러그인이 낮아서, 빌드 로그에 “This version of the Compose Compiler requires Kotlin version …”가 떠서 멈춘 일이었다.
1단계: remember 없이 상태가 왜 날아가는가
처음엔 “state는 mutableStateOf만 쓰면 되겠지”라고 생각하기 쉽다. 그런데 remember 없이 만들면 recomposition 때마다 새 인스턴스가 생성된다. 실행하면 버튼을 눌러도 숫자가 1에서 더 이상 올라가지 않거나, 올라갔다가 바로 0으로 되돌아가는 듯한 느낌을 받는다(실제로는 새 state가 생성되어 화면이 그걸 읽는다).
확인 포인트는 인스턴스 동일성이다. 같은 프레임에서 클릭이 여러 번 들어가도, 다음 recomposition에서 state 객체가 교체되면 이전 쓰기는 의미가 없다. 이 현상은 Slot Table에 저장될 값이 없어서 생긴다. remember가 슬롯에 state를 저장해두면, 다음 호출에서도 동일 객체를 재사용한다.
1import androidx.compose.foundation.layout.Column
2import androidx.compose.material3.Button
3import androidx.compose.material3.Text
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.mutableIntStateOf
6import androidx.compose.ui.tooling.preview.Preview
7
8@Composable
9fun BrokenCounter_NoRemember() {
10 val count = mutableIntStateOf(0)
11
12 Column {
13 Text(text = "count=${count.intValue}")
14 Button(onClick = { count.intValue++ }) {
15 Text("+1")
16 }
17 }
18}
19
20@Preview
21@Composable
22private fun BrokenCounter_NoRememberPreview() {
23 BrokenCounter_NoRemember()
24}이 코드를 띄우고 +1을 누르면, 기대와 다르게 값이 유지되지 않는다. 이유는 Composable 함수가 다시 호출될 때 count가 새로 만들어지기 때문이다. View 시스템에서의 ‘인스턴스 필드’와 달리, Composable 로컬은 지속 저장소가 아니다. 지속 저장소가 Slot Table이고, remember가 그 연결 고리다.
2단계: 상태 변경이 어느 범위를 다시 호출하는지 추적하기
recomposition은 보통 조용히 일어나서 감이 없다. 그래서 로그를 찍어 scope 단위를 확인하는 편이 빠르다. Composable 본문에 Log를 넣으면 너무 자주 찍혀서 오해가 생기므로, SideEffect로 commit 시점 로그를 남긴다.
실행하면 버튼 클릭 시 ChildA/ChildB 중 state를 읽는 쪽만 로그가 증가하는 장면을 기대한다. 만약 둘 다 찍히면, state 읽기가 상위로 끌려 올라갔거나(파라미터로 전달), 상위에서 불필요하게 state를 읽고 있을 가능성이 크다.
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.SideEffect
7import androidx.compose.runtime.mutableIntStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.ui.tooling.preview.Preview
10
11@Composable
12private fun ChildA(count: Int) {
13 SideEffect { Log.d("Scope", "ChildA commit. count=$count") }
14 Text("A sees $count")
15}
16
17@Composable
18private fun ChildB() {
19 SideEffect { Log.d("Scope", "ChildB commit") }
20 Text("B does not read state")
21}
22
23@Composable
24fun ScopeIsolationDemo() {
25 val count = remember { mutableIntStateOf(0) }
26 Column {
27 ChildA(count.intValue)
28 ChildB()
29 Button(onClick = { count.intValue++ }) { Text("inc") }
30 }
31}
32
33@Preview
34@Composable
35private fun ScopeIsolationDemoPreview() {
36 ScopeIsolationDemo()
37}Logcat에서 Scope 태그를 보면, inc를 누를 때 ChildA commit 로그는 계속 증가하고 ChildB는 그대로인 패턴이 이상적이다. 만약 ChildB도 같이 증가하면, 상위 Column이 count를 읽는 구조로 바뀌었을 확률이 높다. 예를 들어 Column의 semantics에서 count를 읽거나, ChildB에 count를 의미 없이 전달하는 경우가 흔한 실수다.
3단계: state hoisting으로 재사용 가능한 Counter 만들기
상태를 내부에 숨기면 데모는 쉬운데, 화면 여러 곳에서 같은 상태를 공유하거나 테스트에서 초기값을 주입하는 순간 막힌다. 그래서 Counter는 value와 onValueChange 형태로 분리한다. 이렇게 하면 화면 회전/프로세스 재생성 대응(rememberSaveable)도 호출자에서 결정할 수 있다.
실행하면 화면에 두 개의 Counter가 보이고, 하나는 독립 상태, 다른 하나는 공유 상태로 동작하게 만들 수 있다. 이 차이가 “상태 소유가 어디인가”를 직관적으로 보여준다.
1import androidx.compose.foundation.layout.Arrangement
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.unit.dp
12import androidx.compose.ui.tooling.preview.Preview
13
14@Composable
15fun HoistedCounter(
16 value: Int,
17 onValueChange: (Int) -> Unit,
18 modifier: Modifier = Modifier
19) {
20 Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
21 Text(text = "value=$value")
22 Button(onClick = { onValueChange(value + 1) }) { Text("+1") }
23 Button(onClick = { onValueChange(0) }) { Text("reset") }
24 }
25}
26
27@Composable
28fun HoistingDemoScreen() {
29 val left = remember { mutableIntStateOf(0) }
30 val shared = remember { mutableIntStateOf(0) }
31
32 Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
33 HoistedCounter(value = left.intValue, onValueChange = { left.intValue = it })
34 HoistedCounter(value = shared.intValue, onValueChange = { shared.intValue = it })
35 HoistedCounter(value = shared.intValue, onValueChange = { shared.intValue = it })
36 }
37}
38
39@Preview
40@Composable
41private fun HoistingDemoScreenPreview() {
42 HoistingDemoScreen()
43}세 번째 Counter는 두 번째와 같은 shared를 사용하므로, 한쪽에서 +1을 누르면 다른 쪽 숫자도 같이 바뀐다. 이 장면이 ‘상태는 UI 밖에 있고, UI는 상태를 읽어 그릴 뿐’이라는 모델을 가장 빠르게 체감시킨다. 그리고 shared를 rememberSaveable로 바꾸면 프로세스 재생성에서도 값이 유지되는지까지 확장 가능하다.
심화: Advanced 버전 만들기
실무에서는 단순 카운터보다 “중복 클릭 방지”, “로딩 상태”, “아이콘+텍스트”, “롱프레스”, “접근성 라벨” 같은 요구가 한 컴포넌트에 동시에 들어온다. 이때 상태를 어디에 둘지 결정이 성능과 버그를 좌우한다. 내부 상태(remember)로 숨기면 편하지만, 상위 비즈니스 상태와 충돌하기 쉽다.
사례 1: debounce를 UI에서 처리할 때 생기는 함정
처음에 나도 버튼 중복 클릭을 막으려고 onClick에서 System.currentTimeMillis를 기억해두는 코드를 Composable 안에 넣었다. QA에서 “가끔 버튼이 영원히 안 눌린다”는 리포트가 왔다. Logcat을 까보니 recomposition 타이밍에 따라 lastClickTime이 초기화되거나, 반대로 remember 키가 꼬여 다른 버튼과 공유되는 상황이 있었다.
원인은 두 가지였다. (1) 버튼이 조건부로 나타났다 사라지는 구조에서 remember 슬롯이 이동했다. (2) debounce 상태를 UI 계층에 숨겨서, 화면 복귀 시 정책이 예측 불가능해졌다. 교정은 debounce를 ‘호출자에서 상태로 소유’하거나, 최소한 rememberSaveable/키를 명시해 슬롯 이동에 강하게 만드는 방식으로 했다.
사례 2: loading 상태가 리컴포지션 폭발로 이어지는 지점
로딩 스피너를 버튼 안에 넣으면, loading이 true일 때만 CircularProgressIndicator가 생긴다. 이때 버튼 content 구조가 조건문으로 크게 흔들리면 Slot Table 그룹 이동이 늘고, 특히 리스트 아이템 안에서 반복되면 스크롤 중 프레임 드랍이 눈에 띈다. 실제로 Recycler 성격의 LazyColumn에서 아이템마다 loading 상태가 바뀌면, 60fps 기준 16.6ms 예산 안에서 composition 비용이 급격히 늘어나는 경우가 있었다.
교정은 구조를 안정화하는 쪽이었다. content를 항상 같은 트리 형태로 유지하고, alpha/visibility로 표현하거나, key를 사용해 그룹 이동을 최소화했다. 또 loading 상태는 derivedStateOf로 계산 비용을 줄이고, 버튼 자체는 가능한 한 stateless로 유지했다.
1import android.os.SystemClock
2import androidx.compose.foundation.combinedClickable
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Icon
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.mutableLongStateOf
15import androidx.compose.runtime.mutableStateOf
16import androidx.compose.runtime.remember
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Alignment
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.semantics.contentDescription
21import androidx.compose.ui.semantics.semantics
22import androidx.compose.ui.unit.dp
23
24@Composable
25fun AdvancedButton(
26 text: String,
27 onClick: () -> Unit,
28 modifier: Modifier = Modifier,
29 enabled: Boolean = true,
30 loading: Boolean = false,
31 debounceMs: Long = 500L,
32 icon: (@Composable (() -> Unit))? = null,
33 onLongPress: (() -> Unit)? = null,
34 a11yLabel: String = text
35) {
36 var lastClickAt by remember { mutableLongStateOf(0L) }
37
38 val clickAllowed = enabled && !loading
39
40 Surface(
41 modifier = modifier
42 .semantics { contentDescription = a11yLabel }
43 .combinedClickable(
44 enabled = clickAllowed,
45 onClick = {
46 val now = SystemClock.elapsedRealtime()
47 if (now - lastClickAt >= debounceMs) {
48 lastClickAt = now
49 onClick()
50 }
51 },
52 onLongClick = onLongPress
53 ),
54 color = if (clickAllowed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
55 contentColor = if (clickAllowed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
56 shape = MaterialTheme.shapes.medium
57 ) {
58 Row(
59 horizontalArrangement = Arrangement.Center,
60 verticalAlignment = Alignment.CenterVertically
61 ) {
62 if (loading) {
63 CircularProgressIndicator(strokeWidth = 2.dp)
64 Spacer(Modifier.width(8.dp))
65 }
66 if (icon != null) {
67 icon()
68 Spacer(Modifier.width(8.dp))
69 }
70 Text(text = text)
71 }
72 }
73}이 코드를 실행하면 loading=true일 때 클릭이 막히고, 스피너가 나타난다. debounceMs 안에 연속 클릭하면 onClick이 한 번만 호출된다. combinedClickable을 쓰는 이유는 long press를 같은 입력 노드에서 처리하기 위해서다. 다만 이 구현은 UI 내부에 lastClickAt 상태를 숨긴다. 리스트 아이템에서 조건부 노출이 많으면 remember 슬롯 이동 리스크가 있다. 제품 요구에 따라 debounce를 상위로 hoist하는 편이 더 안전한 경우가 많다.
1import android.widget.Toast
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material.icons.Icons
6import androidx.compose.material.icons.filled.Favorite
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.platform.LocalContext
16import androidx.compose.ui.tooling.preview.Preview
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun AdvancedButtonDemo() {
21 val ctx = LocalContext.current
22 var loading by remember { mutableStateOf(false) }
23
24 Column(
25 Modifier.padding(16.dp),
26 verticalArrangement = Arrangement.spacedBy(12.dp)
27 ) {
28 AdvancedButton(
29 text = if (loading) "Saving..." else "Save",
30 loading = loading,
31 onClick = {
32 loading = true
33 Toast.makeText(ctx, "clicked", Toast.LENGTH_SHORT).show()
34 },
35 icon = { androidx.compose.material3.Icon(Icons.Default.Favorite, contentDescription = null) },
36 onLongPress = { Toast.makeText(ctx, "long press", Toast.LENGTH_SHORT).show() },
37 a11yLabel = "Save button"
38 )
39
40 Text(
41 text = "loading=$loading (다시 false로 내리는 로직은 데모에서 생략)",
42 style = MaterialTheme.typography.bodySmall
43 )
44 }
45}
46
47@Preview
48@Composable
49private fun AdvancedButtonDemoPreview() {
50 AdvancedButtonDemo()
51}이 데모는 클릭하면 Toast가 뜨고 loading이 true로 바뀌면서 버튼이 잠긴다. 실제 앱에서는 네트워크 응답에서 loading=false로 돌린다. 여기서 확인할 지점은 “loading 상태가 바뀌면 버튼 내부의 조건부 UI가 바뀌고, 그에 따라 Slot Table 그룹 구조도 바뀐다”는 사실이다. 구조 변동이 많은 컴포넌트를 LazyColumn 아이템에 넣을 때는 key/구조 안정화 전략이 필요하다.
자주 하는 실수
remember 없이 mutableStateOf 생성
증상: 버튼을 눌러도 값이 유지되지 않거나, 잠깐 바뀌었다가 0으로 돌아간다. 특히 입력 필드가 타이핑 중 초기화되는 현상으로 자주 나타난다.
원인: Composable은 recomposition 때 다시 호출되며, 로컬 변수는 매번 새로 만들어진다. Runtime이 재사용할 슬롯이 없으니 state 객체가 교체되고, 이전 구독 관계도 끊긴다.
해결: remember 또는 rememberSaveable로 상태 객체를 Slot Table에 저장한다. 조건부 호출 구조가 있다면 key를 통해 슬롯 이동을 방지한다.
state를 너무 상위에서 읽어서 리컴포지션 범위가 커짐
증상: 작은 값 변경인데 화면 전체가 다시 호출되는 느낌이 들고, Logcat에 상위 컴포넌트 로그가 계속 찍힌다. LazyColumn 스크롤 중 프레임 드랍이 동반되기도 한다.
원인: 상위 Composable이 state.value를 읽으면 그 scope가 구독자가 된다. 하위에서만 필요한 값이라도 상위가 읽는 순간 invalidation 범위가 커진다.
해결: state 읽기를 실제로 필요한 가장 아래 레벨로 내린다. 파라미터로 전달할 때는 값만(Int/String) 전달하고, 불필요한 state 객체 전달을 피한다. derivedStateOf로 계산 결과만 구독하게 만드는 방법도 있다.
가변 컬렉션(List/Map)을 그대로 들고 있고 내부만 변경
증상: list.add를 했는데 UI가 갱신되지 않는다. 반대로 어떤 화면에서는 갱신되다가 다른 화면에서는 멈춘다.
원인: MutableState는 ‘참조’ 또는 equals 결과로 변경 여부를 판단한다. 같은 리스트 인스턴스에서 내부만 바꾸면 state.value 자체는 동일 참조라 변경으로 인식되지 않을 수 있다. 스냅샷 상태 컬렉션이 아닌 일반 컬렉션이면 추적이 더 어렵다.
해결: 불변 컬렉션으로 교체(copy)해서 state.value에 새 인스턴스를 넣는다. 또는 snapshotStateListOf 같은 스냅샷 컬렉션을 사용한다. 실무에서는 UI state는 immutable data class + copy 패턴이 디버깅 비용이 낮다.
조건문으로 Composable 호출 구조가 크게 흔들림(remember 슬롯 이동)
증상: 특정 조건에서만 버튼이 나타나는 UI에서, 상태가 다른 컴포넌트로 ‘전이’된 것처럼 보인다. 예를 들어 체크박스 토글 후 텍스트필드 내용이 다른 필드로 이동한다.
원인: Slot Table은 호출 순서 기반이다. 조건문으로 중간 호출이 생기거나 사라지면, 이후 remember 슬롯 인덱스가 밀린다. 키가 없으면 Runtime은 같은 위치의 슬롯을 재사용하려 한다.
해결: key(...)로 그룹을 고정하거나, 조건부 UI를 구조적으로 안정화한다(항상 같은 위치에 두고 alpha/visibility로 제어). Lazy 리스트에서는 item key를 반드시 지정한다.
@Stable/@Immutable을 ‘성능 스위치’처럼 오용
증상: 어노테이션을 붙였는데도 recomposition이 줄지 않거나, 더 미묘한 버그가 생긴다. 특정 필드 변경이 UI에 반영되지 않는 듯 보이기도 한다.
원인: 안정성 어노테이션은 Runtime/Compiler가 최적화 가정을 하게 만든다. 실제로는 내부 가변 상태가 있는데 @Immutable을 붙이면, 변경 감지가 깨질 수 있다. 또한 recomposition의 원인이 state 읽기 범위라면 어노테이션만으로 해결되지 않는다.
해결: 타입을 실제로 불변으로 만들고(data class + val + copy), 안정성은 결과로 따라오게 한다. 최적화는 Layout Inspector의 recomposition counts, tracing으로 병목을 확인한 뒤 적용한다.
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.key
3import androidx.compose.runtime.mutableIntStateOf
4import androidx.compose.runtime.remember
5import androidx.compose.foundation.layout.Column
6import androidx.compose.material3.Text
7import androidx.compose.ui.tooling.preview.Preview
8
9@Composable
10fun RememberSlotShiftFix(showExtra: Boolean) {
11 Column {
12 val a = remember { mutableIntStateOf(0) }
13 Text("a=${a.intValue}")
14
15 if (showExtra) {
16 key("extra") {
17 val b = remember { mutableIntStateOf(100) }
18 Text("b=${b.intValue}")
19 }
20 }
21
22 val c = remember { mutableIntStateOf(1000) }
23 Text("c=${c.intValue}")
24 }
25}
26
27@Preview
28@Composable
29private fun RememberSlotShiftFixPreview() {
30 RememberSlotShiftFix(showExtra = true)
31}이 코드는 조건부 블록에 key를 줘서 remember 슬롯 이동 리스크를 줄인다. showExtra를 토글해도 c의 슬롯이 b 때문에 밀리는 상황을 완화한다. 실제 앱에서는 상태를 hoist하는 편이 더 근본적인 해결인 경우가 많다.
성능 최적화 체크리스트
- 상태가 UI에 반영되지 않으면 먼저 ‘값이 바뀌었는지’가 아니라 ‘MutableState를 통해 바뀌었는지’를 확인한다(var 변경은 Runtime이 모른다).
- Composable 로컬에서 mutableStateOf를 만들었다면 remember/rememberSaveable이 있는지 확인한다(없으면 recomposition마다 새 인스턴스).
- state 읽기 위치를 점검한다. 상위에서 읽으면 그 scope가 구독자가 되어 recomposition 범위가 커진다.
- 파라미터로 State<T> 자체를 전달하는 대신, 가능하면 값(T)과 이벤트(onChange)를 전달해 구독을 필요한 곳으로 제한한다.
- 컬렉션은 불변 교체(copy) 또는 snapshotStateListOf 같은 스냅샷 컬렉션을 사용한다. 같은 인스턴스 내부 변경은 감지되지 않을 수 있다.
- 조건부 UI에서 remember가 섞이면 key 사용 여부를 점검한다. 호출 순서 변화는 Slot Table 슬롯 이동을 만든다.
- derivedStateOf를 통해 ‘계산 결과’만 구독하게 만들고, 비싼 계산은 recomposition마다 돌지 않게 한다.
- SideEffect/LaunchedEffect를 구분한다. 로그/외부 호출을 Composable 본문에 두면 recomposition마다 반복 실행된다.
- Modifier 순서를 점검한다. padding/background/clickable 순서가 바뀌면 측정/히트테스트가 달라지고 불필요한 layout invalidation이 생긴다.
- Layout Inspector의 recomposition counts를 켜고, 클릭 한 번에 어떤 노드가 몇 번 재구성되는지 숫자로 확인한다.
- androidx.compose.runtime:runtime-tracing(사용 환경에 따라) 또는 Perfetto/trace를 통해 composition 비용이 프레임 예산(16.6ms)을 넘는지 확인한다.
- @Stable/@Immutable은 타입이 실제로 안정적일 때만 사용한다. 가변 필드가 있으면 최적화 가정이 깨진다.
자주 묻는 질문
State와 MutableState는 왜 굳이 분리돼 있나? 그냥 var로도 값은 바뀌는데
Compose가 필요한 정보는 “값이 바뀌었다”가 아니라 “누가 그 값을 읽었는지”다. var는 변경 이벤트를 발행하지 않아서 Runtime이 구독 관계를 만들 수 없다. State/MutableState는 읽기 시점에 현재 RecomposeScope를 구독자로 등록하고, 쓰기 시점에 그 scope를 invalid로 만든다. 이 분리 덕분에 하위 컴포넌트에는 State(읽기 전용)만 내려서 ‘표시 전용 UI’를 만들 수 있고, 상위만 MutableState를 소유해 변경 경로를 통제한다. 학습 키워드는 snapshot system, invalidation, state hoisting이다.
remember가 Slot Table에 저장한다는 말이 무슨 뜻인가? 메모리에 그냥 캐시하는 것과 뭐가 다른가
remember는 “현재 호출 위치”에 대응하는 슬롯에 값을 저장한다. 이 호출 위치는 컴파일러가 만든 그룹 경계와 호출 순서로 결정된다. 단순 캐시라면 키를 직접 관리해야 하지만, Compose는 Slot Table이 호출 순서를 기반으로 슬롯 인덱스를 관리한다. 그래서 같은 Composable이 같은 구조로 다시 호출되면 슬롯을 재사용한다. 반대로 조건문으로 호출 순서가 바뀌면 슬롯이 이동해 다른 remember 값과 결합될 수 있다. 이때 key(...)가 그룹을 고정하는 역할을 한다. 학습 키워드는 slot table, group, key, call-site identity다.
mutableStateOf 값 변경 시 즉시 화면이 다시 그려지나? 버튼 클릭 직후에 무슨 일이 일어나나
쓰기는 즉시 draw를 다시 하지 않는다. MutableState 쓰기는 스냅샷에 변경을 기록하고, 그 값을 읽었던 scope를 invalid로 표시한다. 이후 recomposer가 프레임 타이밍에 맞춰 invalid scope를 재실행(recomposition)하고, 변경 사항을 apply 한다. apply 이후에야 Layout/Draw 단계가 필요 여부에 따라 실행된다. 그래서 같은 이벤트 루프에서 값을 여러 번 바꾸면 중간 상태가 화면에 보이지 않고 최종 상태만 반영되는 경우가 많다. 확인은 SideEffect 로그(커밋 시점)와 Layout Inspector recomposition count로 한다. 학습 키워드는 recomposer, snapshot apply, frame clock이다.
State를 읽는 위치가 왜 그렇게 중요하나? 어차피 값 하나 바뀌면 다시 그리면 되는 것 아닌가
Compose의 비용은 ‘다시 그리기’보다 ‘다시 실행되는 함수 범위’에서 먼저 터진다. state.value를 읽는 Composable이 구독자가 되므로, 그 Composable의 scope가 invalid 된다. 상위에서 읽으면 상위 scope가 invalid 되고, 그 아래 호출들이 연쇄적으로 다시 실행될 가능성이 커진다(스킵 최적화가 있어도 파라미터 변경이 얽히면 깨진다). 반대로 필요한 가장 아래에서만 읽으면 recomposition 범위가 작아진다. 실전에서는 “상태를 어디서 읽는가”가 곧 “어디까지 다시 실행되는가”다. 학습 키워드는 recomposition scope, skipping, parameter stability다.
리스트(List) 내부만 바꿨는데 UI가 갱신되지 않는다. 왜 equals 비교가 있는데도 안 되나
MutableState는 기본 정책에서 equals로 변경 여부를 판단하지만, 상태로 들고 있는 값이 ‘같은 리스트 인스턴스’라면 state.value 자체는 동일 참조다. 내부 요소를 add/remove해도 state.value 세터가 호출되지 않으면 변경 이벤트가 발생하지 않는다. 세터를 호출해도 equals가 true로 나오면 invalidation이 생기지 않는다. 그래서 UI state는 불변 컬렉션으로 교체하는 방식이 안전하다(list = list + newItem). 또는 snapshotStateListOf를 사용하면 컬렉션 자체가 스냅샷 관찰 대상이 된다. 학습 키워드는 SnapshotStateList, structuralEqualityPolicy, immutable state model이다.
derivedStateOf는 언제 쓰나? 그냥 계산해서 넣으면 안 되나
derivedStateOf는 “여러 state로부터 계산된 값”을 별도의 state로 만들되, 입력이 바뀔 때만 다시 계산하고, 그 값을 읽는 곳만 구독하게 만든다. 예를 들어 scroll offset으로부터 ‘상단 고정 여부’를 계산할 때, 매 recomposition마다 계산하면 비용이 쌓인다. derivedStateOf는 읽기 추적을 입력 state에 연결하고, 결과가 바뀌지 않으면 invalidation을 줄일 수 있다. 다만 derivedStateOf 자체도 remember로 보관해야 슬롯이 안정적이다. 학습 키워드는 derived state, snapshot read observation, recomposition minimization이다.
@Stable/@Immutable을 붙이면 recomposition이 줄어드나? 어디에 붙여야 효과가 있나
어노테이션은 마법 스위치가 아니다. 컴파일러/런타임이 “이 타입은 안정적”이라는 가정을 하게 만드는 힌트다. 타입이 실제로 불변(val만 있고 내부 가변 참조가 없고, equals가 의미 있게 동작)이라면 파라미터 변경 판단이 쉬워져 스킵이 늘 수 있다. 반대로 내부에 var나 가변 컬렉션이 있는데 @Immutable을 붙이면, 값이 바뀌어도 ‘안 바뀐 것’으로 취급될 여지가 생겨 UI 반영이 깨질 수 있다. 먼저 상태 모델을 immutable data class + copy로 만들고, 그 다음에 필요할 때만 안정성 어노테이션을 검토하는 순서가 안전하다. 학습 키워드는 stability inference, skipping, immutable UI state다.