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

27. Compose Button 기본 구현 이해: onClick·enabled·Modifier가 나뉜 이유

Jetpack Compose Button의 onClick·enabled·Modifier가 왜 분리됐는지, 컴파일러/런타임/Slot Table 관점에서 내부 동작과 성능 포인트를 연결해 이해한다. 실습 코드 포함. (154자)     

27. Compose Button 기본 구현 이해: onClick·enabled·Modifier가 나뉜 이유

Compose Button 기본 구현 이해: onClick·enabled·Modifier가 나뉜 이유

Compose 처음 켰을 때 Button 하나 눌렀는데도 화면이 “다시 그려진다”는 말이 찜찜하게 들린다. onClick에 람다만 넣었을 뿐인데 왜 remember가 필요하다는 글이 나오고, enabled=false면 클릭이 막히는 건 알겠는데 ripple도 사라지고 포커스도 바뀐다. Modifier는 또 왜 체이닝인지, 순서를 바꾸면 왜 터치 영역이 달라지는지까지 한 번에 겪는다. 이 글은 Button을 ‘사용법’이 아니라 ‘구조’로 이해시키는 데 초점을 둔다.

핵심 개념

Button은 단일 위젯이 아니라 “입력(onClick) + 상태(enabled) + 장식(Modifier) + 의미(Semantics) + 시각 효과(Indication)”를 합성한 결과물이다. 이 분리가 없으면 클릭 가능 여부를 바꾸는 것만으로도 레이아웃/드로잉/접근성까지 한 덩어리로 재구성해야 한다. Compose는 UI를 함수 호출로 표현하므로, 재구성 비용을 줄이려면 변경이 잦은 축과 변경이 드문 축을 API 레벨에서 분리하는 편이 유리하다.

용어를 Button 맥락으로 정의한다. - Composition: Button 함수를 호출해 UI 트리를 ‘기록’하는 단계이다. 실제 View를 만드는 게 아니라, Slot Table에 “여기서 Button이 호출됐고, 파라미터는 이 값이었다” 같은 호출 이력을 남긴다. - Recomposition: 기록된 호출 중 ‘의미 있게 바뀐’ 구간만 다시 호출하는 단계이다. enabled만 바뀌면 클릭/semantics 쪽만 업데이트하고, 측정/배치는 유지될 수도 있다. - Slot Table: 컴포저가 호출 순서대로 저장하는 테이블이다. 호출 순서가 곧 키 역할을 한다. Button 내부에서 Row/ProvideTextStyle/Surface 같은 하위 컴포저블 호출 순서가 바뀌면 Slot이 어긋나고 상태가 꼬일 수 있다. - Modifier chain: 레이아웃/그리기/입력/의미 노드를 연결한 단방향 리스트이다. 체이닝은 ‘순서가 의미’라는 사실을 노출하는 설계다. - Stability(@Stable/@Immutable): 런타임이 “이 객체는 내부가 바뀌지 않는다/바뀌어도 관찰 가능한 방식으로만 바뀐다”라고 가정할 수 있게 하는 힌트다. 이 힌트가 없으면 파라미터 비교가 보수적으로 변해 재구성이 커진다.

View 시스템 시절에는 Button이 상태/입력/그리기를 하나의 인스턴스가 들고 있었다. enabled가 바뀌면 View.invalidate()가 걸리고, 클릭 가능 여부는 setEnabled가 내부 플래그를 바꾸며, 접근성은 View가 알아서 이벤트를 냈다. 반면 Compose는 인스턴스 중심이 아니라 호출 기록 중심이라서, enabled와 onClick 같은 입력 파라미터는 ‘업데이트 가능’한 값으로, Modifier는 ‘노드 그래프’로 분리돼야 런타임이 부분 업데이트를 할 수 있다.

처음에 나도 “enabled만 바꿨는데 왜 onClick 람다 캡처 때문에 재구성이 커지지?”에서 3시간을 썼다. Layout Inspector에서 Button 노드가 깜빡이는 걸 보고 전체가 다시 그려진다고 착각했는데, 실제로는 Composition 재호출과 Layout 재측정이 다른 축이었다. recomposition count는 늘어도 measure/layout는 안 늘 수 있고, 반대로 Modifier 순서 하나로 pointer input 노드가 바뀌면 measure는 그대로인데 hit-test 결과만 바뀔 수 있다.

BasicButton.kt
1package com.example.buttonbasics
2
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.ui.Modifier
7
8@Composable
9fun BasicButton(modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit) {
10    Button(
11        modifier = modifier,
12        enabled = enabled,
13        onClick = onClick
14    ) {
15        Text("Tap")
16    }
17}

이 코드를 실행하면 UI는 ‘매 프레임 그리는’ 방식이 아니라, enabled나 onClick이 바뀔 때만 Button 호출이 다시 일어난다. 핵심은 “호출이 다시 일어난다”와 “픽셀이 다시 그려진다”를 분리해서 보는 관점이다. Compose Runtime은 Slot Table에 저장된 이전 파라미터와 현재 파라미터를 비교해, 변경 플래그를 만들고 그 플래그를 Button 내부 하위 호출로 전파한다.

컴포넌트 해부

