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

26. Jetpack Compose Button 기본 구조와 필수 파라미터가 이렇게 생긴 이유

Compose Button의 onClick, enabled, modifier, colors, shape, contentPadding 같은 필수 파라미터가 왜 존재하는지와 Runtime/Slot Table/Recomposition 관점에서 동작 원리를 설명한다.

26. Jetpack Compose Button 기본 구조와 필수 파라미터가 이렇게 생긴 이유

Jetpack Compose Button 기본 구조와 필수 파라미터가 이렇게 생긴 이유

Compose를 처음 붙잡고 Button부터 만들면 ‘onClick만 넣었는데 왜 리컴포지션이 생기지?’, ‘modifier 순서 바꾸면 왜 터치 영역이 달라지지?’, ‘enabled만 바꿨는데 색이 왜 자동으로 바뀌지?’ 같은 당황스러운 순간이 온다. 특히 디버그 로그가 없어서 “그냥 그런가 보다”로 넘기기 쉬운데, Button은 Compose의 설계 의도가 한 덩어리로 들어있는 컴포넌트라 내부를 한 번 뜯어보면 이후 모든 컴포넌트가 같은 방식으로 보이기 시작한다.

핵심 개념

Button은 단순한 ‘클릭 가능한 박스’가 아니다. Material 계층에서는 Surface(배경/그림자/shape), Interaction(pressed/hover/focus), Semantics(접근성/테스트 태그), Content slot(텍스트/아이콘)까지 합쳐진 작은 시스템이다. API가 길어 보이는 이유는 기능을 옵션으로 쪼개서 ‘조합’ 가능하게 만들었기 때문이다. View 시대에는 setOnClickListener, setEnabled, setBackground, setPadding이 한 객체의 mutable 상태를 바꾸는 방식이었고, 순서/타이밍 버그가 자주 났다.

Compose에서 중요한 단어 5개만 Button 맥락으로 정의한다. 1) Composition: Button 호출이 UI 트리에 기록되는 단계다. 2) Slot Table: Composition 결과(호출 순서, remember 값, key 등)가 저장되는 테이블이다. Button이 같은 위치에서 같은 호출 순서를 유지하면 이전 슬롯을 재사용한다. 3) Recomposition: Button 내부에서 읽은 State가 바뀌면 해당 호출 구간만 다시 실행되는 과정이다. 4) Modifier: 레이아웃/그리기/입력/시맨틱을 ‘노드 체인’으로 누적하는 데이터 구조다. Button이 modifier를 받는 이유는 Button 내부 구현을 바꾸지 않고도 외부에서 동작을 덧대기 위해서다. 5) Stability(@Stable/@Immutable): 파라미터 비교를 빠르게 하려는 힌트다. Button은 파라미터가 많아서 비교 비용이 누적되기 쉽다.

처음에 나도 Button 눌렀을 때 리컴포지션이 ‘화면 전체’에서 일어나는 줄 알고 3시간 삽질한 적이 있다. Layout Inspector에서 Recomposition Count가 올라가길래 겁먹었는데, 실제로는 Slot Table에서 State를 읽는 구간만 invalidation이 걸렸고, 상위는 skip되었다. 문제는 내가 onClick 람다 안에서 상위 스코프의 mutableStateOf를 같이 건드려서 트리 상단까지 invalidation이 전파된 것이었다.

BasicButtonCounter.kt
1package com.example.buttonbasics
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.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.tooling.preview.Preview
13
14@Composable
15fun BasicButtonCounter() {
16    var count by remember { mutableIntStateOf(0) }
17
18    Column {
19        Text("count=$count")
20        Button(onClick = {
21            count++
22            Log.d("ButtonDemo", "clicked -> count=$count")
23        }) {
24            Text("Increment")
25        }
26    }
27}
28
29@Preview(showBackground = true)
30@Composable
31private fun PreviewBasicButtonCounter() {
32    BasicButtonCounter()
33}

이 코드를 실행하면 Logcat에 clicked가 찍히고, 화면의 count 텍스트만 바뀐다. Button 자체도 pressed 상태(리플/색 변화)를 잠깐 그리지만, count를 읽는 슬롯만 다시 호출된다. Compose Runtime은 count State를 읽은 위치를 추적하고, count가 바뀌면 그 구간을 invalid로 표시한 뒤 다음 프레임에서 재실행한다. Button의 onClick은 단지 이벤트 진입점이고, UI 갱신은 State 읽기/쓰기의 연결로 이루어진다.

컴포넌트 해부

