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

28. Compose Button 클릭 이벤트와 상태 업데이트가 동작하는 이유

Jetpack Compose Button 클릭 처리와 상태 업데이트가 왜 이렇게 설계됐는지, remember/State/Slot Table/Recomposition 관점에서 내부 동작까지 연결해 설명한다.','primaryKeywords':['Jetpack Compose Button','Compose 상태 관리','Rek

28. Compose Button 클릭 이벤트와 상태 업데이트가 동작하는 이유

Compose Button 클릭 이벤트와 상태 업데이트가 동작하는 이유

Compose를 처음 쓰면 Button(onClick = { count++ }) 같은 코드가 ‘왜’ 화면을 바꾸는지 감이 안 잡는다. 로그를 찍으면 onClick은 실행되는데 텍스트가 그대로이거나, 반대로 화면 전체가 자주 깜빡이는 느낌이 든다. View 시절 setText()를 찾던 습관이 남아 있으면 더 헷갈린다. 클릭 이벤트와 상태 업데이트가 실제로는 Runtime의 추적 시스템을 건드리는 일이라서, 그 구조를 모르면 디버깅 포인트를 못 잡는다. 처음에 나도 “왜 remember가 없으면 값이 리셋되지?”를 이해 못해서 3시간을 날렸다. 버튼을 눌러도 숫자가 0으로 돌아가길래 onClick이 안 타는 줄 알고 breakpoint를 걸었는데, onClick은 정상이고 recomposition 때 로컬변

핵심 개념

Button 클릭 이벤트 처리는 ‘이벤트 → 상태 변경 → UI 재호출’의 파이프라인이다. View 시스템에서는 이벤트 핸들러 안에서 TextView.text 같은 ‘대상 객체’를 직접 수정했다. Compose는 대상 객체가 아니라 ‘함수 호출 결과’를 화면으로 만든다. 그래서 클릭 핸들러가 해야 할 일은 화면을 직접 바꾸는 게 아니라, 화면을 계산하는 데 쓰는 입력(State)을 바꾸는 일이다.

여기서 핵심 용어가 몇 개 있다. (1) Composition: @Composable 함수 호출을 기록해 UI 트리를 만드는 단계다. (2) Recomposition: 입력(State, 파라미터)이 바뀌면 해당 구간의 @Composable을 다시 호출하는 단계다. (3) Snapshot/State: 읽기/쓰기 추적이 가능한 값 컨테이너다. (4) Slot Table: 이전 composition에서 어떤 호출이 어떤 순서로 일어났는지, remember 값이 무엇이었는지 저장하는 테이블이다. (5) Stability(@Stable/@Immutable): ‘이 값이 바뀌었는지’ 비교 비용과 재호출 범위를 줄이기 위한 힌트다.

왜 remember가 필요한지부터 걸린다. @Composable 함수는 “화면을 그리는 함수”처럼 보이지만, 실제로는 매 recomposition 때 다시 호출된다. 로컬 변수는 호출이 끝나면 사라진다. remember는 Slot Table에 값을 저장하고, 같은 호출 위치(정확히는 slot key와 call order)가 유지될 때 그 값을 다시 꺼낸다. remember가 없으면 클릭 때 count를 올려도, 다음 recomposition에서 count는 다시 0으로 초기화된다.

Button의 onClick은 단순 콜백이 아니다. 클릭이 발생하면 PointerInput/Clickable 노드가 Interaction을 만들고, onClick 람다를 호출한다. 그 람다 안에서 mutableStateOf 값을 쓰면 Snapshot 시스템이 write를 기록한다. Runtime은 그 State를 읽었던 composition scope들을 알고 있어서, 다음 프레임(Choreographer)에서 필요한 scope만 invalidation → recomposition 대상으로 올린다.

CounterButtonBasic.kt
1package com.example.buttonstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
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 CounterButtonBasic() {
15    var count by remember { mutableIntStateOf(0) }
16
17    Column {
18        Text(text = "count=$count")
19        Button(onClick = { count += 1 }) {
20            Text(text = "+1")
21        }
22    }
23}
24
25@Preview(showBackground = true)
26@Composable
27private fun PreviewCounterButtonBasic() {
28    CounterButtonBasic()
29}

이 코드를 실행하면 버튼을 누를 때마다 Text의 숫자가 올라간다. 화면을 직접 바꾸는 호출은 없다. 그 대신 Text가 count를 읽었고, onClick이 count를 썼다는 사실이 Runtime에 남는다. 그래서 다음 recomposition 때 Column 전체가 다시 호출될 수도 있지만(구조에 따라), 실제로는 Text의 파라미터가 바뀌는 지점까지의 호출들이 다시 평가된다. 이 ‘다시 호출’이 화면 전체 invalidate가 아닌 이유가 Slot Table 기반 diff와 scope invalidation이다.

컴포넌트 해부

Button은 겉으로는 단순하지만 내부 계층이 나뉜다. 바깥은 Surface 계열(배경, shape, elevation, border), 그 안은 클릭/포인터/리플을 담당하는 interaction/indication, 그리고 content slot을 배치하는 layout(Row/Box)로 구성된다. 이 분리가 없으면 ‘클릭은 되는데 접근성 라벨이 없다’거나 ‘리플만 끄고 싶다’ 같은 요구를 해결하기 어렵다.