Material3 Button은 표면(Surface)과 내용(Content)을 분리해서 구성한다. 표면은 배경색/모양/그림자/클립/리플/입력/semantics를 책임지고, 내용은 Row로 아이콘/텍스트를 배치하며 텍스트 스타일을 제공한다. 이 분리는 enabled와 modifier가 왜 파라미터로 노출되는지까지 연결된다. enabled는 표면과 의미(접근성)에 동시에 영향을 주고, modifier는 표면 노드 그래프의 앞/뒤에 끼워 넣을 수 있어야 한다.

  • modifier: 외부에서 노드 그래프를 주입하는 통로이다. padding/clickable/semantics/testTag 같은 ‘횡단 관심사’를 버튼 내부 구현과 분리한다.
  • onClick: 입력의 핵심이다. 클릭 제스처가 인식되면 호출된다. 이 람다는 안정성(캡처) 때문에 재구성 범위를 키울 수도 있다.
  • enabled: 입력 가능 여부뿐 아니라 semantics의 disabled 상태, indication(리플) 표시, focus 가능 여부까지 바꾼다.
  • shape: hit-test 영역(클립 여부)과 시각적 모양을 동시에 결정한다. clip을 별도로 주는 이유가 여기서 생긴다.
  • colors: enabled/disabled 상태별 컨테이너/콘텐츠 색을 제공한다. 상태에 따라 계산되므로 종종 remember가 섞인다.
  • elevation: 그림자/톤을 상태에 따라 바꾼다. pressed/disabled에 따라 값이 달라질 수 있다.
  • border: 외곽선이 필요한 디자인에서 Surface에 적용된다. 레이아웃 크기에는 영향이 없지만 드로잉 노드가 추가된다.
  • contentPadding: 내부 Row의 padding이다. modifier.padding과 결이 다르다(외부 vs 내부).
  • interactionSource: pressed/hover/focus 같은 상호작용 상태의 스트림이다. ripple/색 변화/테스트에서 공유가 필요하다.
  • indication: ripple 같은 시각 피드백이다. null이면 드로잉 노드가 줄어든다.
  • role: semantics Role.Button 같은 의미를 부여한다. 접근성과 테스트가 이 값에 의존한다.
  • content: 슬롯 API이다. 아이콘+텍스트, 로딩 스피너, 카운터 배지 같은 구성을 버튼 내부 레이아웃에 주입한다.

Surface 계층에서 일어나는 일은 대략 세 묶음이다. (1) 입력: pointer input으로 탭을 인식하고 enabled면 onClick을 호출한다. (2) 표시: pressed 상태에 따라 ripple/색/elevation을 바꾼다. (3) 의미: semantics에 Role과 disabled를 기록해 TalkBack과 테스트가 읽게 한다. 이 셋은 레이아웃과 독립적이어서, enabled 토글이 레이아웃 재측정을 유발하지 않게 설계하는 편이 유리하다.

Content 계층은 Row/ProvideTextStyle 같은 ‘내용 배치’에 집중한다. Button이 content 슬롯을 받는 이유는, 텍스트만 강제하면 앱이 결국 커스텀 버튼을 다시 만들기 때문이다. 대신 내부는 일정한 패턴(최소 높이, 기본 패딩, 텍스트 스타일)을 제공하고, 내용은 슬롯으로 열어둔다. 이 구조 덕분에 Material 버튼의 가이드라인을 유지하면서도 아이콘+텍스트 같은 변형이 가능하다.

파라미터를 생략하면 어떤 일이 생기는지도 중요하다. interactionSource를 넘기지 않으면 내부에서 remember로 새 인스턴스를 만든다. 그러면 외부에서 pressed 상태를 관찰하거나, 여러 컴포넌트가 동일한 pressed 상태를 공유하는 구성이 어려워진다. modifier를 생략하면 버튼 바깥 패딩/테스트 태그/추가 semantics를 붙일 통로가 사라진다. enabled를 생략하면 클릭 차단과 접근성 disabled 상태를 일관되게 다루기 어렵다.

SketchButton.kt
1package com.example.buttonbasics
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.minimumInteractiveComponentSize
9import androidx.compose.foundation.selection.selectable
10import androidx.compose.material3.LocalContentColor
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Surface
13import androidx.compose.material3.contentColorFor
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.remember
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.semantics.Role
18import androidx.compose.ui.semantics.disabled
19import androidx.compose.ui.semantics.role
20import androidx.compose.ui.semantics.semantics
21import androidx.compose.ui.unit.dp
22
23@Composable
24fun SketchButton(
25    onClick: () -> Unit,
26    modifier: Modifier = Modifier,
27    enabled: Boolean = true,
28    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
29    indication: Indication? = null,
30    content: @Composable RowScope.() -> Unit
31) {
32    val containerColor = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
33    val contentColor = contentColorFor(containerColor)
34
35    Surface(
36        color = containerColor,
37        contentColor = contentColor,
38        shape = MaterialTheme.shapes.small,
39        modifier = modifier
40            .minimumInteractiveComponentSize()
41            .semantics {
42                role = Role.Button
43                if (!enabled) disabled()
44            }
45            .selectable(
46                selected = false,
47                enabled = enabled,
48                role = Role.Button,
49                interactionSource = interactionSource,
50                indication = indication,
51                onClick = onClick
52            )
53    ) {
54        Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
55            content()
56        }
57    }
58}

이 재구성 코드는 공식 구현을 그대로 옮긴 게 아니라, 핵심 패턴만 남긴 스케치다. 중요한 관찰 포인트는 두 가지다. 첫째, enabled가 semantics와 selectable 둘 다에 들어간다. 그래서 enabled는 단순히 클릭 막는 플래그가 아니라 ‘의미 + 입력 + 표시’의 스위치다. 둘째, modifier 체인에서 semantics가 selectable보다 앞에 붙는다. hit-test와 semantics 트리가 서로 다른 노드 계층에서 만들어지기 때문에, 순서를 바꾸면 테스트나 접근성 결과가 달라질 수 있다.

ButtonUsageExample.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Icon
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.graphics.vector.ImageVector
12import androidx.compose.ui.semantics.contentDescription
13import androidx.compose.ui.semantics.semantics
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun ButtonUsageExample(
18    icon: ImageVector,
19    enabled: Boolean,
20    onClick: () -> Unit
21) {
22    SketchButton(
23        enabled = enabled,
24        onClick = onClick,
25        modifier = Modifier.semantics { contentDescription = "결제 버튼" }
26    ) {
27        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
28            Icon(imageVector = icon, contentDescription = null)
29            Text("Pay", style = MaterialTheme.typography.labelLarge)
30        }
31    }
32}