Button 시그니처가 길어 보이는 이유는 ‘표현(색/shape)’, ‘행동(클릭/비활성)’, ‘관찰(상호작용/시맨틱)’, ‘확장(modifier)’, ‘내용(content slot)’을 분리했기 때문이다. View 시대에는 한 클래스가 모든 것을 소유했고, 커스텀 요구사항이 생기면 상속/커스텀 드로잉으로 빠지기 쉬웠다. Compose는 함수 파라미터로 분해해서, 필요한 축만 바꾸게 만든다.

  • onClick: 클릭 이벤트의 유일한 진입점이다. Button이 ‘클릭 가능’하다는 의미를 정의한다.
  • modifier: 외부에서 레이아웃/입력/시맨틱을 덧대는 통로다. Button 내부 구현과 독립적이어야 해서 필수로 노출된다.
  • enabled: 클릭 가능 여부뿐 아니라 시각적 톤(색/알파)과 시맨틱까지 함께 바꾸는 스위치다.
  • shape: 클릭 영역/클립/리플 경계에 영향을 준다. 단순히 모서리 둥글기 문제가 아니다.
  • colors: 상태(enabled/pressed 등)에 따른 배경/컨텐츠 색을 계산하는 정책 객체다.
  • elevation: 그림자를 그릴지, pressed에서 높이를 바꿀지에 대한 정책이다.
  • border: outline 버튼 같은 변형을 위해 존재한다. null이면 그리기 비용이 줄어든다.
  • contentPadding: 내부 콘텐츠 배치를 표준화한다. 텍스트만 넣어도 버튼이 ‘버튼처럼’ 보이게 만든다.
  • interactionSource: pressed/hover/focus 같은 상호작용 스트림을 외부로 공유한다. 테스트/동기화에 필요하다.
  • indication: 리플 같은 시각 피드백을 교체하기 위한 포인트다. null이면 표시를 끌 수 있다.
  • content: RowScope 슬롯이다. 텍스트+아이콘 조합을 호출자가 결정한다. Button이 텍스트를 강제하지 않는 이유다.

Surface 계층 관점에서 Button은 ‘모양을 가진 클릭 가능한 표면’을 만든다. shape/colors/elevation/border는 이 계층에 몰려 있다. 이 값들은 레이아웃이 아니라 드로잉과 입력 피드백(클립/리플) 경계에 직접 영향을 준다. enabled는 Surface의 색 정책과 시맨틱을 동시에 바꾸기 때문에, 단순 boolean이지만 파급이 크다.

Content 계층 관점에서 Button은 내부에 Row를 두고, contentPadding과 minimum size를 적용해 텍스트만 넣어도 버튼의 터치 타겟이 유지되게 만든다. content가 슬롯인 이유는 Button이 문자열을 받는 방식(TextButton처럼)을 강제하면 아이콘/로딩/카운트 뱃지 같은 요구사항마다 API가 폭발하기 때문이다. 슬롯은 Slot Table에 ‘호출된 컴포저블들의 연속’으로 기록되기 때문에, 버튼 내부에서 content를 어디에 호출하느냐가 곧 UI 구조가 된다.

SketchButton.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.BorderStroke
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.interaction.MutableInteractionSource
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.RowScope
8import androidx.compose.foundation.layout.padding
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.remember
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.graphics.Shape
16import androidx.compose.ui.unit.Dp
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun SketchButton(
21    onClick: () -> Unit,
22    modifier: Modifier = Modifier,
23    enabled: Boolean = true,
24    shape: Shape = MaterialTheme.shapes.small,
25    border: BorderStroke? = null,
26    tonalElevation: Dp = 0.dp,
27    contentPadding: Dp = 12.dp,
28    content: @Composable RowScope.() -> Unit
29) {
30    val interactionSource = remember { MutableInteractionSource() }
31
32    Surface(
33        modifier = modifier
34            .clickable(
35                enabled = enabled,
36                interactionSource = interactionSource,
37                indication = null,
38                onClick = onClick
39            ),
40        shape = shape,
41        border = border,
42        tonalElevation = tonalElevation
43    ) {
44        Row(modifier = Modifier.padding(contentPadding)) {
45            content()
46        }
47    }
48}
49
50@Composable
51fun SketchButtonSample() {
52    SketchButton(onClick = {}) {
53        Text("Sketch")
54    }
55}

이 재구성 코드는 실제 Material3 Button 원문이 아니라 구조를 설명하기 위한 스케치다. 핵심은 ‘Surface로 외형을 만들고, clickable로 입력을 붙이고, Row 슬롯으로 content를 호출한다’는 계층 분리다. 여기서 interactionSource를 remember로 고정하지 않으면, 리컴포지션마다 새로운 MutableInteractionSource가 생성되고 pressed 상태가 끊긴다. 실제로는 리플/프레스 애니메이션이 눌렀다 떼는 사이에 튀는 것처럼 보일 수 있다.

RealButtonUsage.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.BorderStroke
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.width
8import androidx.compose.material3.Button
9import androidx.compose.material3.ButtonDefaults
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.graphics.RectangleShape
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun RealButtonUsage() {
19    Button(
20        onClick = { /* action */ },
21        enabled = true,
22        shape = RectangleShape,
23        border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
24        colors = ButtonDefaults.buttonColors(
25            containerColor = MaterialTheme.colorScheme.primary,
26            contentColor = MaterialTheme.colorScheme.onPrimary
27        ),
28        contentPadding = ButtonDefaults.ContentPadding,
29        modifier = Modifier
30    ) {
31        Row(horizontalArrangement = Arrangement.Center) {
32            Text("Save")
33            Spacer(Modifier.width(8.dp))
34            Text("v1")
35        }
36    }
37}

이 사용 예에서 shape/border/colors/contentPadding은 Surface+Content의 정책을 바꾼다. modifier는 마지막에 붙여도 되지만, clickable/clip/padding 순서가 바뀌면 터치 영역과 리플 경계가 달라진다. Button 내부에도 여러 Modifier가 이미 존재한다. 호출자가 modifier로 padding을 추가하면 ‘외부 padding’이 되고, contentPadding은 ‘내부 padding’이 된다. 두 값이 섞이면 클릭 타겟이 예상과 달라지는 이유가 여기서 나온다.

내부 동작 원리