파라미터가 왜 이렇게 많은지도 이유가 있다. Compose는 상속보다 조합을 택했기 때문에, Button 하나가 모든 스타일을 하드코딩하지 않는다. colors/shape/elevation 같은 스타일 파라미터는 Material 토큰을 주입하는 통로이고, modifier는 노드 체인을 통해 동작을 덧붙이는 통로다. enabled는 단순히 클릭 차단만이 아니라 semantics(비활성 상태), colors(알파/톤), interaction(pressed 상태)까지 영향을 준다.

  • onClick: 클릭 시 실행할 람다. 이벤트 처리의 ‘끝’이 아니라 상태 write의 시작점이 된다
  • modifier: Layout/Draw/Input/Semantics 노드를 체인으로 붙이는 통로. 순서가 의미를 가진다
  • enabled: 클릭 가능 여부 + semantics disabled + 스타일 변화까지 묶는다
  • shape: Surface clip/outline 계산에 관여. 리플 범위도 shape 영향을 받는다
  • colors: 상태(enabled/pressed)별 컨테이너/콘텐츠 색 결정. recomposition 시 색 계산 비용이 생길 수 있다
  • elevation: 그림자/톤 오버레이에 관여. Surface 계층에서 처리한다
  • border: 테두리 stroke. draw 단계에서 추가 비용이 생긴다
  • contentPadding: 내부 레이아웃의 측정/배치에 직접 영향. 클릭 영역과 혼동하기 쉽다
  • interactionSource: pressed/hover/focus 같은 상호작용 스트림. 외부에서 관찰/공유할 때 필요하다
  • indication: 리플 등 시각적 피드백. null이면 draw 비용과 효과가 사라진다
  • content: 슬롯. RowScope를 통해 Icon+Text 같은 조합을 호출자에게 위임한다

Surface 계층 관점에서 Button은 ‘배경을 그리는 컴포넌트’가 먼저 있다. shape와 colors가 여기서 소비된다. 만약 shape를 생략하면 기본 shape가 들어가고, clip이 없으면 리플이 사각형으로 번지는 것처럼 보일 수 있다. elevation을 0으로 두면 그림자 관련 draw가 줄지만, Material3의 톤 오버레이와 결합된 테마에서는 눈에 띄는 차이가 날 수 있다.

Content 계층 관점에서 Button은 content slot을 Row에 넣고 기본 정렬과 간격을 준다. 호출자가 contentPadding을 바꾸면 Row의 측정 결과가 바뀌고, 그 결과로 터치 영역이 아니라 ‘시각적 크기’가 바뀐다. 터치 영역을 보장하려면 modifier.sizeIn(minWidth, minHeight) 같은 제약을 추가해야 한다.

처음에 나도 contentPadding을 줄여서 ‘작은 버튼’을 만들었는데, QA에서 “터치가 안 된다” 리포트가 왔다. Layout Inspector로 보니 실제 clickable 영역이 너무 작았다. padding은 내부 여백일 뿐이고, 최소 터치 영역은 별도의 제약으로 만들어야 한다는 걸 그때 체감했다.

EducationalButtonSkeleton.kt
1package com.example.buttonstate
2
3import androidx.compose.foundation.Indication
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.RowScope
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.selection.selectable
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.remember
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.semantics.Role
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun EducationalButtonSkeleton(
17    onClick: () -> Unit,
18    modifier: Modifier = Modifier,
19    enabled: Boolean = true,
20    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
21    indication: Indication? = null,
22    content: @Composable RowScope.() -> Unit
23) {
24    Row(
25        modifier = modifier
26            .selectable(
27                selected = false,
28                enabled = enabled,
29                role = Role.Button,
30                interactionSource = interactionSource,
31                indication = indication,
32                onClick = onClick
33            )
34            .padding(horizontal = 16.dp, vertical = 10.dp)
35    ) {
36        content()
37    }
38}

이 스케치는 Material Button 원문이 아니라 구조를 보여주기 위한 재구성이다. 핵심은 ‘Surface(배경/shape)’와 ‘Clickable(입력/interaction/semantics)’과 ‘Content(Row slot)’가 분리된다는 점이다. selectable를 쓴 이유는 role/semantics가 같이 붙기 때문이다. 실제 Button은 더 많은 토큰과 최소 크기, 리플, 색 상태를 처리하지만, 계층 분리는 동일한 의도를 가진다.

ButtonStyledExample.kt
1package com.example.buttonstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.ButtonDefaults
7import androidx.compose.material3.Icon
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.res.painterResource
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun ButtonStyledExample(
19    onClick: () -> Unit,
20    modifier: Modifier = Modifier
21) {
22    Column(modifier = modifier.padding(16.dp)) {
23        Button(
24            onClick = onClick,
25            shape = MaterialTheme.shapes.medium,
26            colors = ButtonDefaults.buttonColors(
27                containerColor = Color(0xFF1E88E5),
28                contentColor = Color.White
29            ),
30            contentPadding = ButtonDefaults.ContentPadding
31        ) {
32            Icon(
33                painter = painterResource(android.R.drawable.ic_menu_send),
34                contentDescription = "send"
35            )
36            Text(text = "Send", modifier = Modifier.padding(start = 8.dp))
37        }
38    }
39}
40
41@Preview(showBackground = true)
42@Composable
43private fun PreviewButtonStyledExample() {
44    ButtonStyledExample(onClick = {})
45}

이 코드를 실행하면 아이콘+텍스트 버튼이 보이고, contentPadding과 start padding이 합쳐져 좌우 여백이 커진다. 아이콘의 contentDescription을 비우면 TalkBack에서 버튼이 “Send, 버튼”으로 읽히지 않고, 경우에 따라 “버튼”만 읽힌다. content slot이 자유로운 대신 접근성 책임이 호출자에게 넘어오는 지점이다.

내부 동작 원리