이 예제를 실행하면 TalkBack에서 “결제 버튼, 사용 불가” 같은 음성이 enabled에 따라 바뀐다. 클릭이 막히는 것만 확인하면 반쪽짜리다. 접근성/테스트 관점에서 disabled semantics가 들어갔는지 확인해야 하고, 이것 때문에 enabled가 Button API에 반드시 존재한다.

내부 동작 원리

Compose에서 Button 호출은 대략 Composition → Layout → Drawing 순서로 흘러간다. Composition은 ‘무엇을 그릴지’를 Slot Table에 기록한다. Layout은 Modifier와 내부 Row를 통해 측정/배치를 계산한다. Drawing은 Surface 배경, content 텍스트/아이콘, indication(리플) 같은 드로잉 노드를 실행한다. enabled 토글은 보통 Composition 재호출을 만들지만, Layout 재측정까지 이어질지는 “측정에 관여하는 값이 바뀌었는가”에 달려 있다.

Compose Compiler는 @Composable 함수를 직접 호출하지 않고, 숨은 파라미터(Composer, changed 플래그 등)를 추가한 형태로 변환한다. 개념적으로는 이런 형태다: fun Button(..., composer: Composer, changed: Int). 런타임은 changed 비트를 이용해 “이 파라미터가 이전과 같은가”를 빠르게 판정하고, 같으면 해당 그룹을 스킵한다. 그래서 onClick 람다가 매번 새 인스턴스로 생성되면 changed가 켜지고 스킵이 깨진다.

Slot Table은 호출 순서 기반이라서, Button 내부에서 조건문으로 하위 컴포저블 호출을 바꾸면 상태가 엉킬 수 있다. 예를 들어 enabled일 때만 Icon을 호출하고 disabled면 호출하지 않으면, Icon이 차지하던 슬롯이 다음 호출로 밀린다. Compose가 key/remember로 그 문제를 완화할 수 있지만, 기본 버튼 구현은 호출 순서를 안정적으로 유지하려고 한다.

Modifier chain은 단순한 데이터 클래스 리스트가 아니라, 런타임에서 실제 노드(MeasureNode, DrawNode, PointerInputNode, SemanticsNode 등)로 물질화된다. 체이닝 순서가 의미 있는 이유는, 레이아웃 단계에서 바깥 modifier가 먼저 측정 정책을 적용하고, 안쪽으로 내려가며 제약을 변형하기 때문이다. 입력(hit-test)도 바깥에서 안쪽으로 내려가며 노드를 통과한다. padding을 clickable 앞에 붙이면 ‘터치 영역이 커진 padding 포함 영역’이 되고, 뒤에 붙이면 ‘시각적 여백만 생기고 터치 영역은 그대로’가 된다.

remember가 Button 구현에 끼는 지점은 interactionSource 같은 ‘상태를 가진 객체’를 안정적으로 유지하기 위해서다. remember 없이 MutableInteractionSource를 매 recomposition마다 새로 만들면 pressed 상태가 프레임마다 초기화돼 ripple이 끊기거나, 테스트에서 press 상태를 관찰할 수 없게 된다. 이 문제는 화면에서 “눌렀는데 리플이 순간 사라졌다”처럼 보이기도 한다.

@Stable/@Immutable은 Button 파라미터 비교의 비용과 범위를 줄인다. colors 같은 객체가 stable로 취급되면, 런타임은 그 객체의 참조가 같을 때 내부 값이 바뀌지 않는다고 가정하고 스킵을 더 공격적으로 할 수 있다. 반대로 안정성이 없으면 참조가 같아도 보수적으로 변경으로 취급하거나, 참조가 바뀌면 무조건 변경으로 본다. 실무에서 “색만 바뀌었는데 버튼 전체가 자주 재구성된다”는 느낌의 상당수가 여기서 나온다.

RecompositionProbe.kt
1package com.example.buttonbasics
2
3import android.util.Log
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.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun RecompositionProbe() {
19    var enabled by remember { mutableStateOf(true) }
20    var count by remember { mutableIntStateOf(0) }
21
22    Log.d("Probe", "RecompositionProbe recomposed: enabled=$enabled count=$count")
23
24    Column(Modifier.padding(16.dp)) {
25        Button(onClick = { enabled = !enabled }) {
26            Text("Toggle enabled")
27        }
28        Button(enabled = enabled, onClick = { count++ }) {
29            Text("Click count=$count")
30        }
31    }
32}

이 코드를 실행하면 Logcat에 recomposed 로그가 찍힌다. Toggle enabled를 누를 때마다 RecompositionProbe가 다시 호출되는 건 정상이다. 중요한 건 두 번째 버튼을 누를 때 count만 바뀌는데도 Column 전체가 다시 호출된다는 사실 자체가 문제가 아니라는 점이다. 실제 비용은 ‘다시 호출된 함수가 얼마나 스킵됐는가’와 ‘측정/배치가 다시 일어났는가’에 달려 있다. Layout Inspector에서 노드가 강조 표시된다고 해서 measure가 다시 돈다는 뜻은 아니다.

ModifierOrderProbe.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.remember
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 ModifierOrderProbe() {
17    val src = remember { MutableInteractionSource() }
18
19    Box(
20        Modifier
21            .padding(24.dp)
22            .semantics { contentDescription = "바깥 박스" }
23            .clickable(interactionSource = src, indication = null) { }
24            .padding(24.dp)
25    ) {
26        Text("Tap area differs by modifier order")
27    }
28}

이 코드는 padding이 clickable 앞/뒤로 섞여 있다. 실행해서 실제로 탭 가능한 영역을 손가락으로 확인하면, 바깥 padding은 터치 영역에 포함되고 안쪽 padding은 터치 영역에 포함되지 않는다. 같은 padding이라도 노드 그래프에서 위치가 달라지면 hit-test가 달라진다. Modifier 체이닝이 ‘읽기 좋은 문법’이라서가 아니라, 노드 적용 순서를 API에 그대로 반영하려는 설계라는 점이 드러난다.