Compose 실행은 Composition → Layout → Drawing 순서로 흘러간다. Button을 호출하면 먼저 Composition 단계에서 Button 함수가 실행되고, 내부에서 Surface/Row/Text 같은 하위 컴포저블 호출이 Slot Table에 기록된다. 이때 기억해야 할 포인트는 ‘UI 트리를 객체로 들고 있는 게 아니라 호출 기록을 들고 있다’는 점이다. 그래서 호출 순서가 UI 구조 그 자체다.

Layout 단계에서는 Button이 만든 LayoutNode들의 measure/layout이 수행된다. contentPadding과 minimum size는 measure 단계에서 크기 계산에 반영된다. Drawing 단계에서는 shape에 따른 clip, border, background, 그리고 indication(리플)이 draw modifier나 layer로 적용된다. pressed 상태는 interactionSource가 방출하는 interaction을 읽어서 색/리플이 바뀌는 식으로 연결된다.

Recomposition은 ‘State를 읽은 위치’가 기준이다. Button 자체가 리컴포지션되는지 여부는 onClick이 아니라, Button 호출 중에 어떤 State를 읽었는지에 달렸다. 예를 들어 enabled가 어떤 State에서 계산되면 enabled를 읽는 Button 호출이 invalidation된다. 반대로 content 슬롯 내부에서만 State를 읽으면, Button 외곽은 skip되고 content만 다시 호출될 수 있다. 이 차이는 Slot Table에 저장된 group 경계(컴파일러가 만든 restart group)로 결정된다.

Compose Compiler는 @Composable 함수를 변환해서 Composer 파라미터와 changed 플래그를 추가하고, restart group을 만든다. 소스 레벨의 Button 호출은 런타임에서 대략 ‘startRestartGroup → 파라미터 변경 체크 → 필요 시 본문 실행 → endRestartGroup’ 형태로 감싸진다. changed 플래그는 안정성(stability)에 따라 비교 전략이 달라진다. @Stable/@Immutable로 판정되면 참조 동일성만으로 스킵 가능한 경우가 늘고, 불안정 타입이면 equals 호출이나 더 보수적인 invalidation이 생긴다.

Modifier 체인은 내부적으로 노드들의 연결 리스트에 가깝다. padding 다음 clickable을 붙이면 ‘패딩된 영역 전체가 클릭 가능’이 되고, clickable 다음 padding이면 ‘클릭 영역은 패딩 전 크기’가 된다. 이게 흔히 말하는 modifier 순서 문제다. Button은 이미 내부에서 clickable/semantics/minSize/padding 같은 modifier를 쌓는다. 호출자가 modifier로 clip을 추가하면 내부 리플보다 바깥에서 클립이 걸릴 수도 있다.

remember는 Slot Table에 값을 저장하는 API다. Button의 interactionSource 같은 것은 프레임을 넘어 유지돼야 한다. remember 없이 매번 새 MutableInteractionSource를 만들면, pressed interaction이 들어간 이전 인스턴스와 새 인스턴스가 분리되고, 리플이 중간에 리셋되는 현상이 나온다. 예전에 실제로 ‘버튼을 누르고 드래그하면 리플이 끊긴다’는 버그를 만났는데, 원인이 interactionSource를 remember로 고정하지 않고 매 recomposition마다 생성한 코드였다.

RecompositionTraceButton.kt
1package com.example.buttonbasics
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.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.tooling.preview.Preview
13
14@Composable
15fun RecompositionTraceButton() {
16    var count by remember { mutableIntStateOf(0) }
17
18    Log.d("Recompose", "RecompositionTraceButton called")
19
20    Column {
21        Text("count=$count")
22        Button(onClick = { count++ }) {
23            Log.d("Recompose", "Button content called")
24            Text("Tap")
25        }
26    }
27}
28
29@Preview(showBackground = true)
30@Composable
31private fun PreviewRecompositionTraceButton() {
32    RecompositionTraceButton()
33}

이 코드를 실행하고 버튼을 누르면 Logcat에 ‘RecompositionTraceButton called’과 ‘Button content called’가 반복해서 찍힌다. 여기서 중요한 관찰 포인트는 호출 로그가 ‘그려진 픽셀’이 아니라 ‘컴포저블 함수 호출’이라는 점이다. 실제 Drawing은 다음 단계에서 일어나고, skip된 그룹은 호출 자체가 생략된다. 로그가 많이 찍힌다고 무조건 느려지는 게 아니라, 변경된 파라미터가 적으면 내부에서 빠르게 skip되는 경로가 많다.

InteractionAndSemanticsButton.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
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.remember
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.semantics.contentDescription
11import androidx.compose.ui.semantics.semantics
12import androidx.compose.ui.tooling.preview.Preview
13
14@Composable
15fun InteractionAndSemanticsButton(
16    modifier: Modifier = Modifier
17) {
18    val interactionSource = remember { MutableInteractionSource() }
19
20    Column {
21        Button(
22            onClick = { },
23            interactionSource = interactionSource,
24            modifier = modifier.semantics { contentDescription = "Save button" }
25        ) {
26            Text("Save")
27        }
28    }
29}
30
31@Preview(showBackground = true)
32@Composable
33private fun PreviewInteractionAndSemanticsButton() {
34    InteractionAndSemanticsButton()
35}