Button 클릭과 상태 업데이트를 이해하려면 Compose 파이프라인 3단계를 Button 맥락에 붙여서 생각해야 한다. (1) Composition: Button/ Text/ Icon 같은 @Composable 호출이 Slot Table에 기록된다. (2) Layout: Row/Column이 measure 정책으로 자식 크기를 계산하고 배치한다. (3) Drawing: Surface/텍스트/아이콘이 DrawScope에 그려지고, indication(리플)이 오버레이로 그려질 수 있다.

Compose Compiler는 @Composable 함수를 그대로 호출하지 않는다. 컴파일 결과는 대략 ‘(Composer, changedFlags)’ 파라미터가 추가된 함수로 변환되고, 함수 내부에 startGroup/endGroup 같은 호출이 삽입된다. 이 그룹이 Slot Table의 구조를 만든다. remember는 Composer.cache(...) 계열로 내려가서 “이 그룹 위치에 캐시가 있으면 재사용, 없으면 생성” 로직을 탄다.

Slot Table은 ‘이전에 어떤 순서로 어떤 Composable이 호출됐는지’를 저장한다. 중요한 점은 “호출 순서가 곧 키”라는 것이다. if문으로 Composable 호출이 조건부가 되면, 조건이 바뀌는 순간 slot 위치가 밀리고 remember가 엉뚱한 값을 꺼낼 수 있다. 그래서 key(...)나 stable한 구조 유지가 필요해진다.

Recomposition 비교는 두 갈래다. (1) 파라미터 비교: changedFlags를 통해 ‘이 파라미터가 바뀌었는지’ 힌트를 전달한다. 안정적(stable) 타입이면 equals/참조 비교 최적화가 가능하다. (2) State 읽기 추적: Snapshot State를 읽은 scope는 read observer에 등록되고, write가 발생하면 그 scope가 invalidated 된다. Button 클릭은 보통 (2)를 통해 UI를 다시 호출하게 만든다.

Modifier 체이닝이 왜 필요한지도 여기서 연결된다. Modifier는 단일 객체가 아니라 Element들의 연결 리스트에 가깝다. layout, draw, pointer input, semantics 같은 서로 다른 concern을 독립적으로 붙일 수 있고, 순서가 곧 래핑 순서다. 예를 들어 padding을 clickable 앞에 두면 클릭 영역이 padding 적용 전/후로 달라진다. 이걸 파라미터로 다 펼치면 조합 폭발이 난다.

InteractionSource/semantics는 ‘보이는 것’과 ‘상태/접근성’의 연결 고리다. pressed 상태는 색 변화나 리플뿐 아니라 semantics state에도 영향을 준다. 디버깅할 때는 Layout Inspector의 recomposition count와 semantics tree를 같이 봐야 한다. 화면이 바뀌지 않는데 recomposition만 늘면 state는 바뀌었는데 draw에 반영되는 데이터 흐름이 끊긴 경우가 많다.

한 문단 요약: Button 클릭은 onClick 람다 호출로 끝나지 않는다. 람다에서 Snapshot State를 쓰면 Runtime이 ‘그 State를 읽은 Composable scope’를 invalidation 대상으로 표시하고, 다음 프레임에 해당 scope만 recomposition 한다. remember는 Slot Table의 같은 호출 위치에 값을 저장해 recomposition을 견딘다.

RecomposeTraceDemo.kt
1package com.example.buttonstate
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.setValue
13import androidx.compose.ui.tooling.preview.Preview
14
15private const val TAG = "RecomposeTrace"
16
17@Composable
18fun RecomposeTraceDemo() {
19    var count by remember { mutableIntStateOf(0) }
20
21    SideEffect {
22        Log.d(TAG, "SideEffect: composed with count=$count")
23    }
24
25    Column {
26        Text(text = "count=$count")
27        Button(onClick = { count++ }) {
28            Text(text = "click")
29        }
30    }
31}
32
33@Preview(showBackground = true)
34@Composable
35private fun PreviewRecomposeTraceDemo() {
36    RecomposeTraceDemo()
37}

이 코드를 실행하고 Logcat에서 RecomposeTrace를 필터링하면, 처음 진입 시 1회 찍히고 버튼을 누를 때마다 1회씩 더 찍힌다. SideEffect는 ‘composition이 성공적으로 적용된 뒤’에 실행되기 때문에, recomposition이 실제로 적용됐는지 확인하기 좋다. 예전에 나는 LaunchedEffect(Unit)로 찍었다가 “왜 한 번만 찍히지?”로 또 삽질했다. LaunchedEffect는 key가 안 바뀌면 재실행되지 않는다.

InteractionAndSemanticsDemo.kt
1package com.example.buttonstate
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.LaunchedEffect
10import androidx.compose.runtime.remember
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.semantics.contentDescription
13import androidx.compose.ui.semantics.semantics
14import androidx.compose.ui.unit.dp
15import kotlinx.coroutines.flow.collectLatest
16
17@Composable
18fun InteractionAndSemanticsDemo() {
19    val interactionSource = remember { MutableInteractionSource() }
20
21    LaunchedEffect(interactionSource) {
22        interactionSource.interactions.collectLatest { interaction ->
23            // 디버깅 포인트: pressed/released 같은 이벤트가 여기로 들어온다
24        }
25    }
26
27    Column(modifier = Modifier.padding(16.dp)) {
28        Button(
29            onClick = { },
30            interactionSource = interactionSource,
31            modifier = Modifier.semantics { contentDescription = "send message" }
32        ) {
33            Text(text = "Send")
34        }
35    }
36}