실습하기

실습의 목표는 세 가지를 눈으로 확인하는 것이다. (1) enabled가 클릭만 막는 게 아니라 semantics/표시까지 바꾼다. (2) onClick 람다의 생성 방식이 재구성 스킵에 영향을 준다. (3) modifier 순서가 터치 영역과 semantics 결과를 바꾼다. 각 단계는 복사 후 실행하면 화면과 로그에서 차이가 드러나도록 구성했다.

build.gradle.kts (Module)
1dependencies {
2    implementation(platform("androidx.compose:compose-bom:2025.01.00"))
3    implementation("androidx.compose.material3:material3")
4    implementation("androidx.activity:activity-compose:1.10.0")
5}
6
7android {
8    buildFeatures { compose = true }
9}

Compose BOM을 쓰면 material3와 runtime 버전 불일치로 생기는 빌드 에러를 줄일 수 있다. 처음 세팅할 때 내가 가장 많이 본 에러는 “java.lang.NoSuchMethodError … Composer” 계열이었다. 대개 컴파일러 플러그인 버전과 runtime 아티팩트 버전이 어긋난 경우다. BOM으로 맞추고, AGP/Compose Compiler 호환 표를 확인하면 이 종류의 런타임 크래시는 급격히 줄어든다.

1단계: 기본 Button과 enabled 토글

첫 화면에서 버튼 두 개를 둔다. 하나는 enabled 토글, 다른 하나는 실제 클릭 카운터다. 실행하면 enabled=false일 때 두 번째 버튼이 눌리지 않고, 눌림 피드백도 달라진다. TalkBack이 켜져 있으면 “사용 불가”로 읽히는 것도 확인된다.

Logcat에는 recomposition 로그가 찍힌다. 토글 버튼을 누르면 enabled가 바뀌고, 그 값을 읽는 두 번째 버튼이 영향을 받는다. 이때 전체 화면 함수가 재호출돼도, 내부에서 스킵이 일어나면 비용이 크지 않다. 반대로 onClick이 매번 새 객체로 만들어지면 스킵이 깨질 수 있다.

Step1Screen.kt
1package com.example.buttonbasics
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.foundation.layout.padding
9import androidx.compose.material3.Button
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.mutableIntStateOf
15import androidx.compose.runtime.mutableStateOf
16import androidx.compose.runtime.remember
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.unit.dp
20
21class MainActivity : ComponentActivity() {
22    override fun onCreate(savedInstanceState: Bundle?) {
23        super.onCreate(savedInstanceState)
24        setContent { MaterialTheme { Step1Screen() } }
25    }
26}
27
28@Composable
29fun Step1Screen() {
30    var enabled by remember { mutableStateOf(true) }
31    var count by remember { mutableIntStateOf(0) }
32
33    Log.d("Step1", "recomposed enabled=$enabled count=$count")
34
35    Column(Modifier.padding(16.dp)) {
36        Button(onClick = { enabled = !enabled }) { Text("enabled=$enabled") }
37        Button(enabled = enabled, onClick = { count++ }) { Text("count=$count") }
38    }
39}

실행 결과는 단순하지만, enabled가 클릭 차단 이상의 역할을 가진다는 점이 드러난다. 버튼이 비활성일 때 ripple이 약해지거나 사라지는 건 indication/interactionSource 경로가 enabled에 의해 분기되기 때문이다. 접근성까지 포함해 “비활성 상태를 표현”하려면 enabled는 Button API에서 분리돼야 한다.

2단계: onClick 람다 캡처와 스킵 깨짐 체감

두 번째 단계는 “람다를 매번 새로 만들면 뭐가 달라지나”를 체감하는 실험이다. 버튼을 60fps로 흔들리게 만드는 게 아니라, recomposition 때마다 람다 참조가 바뀌는 상황을 만든다. 실행하면 동일한 UI인데도 recomposition 로그 패턴이 달라진다.

처음에 나도 여기서 착각했다. “람다는 값이니까 변경되면 당연히 재구성”이라고 생각했는데, 문제는 재구성 자체가 아니라 ‘하위 그룹 스킵이 깨지는 방식’이었다. 특히 onClick에서 외부 변수를 캡처하면, 캡처된 값이 바뀔 때마다 람다 객체가 새로 만들어질 가능성이 커진다.

Step2LambdaCapture.kt
1package com.example.buttonbasics
2
3import android.util.Log
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.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun Step2LambdaCapture() {
19    var salt by remember { mutableIntStateOf(0) }
20    var clicks by remember { mutableIntStateOf(0) }
21    var enabled by remember { mutableStateOf(true) }
22
23    val onClickCapturing = { clicks += 1 + salt }
24
25    Log.d("Step2", "recomposed salt=$salt clicks=$clicks enabled=$enabled onClick=${onClickCapturing.hashCode()}")
26
27    Column(Modifier.padding(16.dp)) {
28        Button(onClick = { salt++ }) { Text("Change salt=$salt") }
29        Button(onClick = { enabled = !enabled }) { Text("Toggle enabled=$enabled") }
30        Button(enabled = enabled, onClick = onClickCapturing) { Text("Click (hash logged)") }
31    }
32}

Logcat에서 onClick hashCode가 salt 변경 때마다 바뀌는 경우가 자주 보인다(최적화/인라이닝에 따라 달라질 수 있다). 이 변화는 런타임 입장에서 “onClick 파라미터가 바뀌었다”로 인식되기 쉽고, Button 내부 그룹 스킵이 덜 일어날 수 있다. 실무에서는 onClick을 remember로 고정하거나, 캡처를 줄이거나, 이벤트를 상위로 올려 stable한 참조를 유지하는 패턴을 쓴다.

3단계: Modifier 순서로 터치 영역과 semantics 바꾸기

세 번째 단계는 손가락으로 확인 가능한 결과를 만든다. 같은 padding인데도 clickable 앞에 두면 터치 영역이 커지고, 뒤에 두면 터치 영역이 그대로다. 화면에서 “텍스트 주변 빈 공간도 눌린다/안 눌린다”가 바로 느껴진다.

