23. remember·mutableStateOf로 Compose 상태를 시작하는 내부 원리
remember와 mutableStateOf가 왜 필요한지, Compose Runtime이 상태 읽기/쓰기와 Slot Table, 리컴포지션 범위를 어떻게 결정하는지 내부 동작으로 설명한다. 초보가 겪는 버그를 재현한다. 140~160자 내외 문장 구성.
remember·mutableStateOf로 Compose 상태를 시작하는 내부 원리
버튼을 눌렀는데 숫자가 안 올라가거나, 올라가긴 하는데 스크롤만 해도 값이 초기화되는 상황이 자주 나온다. 초보 때는 onClick에서 변수를 바꾸면 화면이 다시 그려질 거라 믿는데, Compose는 ‘변수 변경’이 아니라 ‘State 쓰기’만 추적한다. remember와 mutableStateOf가 그 추적의 입구다. 이 둘이 없으면 값이 사라지는 이유가 Slot Table의 저장 방식과 연결된다. 이 글은 그 연결고리를 코드로 눈으로 확인하게 만든다. 나도 처음엔 remember를 빼먹고 “왜 클릭해도 UI가 안 바뀌지?”만 반복했다. Logcat에 찍히는 건 onClick 실행 로그뿐이고, 화면은 그대로였다. 3시간쯤 삽질하다가 Layout Inspector에서 리컴포지션 카운터가 0으로 고정된 걸로
핵심 개념
Compose에서 UI는 ‘현재 상태를 읽어서 그리는 함수 호출 결과’에 가깝다. View 시스템처럼 View 인스턴스가 내부 필드로 상태를 들고 있고 invalidate()로 다시 그리는 모델이 아니다. 그래서 상태가 어디에 저장되고, 누가 그 상태를 읽었는지가 핵심이 된다. remember는 ‘이 호출 위치의 값’을 Slot Table에 저장해 다음 composition에서도 같은 위치에서 꺼내 쓴다.
mutableStateOf는 단순한 var 래퍼가 아니라, Snapshot 시스템과 연결된 관측 가능한 State<T>를 만든다. Composable이 state.value를 읽는 순간 Runtime은 ‘이 RecomposeScope가 이 State를 읽었다’라는 의존성을 기록한다. 이후 state.value에 쓰기가 발생하면, 그 State를 읽었던 스코프만 invalidate되고 다음 프레임에 재호출된다.
용어를 사용 맥락으로 정의한다. Composition은 Composable 호출 트리를 만들고 Slot Table에 위치 기반으로 기록하는 단계다. Recomposition은 이미 존재하는 Slot Table을 재사용하면서 바뀐 입력(파라미터/State)에 의해 필요한 호출만 다시 실행하는 단계다. Snapshot은 State 읽기/쓰기의 일관성을 보장하고, 쓰기 시점에 어떤 스코프를 깨울지 결정하는 기반이다.
remember가 필요한 이유는 ‘로컬 변수는 매 recomposition마다 다시 초기화’되기 때문이다. 버튼 클릭으로 State가 바뀌면 그 State를 읽는 Composable이 다시 호출된다. 그 호출 안의 var count = 0 같은 로컬 변수는 다시 0이 된다. View 시스템에서는 View 인스턴스가 살아있어서 필드가 남지만, Compose는 함수 재호출이 기본이어서 기억 장치가 따로 필요하다.
1package com.example.state
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.material3.Button
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14
15class MainActivity : ComponentActivity() {
16 override fun onCreate(savedInstanceState: Bundle?) {
17 super.onCreate(savedInstanceState)
18 setContent { CounterBasic() }
19 }
20}
21
22@Composable
23fun CounterBasic() {
24 var count by remember { mutableStateOf(0) }
25
26 Column {
27 Text(text = "count=$count")
28 Button(onClick = { count++ }) {
29 Text("+1")
30 }
31 }
32}이 코드를 실행하면 버튼을 누를 때마다 텍스트가 증가한다. 중요한 관찰 포인트는 ‘Button 클릭 → count 쓰기 → count를 읽었던 Text가 포함된 스코프 invalidate → 다음 프레임에 CounterBasic 재호출’ 순서다. count는 CounterBasic의 로컬 변수처럼 보이지만, 실제 저장소는 Slot Table 쪽에 있고 remember 호출 위치로 다시 연결된다.
컴포넌트 해부
상태 예제에서 자주 쓰는 조합이 Text + Button + Column이다. 여기서 상태 API가 어디에 걸리는지 분해하면, 상태는 ‘UI 컴포넌트의 파라미터’로 흘러가야 한다. Text는 text 파라미터로 문자열을 받고, Button은 onClick 이벤트로 상태 쓰기를 트리거한다. Column은 레이아웃만 담당하고 상태를 직접 다루지 않는다.
CounterBasic을 Surface 계층과 Content 계층으로 나누면 설계 의도가 더 선명해진다. Surface 계층은 배경/모양/클릭/리플/semantics 같은 ‘껍데기’를 담당하고, Content 계층은 실제로 state를 읽어 UI를 그린다. 상태가 Surface에 섞이면 클릭 영역과 표시 내용이 불필요하게 같이 리컴포즈되는 경우가 생긴다.
- Column: 자식들을 세로로 배치하는 레이아웃 컨테이너. 상태를 저장하지 않지만 상태 변화로 재측정(remeasure)이 일어날 수 있다.
- Text(text): 상태를 ‘읽는’ 지점이 되기 쉽다. text가 State에서 만들어지면 이 호출이 의존성 등록 지점이 된다.
- Button(onClick): 상태를 ‘쓰는’ 지점이 되기 쉽다. onClick은 컴포지션과 분리된 이벤트 콜백이다.
- modifier: 측정/배치/그리기/입력/semantics를 체이닝으로 누적한다. 체인 순서가 결과를 바꾼다.
- content slot: Button의 content 람다. Slot API는 컴포넌트의 내부 구조를 외부가 채우게 만든다.
- interactionSource: pressed/hovered 같은 상호작용 상태의 스트림. 리플/상태 UI에 연결된다.
- enabled: 입력 처리와 semantics의 disabled 노출에 영향을 준다.
- shape: 클릭 영역/클리핑/리플 마스크에 영향을 준다.
- colors: 상태(enabled/pressed)에 따른 색 전환 정책을 캡슐화한다.
- padding/size: 레이아웃 단계에서 측정값을 바꾼다. modifier 순서에 따라 padding이 안쪽/바깥쪽으로 달라진다.
- semantics: 접근성 라벨/role을 제공한다. 테스트 태그도 여기로 들어간다.
구조를 이해하려면 Button을 ‘재구성한 스케치’가 도움이 된다. 실제 Material Button은 더 복잡하지만, 핵심은 Surface(입력/리플/shape)와 Content(Row/텍스트)의 분리다. 상태는 보통 Content가 읽고, 입력은 Surface가 받는다. 이 분리를 어기면 pressed 같은 상호작용 상태가 텍스트 계산까지 흔든다.
1package com.example.state
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.interaction.MutableInteractionSource
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.LocalContentColor
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.remember
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.draw.clip
14import androidx.compose.ui.graphics.Color
15import androidx.compose.ui.semantics.Role
16import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.semantics.contentDescription
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun SketchButton(
22 onClick: () -> Unit,
23 modifier: Modifier = Modifier,
24 enabled: Boolean = true,
25 containerColor: Color = MaterialTheme.colorScheme.primary,
26 content: @Composable () -> Unit
27) {
28 val interactionSource = remember { MutableInteractionSource() }
29
30 Row(
31 modifier = modifier
32 .clip(MaterialTheme.shapes.small)
33 .background(if (enabled) containerColor else Color.Gray)
34 .semantics { contentDescription = "SketchButton" }
35 .clickable(
36 enabled = enabled,
37 role = Role.Button,
38 interactionSource = interactionSource,
39 indication = null,
40 onClick = onClick
41 )
42 .padding(horizontal = 12.dp, vertical = 8.dp)
43 ) {
44 androidx.compose.runtime.CompositionLocalProvider(
45 LocalContentColor provides Color.White
46 ) { content() }
47 }
48}이 스케치는 ‘왜 Button이 content slot을 받는지’를 보여준다. 텍스트만 박아두면 아이콘+텍스트, 로딩 인디케이터, 카운트 배지 같은 변형이 어려워진다. Slot은 내부 레이아웃(Row)만 고정하고 내용은 외부에서 주입한다. remember는 interactionSource 같은 객체를 재사용하게 만들어 pressed 상태 스트림이 매번 새로 생성되지 않게 한다.
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.height
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun CounterUsingSketchButton() {
18 var count by remember { mutableStateOf(0) }
19
20 Column {
21 Text(text = "count=$count", style = MaterialTheme.typography.titleMedium)
22 Spacer(Modifier.height(12.dp))
23 SketchButton(onClick = { count++ }) {
24 Text(text = "increase")
25 }
26 }
27}이 코드를 실행하면 count가 증가하는 것 외에, 클릭 영역/배경/패딩이 Button의 ‘표면’에서 처리되는 걸 확인한다. enabled=false로 바꾸면 클릭이 막히고 배경이 회색으로 바뀐다. 상태(count)는 Content 쪽에서만 읽으니, 배경색 계산과 텍스트 계산이 분리된 리컴포지션 단위가 된다.
내부 동작 원리
Compose Runtime은 Composable 호출을 ‘위치 기반’으로 기억한다. 같은 Composable이 같은 순서로 호출되면 같은 슬롯 인덱스에 매칭된다. remember는 그 슬롯에 값을 저장하고, 다음 composition에서 같은 위치로 다시 오면 저장된 값을 돌려준다. 그래서 remember는 if/for로 호출 순서가 바뀌면 즉시 깨진다. 값이 다른 슬롯으로 밀려나면 엉뚱한 상태가 붙는다.
Compose Compiler는 @Composable 함수를 그대로 호출하지 않는다. 컴파일 결과는 대략 (composer, changedFlags) 같은 파라미터가 추가된 형태로 바뀌고, 함수 본문에는 startGroup/endGroup 같은 그룹 경계가 생긴다. 개발자가 보는 코드는 순수 함수처럼 보이지만, 실제 실행은 Composer가 Slot Table에 그룹과 슬롯을 쌓는 절차 코드에 가깝다.
mutableStateOf로 만든 State는 읽기 시점에 ‘현재 실행 중인 RecomposeScope’를 알아야 한다. Runtime은 composition 중에 현재 스코프를 TLS 비슷한 컨텍스트로 들고 있고, State.value getter가 호출되면 그 스코프를 관찰자로 등록한다. 이 등록이 없으면, 값이 바뀌어도 어느 Composable을 다시 호출해야 할지 알 수 없다.
State 쓰기는 즉시 UI를 다시 그리지 않는다. Snapshot 쓰기 트랜잭션이 커밋되면, 연결된 관찰자 스코프들이 invalidate된다. 그 다음 Choreographer 프레임에서 recomposition이 예약되고, invalidate된 스코프의 그룹만 다시 실행된다. 전체 트리를 다시 도는 게 아니라, Slot Table에서 해당 그룹 범위를 찾아 재진입한다.
composition → layout → drawing 단계로 나누면 상태 변경의 파급 범위를 예측할 수 있다. count만 바뀌면 composition은 CounterBasic 그룹만 재실행될 수 있지만, layout은 텍스트 길이가 바뀌면 측정값이 달라져 상위까지 remeasure가 전파될 수 있다. drawing은 색/텍스트만 바뀌면 레이아웃 없이도 다시 그려질 수 있다.
Modifier 체이닝은 ‘데코레이터 리스트’를 만드는 방식이다. padding().background().clickable() 순서가 바뀌면 입력 hit test 영역과 그리기 영역이 바뀐다. Runtime 관점에서는 Modifier는 파라미터라서 변경되면 해당 노드가 업데이트되지만, 변경이 없는 Modifier는 equality로 스킵될 수 있다. 그래서 Modifier에 매 recomposition마다 새 객체를 만들면 쓸데없는 업데이트가 늘어난다.
1package com.example.state
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.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13
14@Composable
15fun RecompositionTrace() {
16 var clicks by remember { mutableIntStateOf(0) }
17 var label by remember { mutableStateOf("idle") }
18
19 Log.d("Trace", "RecompositionTrace called: clicks=$clicks label=$label")
20
21 Column {
22 Text(text = "clicks=$clicks")
23 Text(text = "label=$label")
24 Button(onClick = {
25 clicks++
26 label = if (clicks % 2 == 0) "even" else "odd"
27 Log.d("Trace", "onClick executed")
28 }) {
29 Text("tap")
30 }
31 }
32}이 코드를 실행하고 버튼을 누르면 Logcat에 onClick executed가 먼저 찍히고, 그 다음 프레임에서 RecompositionTrace called가 다시 찍힌다. 초보가 자주 착각하는 지점이 ‘onClick 안에서 UI가 즉시 다시 그려진다’는 믿음이다. 실제로는 쓰기 → invalidate → 다음 프레임 재호출 흐름이라서, 로그 순서가 그 사실을 증명한다.
1package com.example.state
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.selection.toggleable
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.semantics.Role
15import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.semantics.stateDescription
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun ModifierSemanticsInteractionDemo() {
21 var checked by remember { mutableStateOf(false) }
22 val interactionSource = remember { MutableInteractionSource() }
23
24 Column(
25 modifier = Modifier
26 .padding(16.dp)
27 .semantics { stateDescription = if (checked) "checked" else "unchecked" }
28 .toggleable(
29 value = checked,
30 enabled = true,
31 role = Role.Checkbox,
32 interactionSource = interactionSource,
33 indication = null,
34 onValueChange = { checked = it }
35 )
36 ) {
37 Text(text = "checked=$checked")
38 Text(text = "tap anywhere in this column")
39 }
40}실행하면 Column 전체가 토글 영역이 되고, 텍스트가 checked=true/false로 바뀐다. 중요한 건 Modifier 체인 순서다. padding이 toggleable 앞에 있으면 패딩까지 터치 영역에 포함된다. semantics는 접근성 트리에 상태 설명을 넣는다. interactionSource는 pressed 같은 상호작용 상태를 외부로 공유할 수 있게 하고, remember로 재사용하지 않으면 pressed 스트림이 매번 새로 만들어져 리플/상태 UI가 흔들릴 수 있다.
실습하기
실습 목표는 3단계다. 1단계에서 ‘remember가 없으면 값이 왜 초기화되는지’를 눈으로 확인한다. 2단계에서 mutableStateOf 읽기/쓰기와 리컴포지션 로그를 연결한다. 3단계에서 Modifier와 스타일을 얹으면서도 리컴포지션 범위를 좁히는 감각을 만든다.
환경은 Material3 기준이다. Android Studio 최신 버전에서 Empty Activity(Compose)로 생성하면 대부분 갖춰져 있다. 버전이 다르면 API 이름이 조금씩 달라질 수 있으니, gradle은 힌트 정도만 사용한다.
1android {
2 buildFeatures { compose true }
3 composeOptions {
4 kotlinCompilerExtensionVersion = "1.5.14"
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.2")
12}BOM을 쓰면 Compose 라이브러리 버전 충돌이 줄어든다. compilerExtensionVersion은 프로젝트 템플릿과 맞춰야 한다. 여기서 mismatch가 나면 빌드 에러가 나는데, 실제로 예전에 "This version of the Compose Compiler requires Kotlin version ..." 메시지로 30분 날린 적이 있다. Kotlin 플러그인 버전과 컴파일러 확장 버전이 엮여 있다.
1단계: remember 없이 실패를 재현한다
실행하면 버튼을 눌러도 숫자가 0에서 변하지 않는다. Logcat에는 onClick 로그만 찍히고, 화면 텍스트는 그대로다. 실패를 먼저 재현해야 State 추적 모델이 몸에 들어온다.
원인은 count가 일반 var이라서 Compose Runtime이 관찰하지 못한다는 점이다. 값은 바뀌지만, 어느 스코프를 invalidate해야 하는지 정보가 없다. View 시스템의 invalidate() 호출이 자동으로 생기는 게 아니라, State 쓰기라는 계약이 있어야 한다.
1package com.example.state
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11
12class Step1Activity : ComponentActivity() {
13 override fun onCreate(savedInstanceState: Bundle?) {
14 super.onCreate(savedInstanceState)
15 setContent { Step1_NoState() }
16 }
17}
18
19@Composable
20fun Step1_NoState() {
21 var count = 0
22
23 Column {
24 Text(text = "count=$count")
25 Button(onClick = {
26 count++
27 Log.d("Step1", "clicked count=$count")
28 }) {
29 Text("+1")
30 }
31 }
32}2단계: mutableStateOf + remember로 연결한다
실행하면 버튼 클릭마다 텍스트가 증가한다. Logcat에는 clicked 로그가 찍힌 다음, 다음 프레임에 Composable 재호출 로그가 찍힌다. 이 순서를 확인하면 ‘이벤트 콜백’과 ‘컴포지션’이 분리된다는 감각이 생긴다.
remember가 없으면 recomposition 때마다 새 State가 만들어져 값이 초기화된다. mutableStateOf만으로는 부족하고, 그 State 인스턴스를 Slot Table에 고정해야 한다. remember의 본질은 ‘이 호출 위치의 슬롯에 객체를 저장’이다.
1package com.example.state
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
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.remember
14import androidx.compose.runtime.setValue
15
16class Step2Activity : ComponentActivity() {
17 override fun onCreate(savedInstanceState: Bundle?) {
18 super.onCreate(savedInstanceState)
19 setContent { Step2_StateWorks() }
20 }
21}
22
23@Composable
24fun Step2_StateWorks() {
25 var count by remember { mutableIntStateOf(0) }
26
27 Log.d("Step2", "composed count=$count")
28
29 Column {
30 Text(text = "count=$count")
31 Button(onClick = {
32 count++
33 Log.d("Step2", "clicked")
34 }) {
35 Text("+1")
36 }
37 }
38}3단계: 스타일과 Modifier를 얹고 리컴포지션 범위를 관찰한다
실행하면 카드 같은 배경 안에 카운터가 보이고, 버튼은 둥근 모양과 패딩을 가진다. 클릭 시 텍스트만 바뀌는 것처럼 보여도, 실제로는 CounterCard 전체가 재호출될 수 있다. 어느 범위가 재호출되는지 로그로 확인한다.
Modifier는 체인 순서가 동작을 바꾼다. padding을 background 앞에 두면 배경 영역이 줄어든다. clickable을 padding 앞에 두면 패딩까지 터치 영역이 된다. UI가 ‘왜 이렇게 눌리는지’는 체인 순서로 설명된다.
1package com.example.state
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.background
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.height
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.shape.RoundedCornerShape
14import androidx.compose.material3.Button
15import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.mutableIntStateOf
20import androidx.compose.runtime.remember
21import androidx.compose.runtime.setValue
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.graphics.Color
24import androidx.compose.ui.tooling.preview.Preview
25import androidx.compose.ui.unit.dp
26
27class Step3Activity : ComponentActivity() {
28 override fun onCreate(savedInstanceState: Bundle?) {
29 super.onCreate(savedInstanceState)
30 setContent { Step3_CounterCard() }
31 }
32}
33
34@Composable
35fun Step3_CounterCard() {
36 var count by remember { mutableIntStateOf(0) }
37
38 Column(
39 modifier = Modifier
40 .fillMaxSize()
41 .background(Color(0xFF101418))
42 .padding(16.dp)
43 ) {
44 CounterCard(count = count, onIncrease = { count++ })
45 }
46}
47
48@Composable
49private fun CounterCard(count: Int, onIncrease: () -> Unit) {
50 Log.d("Step3", "CounterCard composed count=$count")
51
52 Column(
53 modifier = Modifier
54 .background(Color(0xFF1B222A), RoundedCornerShape(16.dp))
55 .padding(16.dp)
56 ) {
57 Text(text = "count=$count", style = MaterialTheme.typography.titleLarge, color = Color.White)
58 Spacer(Modifier.height(12.dp))
59 Button(onClick = onIncrease) { Text("+1") }
60 }
61}
62
63@Preview
64@Composable
65private fun PreviewStep3() {
66 Step3_CounterCard()
67}여기서 onIncrease를 람다로 분리한 이유는 상태를 소유하는 쪽과 표시하는 쪽을 분리하기 위해서다(상태 끌어올리기). count가 바뀌면 Step3_CounterCard도 재호출되지만, 구조가 더 커지면 CounterCard 같은 하위 컴포넌트로 리컴포지션 범위를 가두는 게 중요해진다.
심화: Advanced 버전 만들기
한 문단 요약: remember는 Slot Table의 ‘호출 위치 슬롯’에 값을 고정하고, mutableStateOf는 Snapshot 기반 관측 가능한 상태를 만든다. State를 읽은 스코프만 invalidate되므로, 상태 소유 위치와 컴포넌트 경계를 잘 잡으면 리컴포지션과 레이아웃 전파를 통제할 수 있다.
실무 버튼은 클릭 한 번으로 끝나지 않는다. 로딩 중에는 중복 클릭을 막아야 하고, 네트워크 요청이 겹치면 debounce가 필요하다. 아이콘+텍스트 조합, 롱프레스, 접근성 라벨도 빠지면 QA에서 걸린다. 상태를 어디에 두느냐에 따라 ‘버튼만’ 다시 그릴지 ‘화면 전체’가 흔들릴지가 갈린다.
첫 흑역사는 로딩 상태를 버튼 내부 remember로 들고 갔던 건이다. 화면 회전이나 네비게이션 백스택 복원에서 로딩이 갑자기 풀리거나, 반대로 영원히 로딩으로 고정됐다. Logcat에는 에러가 없고, 사용자는 “가끔 버튼이 먹통”이라고만 말한다. 원인은 단순했다. remember는 구성 재시작(프로세스 재생성, save/restore)까지 보장하지 않는다. 저장이 필요하면 rememberSaveable이나 상위 상태 소유가 필요하다.
두 번째 흑역사는 debounce를 LaunchedEffect로 만들면서 key를 잘못 준 건이다. key를 onClick 람다로 걸어두니 recomposition마다 새 람다로 인식되어 effect가 계속 재시작됐다. 실제 현상은 “버튼을 한 번 눌렀는데 두 번 호출”이었고, 서버 로그에 요청이 2개 찍혔다. key는 안정적인 값이어야 한다.
사례 1: loading + debounce + 접근성 라벨을 가진 AdvancedButton
AdvancedButton은 상태를 두 층으로 나눈다. 외부에서 주는 loading은 화면 상태(예: ViewModel)로 관리하는 게 일반적이다. 내부에서는 마지막 클릭 시간을 remember로만 들고 있어도 된다. debounce는 프로세스 재생성까지 유지할 필요가 없고, UI 순간 상태에 가깝다.
1package com.example.state
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.size
8import androidx.compose.foundation.layout.width
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.remember
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.graphics.vector.ImageVector
20import androidx.compose.ui.semantics.Role
21import androidx.compose.ui.semantics.semantics
22import androidx.compose.ui.semantics.contentDescription
23import androidx.compose.ui.unit.dp
24
25@Composable
26fun AdvancedButton(
27 text: String,
28 onClick: () -> Unit,
29 modifier: Modifier = Modifier,
30 loading: Boolean = false,
31 enabled: Boolean = true,
32 debounceMs: Long = 600L,
33 icon: ImageVector? = null,
34 onLongClick: (() -> Unit)? = null,
35 a11yLabel: String = text
36) {
37 var lastClickAt by remember { mutableLongStateOf(0L) }
38
39 val clickableEnabled = enabled && !loading
40
41 SketchButton(
42 enabled = clickableEnabled,
43 modifier = modifier
44 .semantics { contentDescription = a11yLabel }
45 .combinedClickable(
46 enabled = clickableEnabled,
47 role = Role.Button,
48 onClick = {
49 val now = SystemClock.elapsedRealtime()
50 if (now - lastClickAt < debounceMs) return@combinedClickable
51 lastClickAt = now
52 onClick()
53 },
54 onLongClick = onLongClick
55 ),
56 onClick = { /* combinedClickable에서 처리 */ }
57 ) {
58 Row {
59 if (loading) {
60 CircularProgressIndicator(
61 modifier = Modifier.size(16.dp),
62 strokeWidth = 2.dp
63 )
64 Spacer(Modifier.width(8.dp))
65 } else if (icon != null) {
66 Icon(imageVector = icon, contentDescription = null)
67 Spacer(Modifier.width(8.dp))
68 }
69 Text(text = text, style = MaterialTheme.typography.labelLarge)
70 }
71 }
72}여기서 확인할 수 있는 포인트가 3개다. (1) debounce 상태(lastClickAt)는 remember로 충분하다. 이 값이 초기화돼도 UX가 크게 깨지지 않는다. (2) loading은 외부에서 주입해야 화면 전체 정책(예: API 실패 시 재시도)과 일관된다. (3) a11yLabel은 contentDescription으로 들어가 TalkBack에서 읽히고, 테스트 자동화에서도 식별자로 쓰인다.
사례 2: 상태 끌어올리기 + derivedStateOf로 불필요한 계산을 줄인다
버튼 텍스트를 상태에서 만들어낼 때, 문자열 조합이 무겁거나 리스트 필터링 같은 계산이 끼면 매 recomposition마다 비용이 생긴다. derivedStateOf는 ‘입력 State가 바뀔 때만’ 계산 결과를 갱신하고, 그 결과를 읽는 쪽만 invalidate한다.
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.height
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.derivedStateOf
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun AdvancedUsageScreen() {
18 var count by remember { mutableIntStateOf(0) }
19
20 val label by remember {
21 derivedStateOf {
22 if (count == 0) "ready" else "clicked $count times"
23 }
24 }
25
26 Column {
27 Text(text = "label=$label")
28 Spacer(Modifier.height(8.dp))
29 AdvancedButton(
30 text = "increase",
31 loading = false,
32 onClick = { count++ },
33 onLongClick = { count = 0 },
34 a11yLabel = "increase counter button"
35 )
36 }
37}롱프레스로 0으로 리셋되는 걸 화면에서 확인할 수 있다. label은 count가 바뀔 때만 다시 계산된다. derivedStateOf를 안 쓰면 label 계산이 매번 실행되는데, 문자열 정도는 티가 안 나도 리스트/정렬이 들어가면 프레임 드랍이 나온다. 실제로 60fps 기준 한 프레임 예산은 16.6ms이고, UI 스레드에서 2~3ms만 더 써도 스크롤이 끊긴다.
자주 하는 실수
1) remember 없이 mutableStateOf를 매번 새로 만든다
증상: 버튼을 누르면 값이 잠깐 바뀌는 듯하다가 곧바로 초기값으로 돌아간다. 또는 스크롤/상위 상태 변경 같은 다른 리컴포지션 이벤트가 오면 값이 리셋된다.
원인: Composable 본문에서 mutableStateOf(0)를 직접 호출하면 recomposition 때마다 새로운 State 인스턴스가 생성된다. 기존 State를 읽던 스코프와 새 State는 다른 객체라서, 관찰 관계가 끊기고 값도 초기화된다.
해결: remember { mutableStateOf(...) }로 State 인스턴스를 Slot Table에 고정한다. 상태를 더 상위로 끌어올려 파라미터로 내려보내면, 컴포넌트 재사용과 테스트가 쉬워진다.
2) remember를 if/for 안에서 조건적으로 호출한다
증상: 조건을 토글하면 다른 컴포넌트의 상태가 섞인다. 예를 들어 체크박스를 켜면 카운터가 갑자기 다른 값이 되거나, 텍스트 입력이 다른 필드로 이동한다.
원인: remember는 호출 ‘순서’로 슬롯을 매칭한다. 조건 분기로 remember 호출 개수가 바뀌면 슬롯 인덱스가 밀리고, 이전에 A가 쓰던 슬롯을 B가 가져간다. Slot Table이 위치 기반이라는 사실이 그대로 드러나는 버그다.
해결: remember 호출 구조를 안정적으로 유지한다. 조건에 따라 값이 달라져야 한다면 remember(key) 형태로 키를 주거나, 분기 밖에서 remember로 상태를 만들고 분기 안에서는 읽기만 한다.
3) 일반 var 변경으로 UI가 바뀔 거라 믿는다
증상: Logcat에는 값이 증가하는데 화면은 그대로다. 특히 onClick에서 count++ 했는데 Text가 안 바뀌는 형태로 나온다.
원인: Compose는 ‘변수 변경’을 감지하지 않는다. Runtime이 추적하는 것은 Snapshot State의 쓰기 이벤트다. var는 관찰 대상이 아니므로 invalidate가 걸리지 않는다.
해결: UI에 영향을 주는 값은 State로 만든다. 단순 Int는 mutableIntStateOf가 박싱을 피한다. 외부 상태(예: ViewModel)는 StateFlow/LiveData를 collectAsState로 연결한다.
4) remember에 Context/View 같은 수명 긴 객체를 붙잡아 메모리 누수가 난다
증상: 화면을 나갔는데도 Activity가 GC되지 않는다. LeakCanary에서 ‘Activity has leaked’가 뜨고, reference chain에 remember된 람다/객체가 잡힌다.
원인: remember는 composition이 살아있는 동안 객체를 강하게 참조한다. Activity/Fragment/뷰를 remember에 넣거나, 그들을 캡처한 람다를 장기 보관하면 화면 수명과 함께 정리되지 않는다.
해결: Context는 LocalContext.current를 필요할 때 읽고 즉시 사용한다. 콜백은 필요한 최소 정보만 캡처한다. 장기 작업은 ViewModel/Repository로 옮기고, Composable은 이벤트만 위로 전달한다.
5) derivedStateOf 없이 무거운 계산을 Composable 본문에서 매번 한다
증상: 스크롤 중에 카운터 같은 작은 상태가 바뀌기만 해도 CPU 사용량이 튄다. Systrace에서 recomposition 구간이 길어지고, UI 스레드가 16.6ms를 넘는 프레임이 늘어난다.
원인: Composable은 자주 재호출된다. 본문에서 리스트 필터링/정렬/JSON 파싱 같은 작업을 하면, 상태 하나 바뀔 때마다 그 작업이 반복된다.
해결: derivedStateOf로 계산을 캐시하고 입력 State가 바뀔 때만 갱신한다. 더 무거우면 remember(key)로 캐시하거나, 아예 비동기 처리 후 결과만 State로 흘린다.
1package com.example.state
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.derivedStateOf
5import androidx.compose.runtime.getValue
6import androidx.compose.runtime.mutableStateOf
7import androidx.compose.runtime.remember
8import androidx.compose.runtime.setValue
9
10@Composable
11fun MistakeFix_DerivedState() {
12 var query by remember { mutableStateOf("") }
13 val expensive by remember {
14 derivedStateOf {
15 // 실제로는 리스트 필터/정렬 같은 비용이 큰 작업이 들어간다고 가정
16 "computed for '${query.trim()}'"
17 }
18 }
19
20 // query가 바뀔 때만 expensive가 갱신된다
21 androidx.compose.material3.Text(text = expensive)
22}성능 최적화 체크리스트
- UI에 영향을 주는 값이 일반 var인지, Snapshot State인지 구분한다(화면이 안 바뀌면 1순위로 확인).
- mutableStateOf를 Composable 본문에서 직접 호출했는지 확인하고, remember로 감쌌는지 점검한다.
- remember 호출이 조건문/반복문으로 인해 호출 개수가 바뀌지 않는지 확인한다(슬롯 밀림 방지).
- State를 읽는 지점(Text/조건 분기/when)이 어디인지 찾고, 그 범위가 리컴포지션 단위가 된다라는 가정으로 트리를 그린다.
- 상태 소유자를 한 곳으로 모은다(상태 끌어올리기). 하위 컴포넌트는 값 + 이벤트 콜백만 받게 만든다.
- Int/Long/Boolean은 mutableIntStateOf 등 primitive state를 우선 사용해 박싱 할당을 줄인다.
- Modifier를 매번 새로 만들며 캡처를 늘리지 않는지 확인한다(특히 pointerInput/graphicsLayer).
- derivedStateOf로 계산 캐시가 가능한지 확인한다(리스트 필터/정렬/문자열 조합이 누적되면 프레임 예산 초과).
- remember에 Activity/Context/View를 저장하지 않는지 점검하고, 필요 시 DisposableEffect로 정리 루틴을 둔다.
- 리컴포지션이 과도하면 Layout Inspector의 Recomposition Counts와 Logcat 트레이스를 같이 본다(보이는 UI와 실제 호출 빈도 분리).
- 클릭 연타/중복 요청이 문제면 debounce를 UI에 둘지, 도메인 계층에 둘지 결정하고 책임을 분리한다.
- 접근성(semantics contentDescription/stateDescription)이 상태 변화와 함께 갱신되는지 TalkBack으로 확인한다.
자주 묻는 질문
remember가 없으면 왜 값이 유지되지 않나? 로컬 변수도 함수가 끝나기 전엔 값이 있지 않나?
Composable은 이벤트 때마다 ‘다시 호출’될 수 있는 함수이고, recomposition은 같은 함수를 여러 번 실행하는 모델이다. 로컬 변수는 호출 프레임에만 존재하니, 다음 recomposition 호출에서는 초기화된다. remember는 그 값을 함수 스택이 아니라 Slot Table에 저장해 ‘호출 위치’로 다시 찾아오게 만든다. 학습 키워드는 Slot Table, group, call-site identity이고, 코드로는 remember { mutableStateOf(...) }가 그 연결을 만든다.
mutableStateOf는 왜 그냥 ObservableField 같은 게 아니라 Snapshot이라는 걸 쓰나?
Compose는 멀티스레드/트랜잭션 일관성을 고려해 상태 읽기/쓰기를 스냅샷으로 관리한다. State.value 읽기는 현재 스냅샷에서 값을 읽고, 쓰기는 쓰기 스냅샷에 기록된 뒤 커밋 시점에 관찰자 스코프를 invalidate한다. 이 구조 덕분에 여러 상태 변경을 한 번에 커밋하고, 중간 상태가 화면에 섞여 보이는 걸 줄일 수 있다. 검색 키워드는 Snapshot, snapshotFlow, applyObservers이며, 실전에서는 여러 값을 한 이벤트에서 바꾸고도 UI가 한 프레임에 안정적으로 갱신되는 걸 체감한다.
remember와 rememberSaveable은 무엇이 다른가? 언제 saveable이 필요한가?
remember는 composition이 살아있는 동안만 값을 유지한다. 화면 회전, 프로세스 종료 후 복원, 액티비티 재생성 같은 상황에서는 composition 자체가 새로 만들어질 수 있어 값이 사라진다. rememberSaveable은 SavedStateRegistry/Bundle을 통해 저장 가능한 타입을 직렬화해 복원한다. 폼 입력, 탭 선택, 스크롤 위치처럼 사용자가 ‘돌아왔을 때 그대로’ 기대하는 값은 saveable 후보이고, debounce 타임스탬프처럼 일시적 UI 제어 값은 remember로 충분한 경우가 많다. 키워드는 Saver, rememberSaveable, process death이다.
State를 읽는 범위만 리컴포즈된다는데, 왜 화면 전체가 다시 그려지는 느낌이 나나?
리컴포지션(함수 재호출)과 실제 그리기(drawing), 레이아웃 재측정(layout)은 서로 다르다. 작은 State 변경이더라도 텍스트 길이 변화처럼 측정값이 달라지면 상위 레이아웃까지 remeasure가 전파되어 ‘전체가 움직이는’ 느낌이 난다. 또 상위 Composable이 상태를 읽고 있으면 그 상위가 재호출되며, 그 안에서 많은 하위 호출이 다시 실행될 수 있다(스킵 최적화가 있어도). Layout Inspector의 recomposition count와, 텍스트 길이/constraints 변화 여부를 같이 확인해야 원인을 좁힐 수 있다. 키워드는 remeasure, skip, stability이다.
@Stable, @Immutable은 왜 존재하나? 없어도 코드가 돌아가는데 의미가 있나?
Compose Compiler/Runtime은 파라미터가 ‘안 변했다’고 판단되면 해당 호출을 스킵할 수 있다. 이때 판단 근거가 안정성(stability)이다. @Immutable은 인스턴스가 생성 후 내부 상태가 바뀌지 않는다는 약속이고, @Stable은 공개 프로퍼티 변화가 관찰 가능하고 equality/변경 규칙이 안정적이라는 약속에 가깝다. 이 힌트가 있으면 changedFlags 계산과 스킵이 더 공격적으로 가능해진다. 반대로 안정성이 깨진 타입(가변 컬렉션을 그대로 노출 등)을 파라미터로 넘기면 매번 변경으로 간주돼 불필요한 재호출이 늘 수 있다. 키워드는 stability inference, skippable, restartable이다.
Modifier는 왜 체이닝 형태인가? 내부적으로 어떤 순서로 적용되나?
Modifier는 노드에 부착되는 ‘행동/데코레이션의 리스트’이고, 체이닝은 그 리스트를 선언적으로 쌓는 문법이다. 측정/배치 관련 Modifier는 Layout 단계에서 바깥에서 안쪽으로 또는 안쪽에서 바깥으로 영향을 주고, 입력/그리기 관련 Modifier는 hit test와 draw pass 순서에 영향을 준다. 예를 들어 padding 뒤에 clickable을 두면 패딩을 제외한 영역만 눌리고, 반대로 clickable 뒤에 padding을 두면 패딩까지 터치가 먹는다. 체이닝은 이 순서를 코드로 명시하게 만들고, 런타임은 Modifier.Element들을 순회하며 노드를 업데이트한다. 키워드는 Modifier.Node, hitTest, drawWithContent이다.
상태는 어디에 두는 게 맞나? Composable 내부 remember vs 상위로 끌어올리기 기준이 헷갈린다.
기준은 ‘누가 그 상태를 소유해야 일관된 정책이 되는가’다. 화면의 비즈니스 상태(로딩, 에러, 선택 결과, 서버 데이터)는 ViewModel 같은 상위 소유자가 가져야 화면 회전/복원/테스트에서 안정적이다. 반면 UI 순간 상태(리플 상호작용, debounce 타임스탬프, 애니메이션 진행률처럼 화면을 떠나면 의미 없는 값)는 Composable 내부 remember로 충분한 경우가 많다. 또 여러 하위가 같은 상태를 읽어야 하면 끌어올려 단일 소스로 만들고, 하위에는 값과 이벤트만 내려보낸다. 키워드는 state hoisting, single source of truth, unidirectional data flow이다.
리컴포지션을 실제로 어떻게 측정하고 디버깅하나? 눈으로는 잘 안 보인다.
Logcat 트레이스는 가장 단순하고 강력하다. Composable 시작 부분에 Log.d를 찍고, 클릭/상태 변경 시 로그 순서(이벤트 → 다음 프레임 재호출)를 확인한다. 그 다음 Layout Inspector에서 Recomposition Counts를 켜면 어떤 노드가 자주 재호출되는지 시각화된다. 더 깊게는 android.os.Trace를 써서 Systrace/Perfetto에서 recomposition 구간을 확인할 수 있고, 리스트 스크롤 끊김이면 layout/measure 시간도 같이 본다. 학습 키워드는 Layout Inspector recomposition, Perfetto, trace sections이며, 실전 처방은 ‘상태 읽는 위치를 아래로 내리고(분리), 파라미터 안정성을 높이고, 무거운 계산은 derivedStateOf/비동기로 옮긴다’ 순서로 진행된다.