이 코드를 실행하면 화면 변화는 없지만, 상호작용 스트림이 살아 있다는 걸 확인할 수 있다. pressed 이벤트는 리플/색 변화의 입력이 된다. semantics의 contentDescription은 TalkBack에서 읽히는 문자열을 바꾼다. 클릭이 되는데 접근성 테스트에서 실패하는 케이스는 대부분 이 레이어를 놓친 탓이다.

실습하기

실습 목표는 3단계로 나뉜다. 1단계는 클릭이 실제로 상태 write를 만들고 UI가 바뀌는지 확인한다. 2단계는 상태가 늘어날 때 recomposition 범위를 눈으로 확인한다. 3단계는 Button 커스터마이징이 modifier/slot/토큰을 통해 어떻게 조합되는지 확인한다.

Android Studio에서 새 Compose 프로젝트를 만들고, Material3 템플릿을 쓴다. 의존성은 BOM을 쓰면 버전 충돌이 줄어든다. 실무에서 ‘컴파일은 되는데 런타임에서 NoSuchMethodError’가 나는 케이스는 대개 Compose Compiler 확장 버전과 라이브러리 버전이 엇갈렸을 때였다.

app/build.gradle.kts
1dependencies {
2    implementation(platform("androidx.compose:compose-bom:2024.12.01"))
3    implementation("androidx.compose.ui:ui")
4    implementation("androidx.compose.ui:ui-tooling-preview")
5    implementation("androidx.compose.material3:material3")
6
7    debugImplementation("androidx.compose.ui:ui-tooling")
8    debugImplementation("androidx.compose.ui:ui-test-manifest")
9}

BOM을 적용한 뒤 Sync가 성공하면, 아래 1단계 코드를 MainActivity의 setContent 안에서 호출한다. 실행하면 중앙에 count 텍스트와 버튼이 보이고, 버튼을 누를 때마다 숫자가 증가한다. 만약 숫자가 증가했다가 다시 0으로 돌아가면 remember 위치가 바뀌는 조건부 호출이 섞였을 가능성이 높다.

1단계: 클릭 → 상태 업데이트 → UI 반영

이 단계에서 확인할 포인트는 두 가지다. 첫째, onClick은 단순 콜백이고 UI 업데이트는 state write에 의해 발생한다. 둘째, state는 composable이 읽어야 UI에 반영된다. count를 올리기만 하고 어디에서도 읽지 않으면 recomposition은 일어나도 화면 변화가 없다.

실행하면 버튼 위에 count 텍스트가 보인다. 버튼을 5번 누르면 count=5가 된다. 여기서 Text를 지우면, 로그로는 count가 올라가도 화면 변화가 없다는 걸 체감한다. ‘읽기’가 추적의 기준이기 때문이다.

MainActivity.kt
1package com.example.buttonstate
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.fillMaxSize
9import androidx.compose.material3.Button
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Surface
12import androidx.compose.material3.Text
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.mutableIntStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Alignment
18import androidx.compose.ui.Modifier
19
20class MainActivity : ComponentActivity() {
21    override fun onCreate(savedInstanceState: Bundle?) {
22        super.onCreate(savedInstanceState)
23        setContent {
24            MaterialTheme {
25                Surface {
26                    CounterScreen()
27                }
28            }
29        }
30    }
31}
32
33@androidx.compose.runtime.Composable
34fun CounterScreen() {
35    var count by remember { mutableIntStateOf(0) }
36
37    Column(
38        modifier = Modifier.fillMaxSize(),
39        verticalArrangement = Arrangement.Center,
40        horizontalAlignment = Alignment.CenterHorizontally
41    ) {
42        Text(text = "count=$count")
43        Button(onClick = { count++ }) {
44            Text(text = "Increment")
45        }
46    }
47}

이 코드에서 count는 CounterScreen 스코프에 묶인다. CounterScreen이 recomposition되면 로컬 변수는 다시 평가되지만, remember가 Slot Table에서 이전 값을 꺼내기 때문에 증가분이 유지된다. View의 onSaveInstanceState와는 성격이 다르다. 프로세스가 죽으면 값도 사라진다(그건 rememberSaveable의 영역이다).

2단계: recomposition 범위 눈으로 확인하기

이 단계는 “버튼 하나 눌렀는데 왜 다른 UI까지 다시 그려지지?”를 잡는 연습이다. Compose는 ‘다시 호출’과 ‘다시 그리기’를 분리한다. recomposition이 일어나도 draw 결과가 같으면 실제 픽셀 변경은 적을 수 있다. 그래도 불필요한 recomposition은 비용이 된다.

실행하면 두 개의 텍스트가 보인다. 위 텍스트는 count와 무관한 고정 문자열이다. 버튼을 눌렀을 때 아래쪽 count만 바뀌는지, 그리고 Logcat에서 어떤 Composable이 다시 호출되는지 확인한다. SideEffect 로그를 컴포넌트별로 넣으면 호출 범위가 눈에 보인다.

ScopeTraceScreen.kt
1package com.example.buttonstate
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.setValue
13import androidx.compose.ui.tooling.preview.Preview
14
15private const val TAG2 = "ScopeTrace"
16
17@Composable
18fun ScopeTraceScreen() {
19    var count by remember { mutableIntStateOf(0) }
20
21    Column {
22        StaticHeader()
23        CounterBody(count = count, onInc = { count++ })
24    }
25}
26
27@Composable
28private fun StaticHeader() {
29    SideEffect { Log.d(TAG2, "StaticHeader applied") }
30    Text(text = "I should not change")
31}
32
33@Composable
34private fun CounterBody(count: Int, onInc: () -> Unit) {
35    SideEffect { Log.d(TAG2, "CounterBody applied count=$count") }
36    Text(text = "count=$count")
37    Button(onClick = onInc) { Text(text = "Increment") }
38}
39
40@Preview(showBackground = true)
41@Composable
42private fun PreviewScopeTraceScreen() {
43    ScopeTraceScreen()
44}