테스트에서도 차이가 난다. semantics를 어디에 붙이느냐에 따라 노드가 합쳐지거나 분리될 수 있고, contentDescription이 어느 노드에 달리는지가 바뀐다. 이 때문에 modifier는 단순 옵션이 아니라 ‘노드 그래프를 조립하는 언어’에 가깝다.

Step3ModifierOrder.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Surface
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
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 Step3ModifierOrder() {
17    Column(Modifier.padding(16.dp)) {
18        Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
19            Text(
20                "Padding before clickable: bigger hit area",
21                Modifier
22                    .padding(16.dp)
23                    .semantics { contentDescription = "큰 터치 영역" }
24                    .clickable { }
25            )
26        }
27        Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
28            Text(
29                "Padding after clickable: same hit area",
30                Modifier
31                    .clickable { }
32                    .padding(16.dp)
33                    .semantics { contentDescription = "작은 터치 영역" }
34            )
35        }
36    }
37}

두 줄을 번갈아 탭하면 첫 줄은 여백까지 눌리고, 둘째 줄은 글자 주변만 눌리는 느낌이 난다. 이 차이는 modifier가 적용 순서대로 노드가 쌓이기 때문이다. Button의 modifier 파라미터가 맨 앞에 있는 이유도 여기와 연결된다. 호출자에게 “외부에서 어떤 노드를 앞/뒤에 붙일지” 결정권을 주려면 modifier는 반드시 파라미터로 열려 있어야 한다.

심화: Advanced 버전 만들기

실무 버튼은 단순 클릭만으로 끝나지 않는다. 네트워크 요청 동안 로딩을 보여야 하고, 연타로 중복 요청이 나가면 장애로 이어진다. 아이콘+텍스트는 기본이고, 길게 누르기(long press)로 보조 동작을 넣기도 한다. 접근성 라벨은 디자이너가 준 문구와 실제 텍스트가 다를 때가 많아서 별도 파라미터가 필요하다.

한 문단 요약: Button API는 onClick(입력), enabled(상태), modifier(노드 그래프 주입)를 분리해 런타임이 Slot Table 기반으로 변경 범위를 좁히고, semantics/interaction/indication을 일관되게 업데이트하도록 설계돼 있다.

사례 1: 로딩 + 중복 클릭 방지(debounce) + 접근성 라벨

중복 클릭 방지는 UI 문제처럼 보이지만, 실제로는 서버에 같은 요청이 2~3번 들어가면서 장애로 번진다. 내가 겪은 케이스는 결제 화면에서 300ms 사이에 2번 눌려 PG 요청이 중복으로 나간 사건이었다. 로그에는 동일한 orderId로 결제 승인 요청이 2회 찍혔고, 앱 쪽은 “버튼이 잠깐 멈춘 것 같아서 다시 눌렀다”가 원인이었다.

이 문제는 onClick 내부에서 enabled를 바꾸는 방식만으로는 완전히 막기 어렵다. recomposition이 다음 프레임에 반영되기 전 아주 짧은 창에서 추가 탭이 들어올 수 있고, pointer input이 이미 이벤트를 큐에 넣은 상태일 수도 있다. 그래서 입력 레이어에서 시간 기반 차단을 한 번 더 거는 편이 안전하다.

AdvancedButton.kt
1package com.example.buttonbasics
2
3import android.os.SystemClock
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.size
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.graphics.vector.ImageVector
18import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.unit.dp
21
22@Composable
23fun AdvancedButton(
24    text: String,
25    icon: ImageVector? = null,
26    loading: Boolean = false,
27    enabled: Boolean = true,
28    debounceMs: Long = 600L,
29    a11yLabel: String? = null,
30    onLongClick: (() -> Unit)? = null,
31    onClick: () -> Unit
32) {
33    var lastClickUptime by remember { mutableLongStateOf(0L) }
34
35    val effectiveEnabled = enabled && !loading
36
37    Button(
38        enabled = effectiveEnabled,
39        modifier = Modifier.semantics {
40            if (a11yLabel != null) contentDescription = a11yLabel
41        },
42        onClick = {
43            val now = SystemClock.uptimeMillis()
44            if (now - lastClickUptime < debounceMs) return@Button
45            lastClickUptime = now
46            onClick()
47        }
48    ) {
49        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
50            if (loading) {
51                CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
52            } else if (icon != null) {
53                Icon(imageVector = icon, contentDescription = null)
54            }
55            Text(text)
56        }
57    }
58
59    // onLongClick은 Button 기본 API에 없어서 별도 pointerInput 조합이 필요하다.
60    // 여기서는 API 표면만 노출하고, 아래 사례 2에서 구현한다.
61}

여기서 debounce는 remember로 lastClickUptime을 유지한다. remember가 없으면 recomposition 때마다 0으로 초기화돼 연타 차단이 사라진다. 또한 loading이면 enabled를 강제로 false로 만들어 입력과 semantics를 동시에 막는다. 이 상태는 Slot Table에 저장된 enabled 파라미터가 바뀌면서 Button 내부의 selectable/semantics 업데이트로 이어진다.

사례 2: long press와 Modifier 기반 입력 분리

long press는 Button이 기본 제공하지 않으니 modifier로 입력 노드를 추가해야 한다. 여기서 흔한 함정은 clickable과 combinedClickable을 섞어 두 번 이벤트가 나가게 만드는 것이다. 내가 실제로 본 버그는 “길게 누르면 onClick도 같이 호출됨”이었다. QA가 남긴 로그는 ‘onClick fired’가 long press마다 같이 찍혔다.

원인은 modifier 체인에 clickable과 pointerInput을 둘 다 붙여서, long press를 감지한 뒤 손을 떼는 순간 clickable이 다시 처리한 케이스였다. 해결은 입력을 한 곳(combinedClickable)로 모으고, enabled/indication/interactionSource를 공유하는 방식이었다.