이 코드를 실행하면 UI는 그냥 Save 버튼인데, Accessibility Scanner나 테스트에서 contentDescription이 잡힌다. semantics는 ‘그리기’가 아니라 ‘노드 메타데이터’를 만드는 modifier라서, 화면에 보이는 것과 별개로 트리에 정보가 추가된다. interactionSource를 외부로 빼면 여러 버튼의 pressed 상태를 동기화하는 것도 가능해진다. API에 interactionSource가 노출된 이유가 여기 있다.

실습하기

실습은 3단계로 진행한다. 각 단계는 ‘실행했을 때 무엇이 보이는지’가 명확해야 한다. 에디터에서 Preview만 돌려도 되지만, 클릭/리플/로그를 확인하려면 에뮬레이터 실행이 필요하다. 특히 2단계부터는 State 변경이 리컴포지션을 어떻게 유발하는지 Logcat으로 확인한다.

build.gradle
1android {
2    buildFeatures {
3        compose true
4    }
5    composeOptions {
6        kotlinCompilerExtensionVersion = "1.5.14"
7    }
8}
9
10dependencies {
11    implementation(platform("androidx.compose:compose-bom:2024.06.00"))
12    implementation("androidx.compose.material3:material3")
13    implementation("androidx.activity:activity-compose:1.9.0")
14}

버전은 프로젝트 템플릿과 맞춰야 한다. Compose BOM을 쓰면 material3/runtime/ui 버전이 묶여서 충돌이 줄어든다. 컴파일러 확장 버전이 런타임과 어긋나면 ‘This version of the Compose Compiler requires Kotlin version …’ 같은 에러가 난다. 예전에 Kotlin 1.9.22에 컴파일러 1.5.3을 그대로 두고 빌드했다가 이 메시지로 30분을 날린 적이 있다.

1단계: 가장 기본 형태(클릭만)

화면에는 버튼 하나가 보이고, 눌렀을 때 리플이 퍼진다. 아직 State를 안 쓰기 때문에 텍스트는 바뀌지 않는다. 이 단계의 목적은 Button이 기본적으로 Surface+Indication을 포함한다는 사실을 눈으로 확인하는 것이다.

onClick은 비워둘 수 없다. 빈 람다라도 전달해야 Button이 클릭 가능한 컴포넌트로 구성된다. View 시스템의 setOnClickListener(null)과 다르게, Compose는 ‘클릭 가능성’ 자체가 파라미터로 결정된다.

MainActivity.kt
1package com.example.buttonbasics
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.material3.Button
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.tooling.preview.Preview
16
17class MainActivity : ComponentActivity() {
18    override fun onCreate(savedInstanceState: Bundle?) {
19        super.onCreate(savedInstanceState)
20        setContent {
21            MaterialTheme {
22                Surface(Modifier.fillMaxSize()) {
23                    Step1BasicButton()
24                }
25            }
26        }
27    }
28}
29
30@Composable
31fun Step1BasicButton() {
32    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
33        Button(onClick = { }) {
34            Text("Tap")
35        }
36    }
37}
38
39@Preview(showBackground = true)
40@Composable
41private fun PreviewStep1BasicButton() {
42    MaterialTheme { Step1BasicButton() }
43}

이 단계에서 Layout Inspector를 켜면 Button 노드 아래에 Text가 있고, semantics에 role=Button 같은 정보가 붙어 있는 걸 확인할 수 있다. 화면에 보이는 UI와 semantics 트리는 겹치지만 동일하지 않다. 테스트가 semantics를 기준으로 동작하는 이유도 여기서 연결된다.

2단계: 상태 연동(enabled + 텍스트 변경)

화면에는 count와 버튼이 보인다. 버튼을 누르면 count가 증가하고, 특정 값에 도달하면 버튼이 비활성화되면서 색이 바뀐다. enabled 하나로 클릭 차단과 색 변경이 같이 일어나는 걸 확인한다.

여기서 관찰할 것은 리컴포지션 범위다. count를 읽는 Text와 enabled를 계산하는 Button 호출 구간이 invalidation된다. Slot Table은 이전 composition의 group을 유지하고, 바뀐 파라미터만 비교해서 스킵 가능한 곳은 스킵한다.

Step2StatefulButton.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.height
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.ui.Modifier
14import androidx.compose.ui.unit.dp
15import androidx.compose.ui.tooling.preview.Preview
16
17@Composable
18fun Step2StatefulButton() {
19    var count by remember { mutableIntStateOf(0) }
20    val enabled = count < 5
21
22    Column {
23        Text("count=$count")
24        Spacer(Modifier.height(12.dp))
25        Button(
26            onClick = { count++ },
27            enabled = enabled
28        ) {
29            Text(if (enabled) "Increment" else "Disabled")
30        }
31    }
32}
33
34@Preview(showBackground = true)
35@Composable
36private fun PreviewStep2StatefulButton() {
37    Step2StatefulButton()
38}

enabled가 false가 되는 순간부터는 onClick이 호출되지 않는다. 동시에 Material3의 colors 정책이 disabled 톤을 적용한다. View에서 setEnabled(false)로 alpha만 바꾸는 패턴과 달리, Compose Button은 상태별 색을 정책 객체(colors)가 계산하고, semantics까지 같이 바꾼다.

3단계: 커스터마이징(modifier/shape/colors/contentPadding)

화면에는 둥근 버튼이 보이고, 내부 텍스트가 넓은 패딩을 가진다. 버튼 바깥에도 여백이 생기고, 배경색/글자색이 확실히 달라진다. modifier의 padding과 contentPadding의 차이가 눈으로 구분된다.