버튼을 누르면 CounterBody 쪽 로그는 count 값이 바뀌며 계속 찍힌다. StaticHeader 로그는 환경에 따라 다시 찍힐 수도 있고 아닐 수도 있다. composition scope 분리와 안정성에 따라 스킵이 가능하기 때문이다. 여기서 중요한 감각은 “상태를 읽는 범위가 넓으면 invalidation 범위도 넓다”는 점이다.

3단계: 커스터마이징(색/shape/modifier)과 클릭 영역

이 단계는 modifier 순서가 왜 중요한지 확인한다. padding을 clickable 앞에 두면 클릭 영역이 줄어들고, padding을 clickable 뒤에 두면 시각적 여백만 늘어난다. Button은 내부에서 clickable을 이미 갖고 있으니, 바깥 modifier로 padding/sizeIn을 어떻게 주는지가 터치 영역을 좌우한다.

실행하면 파란색 둥근 버튼이 보이고, 버튼 주변에 여백이 있다. sizeIn으로 최소 높이를 주면 contentPadding을 줄여도 터치 영역이 유지된다. QA에서 “버튼이 작아서 누르기 힘들다”가 나오면 이 조합이 바로 처방이 된다.

ButtonTouchAreaDemo.kt
1package com.example.buttonstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.layout.sizeIn
6import androidx.compose.material3.Button
7import androidx.compose.material3.ButtonDefaults
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun ButtonTouchAreaDemo(onClick: () -> Unit) {
18    Column(modifier = Modifier.padding(16.dp)) {
19        Button(
20            onClick = onClick,
21            modifier = Modifier
22                .sizeIn(minHeight = 48.dp)
23                .padding(vertical = 8.dp),
24            shape = MaterialTheme.shapes.large,
25            colors = ButtonDefaults.buttonColors(
26                containerColor = Color(0xFF1565C0),
27                contentColor = Color.White
28            ),
29            contentPadding = ButtonDefaults.ContentPadding
30        ) {
31            Text(text = "Min touch 48dp")
32        }
33    }
34}
35
36@Preview(showBackground = true)
37@Composable
38private fun PreviewButtonTouchAreaDemo() {
39    ButtonTouchAreaDemo(onClick = {})
40}

여기서 padding은 Button 바깥 여백이라 클릭 영역을 줄이지 않는다. 반대로 Button 내부 contentPadding을 줄이면 텍스트 주변 여백만 줄어든다. 이 차이가 modifier 체이닝 순서와 ‘어떤 노드에 어떤 제약이 걸리는지’에서 나온다.

심화: Advanced 버전 만들기

실무 버튼은 단순 클릭만으로 끝나지 않는다. 네트워크 요청 중 로딩 표시, 연타 방지(debounce), 아이콘+텍스트의 정렬, 롱프레스, 접근성 라벨까지 한 번에 요구된다. 이 요구를 Button 호출부마다 복붙하면, 상태와 interaction이 화면마다 제각각이 되고 버그가 터진다.

AdvancedButton의 설계 포인트는 두 가지다. 첫째, 상태는 외부에서 주입 가능해야 한다(loading을 내부에서 숨기면 화면 상태와 분리된다). 둘째, 이벤트는 단발성이고 상태는 지속이라는 점을 코드로 분리해야 한다. debounce는 이벤트 레벨에서 처리하고, loading은 상태 레벨에서 처리한다.

사례 1: 연타로 중복 결제 요청이 날아간 사고

처음에 나도 onClick에서 바로 결제 API를 호출했다. 테스트에서는 잘 됐는데, 실기기에서 120Hz 화면 + 리플 피드백이 빠르다 보니 사용자가 2~3번 연타했고 서버에 중복 요청이 들어갔다. 서버 로그에는 같은 userId로 200ms 간격의 요청이 찍혔다.

원인은 UI 레이어에 ‘한 번만 클릭’이라는 계약이 없었던 것이다. enabled를 false로 바꾸는 방식은 recomposition 타이밍에 따라 한 프레임 늦을 수 있다. debounce를 이벤트 직전에 걸어야 한다. 그때 Logcat에 찍힌 건 “onClick invoked”가 한 번이 아니라 3번이었다.

교정은 이벤트 레벨에서 마지막 클릭 시간을 기억하고, 일정 시간 이내면 무시하는 방식으로 했다. remember로 시간을 저장하면 recomposition에도 유지된다. 더 안전하게는 서버 idempotency 키까지 붙여야 하지만, UI 레벨에서도 1차 방어가 된다.

사례 2: 로딩 중에도 클릭이 먹히는 버그

로딩 스피너를 버튼 위에 얹었는데, 사용자가 로딩 중에도 누를 수 있었다. enabled=false로 막았다고 생각했는데, 실제로는 overlay가 클릭을 가로채지 않았고, 버튼 자체는 enabled=true였다. Layout Inspector에서 semantics를 보니 disabled가 붙어 있지 않았다.

원인은 loading 상태를 UI에만 표시하고, 실제 enabled 계산에 연결하지 않았기 때문이다. ‘보이는 로딩’과 ‘클릭 가능’은 별개의 레이어다. 이 둘을 같은 파라미터로 묶어야 한다.

교정은 enabledEffective = enabled && !loading으로 강제하고, semantics에도 상태를 반영했다. TalkBack에서 “비활성화됨”이 같이 읽히도록 contentDescription/ stateDescription도 같이 설정했다.