AdvancedSurfaceButton.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.Indication
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.interaction.MutableInteractionSource
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Row
8import androidx.compose.material3.Surface
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.remember
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.semantics.Role
14import androidx.compose.ui.semantics.disabled
15import androidx.compose.ui.semantics.role
16import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun AdvancedSurfaceButton(
21    text: String,
22    enabled: Boolean = true,
23    indication: Indication? = null,
24    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
25    onLongClick: (() -> Unit)? = null,
26    onClick: () -> Unit
27) {
28    Surface(
29        modifier = Modifier
30            .semantics {
31                role = Role.Button
32                if (!enabled) disabled()
33            }
34            .combinedClickable(
35                enabled = enabled,
36                role = Role.Button,
37                interactionSource = interactionSource,
38                indication = indication,
39                onLongClick = onLongClick,
40                onClick = onClick
41            ),
42        tonalElevation = 1.dp
43    ) {
44        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
45            Text(text)
46        }
47    }
48}

combinedClickable 하나로 입력을 모으면 onClick/onLongClick이 동일한 interactionSource를 공유한다. pressed 상태가 한 스트림으로 흘러서 ripple과 상태 변화가 일관된다. 또한 enabled를 semantics와 입력 노드에 같이 주입해, 접근성에서 ‘비활성’이 제대로 읽히고 테스트에서도 disabled로 잡힌다.

AdvancedButtonDemo.kt
1package com.example.buttonbasics
2
3import android.widget.Toast
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.platform.LocalContext
10import androidx.compose.ui.unit.dp
11
12@Composable
13fun AdvancedButtonDemo() {
14    val ctx = LocalContext.current
15
16    Column(Modifier.padding(16.dp)) {
17        AdvancedSurfaceButton(
18            text = "Hold me",
19            onLongClick = { Toast.makeText(ctx, "long press", Toast.LENGTH_SHORT).show() },
20            onClick = { Toast.makeText(ctx, "click", Toast.LENGTH_SHORT).show() }
21        )
22        androidx.compose.material3.Text(
23            "Toast 두 종류가 섞이면 입력이 중복으로 나간 것이다.",
24            style = MaterialTheme.typography.bodySmall
25        )
26    }
27}

이 데모에서 길게 눌렀을 때 Toast가 두 번 뜨면 입력이 중복 처리된 것이다. combinedClickable로 모으면 long press 후 손을 떼도 click이 같이 뜨지 않는 쪽으로 정렬된다(플랫폼/제스처 설정에 따라 다를 수 있지만, 중복 호출 가능성은 크게 줄어든다).

흑역사 1: 나는 한 번 debounce를 enabled 토글로만 처리했다. onClick에서 enabled=false로 바꾸고 네트워크 끝나면 true로 돌리는 방식이었다. 실제 장애 상황에서 120Hz 기기에서 연타가 들어오면, enabled가 recomposition으로 반영되기 전 이벤트가 1회 더 들어갔다. 서버 로그에는 200ms 간격으로 동일 요청이 2회 찍혔다.

흑역사 2: long press를 pointerInput으로 직접 구현하고, 기존 Button의 onClick도 살려두었다. 결과는 long press 후 손을 떼는 순간 onClick이 추가로 호출됐다. 디버깅할 때는 Modifier 순서를 바꿔도 증상이 들쭉날쭉해서 더 헷갈렸다. 입력은 한 노드로 모으고(interactionSource 공유), semantics도 그 노드에 붙이는 쪽이 재현성과 테스트 안정성이 좋았다.

자주 하는 실수

1) enabled=false인데도 테스트에서 클릭이 통과하는 것처럼 보임

증상: UI 테스트에서 performClick이 성공하거나, TalkBack에서 버튼이 여전히 ‘버튼’으로만 읽히고 ‘사용 불가’가 붙지 않는다. 화면에서는 눌리지 않는 것처럼 보여서 더 혼란스럽다.

원인: enabled를 clickable/combinedClickable에는 전달했지만 semantics에 disabled를 기록하지 않았다. 또는 modifier.semantics를 잘못된 위치에 붙여 노드가 merge되면서 disabled가 사라졌다.

해결: enabled는 입력 노드와 semantics에 동시에 반영한다. Role.Button과 disabled()를 같은 semantics 블록에서 관리하고, 테스트에서는 semantics matcher로 disabled 여부를 확인한다.

2) Modifier.padding 순서 때문에 터치 영역이 예상과 다름

증상: 디자인상 여백이 넓은데 실제로는 글자 근처만 눌린다. 반대로 의도하지 않은 빈 공간이 눌려서 리스트 아이템에서 오동작한다.

원인: padding을 clickable 뒤에 붙였다. 레이아웃은 커졌지만 hit-test는 clickable이 붙은 시점의 크기를 기준으로 결정된다. Modifier는 ‘선언적 옵션’이 아니라 ‘노드 적용 순서’다.

해결: 터치 영역을 키우려면 padding을 clickable 앞에 둔다. 시각적 여백만 원하면 clickable 뒤에 둔다. minimumInteractiveComponentSize를 함께 써서 최소 터치 크기도 보장한다.

3) onClick 람다 캡처로 불필요한 재구성이 늘어남

증상: 버튼 주변 UI까지 자주 재구성되는 것처럼 로그가 나온다. 프로파일링에서 Compose Recompose가 많아 보이는데, 실제 변경은 버튼 텍스트 정도다.

원인: onClick이 상위 상태를 많이 캡처하면서 recomposition마다 새 람다 인스턴스가 만들어진다. 런타임은 파라미터 변경으로 판단하고 스킵을 덜 한다.

해결: 캡처를 줄이고 이벤트를 상위로 올린다. 필요하면 remember로 이벤트 람다를 고정하거나, stable한 이벤트 핸들러 객체를 둔다. 단, remember로 고정할 때 캡처된 값이 최신인지(스테일 클로저)도 같이 확인한다.