modifier 체인 순서가 왜 중요한지도 같이 확인한다. 외부 padding은 레이아웃 크기를 바꾸고, 내부 padding은 콘텐츠 배치만 바꾼다. 이 차이가 터치 영역과 리플 경계에 영향을 준다.

Step3CustomizeButton.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.ButtonDefaults
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Alignment
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.RoundedCornerShape
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun Step3CustomizeButton() {
18    Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(24.dp)) {
19        Button(
20            onClick = { },
21            shape = RoundedCornerShape(18.dp),
22            colors = ButtonDefaults.buttonColors(
23                containerColor = MaterialTheme.colorScheme.tertiary,
24                contentColor = MaterialTheme.colorScheme.onTertiary
25            ),
26            contentPadding = ButtonDefaults.ContentPadding
27        ) {
28            Text("Custom")
29        }
30    }
31}
32
33@Preview(showBackground = true)
34@Composable
35private fun PreviewStep3CustomizeButton() {
36    MaterialTheme { Step3CustomizeButton() }
37}

이 단계에서 modifier에 padding을 추가하면 버튼 외부 여백이 늘어나고, contentPadding을 바꾸면 텍스트가 버튼 안에서 이동한다. 두 padding을 섞으면 ‘보이는 크기’와 ‘클릭되는 영역’이 엇갈릴 수 있다. 특히 clickable이 어디에 붙는지(내부/외부)에 따라 터치 타겟이 달라진다.

심화: Advanced 버전 만들기

실무 버튼 요구사항은 금방 늘어난다. 로딩 중에는 중복 클릭이 막혀야 하고, 네트워크가 느리면 버튼이 스피너로 바뀌어야 한다. 아이콘+텍스트 조합이 필요하고, 롱프레스나 접근성 라벨도 빠지면 QA에서 걸린다. 이 요구사항을 Button 상속으로 해결하려고 하면 View 시대의 함정으로 돌아간다. Compose에서는 슬롯과 상태를 조합해서 해결한다.

한 문단 요약: Button은 Surface(외형) + 입력(clickable/interaction) + Semantics(접근성/테스트) + Content slot(내부 UI)의 합성물이다. remember는 interaction 같은 ‘프레임을 넘어 유지돼야 하는 값’을 Slot Table에 고정하고, 상태 변경은 State를 읽은 group만 리컴포지션한다. modifier 순서는 클릭 영역/리플 경계를 바꾼다.

사례 1: 로딩 + 디바운스 + 접근성 라벨

내가 겪은 실제 사고가 하나 있다. 결제 화면에서 버튼을 2번 눌러 API가 2번 호출되었고, 서버가 멱등성을 보장하지 않아 주문이 중복 생성됐다. 당시 로그에는 Retrofit이 같은 요청을 연속으로 날린 흔적만 남아 있었다. UI 쪽에서는 버튼이 enabled=true로 유지되고 있었고, 클릭 이벤트를 막는 장치가 없었다.

디바운스는 단순히 delay를 거는 게 아니라, ‘마지막 클릭 시각’을 기억하고, 그 값이 바뀔 때만 리컴포지션이 생기게 설계해야 한다. 여기서는 remember로 lastClickMs를 고정하고, onClick 진입 시점에서 조건을 검사한다. loading은 enabled를 강제로 false로 만들고, content slot을 스피너로 바꾼다.

AdvancedButton.kt
1package com.example.buttonbasics
2
3import android.os.SystemClock
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.RowScope
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.width
8import androidx.compose.material3.Button
9import androidx.compose.material3.CircularProgressIndicator
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    onClick: () -> Unit,
24    modifier: Modifier = Modifier,
25    enabled: Boolean = true,
26    loading: Boolean = false,
27    debounceMs: Long = 600L,
28    a11yLabel: String? = null,
29    content: @Composable RowScope.() -> Unit
30) {
31    var lastClickMs by remember { mutableLongStateOf(0L) }
32
33    val effectiveEnabled = enabled && !loading
34
35    val semanticsModifier = if (a11yLabel != null) {
36        modifier.semantics { contentDescription = a11yLabel }
37    } else {
38        modifier
39    }
40
41    Button(
42        onClick = {
43            val now = SystemClock.elapsedRealtime()
44            if (now - lastClickMs < debounceMs) return@Button
45            lastClickMs = now
46            onClick()
47        },
48        enabled = effectiveEnabled,
49        modifier = semanticsModifier
50    ) {
51        if (loading) {
52            CircularProgressIndicator(strokeWidth = 2.dp)
53            Spacer(Modifier.width(10.dp))
54            Text("Loading")
55        } else {
56            Row { content() }
57        }
58    }
59}

이 코드를 실행하면 loading=true일 때 버튼이 비활성화되고, 클릭해도 onClick이 들어가지 않는다. debounce는 600ms 안에 연속 클릭해도 두 번째 클릭이 무시된다. 여기서 lastClickMs는 UI에 표시되지 않지만, 이벤트 처리의 일관성을 위해 Slot Table에 저장돼야 한다. remember를 빼면 리컴포지션으로 lastClickMs가 0으로 돌아가서 디바운스가 무력화될 수 있다.

사례 2: 아이콘+텍스트 슬롯, 롱프레스, 상태 최소 리컴포지션