AdvancedButton.kt
1package com.example.buttonstate
2
3import android.os.SystemClock
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.Button
8import androidx.compose.material3.CircularProgressIndicator
9import androidx.compose.material3.Icon
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableLongStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.semantics.contentDescription
18import androidx.compose.ui.semantics.semantics
19import androidx.compose.ui.unit.dp
20
21@Composable
22fun AdvancedButton(
23    text: String,
24    onClick: () -> Unit,
25    modifier: Modifier = Modifier,
26    enabled: Boolean = true,
27    loading: Boolean = false,
28    debounceMillis: Long = 600L,
29    contentDescription: String? = null,
30    leadingIcon: (@Composable () -> Unit)? = null
31) {
32    var lastClickAt by remember { mutableLongStateOf(0L) }
33    val enabledEffective = enabled && !loading
34
35    Button(
36        onClick = {
37            val now = SystemClock.uptimeMillis()
38            if (now - lastClickAt < debounceMillis) return@Button
39            lastClickAt = now
40            onClick()
41        },
42        enabled = enabledEffective,
43        modifier = modifier.semantics {
44            if (contentDescription != null) this.contentDescription = contentDescription
45        }
46    ) {
47        Row {
48            if (loading) {
49                CircularProgressIndicator(strokeWidth = 2.dp)
50                Spacer(Modifier.width(8.dp))
51            } else if (leadingIcon != null) {
52                leadingIcon()
53                Spacer(Modifier.width(8.dp))
54            }
55            Text(text = text)
56        }
57    }
58}

이 코드에서 debounce는 remember로 저장된 lastClickAt을 사용한다. 이 값은 recomposition으로 함수가 다시 호출돼도 Slot Table에서 유지된다. enabledEffective는 loading과 enabled를 묶어 클릭 가능성과 시각 상태를 같이 바꾼다. contentDescription을 modifier.semantics로 주입해 접근성 테스트에서 버튼 목적이 명확히 읽히도록 한다.

AdvancedButtonUsageDemo.kt
1package com.example.buttonstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Icon
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.mutableStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.res.painterResource
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16import kotlinx.coroutines.delay
17import kotlinx.coroutines.launch
18import androidx.compose.runtime.rememberCoroutineScope
19
20@Composable
21fun AdvancedButtonUsageDemo() {
22    val scope = rememberCoroutineScope()
23    var loading by remember { mutableStateOf(false) }
24    var status by remember { mutableStateOf("idle") }
25
26    Column(modifier = Modifier.padding(16.dp)) {
27        Text(text = "status=$status")
28        AdvancedButton(
29            text = if (loading) "Sending" else "Send",
30            loading = loading,
31            contentDescription = "send message",
32            leadingIcon = {
33                Icon(
34                    painter = painterResource(android.R.drawable.ic_menu_send),
35                    contentDescription = null
36                )
37            },
38            onClick = {
39                if (loading) return@AdvancedButton
40                loading = true
41                status = "request started"
42                scope.launch {
43                    delay(1200)
44                    status = "done"
45                    loading = false
46                }
47            }
48        )
49    }
50}
51
52@Preview(showBackground = true)
53@Composable
54private fun PreviewAdvancedButtonUsageDemo() {
55    AdvancedButtonUsageDemo()
56}

실행하면 status 텍스트와 버튼이 보인다. 버튼을 누르면 스피너가 나타나고 1.2초 뒤 status가 done으로 바뀐다. 연타해도 debounceMillis 때문에 onClick이 연속으로 타지 않는다. 여기서 중요한 점은 loading이 바뀌면 AdvancedButton의 content slot이 다시 호출되고, 그 결과 Row 내부 분기가 바뀌면서 layout이 다시 측정될 수 있다는 점이다(스피너 등장으로 폭이 바뀐다).

자주 하는 실수

1) remember 없이 로컬 변수로 카운터를 만든다

증상: 버튼을 눌렀을 때 로그로는 값이 증가하는데, 화면의 숫자는 0으로 돌아가거나 아예 변하지 않는다. 구성 변경(회전)과 상관없이 클릭 직후에도 리셋되는 것처럼 보인다.

원인: @Composable은 한 번만 실행되는 함수가 아니라 recomposition 때마다 다시 호출된다. 로컬 변수는 호출이 끝나면 사라지고, 다음 호출에서 초기값으로 다시 만들어진다. Slot Table에 저장되지 않으니 유지될 수 없다.

해결: remember { mutableStateOf(...) } 또는 remember { mutableIntStateOf(...) }로 State를 만들고, UI가 그 State를 읽도록 한다. 프로세스/구성 변경까지 유지가 필요하면 rememberSaveable로 확장한다.

2) 상태를 바꿨는데 UI가 안 바뀐다(읽지 않는다)

증상: onClick에서 상태 값을 바꾸는 로그는 찍히는데, Text가 그대로다. 심지어 recomposition 로그도 찍히는 것처럼 보일 수 있다.

원인: Compose는 State의 ‘읽기’를 기준으로 invalidation 대상을 추적한다. 상태를 쓰기만 하고, 그 값을 화면에서 읽지 않으면 바뀐 데이터가 그릴 곳이 없다. 또는 읽기는 했지만 다른 State를 읽고 있는 Text를 기대하고 있을 수 있다.

해결: Text(text = "count=$count")처럼 화면 계산에 실제로 사용한다. 디버깅은 SideEffect 로그를 읽는 컴포넌트에 넣고, 상태 read 지점을 명확히 만든다.

3) onClick 람다에 무거운 작업을 넣는다

증상: 버튼을 누르면 리플이 늦게 나오고, UI가 잠깐 멈춘다. Systrace에서 main thread가 길게 블로킹된 구간이 보인다.