4) interactionSource를 매번 새로 만들어 리플/pressed가 끊김

증상: 버튼을 누르고 있는 동안 ripple이 끊기거나, pressed 색 변화가 깜빡인다. 테스트에서 press 상태를 관찰하는 코드가 불안정하다.

원인: MutableInteractionSource를 remember 없이 생성했다. recomposition마다 새 인스턴스가 생기면서 pressed 상태 스트림이 리셋된다.

해결: interactionSource는 remember로 유지하거나 외부에서 주입한다. 여러 컴포넌트가 동일한 pressed 상태를 공유해야 하면 상위에서 생성해 전달한다.

5) long press를 pointerInput으로 추가했다가 click이 중복 호출됨

증상: 길게 누르면 onLongClick과 onClick이 둘 다 호출된다. QA는 “길게 눌렀더니 두 동작이 실행됨”이라고 리포트한다.

원인: clickable과 pointerInput(또는 또 다른 clickable)을 동시에 붙여 이벤트 경로가 두 개가 됐다. Modifier 순서에 따라 어느 쪽이 먼저 소비하는지도 달라져 재현이 흔들린다.

해결: combinedClickable로 입력을 한 노드로 통합한다. interactionSource/indication도 그 노드에서 관리해 pressed 상태와 ripple을 일관되게 만든다.

WrongAndRightLongPress.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.runtime.Composable
6import androidx.compose.ui.Modifier
7
8@Composable
9fun WrongAndRightLongPress(
10    wrong: Boolean,
11    onClick: () -> Unit,
12    onLongClick: () -> Unit
13): Modifier {
14    return if (wrong) {
15        Modifier
16            .clickable { onClick() }
17            .clickable { onLongClick() }
18    } else {
19        Modifier.combinedClickable(
20            onClick = onClick,
21            onLongClick = onLongClick
22        )
23    }
24}

두 clickable을 연달아 붙이는 형태는 의도한 입력 모델이 아니다. 이벤트 소비/전파가 꼬여서 기기별로 결과가 달라질 수 있다. combinedClickable은 동일 노드에서 제스처를 조정하므로 중복 호출 가능성이 줄어든다.

성능 최적화 체크리스트

  • enabled는 입력 노드(clickable/combinedClickable)와 semantics(disabled) 둘 다에 반영했는가
  • Modifier.padding의 위치가 ‘터치 영역’ 요구사항과 일치하는가(앞: 확대, 뒤: 시각만)
  • minimumInteractiveComponentSize 또는 최소 터치 크기 정책을 적용했는가
  • interactionSource를 remember로 유지하거나 상위에서 주입했는가(pressed 상태 공유 필요 여부 포함)
  • indication을 null로 끄는 경우, 사용자 피드백(리플/하이라이트) 요구사항을 다른 방식으로 충족했는가
  • onClick 람다가 불필요한 상태를 과도하게 캡처하지 않는가(람다 인스턴스 churn 점검)
  • 로딩 중 중복 클릭 방지 로직이 UI(enabled 토글)만 의존하지 않고 입력 레벨에서도 차단하는가(debounce/throttle)
  • Button content 슬롯 내부에서 조건부 컴포저블 호출 순서가 크게 흔들리지 않는가(상태 꼬임/슬롯 이동 위험)
  • 접근성 라벨(contentDescription)을 텍스트와 분리해 설계했는가(동적 텍스트/약어/아이콘 버튼)
  • UI 테스트에서 노드 선택을 텍스트에만 의존하지 않고 semantics/testTag로 고정했는가
  • Recomposition이 많아 보일 때 measure/layout까지 늘었는지 별도로 확인했는가(Layout Inspector, tracing)
  • 버튼 주변에 불필요한 Modifier 객체 할당이 반복되지 않는가(매 recomposition마다 새 체인 생성)

자주 묻는 질문

Button의 onClick이 바뀌면 왜 재구성이 늘어날 수 있나? 람다는 그냥 함수 아닌가?

Compose Runtime은 이전 composition에서 저장한 파라미터와 현재 파라미터를 비교해 changed 플래그를 만든다. onClick은 함수 타입이지만 런타임 입장에서는 ‘객체 참조’로 비교되는 경우가 많다. recomposition 때마다 새 람다 인스턴스가 만들어지면 참조가 달라져 변경으로 인식되기 쉽고, Button 내부 그룹 스킵이 덜 일어난다. 특히 람다가 상위 상태를 캡처하면 상태 변경마다 새 인스턴스가 생길 가능성이 커진다. 처방은 (1) 캡처를 줄이고 이벤트를 상위로 올리기, (2) 필요하면 remember로 이벤트 람다를 고정하기, (3) 스테일 클로저를 피하려면 최신 값을 rememberUpdatedState 같은 키워드로 분리하기다. 학습 키워드는 changed flags, stability, rememberUpdatedState, lambda capture이다.

enabled=false면 클릭만 막으면 되지 왜 semantics까지 바뀌어야 하나?

UI는 손가락 입력만 받지 않는다. TalkBack/키보드/스위치 액세스/자동화 테스트는 semantics 트리를 통해 ‘이 노드가 무엇이며 가능한 동작이 무엇인지’를 판단한다. enabled=false인데 semantics에 disabled가 기록되지 않으면, 접근성 서비스는 여전히 “버튼”으로 인식하고 포커스 이동 대상으로 취급할 수 있다. 그 상태에서 activate 액션을 보내면 앱이 무반응이거나, 테스트가 잘못된 성공/실패를 기록한다. Material Button이 enabled를 파라미터로 노출하는 이유는 입력/표시/의미를 동시에 일관되게 바꾸기 위해서다. 처방은 Role.Button과 disabled()를 semantics에 넣고, clickable/combinedClickable에도 enabled를 전달하는 것이다. 학습 키워드는 Semantics, Role, disabled(), accessibility actions, compose-ui-test semantics matcher이다.