롱프레스는 Button의 기본 onClick과 별개로 입력 제스처 레이어가 필요하다. 실무에서는 ‘길게 누르면 툴팁’ 같은 요구가 자주 나온다. 이때 Button 위에 pointerInput을 얹어도 되지만, modifier 순서가 잘못되면 클릭과 롱프레스가 서로 먹어버린다. 입력은 modifier 체인에서 소비(consumption)되기 때문이다.

AdvancedButtonWithLongPress.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.gestures.detectTapGestures
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.RowScope
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.width
8import androidx.compose.material3.Icon
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.input.pointer.pointerInput
14import androidx.compose.ui.graphics.vector.ImageVector
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun IconTextContent(
19    icon: ImageVector,
20    text: String
21) {
22    Row {
23        Icon(imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary)
24        Spacer(Modifier.width(8.dp))
25        Text(text)
26    }
27}
28
29@Composable
30fun AdvancedButtonWithLongPress(
31    onClick: () -> Unit,
32    onLongPress: () -> Unit,
33    modifier: Modifier = Modifier,
34    enabled: Boolean = true,
35    content: @Composable RowScope.() -> Unit
36) {
37    AdvancedButton(
38        onClick = onClick,
39        enabled = enabled,
40        modifier = modifier.pointerInput(Unit) {
41            detectTapGestures(onLongPress = { onLongPress() })
42        }
43    ) {
44        content()
45    }
46}

이 구조에서 롱프레스는 pointerInput으로 처리하고, 일반 클릭은 내부 Button이 처리한다. 둘이 충돌하면 detectTapGestures가 onTap까지 처리하는지 여부를 확인해야 한다. 실제로 예전에 onTap도 같이 넣었다가 Button의 onClick이 안 타는 현상을 겪었고, 원인은 제스처가 이벤트를 소비해서 clickable까지 전달되지 않은 것이었다. 해결은 롱프레스만 처리하거나, onTap에서 consume하지 않는 쪽으로 구성하는 것이다.

AdvancedButtonDemoScreen.kt
1package com.example.buttonbasics
2
3import android.util.Log
4import androidx.compose.material.icons.Icons
5import androidx.compose.material.icons.filled.Save
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.tooling.preview.Preview
13
14@Composable
15fun AdvancedButtonDemoScreen() {
16    var clicks by remember { mutableIntStateOf(0) }
17
18    AdvancedButtonWithLongPress(
19        onClick = {
20            clicks++
21            Log.d("AdvancedButton", "clicks=$clicks")
22        },
23        onLongPress = {
24            Log.d("AdvancedButton", "long-press")
25        },
26        enabled = true
27    ) {
28        IconTextContent(icon = Icons.Filled.Save, text = "Save ($clicks)")
29    }
30}
31
32@Preview(showBackground = true)
33@Composable
34private fun PreviewAdvancedButtonDemoScreen() {
35    MaterialTheme { AdvancedButtonDemoScreen() }
36}

이 화면에서 클릭하면 텍스트의 숫자만 바뀌고, 롱프레스하면 Logcat에 long-press가 찍힌다. clicks State를 읽는 곳이 content 슬롯 내부라서, Button 외곽 파라미터(enabled/shape/colors)가 고정돼 있으면 외곽은 skip될 여지가 크다. 이런 식으로 ‘상태를 가능한 한 아래로 내리는’ 구조가 리컴포지션 범위를 줄인다.

흑역사 하나 더 있다. loading 상태를 Boolean이 아니라 sealed class로 만들고, 그 객체를 매번 새로 생성해서 파라미터로 넘겼더니 버튼이 매 프레임 리컴포지션되는 것처럼 보였다. 원인은 그 타입이 안정적(stable)로 판정되지 않았고, equals도 매번 false가 나오는 구조였기 때문이다. 해결은 @Immutable 데이터로 만들거나, 상태를 remember로 보관하고 변경이 있을 때만 새 인스턴스를 만들도록 바꿨다.

자주 하는 실수

1) onClick 안에서 상위 State를 과하게 건드림

증상: 버튼을 눌렀을 뿐인데 화면 상단 AppBar까지 깜빡이거나, Logcat에 상위 컴포저블 호출 로그가 연쇄적으로 찍힌다.

원인: onClick에서 전역/상위 스코프의 mutableStateOf를 변경하고, 그 State를 상위가 읽고 있어서 invalidation이 위로 번진다. Slot Table은 ‘읽은 위치’를 기준으로 리컴포지션을 잡기 때문에 상위가 읽으면 상위가 다시 호출된다.

해결: 상태를 가능한 한 아래로 내리고, 상위에는 이벤트만 올린다. 화면 전체 상태를 갱신해야 한다면 derivedStateOf로 파생값을 분리해서 읽는 구간을 줄인다.

2) modifier 순서로 클릭 영역이 바뀌는 걸 놓침

증상: padding을 줬는데도 클릭이 안 되는 가장자리가 생기거나, 리플이 텍스트 주변에서만 퍼진다.

원인: Modifier는 선언형이지만 순서가 의미를 가진다. clickable이 padding 이전에 적용되면 클릭 영역은 작고, padding은 그 바깥에 붙은 장식이 된다.

해결: 클릭 타겟을 넓히려면 clickable보다 앞에서 size/padding을 확정하거나, Button의 contentPadding과 외부 padding을 구분한다. Layout Inspector에서 bounds와 touch target을 같이 확인한다.

3) interactionSource를 remember 없이 생성