원인: onClick은 main thread에서 실행된다. 네트워크/디스크/복잡한 JSON 파싱을 바로 넣으면 input 처리와 프레임 렌더링이 막힌다. Compose 문제가 아니라 이벤트 핸들러 설계 문제다.

해결: rememberCoroutineScope로 코루틴을 띄우거나 ViewModel scope로 위임한다. UI에서는 loading 상태만 바꾸고, 실제 작업은 비동기로 수행한다.

4) Modifier 순서 때문에 클릭 영역이 예상과 다르다

증상: 버튼 주변 여백이 있는데도 그 여백을 눌러도 클릭이 안 된다. 또는 padding을 줬더니 오히려 클릭이 더 어려워졌다.

원인: Modifier는 체인 순서대로 노드를 감싼다. padding이 clickable 앞에 붙으면 clickable이 적용되는 영역이 줄어들 수 있다. Button 내부에도 clickable이 있으니, 바깥 modifier가 어떤 제약을 주는지가 중요하다.

해결: 바깥 여백은 Button의 modifier.padding으로 주고, 최소 터치 영역은 sizeIn(minHeight=48.dp) 같은 제약으로 보장한다. 내부 여백은 contentPadding으로 조절한다.

5) derivedStateOf 없이 매 recomposition마다 계산을 반복한다

증상: 버튼을 누를 때마다 관련 없는 계산(문자열 포맷, 리스트 필터)이 계속 실행된다. 프로파일링에서 allocation이 누적되고 GC가 잦아진다.

원인: @Composable 본문은 recomposition 때 다시 실행된다. 본문에서 매번 새 객체를 만들거나 O(n) 계산을 하면, 버튼 클릭 같은 작은 상태 변화가 큰 비용으로 증폭된다.

해결: 입력 State에만 의존하는 파생 값은 remember + derivedStateOf로 캐시한다. 또는 계산을 ViewModel로 옮기고 UI는 결과만 관찰한다.

DerivedStateFixDemo.kt
1package com.example.buttonstate
2
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.derivedStateOf
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11
12@Composable
13fun DerivedStateFixDemo() {
14    var count by remember { mutableIntStateOf(0) }
15
16    val label by remember {
17        derivedStateOf {
18            // count가 바뀔 때만 다시 계산된다
19            "Clicked $count times"
20        }
21    }
22
23    Button(onClick = { count++ }) {
24        Text(text = label)
25    }
26}

이 예시는 단순 문자열이라 체감이 약하지만, 실제 앱에서는 리스트 필터/정렬/포맷이 들어간다. derivedStateOf가 없으면 버튼 클릭마다 그 계산이 다시 돈다. derivedStateOf는 ‘이 파생 값이 어떤 State를 읽는지’를 추적하고, 그 입력이 바뀔 때만 invalidation 된다.

성능 최적화 체크리스트

  • Button onClick에서 UI를 직접 수정하려는 코드(setText 등)를 제거하고, 상태(State)만 변경한다
  • 상태는 remember로 보존하고, 조건부 호출로 remember 위치가 흔들리지 않게 구조를 고정한다
  • 상태를 변경한 뒤 UI가 안 바뀌면 ‘그 State를 읽는 Text/Composable이 존재하는지’부터 확인한다
  • onClick 내부에서 main thread를 블로킹하는 작업(네트워크/디스크/대용량 파싱)을 금지한다
  • 연타가 문제인 액션(결제/전송)은 debounce 또는 enabledEffective(loading 연동)로 1차 방어한다
  • loading 표시를 넣을 때 enabled=false와 semantics disabled가 같이 적용되는지 확인한다
  • Modifier 순서를 점검한다: 바깥 여백은 modifier.padding, 최소 터치 영역은 sizeIn으로 보장한다
  • InteractionSource를 공유하는 경우 remember로 생성하고, recomposition마다 새로 만들지 않는다
  • 접근성: contentDescription/stateDescription을 넣고 TalkBack에서 실제로 어떻게 읽히는지 확인한다
  • 불필요한 객체 할당을 줄인다: recomposition마다 새 람다/리스트/formatter를 만들지 않게 remember를 쓴다
  • 파생 값 계산은 derivedStateOf로 묶고, 입력 State가 바뀔 때만 재계산되게 한다
  • Layout Inspector에서 recomposition count와 semantics tree를 같이 확인해 ‘보이는 문제 vs 상태 문제’를 분리한다

자주 묻는 질문

왜 onClick에서 count++만 했는데 Text가 자동으로 바뀌나? setText 호출이 없는데도.

Text가 count를 읽는 순간, Snapshot State의 read가 기록된다. onClick에서 count에 write가 발생하면 Snapshot 시스템이 해당 State를 관찰 중인 composition scope들을 invalidation 대상으로 표시한다. 다음 프레임에 Runtime이 그 scope를 recomposition하고, Text(text="count=$count")가 다시 평가되면서 새 문자열이 만들어진다. 학습 키워드는 Snapshot, read/write observer, invalidation, recomposition scope, Slot Table이다. 로그로 확인하려면 Text가 있는 컴포넌트에 SideEffect를 넣고 버튼 클릭마다 호출 여부를 확인한다.

remember가 없으면 왜 값이 리셋되나? 함수 안에 변수가 있는데도.