Modifier는 왜 체이닝 형태인가? 그냥 옵션 객체 하나로 받으면 더 깔끔하지 않나?

Modifier는 단순 설정 값 묶음이 아니라, 레이아웃/그리기/입력/의미 노드를 연결한 순서 있는 그래프(실제로는 연결 리스트)에 가깝다. padding을 clickable 앞에 두면 hit-test 영역이 커지고, 뒤에 두면 시각적 여백만 생긴다. 즉 ‘순서’가 기능이다. 옵션 객체 하나로 합치면 순서를 표현하기 어렵고, 런타임이 노드를 어떤 단계(Composition/Layout/Drawing/Input/Semantics)에 끼워 넣을지도 불명확해진다. 체이닝은 호출자가 노드 적용 순서를 명시하게 하고, 런타임은 그 순서대로 노드를 물질화해 측정/배치/그리기/입력 처리를 구성한다. 처방은 modifier 순서를 요구사항(터치 영역, 클립, semantics 병합, 테스트 태그)에 맞춰 설계하고, 의도적인 순서를 코드 리뷰 항목으로 두는 것이다. 학습 키워드는 Modifier.Node, hit-test, measure policy, semantics merge, pointer input이다.

remember가 없으면 실제로 어떤 문제가 생기나? interactionSource는 매번 새로 만들어도 되지 않나?

remember는 Slot Table에 값을 저장해 recomposition 때 같은 슬롯에서 같은 값을 돌려주게 한다. interactionSource 같은 객체는 ‘상호작용 상태 스트림’을 보관한다. 매 recomposition마다 새 MutableInteractionSource를 만들면 pressed/focused/hovered 상태가 초기화돼 ripple이 끊기거나, 길게 누르는 동안 pressed가 유지되지 않는 것처럼 보일 수 있다. 또한 테스트 코드에서 press 상태를 관찰할 때 관찰 대상이 계속 바뀌어 불안정해진다. 처방은 (1) remember로 interactionSource를 유지하거나, (2) 여러 컴포넌트가 상태를 공유해야 하면 상위에서 생성해 주입하는 것이다. 학습 키워드는 remember slot, MutableInteractionSource, Indication, pressed state, recomposition object churn이다.

Slot Table에 Button이 어떻게 저장된다는 말이 체감이 안 된다. 디버깅 방법이 있나?

Slot Table은 내부 구조라 직접 보기는 어렵지만, ‘호출 순서가 키’라는 성질은 재현 가능하다. 예를 들어 content 슬롯 내부에서 조건에 따라 컴포저블 호출을 빼거나 넣으면, remember로 저장한 값이 다른 위치로 밀리면서 상태가 엉키는 현상을 볼 수 있다. 또한 recomposition 로그를 찍어 호출 횟수와 변경 파라미터를 추적하면, 어떤 그룹이 스킵되는지 간접적으로 감이 온다. 처방은 (1) 조건부 호출이 있으면 key를 사용하거나, (2) 호출 순서를 안정적으로 유지하는 구조(항상 Icon 자리 유지, alpha로 숨김 등)를 선택하는 것이다. 학습 키워드는 SlotTable, group, key(), remember position, restartable/skippable composable이다.

Button을 커스텀할 때 Surface로 다시 만드는 게 좋은가, Material3 Button을 감싸는 게 좋은가?

요구사항이 ‘시각만 변경’이면 Material3 Button을 감싸는 쪽이 유지보수 비용이 낮다. Material이 제공하는 semantics, 최소 터치 크기, 상태별 색/타이포, 상호작용 처리(리플) 같은 디테일을 그대로 가져갈 수 있기 때문이다. 반대로 long press, debounce, 로딩, 복합 제스처처럼 입력 모델 자체를 바꿔야 하면 Surface + combinedClickable로 재구성하는 편이 낫다. 감싸기로 해결하려다 modifier에 clickable을 덧붙이면, 기본 Button의 입력 노드와 중복되기 쉬워 이벤트가 두 번 나갈 수 있다. 처방은 (1) 입력 모델을 바꾸지 않으면 감싸기, (2) 입력 모델이 바뀌면 Surface 기반으로 명시적으로 구성, (3) interactionSource/semantics를 한 노드에 모으는 원칙을 지키는 것이다. 학습 키워드는 delegation vs reimplementation, combinedClickable, semantics consistency, indication layering이다.

Recomposition이 많아 보이면 무조건 성능 문제인가? 버튼 클릭할 때 로그가 계속 찍혀서 불안하다.

recomposition은 ‘함수 재호출’이고, 성능을 결정하는 건 그 재호출이 스킵을 얼마나 타는지와 measure/layout/draw가 실제로 다시 실행되는지다. 버튼 클릭은 상태 변경을 동반하므로 recomposition 로그가 찍히는 건 자연스럽다. 문제는 (1) 변경과 무관한 큰 트리까지 매번 invalidation이 퍼지는지, (2) measure/layout 횟수가 함께 증가하는지, (3) 람다/Modifier 객체가 매번 새로 할당돼 GC 압박이 생기는지다. 처방은 (a) Layout Inspector와 함께 ‘Recomposition counts’만 보지 말고 measure/layout 패널을 같이 보고, (b) tracing(예: android.os.Trace)으로 프레임당 비용을 확인하고, (c) 안정성(stability)과 캡처를 점검해 스킵을 회복시키는 것이다. 학습 키워드는 recomposition vs relayout, skip, stability inference, tracing, allocation tracking이다.

관련 글

27. Compose Button 기본 구현 이해: onClick·enabled·Modifier가 나뉜 이유
Compose 기본2026.03.06

27. Compose Button 기본 구현 이해: onClick·enabled·Modifier가 나뉜 이유

Jetpack Compose Button의 onClick·enabled·Modifier가 왜 분리됐는지, 컴파일러/런타임/Slot Table 관점에서 내부 동작과 성능 포인트를 연결해 이해한다. 실습 코드 포함. (154자)     

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

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

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