증상: 버튼을 누른 채로 드래그하거나 빠르게 탭하면 pressed/리플 상태가 중간에 끊기는 느낌이 난다. 어떤 기기에서는 리플이 아예 안 보이는 것처럼 보이기도 한다.

원인: 리컴포지션마다 MutableInteractionSource가 새로 만들어지면, 이전 인스턴스에 쌓인 Interaction이 버려진다. pressed 상태는 스트림으로 흘러가는데 스트림이 갈아끼워지는 셈이다.

해결: interactionSource는 remember { MutableInteractionSource() }로 고정한다. 외부에서 공유해야 하면 파라미터로 받아서 상위에서 remember한다.

4) 불안정 객체를 colors/shape 등에 매번 새로 생성

증상: 버튼이 자주 리컴포지션되고, 애니메이션이 없는 화면에서도 Recomposition Count가 빠르게 증가한다.

원인: 파라미터로 넘기는 객체가 매 호출마다 새 인스턴스면 changed 플래그가 계속 true가 된다. 특히 안정성 판정이 불리하면 스킵이 줄어든다.

해결: ButtonDefaults.* 같은 stable 정책을 재사용한다. 커스텀 정책 객체는 remember로 캐시하거나 @Immutable 데이터로 만든다.

5) enabled를 UI만 바꾸는 값으로 착각

증상: enabled=false인데도 테스트에서 클릭 이벤트가 들어오거나, 접근성에서 여전히 버튼으로 읽히는 등 기대와 다른 결과가 나온다.

원인: enabled는 semantics와 입력 처리까지 포함하는 계약이다. 하지만 커스텀 clickable을 붙여놓고 enabled를 Button에만 주면, 외부 clickable이 살아있을 수 있다.

해결: 클릭 처리는 한 곳에만 둔다. 커스텀 입력을 붙였다면 enabled를 그 입력에도 연결한다. semantics를 직접 구성했다면 disabled semantics도 같이 설정한다.

MistakeEnabledWithOuterClickable.kt
1package com.example.buttonbasics
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.unit.dp
11
12@Composable
13fun MistakeEnabledWithOuterClickable(enabled: Boolean) {
14    Box(
15        modifier = Modifier
16            .padding(16.dp)
17            .clickable(enabled = enabled) { /* outer click */ }
18    ) {
19        Button(
20            onClick = { /* inner click */ },
21            enabled = enabled
22        ) {
23            Text("Pay")
24        }
25    }
26}

이 코드는 enabled=false일 때도 outer clickable이 살아 있으면(혹은 enabled 연결이 빠지면) 버튼이 비활성처럼 보여도 클릭이 들어갈 수 있다. UI는 Button이 그리지만 입력은 바깥 clickable이 처리하는 구조가 되기 때문이다. 이벤트 경로를 한 군데로 모으는 게 사고를 줄인다.

성능 최적화 체크리스트

  • Button에 전달하는 람다(onClick)가 상위 State를 직접 변경해서 화면 상단까지 invalidation을 만들지 점검한다.
  • content 슬롯 내부에서만 변하는 State를 읽게 배치했는지 확인한다(상태를 아래로 내리기).
  • interactionSource를 직접 만들었다면 remember로 고정했는지 확인한다(pressed 상태 끊김 방지).
  • modifier 체인에서 padding/size와 clickable의 순서를 의도대로 구성했는지 확인한다(터치 타겟/리플 경계).
  • 외부 modifier에 clip을 추가했을 때 리플이 잘리는지 확인한다(내부 indication과의 레이어 순서).
  • colors/shape/elevation 같은 정책 객체를 매 리컴포지션마다 새로 만들지 확인한다(불필요한 changed 플래그).
  • enabled=false일 때 semantics가 disabled로 내려가는지 확인한다(접근성/테스트 일관성).
  • 로딩 중 중복 클릭 방지를 enabled와 별도로 보장했는지 확인한다(debounce 또는 loading lock).
  • Layout Inspector에서 Button의 bounds와 padding(외부/내부)을 분리해서 확인한다(레이아웃 착시 방지).
  • Recomposition Count만 보고 성능을 판단하지 않고, 실제 프레임 드랍(Choreographer)과 함께 본다.
  • 버튼 텍스트/아이콘이 자주 바뀐다면 content 슬롯을 작은 컴포저블로 쪼개서 스킵을 유도했는지 확인한다.
  • 테스트에서 contentDescription/testTag를 semantics로 제공했는지 확인한다(문자열 변경에 강한 테스트).

자주 묻는 질문

Button의 onClick은 왜 필수 파라미터인가? 비활성 버튼도 만들 수 있는데 굳이 강제하는 이유가 있나?

Compose에서 Button은 ‘클릭 가능한 표면’이라는 의미를 가진 컴포넌트다. onClick이 없으면 Button은 Button이 아니라 단순 Surface/Text 조합이 되어야 하고, semantics(role=Button), interaction(pressed), indication(리플) 같은 계약도 애매해진다. 비활성 버튼은 onClick을 없애는 방식이 아니라 enabled=false로 표현한다. 그래야 접근성 트리에 ‘비활성 버튼’이 남고, 테스트에서도 버튼으로 식별 가능하다. 학습 키워드는 semantics, role, enabled contract, Material3 ButtonDefaults이다.

enabled만 바꿨는데 색이 같이 바뀌는 게 싫다. 색은 그대로 두고 클릭만 막고 싶다. 어떻게 해야 하나?