@Composable 함수는 ‘화면을 만드는 선언’이지만 실행 모델은 일반 함수 호출과 같다. recomposition이 일어나면 함수 본문이 다시 실행되고 로컬 변수는 초기화된다. remember는 Composer가 관리하는 Slot Table에 값을 저장하고, 같은 호출 위치로 다시 들어왔을 때 그 값을 재사용한다. 호출 위치가 바뀌면 다른 슬롯으로 간주되므로 값이 섞이거나 초기화된 것처럼 보인다. 학습 키워드는 Composer.cache, Slot Table, group key, call order, key() 함수다. 실전 처방은 조건부로 remember를 두지 말고, 구조를 고정하거나 key로 경계를 명확히 하는 것이다.

버튼을 눌렀는데 화면이 너무 많이 다시 그려지는 느낌이다. 전체가 recomposition되는 건가?

recomposition은 ‘함수 재호출’이고, draw는 ‘픽셀 변경’이다. 많은 Composable이 다시 호출돼도 실제로 그려지는 결과가 같으면 draw 단계의 변경은 제한적일 수 있다. 그래도 재호출 자체가 비용이므로 범위를 줄이는 게 필요하다. 상태를 읽는 범위를 좁히고(상태를 필요한 하위 Composable로 내려보냄), 안정적인 파라미터를 유지하면 스킵이 늘어난다. Layout Inspector에서 recomposition count를 보고, SideEffect 로그를 컴포넌트별로 넣어 호출 범위를 추적한다. 학습 키워드는 stability, skip, changed flags, recomposition count, Inspector다.

Modifier는 왜 체이닝 형태인가? 파라미터로 다 받으면 더 단순하지 않나?

Modifier는 서로 다른 concern(layout, draw, input, semantics)을 독립적으로 조합하기 위한 구조다. 체이닝 순서가 래핑 순서가 되므로, padding→clickable과 clickable→padding은 의미가 달라진다. 이걸 Button 파라미터로 펼치면 조합이 폭발하고, 새로운 동작을 추가할 때 API가 계속 늘어난다. Modifier는 Element들의 연결 구조로 런타임에서 노드로 물리화되고, 각 노드가 measure/draw/input 파이프라인의 해당 단계에 참여한다. 학습 키워드는 Modifier.Node, semantics, pointer input, draw modifier, layout modifier, order dependency다.

@Stable, @Immutable은 왜 필요한가? 없어도 동작은 하던데.

없어도 동작은 한다. 문제는 ‘변경 여부 판정’과 ‘스킵 가능성’이다. 안정적인 타입이면 Compose가 파라미터 비교에서 더 공격적으로 스킵할 수 있다. 반대로 불안정한 타입(가변 컬렉션, 커스텀 클래스의 내부 변경 등)을 파라미터로 넘기면, 값이 안 바뀐 것처럼 보여도 Compose는 안전하게 다시 호출하는 쪽을 택할 수 있다. 실전에서는 UI 모델을 data class + 불변 컬렉션으로 만들고, 변경은 새 인스턴스로 교체하는 패턴이 안정성을 만든다. 학습 키워드는 stability inference, immutable model, referential equality policy, SnapshotStateList/Map과의 차이다.

왜 상태를 ViewModel로 올리라고 하나? remember로도 되는데.

remember는 composition 생명주기에 묶인다. 화면에서 Composable이 제거되면 상태도 같이 사라진다. 네비게이션 뒤로가기, 프로세스 재시작, 구성 변경 등에서 유지가 필요하면 remember만으로는 부족하다. ViewModel은 화면 스코프에서 상태를 보존하고, 비즈니스 로직과 이벤트 처리를 UI에서 분리한다. 버튼 클릭은 이벤트이고, 로딩/에러/결과는 상태다. 이 분리를 ViewModel이 담당하면 테스트와 재사용이 쉬워진다. 학습 키워드는 state hoisting, unidirectional data flow, rememberSaveable vs ViewModel, Flow/StateFlow 수집이다. 처방은 UI는 onClick만 위임하고, 상태는 ViewModel의 StateFlow를 collectAsState로 받는 구조다.

버튼 로딩/연타 방지/접근성까지 넣으면 코드가 커진다. 어디까지가 UI 책임인가?

UI 책임은 ‘사용자 입력을 이벤트로 변환’하고 ‘상태를 화면으로 표현’하는 데 있다. 연타 방지는 입력 이벤트의 품질을 올리는 UI 레벨 책임으로 볼 수 있고, 로딩은 상태 표현이므로 UI에 있어도 된다. 다만 로딩 상태의 소스는 비즈니스 로직과 연결돼야 하므로 외부(화면/VM)에서 주입하는 편이 안전하다. 접근성 라벨은 UI가 최종 책임을 진다. 실전 처방은 공통 컴포넌트를 만든 뒤, (1) enabledEffective=enabled && !loading, (2) debounce는 이벤트 직전, (3) semantics(contentDescription) 기본값 제공, (4) content slot으로 아이콘/텍스트 확장 가능하게 설계한다. 학습 키워드는 state hoisting, semantics, InteractionSource, debounce, idempotency다.

관련 글

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

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

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

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부
Compose 기본2026.03.01

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부

mutableStateOf와 remember로 UI가 갱신되는 이유를 Compose Runtime 관점에서 설명한다. Slot Table, recomposition 추적, 안정성(@Stable)까지 연결한다. 초보도 내부 흐름을 잡는다.','primaryKeywords':['Jetpack Compose State','m

11. Compose @Composable 함수: 선언형 UI가 동작하는 진짜 이유
Compose 기본2026.03.01

11. Compose @Composable 함수: 선언형 UI가 동작하는 진짜 이유

Compose @Composable의 선언형 UI가 왜 가능한지, 컴파일러가 생성하는 코드와 Runtime의 Slot Table·Recomposition 비교까지 내부 동작으로 설명한다. 초보도 원리를 잡는다. ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