Material3 Button은 enabled 상태에 따라 colors 정책이 자동으로 disabled 톤을 적용한다. 클릭만 막고 색을 유지하려면 두 가지 선택지가 있다. 1) enabled는 true로 두고 onClick에서 early return, 또는 debounce/lock을 둔다. 이 경우 semantics는 여전히 활성 버튼으로 남는다. 2) enabled=false를 유지하되 colors를 커스텀해서 disabledContainerColor/disabledContentColor를 활성 색과 동일하게 지정한다. 접근성/테스트 의미를 유지하려면 2번이 더 일관적이다. 학습 키워드는 ButtonDefaults.buttonColors, disabled colors, semantics disabled state이다.

Modifier 순서가 왜 그렇게 중요하나? 선언형이면 순서가 의미 없어야 하는 것 아닌가?

Modifier는 ‘설정 값’이 아니라 ‘노드 체인’이다. padding, size, clickable, semantics, draw 같은 기능이 각자 노드로 추가되고, 입력/레이아웃/드로잉 파이프라인에서 노드 순서대로 처리된다. padding이 먼저면 레이아웃 크기가 커진 뒤 clickable이 그 전체를 감싼다. clickable이 먼저면 입력 노드가 작은 영역에 붙고, 그 바깥 padding은 입력과 무관한 장식이 된다. Layout Inspector에서 bounds를 보면 이 차이가 바로 보인다. 학습 키워드는 Modifier.Node, pointer input, layout modifier order, hit test이다.

Slot Table에는 Button의 무엇이 저장되나? 실제 View 객체처럼 버튼 인스턴스가 저장되는 건가?

Slot Table에는 ‘컴포저블 호출의 그룹 구조’와 remember로 저장한 값, key, 그리고 변경 추적에 필요한 메타데이터가 저장된다. Button 인스턴스 같은 객체가 통째로 저장되는 방식이 아니다. Button 호출은 Surface/Row/Text 호출로 분해되고, 각 호출이 Slot Table에서 자신의 위치(슬롯)를 가진다. remember { MutableInteractionSource() } 같은 값은 해당 그룹 슬롯에 저장돼 다음 리컴포지션에서 재사용된다. 그래서 호출 순서가 바뀌면 슬롯 매칭이 깨지고, remember 값이 예상과 다르게 재생성될 수 있다. 학습 키워드는 Composer, group, remember slot, key()이다.

Recomposition이 발생하면 Layout과 Drawing도 항상 다시 실행되나? 버튼 클릭할 때마다 전부 다시 하면 느릴 것 같다.

Recomposition은 ‘컴포저블 함수 재호출’ 단계다. 그 결과가 이전과 동일하면 Layout/Drawing까지 매번 전체가 다시 도는 것은 아니다. Compose는 변경된 노드만 invalidate하고, 레이아웃이 바뀌지 않으면 measure/layout을 스킵할 수 있다. 예를 들어 텍스트만 바뀌면 Text 노드의 측정이 다시 필요할 수 있지만, 버튼 외곽 크기가 고정이면 상위 레이아웃은 그대로일 수 있다. pressed 리플은 주로 드로잉 레이어에서 처리되어, 컴포지션 변화 없이도 애니메이션이 진행될 수 있다. 학습 키워드는 invalidate, snapshot state, layout remeasure, draw invalidation이다.

remember가 없으면 뭐가 실제로 깨지나? 단지 객체가 새로 만들어질 뿐 아닌가?

remember가 없으면 리컴포지션마다 값이 재생성되고, 그 값이 ‘상태를 담는 객체’일 때 문제가 터진다. interactionSource는 pressed/focus 같은 상호작용을 스트림으로 들고 있어야 하는데, 새 인스턴스로 갈아끼우면 기존 스트림이 끊긴다. debounce의 lastClickMs도 마찬가지다. UI에 표시되지 않는 값이라도 이벤트 처리의 일관성을 위해 프레임을 넘어 유지돼야 한다. remember는 그 값을 Slot Table에 저장해 같은 호출 위치에서 재사용하게 만든다. 학습 키워드는 remember lifecycle, recomposition, interaction stream, state hoisting이다.

Button content에 상태를 넣으면 버튼 전체가 리컴포지션되나? content만 다시 그릴 수 있나?

상태를 어디서 ‘읽느냐’가 기준이다. clicks를 content 슬롯 내부에서만 읽고, Button 외곽 파라미터(enabled/shape/colors)가 고정이면 컴파일러가 만든 그룹 경계 덕분에 외곽은 skip되고 content만 다시 호출될 가능성이 크다. 반대로 enabled가 clicks에 의존하면 Button 호출 구간 자체가 invalidation된다. 그래서 ‘상태를 아래로 내린다’는 말은 단순 스타일이 아니라 Slot Table의 invalidation 범위를 줄이려는 구조적 선택이다. 학습 키워드는 restart group, parameter changes, stability, skipping이다.

관련 글

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

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

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

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자)     

21. Compose Column/Row 레이아웃 기초: 왜 이렇게 배치되고 다시 그려지나
Compose 기본2026.03.04

21. Compose Column/Row 레이아웃 기초: 왜 이렇게 배치되고 다시 그려지나

Jetpack Compose Column/Row 배치 규칙을 내부 동작(컴파일러, Slot Table, recomposition) 관점에서 설명하고, 상태/Modifier/성능 함정까지 실습 코드로 연결한다. 140~160자 맞춤 설명 문장이다!?!?