11. Compose @Composable 함수: 선언형 UI가 동작하는 진짜 이유
Compose @Composable의 선언형 UI가 왜 가능한지, 컴파일러가 생성하는 코드와 Runtime의 Slot Table·Recomposition 비교까지 내부 동작으로 설명한다. 초보도 원리를 잡는다. ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ
Compose @Composable 함수: 선언형 UI가 동작하는 진짜 이유
Compose를 처음 붙잡고 Text("Hello")를 찍는 건 쉽다. 문제는 버튼을 눌러 state를 바꿨는데 어떤 때는 화면이 바뀌고, 어떤 때는 안 바뀌며, 어떤 때는 리스트 전체가 다시 그려지는 것처럼 느껴지는 순간이다. Layout Inspector에서 recomposition 카운터가 오르는데도 실제로 무엇이 다시 실행되는지 감이 없다. 이 혼란은 @Composable을 ‘UI를 그리는 함수’로 오해할 때 시작된다. @Composable은 Runtime과 계약을 맺는 함수이며, 그 계약이 Slot Table과 비교 로직으로 구현된다.
핵심 개념
@Composable은 “화면을 리턴하는 함수”가 아니다. 리턴값이 Unit인 이유부터가 힌트다. @Composable 호출은 Runtime의 Composer에게 “이 위치에 이런 UI 노드를 배치한다”는 기록을 남기는 행위에 가깝다. 이 기록이 Slot Table에 쌓이고, 다음 프레임에서 같은 위치의 호출이 다시 오면 이전 슬롯과 비교해 재사용할지, 업데이트할지, 버릴지를 결정한다.
선언형 UI는 ‘상태 → UI’의 단방향 매핑을 강제한다. View 시스템에서는 setText, setVisibility, notifyItemChanged 같은 명령을 개발자가 직접 호출했다. 그 방식은 “누가 언제 무엇을 바꿨는지”가 코드 곳곳에 흩어지고, 누락되면 화면과 모델이 쉽게 어긋난다. Compose는 상태를 읽는 지점을 Runtime이 추적하고, 그 상태가 바뀌면 그 지점을 다시 실행한다. 개발자가 ‘업데이트 명령’을 쓰지 않아도 되는 이유가 여기 있다.
핵심 용어를 실제 맥락으로 정의한다. Composition은 @Composable 호출을 실행해 UI 트리를 ‘기록’하는 단계다. Recomposition은 이미 만들어진 기록(슬롯)을 기반으로 같은 호출을 다시 실행해 변경점을 반영하는 단계다. Slot Table은 호출 순서/그룹/remember 값/키 등을 담는 런타임 저장소다. Stability(@Stable/@Immutable)는 “이 객체가 바뀌었는지”를 값 비교 없이 추론하기 위한 힌트이며, 불필요한 recomposition을 줄이는 근거가 된다.
처음에 나도 @Composable을 그냥 ‘XML 대체’ 정도로 생각했다가, 리스트 스크롤 중에 프레임이 뚝뚝 끊기는 상황을 겪었다. Layout Inspector에서 특정 Row가 매 프레임 recomposition 되는 걸 보고 원인을 찾았는데, 문제는 onClick 람다 안에서 state를 바꾸는 게 아니라, 매 recomposition마다 새로운 객체를 만들어 파라미터로 넘기고 있던 것이었다. “함수 호출이 곧 UI 업데이트”라는 모델을 받아들이지 못하면 이런 함정에 계속 빠진다.
1package com.example.composablebasics
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.tooling.preview.Preview
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun CounterBasic() {
16 val countState = remember { mutableStateOf(0) }
17
18 Column(Modifier.padding(16.dp)) {
19 Text(text = "count=${countState.value}")
20 Button(onClick = { countState.value++ }) {
21 Text("+1")
22 }
23 }
24}
25
26@Preview(showBackground = true)
27@Composable
28private fun CounterBasicPreview() {
29 CounterBasic()
30}이 코드를 실행하면 버튼을 누를 때마다 숫자가 바뀐다. 중요한 관찰 포인트는 ‘Text만 바뀌는 것처럼 보이지만’ 실제로는 CounterBasic이 다시 호출된다는 점이다. 다시 호출되어도 remember가 Slot Table에 저장된 state를 같은 위치에서 꺼내오기 때문에 값이 유지된다. remember가 없으면 recomposition마다 mutableStateOf(0)이 새로 만들어져 클릭해도 0으로 되돌아간다.
컴포넌트 해부
Compose의 컴포넌트는 대체로 ‘Surface(외피)’와 ‘Content(내용 슬롯)’으로 나뉜다. Surface는 배경/테두리/그림자/클릭/리플/semantics 같은 외부 효과를 담당하고, Content는 Row/Column 같은 레이아웃과 실제 자식 composable들을 담는다. 이 분리 덕분에 버튼처럼 보이는 컴포넌트를 만들 때도 ‘외피만 교체’하거나 ‘내용만 교체’가 가능해진다.
Button을 예로 들면 파라미터는 단순 옵션이 아니라 런타임의 비교 단위다. 어떤 파라미터가 바뀌면 어떤 노드가 업데이트되는지, 어떤 파라미터는 recomposition을 유발하지만 실제 레이아웃/드로잉까지 이어지지 않는지까지 연결된다. 특히 Modifier는 체인으로 연결되며, 체인 순서가 hit-test, semantics, layout, draw 순서에 영향을 준다.
- modifier: 왜 체이닝인가 → 각 노드가 ‘연결 리스트’처럼 쌓여 순서가 의미를 가진다(패딩과 클릭 영역의 순서가 대표 예).
- enabled: 왜 Boolean인가 → disabled일 때 interaction/semantics가 달라지고, 리플과 클릭 처리가 끊긴다.
- onClick: 왜 함수 타입인가 → 이벤트는 상태와 분리되어야 하며, recomposition 중에는 실행되지 않는다.
- shape: 왜 외부에서 주입인가 → clip, shadow, background가 같은 shape를 공유해야 일관성이 생긴다.
- colors: 왜 객체인가 → 상태(enabled/pressed 등)에 따라 색이 달라지고, 이를 한 덩어리 정책으로 묶는다.
- contentPadding: 왜 Dp가 아니라 PaddingValues인가 → 방향별/레이아웃 방향(RTL) 대응이 필요하다.
- elevation: 왜 별도 정책인가 → 그림자는 draw 단계에서 처리되며, 상태에 따라 다르게 줄 수 있다.
- border: 왜 nullable인가 → 없을 때는 draw 비용을 줄이고, 있을 때만 stroke를 추가한다.
- interactionSource: 왜 주입 가능한가 → 외부에서 pressed/hover를 관찰하거나 공유해야 하는 케이스가 있다.
- content: 왜 slot인가 → 아이콘+텍스트, 로딩 인디케이터 등 구조가 바뀌어도 버튼 외피는 유지된다.
- contentDescription(semantics): 왜 따로 잡나 → 접근성 트리에서 읽히는 문자열은 UI 텍스트와 별개인 경우가 많다.
Surface 계층은 ‘그릴 것’과 ‘입력 받을 것’을 책임진다. 배경색과 그림자만 바뀌어도 실제로는 draw 단계만 다시 일어나야 한다. 클릭 가능 여부가 바뀌면 pointer input과 semantics가 바뀌어야 한다. 이 레이어를 content와 분리하면, content가 복잡해도 외피 정책은 고정된 채로 교체가 가능하다.
1package com.example.composablebasics
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.shape.RoundedCornerShape
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.draw.clip
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun EducationalButtonShell(
17 modifier: Modifier = Modifier,
18 enabled: Boolean = true,
19 onClick: () -> Unit,
20 content: @Composable () -> Unit
21) {
22 val bg = if (enabled) Color(0xFF2E7D32) else Color(0xFF9E9E9E)
23
24 Row(
25 modifier = modifier
26 .clip(RoundedCornerShape(12.dp))
27 .background(bg)
28 .clickable(enabled = enabled, onClick = onClick)
29 .padding(horizontal = 14.dp, vertical = 10.dp)
30 ) {
31 content()
32 }
33}
34
35@Composable
36fun EducationalButtonExample() {
37 EducationalButtonShell(onClick = {}) {
38 Text(text = "Shell + Content slot", color = Color.White)
39 }
40}이 코드를 실행하면 버튼처럼 보이는 Row가 생긴다. clip → background → clickable → padding 순서를 바꾸면 바로 체감된다. padding을 clickable 앞에 두면 클릭 영역이 패딩까지 포함되고, 뒤에 두면 텍스트 주변만 클릭된다. Modifier가 체인인 이유는 ‘옵션 나열’이 아니라 ‘처리 파이프라인 구성’이기 때문이다.
1package com.example.composablebasics
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.Button
8import androidx.compose.material3.ButtonDefaults
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.graphics.RectangleShape
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun ButtonRealWorldUsage(
18 onClick: () -> Unit,
19 modifier: Modifier = Modifier
20) {
21 Button(
22 onClick = onClick,
23 modifier = modifier,
24 shape = RectangleShape,
25 colors = ButtonDefaults.buttonColors(
26 containerColor = Color(0xFF1B1B1B),
27 contentColor = Color(0xFFEDEDED)
28 ),
29 contentPadding = ButtonDefaults.ContentPadding
30 ) {
31 Row(horizontalArrangement = Arrangement.Center) {
32 Text("Save")
33 Spacer(Modifier.width(8.dp))
34 Text("(local)")
35 }
36 }
37}이 사용 예는 content slot이 단순 텍스트 1개가 아니라 레이아웃 자체가 될 수 있음을 보여준다. content를 슬롯으로 받지 않으면 Button은 “텍스트 버튼”에서 멈춘다. 슬롯을 받으면 아이콘/텍스트/로딩/뱃지 같은 조합을 호출자가 결정하고, Button은 외피 정책만 제공한다.
내부 동작 원리
Compose 파이프라인은 Composition → Layout → Drawing 3단계다. Composition은 @Composable 실행으로 ‘무엇을 배치할지’를 결정하고, Layout은 측정/배치로 ‘어디에 놓을지’를 결정하며, Drawing은 실제 픽셀 렌더링이다. state가 바뀌면 항상 3단계가 다 다시 도는 게 아니라, 변경이 어느 단계에 영향을 주는지에 따라 필요한 단계만 다시 수행된다.
Composition 단계에서 Runtime은 Composer를 통해 그룹을 열고 닫으며 Slot Table에 호출 트리를 저장한다. 각 @Composable 호출은 내부적으로 “restartable group” 같은 단위로 묶이고, 파라미터 변경 여부를 비트 플래그로 전달받아 스킵 가능성을 판단한다. 이 플래그는 Compose Compiler가 생성한다.
컴파일러가 생성하는 코드를 그대로 복사할 필요는 없지만, 구조는 알아야 한다. @Composable fun Foo(x: Int)는 대략 fun Foo(x: Int, composer: Composer, changed: Int) 같은 형태로 바뀐다. 호출자는 changed 플래그에 “x가 이전과 같은가” 정보를 담아 넘기고, Foo 내부는 composer.skipping 여부를 보고 본문 실행을 건너뛸 수 있다. 이 스킵이 가능한 이유가 Slot Table에 ‘이 호출 위치의 이전 실행 결과’가 있기 때문이다.
State 읽기 추적은 Snapshot 시스템과 엮인다. mutableStateOf 값을 읽는 순간, 현재 composition 범위가 그 state의 ‘observer’로 등록된다. 이후 값이 바뀌면 Runtime은 그 observer 범위를 invalidation 목록에 넣고, 다음 프레임에 그 범위만 recomposition 한다. “전체가 다시 그려지는 것처럼 보이는데 실제로는 일부만 다시 호출되는” 이유가 여기 있다.
remember는 Slot Table의 ‘이 위치에 붙는 값’이다. 위치 기반이므로 호출 순서가 바뀌면 다른 값이 붙는다. 그래서 조건문 안에서 remember를 쓰면 위험하다는 규칙이 나온다. 키(remember(key1, key2))는 ‘이 위치의 값이지만, 키가 바뀌면 새 값으로 교체’라는 의미를 더한다.
Stability는 비교 비용과 recomposition 범위를 줄이기 위한 설계다. 매 recomposition마다 깊은 equals를 돌리면 비용이 커진다. @Immutable이면 내부가 절대 바뀌지 않는다고 가정하고 참조 동일성만으로도 안전하게 스킵할 수 있다. @Stable은 “외부에서 관찰 가능한 상태 변경이 있으면 Compose가 알 수 있다”는 약속이다. 이 약속을 깨면 UI가 갱신되지 않는 버그가 나온다.
한 문단 요약: @Composable 호출은 Slot Table에 ‘호출 위치별 기록’을 남기고, state 읽기 추적으로 invalidation 범위를 좁히며, 컴파일러가 만든 changed 플래그로 스킵 여부를 판단해 필요한 부분만 다시 실행한다.
1package com.example.composablebasics
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 RecompositionTrace() {
16 var count by remember { mutableIntStateOf(0) }
17
18 Log.d("Trace", "RecompositionTrace() called, count=$count")
19
20 Column {
21 StaticHeader()
22 Text("count=$count")
23 Button(onClick = { count++ }) { Text("inc") }
24 }
25}
26
27@Composable
28private fun StaticHeader() {
29 Log.d("Trace", "StaticHeader() called")
30 Text("header")
31}
32
33@Preview(showBackground = true)
34@Composable
35private fun RecompositionTracePreview() {
36 RecompositionTrace()
37}이 코드를 실행하고 Logcat에서 Trace를 필터링하면 버튼 클릭마다 RecompositionTrace가 다시 찍힌다. StaticHeader도 같이 찍히면 “왜 header까지 다시 호출되나”가 질문으로 남는다. 답은 단순하다. StaticHeader 호출은 같은 그룹 안에 있고, 컴파일러가 스킵 가능하다고 판단하려면 파라미터 안정성/changed 플래그가 만족되어야 한다. StaticHeader는 파라미터가 없으니 스킵될 수 있지만, 실제 스킵 여부는 주변 그룹 구조와 스킵 가능 조건에 영향을 받는다. Layout Inspector의 recomposition count는 ‘호출 시도’를 의미하고, 내부에서 스킵되면 실제 본문 실행은 줄어든다.
1package com.example.composablebasics
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.text.BasicText
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
13import androidx.compose.ui.unit.dp
14import androidx.compose.foundation.clickable
15
16@Composable
17fun ModifierOrderAndSemantics() {
18 val interaction = remember { MutableInteractionSource() }
19
20 Column(Modifier.padding(16.dp)) {
21 BasicText(
22 text = "Tap area differs by modifier order",
23 modifier = Modifier
24 .semantics { contentDescription = "order-demo" }
25 .padding(24.dp)
26 .clickable(
27 interactionSource = interaction,
28 indication = null,
29 onClick = {}
30 )
31 )
32
33 BasicText(
34 text = "Tap only on text bounds",
35 modifier = Modifier
36 .semantics { contentDescription = "order-demo-2" }
37 .clickable(
38 interactionSource = interaction,
39 indication = null,
40 onClick = {}
41 )
42 .padding(24.dp)
43 )
44 }
45}
46
47@Preview(showBackground = true)
48@Composable
49private fun ModifierOrderAndSemanticsPreview() {
50 ModifierOrderAndSemantics()
51}첫 번째 텍스트는 패딩까지 클릭이 잡히고, 두 번째는 텍스트 본문 근처만 클릭이 잡히는 경우가 많다(폰트/밀도에 따라 체감 차이가 난다). hit-test는 Modifier 체인 순서에 민감하다. semantics는 접근성 트리에 별도 노드를 만들며, contentDescription이 TalkBack에서 읽히는 문자열로 쓰인다. interactionSource를 remember로 고정하지 않으면 pressed 상태가 매 recomposition마다 초기화되어 리플/눌림 상태가 ‘깜빡’이는 현상이 나온다.
실습하기
실습의 목표는 3가지를 눈으로 확인하는 것이다. 첫째, @Composable 호출이 다시 일어나도 UI가 유지되는 이유가 remember 때문이라는 점. 둘째, state를 어디서 읽느냐가 recomposition 범위를 결정한다는 점. 셋째, Modifier 순서가 입력/레이아웃/드로잉에 영향을 준다는 점.
1android {
2 buildFeatures {
3 compose true
4 }
5 composeOptions {
6 kotlinCompilerExtensionVersion = "1.5.15"
7 }
8}
9
10dependencies {
11 implementation(platform("androidx.compose:compose-bom:2024.10.00"))
12 implementation("androidx.compose.material3:material3")
13 implementation("androidx.activity:activity-compose:1.9.3")
14 debugImplementation("androidx.compose.ui:ui-tooling")
15}버전은 프로젝트에 맞게 조정한다. 중요한 건 BOM을 쓰면 material3, ui-tooling 같은 아티팩트 버전을 따로 맞추느라 시간을 덜 쓴다는 점이다. 처음에 나는 compiler extension 버전과 BOM 조합을 엇갈리게 맞춰서 빌드가 깨졌고, 에러 메시지가 “Compose Compiler and Kotlin versions are incompatible”로 나왔다. 그때 Gradle dependency insight로 어떤 버전이 끌려오는지 추적하는 데 3시간을 썼다.
1단계: 가장 기본 형태
Activity 하나로 시작한다. 실행하면 상단에 텍스트와 버튼이 보인다. 버튼을 눌러도 아직 숫자는 안 바뀐다. 이 단계는 “UI 선언은 함수 호출 순서로 기록된다”를 감각적으로 익히는 용도다.
여기서 확인할 건 Layout Inspector에서 composable 트리가 어떻게 생기는지다. Text와 Button이 Column 아래에 들어가며, XML의 View 계층과 비슷해 보이지만 생성/업데이트 모델이 다르다는 점을 다음 단계에서 체감한다.
1package com.example.composablebasics
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.padding
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.Modifier
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17class MainActivity : ComponentActivity() {
18 override fun onCreate(savedInstanceState: Bundle?) {
19 super.onCreate(savedInstanceState)
20 setContent {
21 MaterialTheme {
22 Surface {
23 Step1StaticUi()
24 }
25 }
26 }
27 }
28}
29
30@Composable
31fun Step1StaticUi() {
32 Column(Modifier.padding(16.dp)) {
33 Text("Static UI")
34 Button(onClick = { }) { Text("Click") }
35 }
36}
37
38@Preview(showBackground = true)
39@Composable
40private fun Step1StaticUiPreview() {
41 MaterialTheme { Surface { Step1StaticUi() } }
42}이 코드는 클릭해도 아무 변화가 없다. 변화가 없다는 사실이 중요하다. Compose는 클릭을 자동으로 화면 변화로 연결하지 않는다. 상태를 바꾸는 코드가 있어야 하고, 그 상태를 읽는 composable이 있어야 한다. 이 분리가 “이벤트는 이벤트, 화면은 상태”라는 구조를 강제한다.
2단계: 상태 연동
이 단계에서 버튼을 누르면 숫자가 증가한다. 실행하면 화면에 count=0이 보이고, 클릭할 때마다 1씩 오른다. Logcat을 열면 composable이 다시 호출되는 로그가 찍힌다.
여기서 눈여겨볼 지점은 “UI 업데이트 명령이 없다”는 점이다. invalidate, requestLayout 같은 호출이 없는데도 숫자가 바뀐다. mutableStateOf를 읽는 순간 Runtime이 관찰을 등록하고, 값이 바뀌면 해당 범위를 invalidation 한다.
1package com.example.composablebasics
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.ui.Modifier
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun Step2StateDrivenUi() {
19 var count by remember { mutableIntStateOf(0) }
20
21 Log.d("Step2", "recomposed count=$count")
22
23 Column(Modifier.padding(16.dp)) {
24 Text("count=$count")
25 Button(onClick = { count++ }) { Text("+1") }
26 }
27}
28
29@Preview(showBackground = true)
30@Composable
31private fun Step2StateDrivenUiPreview() {
32 Step2StateDrivenUi()
33}Logcat에서 recomposed가 클릭마다 찍힌다. 이 로그는 “그리는 함수가 다시 돈다”를 보여준다. 그럼에도 값이 유지되는 이유는 remember가 Slot Table의 같은 위치에서 state를 재사용하기 때문이다. View 시스템에서 onSaveInstanceState 같은 저장/복원이 떠오르지만, 여기서는 더 미세한 범위(호출 위치 단위)로 캐시가 붙는다.
3단계: 커스터마이징(Modifier/shape/colors)
이 단계는 외피와 내용이 분리된 설계가 체감되는 구간이다. 실행하면 둥근 모서리의 검은 버튼과, 패딩/클릭 영역이 다른 두 텍스트가 같이 보인다. 클릭 영역 차이를 손가락으로 확인 가능하다.
특히 Modifier 순서가 바뀌면 hit-test 영역이 바뀌고, clip이 background보다 뒤로 가면 모서리가 각져 보이는 등 시각적 결과가 달라진다. 이 차이는 Layout Inspector에서 Modifier 체인이 어떻게 붙는지로도 확인된다.
1package com.example.composablebasics
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.height
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.tooling.preview.Preview
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun Step3CustomizationScreen() {
16 Column(Modifier.padding(16.dp)) {
17 ButtonRealWorldUsage(onClick = {})
18 Spacer(Modifier.height(16.dp))
19 ModifierOrderAndSemantics()
20 }
21}
22
23@Preview(showBackground = true)
24@Composable
25private fun Step3CustomizationScreenPreview() {
26 MaterialTheme { Surface { Step3CustomizationScreen() } }
27}이 화면에서 ‘같은 clickable인데 왜 클릭 영역이 다르지?’라는 질문이 생기면 성공이다. 답은 Modifier 체인의 순서가 입력 처리 파이프라인을 바꾸기 때문이다. 선언형 UI는 결과만 선언하는 것처럼 보이지만, 실제로는 런타임이 해석할 수 있는 ‘구성’을 선언한다.
심화: Advanced 버전 만들기
실무에서는 Button 하나에도 요구사항이 붙는다. 로딩 중에는 중복 클릭을 막아야 하고, 네트워크 요청이 끝날 때까지 시각적으로 피드백이 있어야 한다. 빠르게 연타하면 debounce가 필요하고, 아이콘+텍스트 조합이 흔하며, 길게 누르기(long press) 같은 제스처가 들어가기도 한다. 접근성 라벨은 화면 텍스트와 다르게 지정해야 하는 경우가 많다.
사례 1: 로딩 + debounce + 접근성 라벨
로딩 버튼에서 가장 흔한 버그는 ‘로딩 상태가 바뀌었는데 버튼이 다시 활성화되지 않음’이다. 원인은 대개 state를 remember로 잡아두고 외부 파라미터(isLoading)를 무시하는 형태로 구현했기 때문이다. 외부 상태는 파라미터로 받고, 내부 상태(마지막 클릭 시간)만 remember로 잡아야 한다.
1package com.example.composablebasics
2
3import android.os.SystemClock
4import androidx.compose.foundation.background
5import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.width
11import androidx.compose.foundation.shape.RoundedCornerShape
12import androidx.compose.material3.CircularProgressIndicator
13import androidx.compose.material3.LocalContentColor
14import androidx.compose.material3.Text
15import androidx.compose.runtime.Composable
16import androidx.compose.runtime.getValue
17import androidx.compose.runtime.mutableLongStateOf
18import androidx.compose.runtime.remember
19import androidx.compose.runtime.setValue
20import androidx.compose.ui.Alignment
21import androidx.compose.ui.Modifier
22import androidx.compose.ui.draw.clip
23import androidx.compose.ui.graphics.Color
24import androidx.compose.ui.semantics.contentDescription
25import androidx.compose.ui.semantics.semantics
26import androidx.compose.ui.unit.dp
27
28@Composable
29fun AdvancedButton(
30 text: String,
31 modifier: Modifier = Modifier,
32 enabled: Boolean = true,
33 isLoading: Boolean = false,
34 debounceMs: Long = 600L,
35 accessibilityLabel: String? = null,
36 leadingIcon: (@Composable () -> Unit)? = null,
37 onLongClick: (() -> Unit)? = null,
38 onClick: () -> Unit
39) {
40 var lastClickUptime by remember { mutableLongStateOf(0L) }
41
42 val actuallyEnabled = enabled && !isLoading
43 val bg = if (actuallyEnabled) Color(0xFF111111) else Color(0xFF7A7A7A)
44
45 Row(
46 modifier = modifier
47 .clip(RoundedCornerShape(12.dp))
48 .background(bg)
49 .semantics {
50 if (accessibilityLabel != null) contentDescription = accessibilityLabel
51 }
52 .combinedClickable(
53 enabled = actuallyEnabled,
54 onLongClick = onLongClick,
55 onClick = {
56 val now = SystemClock.uptimeMillis()
57 if (now - lastClickUptime >= debounceMs) {
58 lastClickUptime = now
59 onClick()
60 }
61 }
62 )
63 .padding(horizontal = 14.dp, vertical = 10.dp),
64 verticalAlignment = Alignment.CenterVertically
65 ) {
66 if (isLoading) {
67 CircularProgressIndicator(
68 modifier = Modifier.size(16.dp),
69 strokeWidth = 2.dp,
70 color = LocalContentColor.current
71 )
72 Spacer(Modifier.width(10.dp))
73 } else if (leadingIcon != null) {
74 leadingIcon()
75 Spacer(Modifier.width(10.dp))
76 }
77 Text(text = text, color = Color.White)
78 }
79}이 구현을 실행하면 로딩 중에는 클릭이 막히고, 연타해도 debounceMs 내의 클릭이 무시된다. combinedClickable을 쓴 이유는 onLongClick을 같이 처리하기 위해서다. semantics로 contentDescription을 주입하면 TalkBack에서 텍스트 대신 라벨이 읽힌다. 여기서 remember는 lastClickUptime에만 쓰인다. isLoading을 remember로 감싸면 외부 상태 변화가 반영되지 않는 버그가 생긴다.
사례 2: 아이콘+텍스트 슬롯과 recomposition 비용
아이콘 슬롯을 함수로 받으면 유연하지만, 매 recomposition마다 람다 캡처가 바뀌면 스킵이 깨질 수 있다. 특히 leadingIcon이 외부 state를 캡처하면, 아이콘과 무관한 state 변경에도 버튼 content 그룹이 다시 실행되는 일이 생긴다. 의도적으로 분리된 composable로 빼면 추적 범위가 줄어든다.
1package com.example.composablebasics
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Icon
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Surface
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableIntStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.graphics.Color
16import androidx.compose.ui.tooling.preview.Preview
17import androidx.compose.ui.unit.dp
18import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.filled.Favorite
20
21@Composable
22fun AdvancedButtonUsageDemo() {
23 var clicks by remember { mutableIntStateOf(0) }
24
25 Column(Modifier.padding(16.dp)) {
26 Text("clicks=$clicks")
27 AdvancedButton(
28 text = "Like",
29 isLoading = false,
30 accessibilityLabel = "Like button",
31 leadingIcon = {
32 Icon(Icons.Filled.Favorite, contentDescription = null, tint = Color.Red)
33 },
34 onLongClick = { clicks += 10 },
35 onClick = { clicks++ }
36 )
37 }
38}
39
40@Preview(showBackground = true)
41@Composable
42private fun AdvancedButtonUsageDemoPreview() {
43 MaterialTheme { Surface { AdvancedButtonUsageDemo() } }
44}롱프레스를 하면 clicks가 10씩 오르고, 탭은 1씩 오른다. 여기서 leadingIcon은 외부 state를 캡처하지 않으므로 불필요한 invalidation을 덜 만든다. 만약 leadingIcon 내부에서 clicks를 읽으면, clicks 변경이 버튼 content 그룹까지 파급된다. 이런 전파는 Slot Table 관점에서 “state 읽기 위치가 observer 범위를 넓힌다”로 해석된다.
내가 이걸 잘못 써서 생긴 흑역사가 있다. 로딩 버튼을 만들면서 interactionSource를 remember 없이 매번 새로 만들었다. 증상은 ‘눌렀을 때 회색 눌림 상태가 1프레임만 보이고 사라짐’이었다. 디버깅은 Layout Inspector가 아니라 단순했다. pressed 상태를 collect해서 로그를 찍었더니, recomposition마다 interactionSource가 갈아끼워져 pressed 스트림이 끊겼다.
또 한 번은 debounce를 remember로 감싼 state와 섞어 구현했다가, 화면 회전 후에 클릭이 영원히 막히는 버그를 만들었다. 원인은 lastClickUptime을 saveable로 저장해두고, uptime 기준(부팅 이후 시간)을 복원해버린 것이었다. 복원된 값이 현재 uptime보다 커져서 now - lastClickUptime이 음수가 되었고, debounce 조건이 영원히 false가 됐다. 시간 기준은 wall clock/uptime/elapsed를 섞지 않는 게 핵심이고, 저장할 값의 의미를 먼저 정의해야 한다.
자주 하는 실수
1) remember를 조건문 안에서 호출
증상: 특정 조건에서만 UI가 깨지거나, 값이 엉뚱한 컴포넌트에 붙는 것처럼 보인다. 어떤 날은 잘 되다가 조건 토글 한 번에 state가 뒤섞인다.
원인: remember는 ‘호출 위치’에 값을 바인딩한다. 조건에 따라 호출 횟수/순서가 바뀌면 Slot Table에서 같은 인덱스가 다른 값에 매칭된다.
해결: remember는 항상 동일한 호출 경로에서 실행되게 둔다. 조건에 따라 값이 달라져야 하면 remember(key)로 키를 주거나, 조건 밖에서 remember로 만든 뒤 조건에 따라 사용만 분기한다.
2) state를 읽는 위치를 무심코 넓힘
증상: 리스트 아이템 하나의 토글을 바꿨는데 리스트 전체가 recomposition 되는 것처럼 보인다. 스크롤 중에 프레임 드랍이 생긴다.
원인: state를 상위 composable에서 읽고 하위로 값만 내려도 되는데, 상위에서 읽어버리면 상위 그룹이 observer가 된다. invalidation 범위가 커지고, 더 많은 그룹이 다시 실행된다.
해결: state 읽기를 가능한 한 실제로 필요한 하위에 둔다. derivedStateOf로 계산 값을 캐시하거나, 이벤트는 상위로 끌어올리되 읽기는 하위에 두는 구조로 바꾼다.
3) 매 recomposition마다 새 객체를 만들어 파라미터로 전달
증상: UI 변경이 없는데도 recomposition 카운터가 계속 오른다. 특히 Modifier나 colors 같은 정책 객체를 매번 새로 만들면 스킵이 잘 안 된다.
원인: 파라미터 비교에서 참조가 매번 달라지면 changed 플래그가 ‘변경됨’으로 찍힌다. 값이 같아 보여도 equals가 없거나 비용이 커서 안정성 추론이 깨진다.
해결: 정책 객체는 remember로 고정하거나, stable한 타입을 사용한다. Modifier는 체인 자체가 값 객체라서 조합을 반복 생성해도 괜찮은 경우가 많지만, 캡처된 람다/커스텀 클래스는 특히 주의한다.
4) Modifier 순서로 클릭 영역/레이아웃을 망침
증상: 버튼이 커 보이는데 클릭이 안 된다. 또는 패딩 영역까지 클릭이 잡혀 의도치 않은 터치가 발생한다.
원인: Modifier는 선언 순서대로 체인에 쌓이고, pointer input/hit-test와 layout padding이 적용되는 위치가 달라진다. clickable을 padding 앞에 둘지 뒤에 둘지가 클릭 영역을 바꾼다.
해결: 클릭 영역을 먼저 정의한다. “터치 영역은 패딩 포함”이면 padding 후 clickable, “콘텐츠만 클릭”이면 clickable 후 padding 같은 식으로 의도를 코드 순서로 표현한다.
5) @Stable/@Immutable을 ‘성능 스위치’처럼 남발
증상: 값이 바뀌었는데 UI가 갱신되지 않는다. 디버깅하면 state는 변했는데 recomposition이 안 잡히거나, 스킵이 과하게 일어난다.
원인: 안정성 어노테이션은 약속이다. 내부가 바뀌는데도 @Immutable을 붙이면 Compose는 ‘안 바뀐다’고 가정하고 스킵할 수 있다. 그 결과 UI가 stale해진다.
해결: 불변 데이터 클래스에만 @Immutable을 고려한다. 가변 필드가 있으면 @Stable이어도 “변경을 Compose가 관찰 가능하게” 만들어야 한다(예: mutableStateOf로 노출). 애매하면 어노테이션 없이 프로파일링으로 병목을 확인한다.
1package com.example.composablebasics
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.remember
6
7@Composable
8fun BadRememberInIf(show: Boolean) {
9 if (show) {
10 val state = remember { mutableStateOf(0) }
11 // show가 false로 바뀌었다가 true로 돌아오면
12 // 호출 위치가 흔들리며 다른 remember와 엮일 수 있다.
13 state.value++
14 }
15}이 코드는 컴파일은 되지만, 구조가 커지면 슬롯 매칭 문제가 터진다. “조건부 remember 금지”는 스타일 규칙이 아니라 Slot Table의 위치 기반 저장 모델에서 나온 제약이다.
성능 최적화 체크리스트
- Layout Inspector에서 Recomposition Count를 켜고, 클릭 10회 동안 어떤 노드가 증가하는지 기록한다
- state를 읽는 composable 범위를 최소화했는지 확인한다(상위에서 읽고 하위로 내려보내는 습관 점검)
- remember가 조건문/반복문 내부에서 호출되지 않는지 검색한다(특히 if/when/for)
- 람다 파라미터가 외부 state를 과도하게 캡처하지 않는지 확인한다(아이템 단위 캡처는 특히 위험)
- 정책 객체(colors, shapes, formatter 등)를 매 recomposition마다 생성하지 않는지 확인한다(필요하면 remember로 고정)
- Modifier 순서가 의도한 hit-test/패딩/클립 순서인지 손가락으로 검증한다(패딩 포함 클릭인지 여부)
- 리스트에서 key를 안정적으로 제공하는지 확인한다(LazyColumn items에 key 사용, 아이템 id 기반)
- derivedStateOf를 남발하지 않고, 계산 비용이 큰 값에만 적용했는지 확인한다(계산 횟수 로그로 검증)
- SideEffect/LaunchedEffect가 매 recomposition마다 재실행되지 않도록 key를 의도적으로 설계했는지 확인한다
- InteractionSource를 공유/관찰해야 하는 컴포넌트에서 remember로 고정했는지 확인한다(pressed 깜빡임 방지)
- @Stable/@Immutable을 붙인 타입이 실제로 약속을 지키는지 코드 리뷰 항목으로 넣는다(가변 필드 존재 여부)
- Android Studio Profiler에서 클릭 100회 동안 Allocation을 보고, 매 클릭마다 객체가 폭증하는 지점이 있는지 찾는다
자주 묻는 질문
@Composable은 왜 Unit을 리턴하나? UI 트리를 리턴하면 더 직관적이지 않나?
Compose의 핵심은 “리턴값으로 트리를 조립”이 아니라 “Composer에 기록을 남기는 실행”이다. @Composable 호출은 런타임이 관리하는 Slot Table에 그룹을 열고 닫으며 노드 생성/업데이트 정보를 쌓는다. 리턴으로 트리를 만들면 매번 새 트리를 만들고 diff를 해야 하는데, Compose는 위치 기반 슬롯과 changed 플래그로 스킵/재사용을 한다. 학습 키워드는 Composer, Slot Table, restartable group, changed flags이며, 소스 레벨에서는 Compose Compiler가 composer 파라미터를 추가하는 변환을 떠올리면 이해가 된다.
remember가 없으면 왜 state가 유지되지 않나? mutableStateOf는 이미 객체인데도?
mutableStateOf 자체는 객체지만, 그 객체를 어디에 저장하느냐가 문제다. remember가 없으면 recomposition 때마다 함수 본문이 다시 실행되고 mutableStateOf(0)도 다시 생성된다. 그러면 이전 객체는 더 이상 참조되지 않아 버려지고, 값은 초기값으로 되돌아간다. remember는 Slot Table의 ‘호출 위치’에 값을 저장해 다음 recomposition에서 같은 위치에서 꺼내준다. 학습 키워드는 slot, remember scope, call position이며, 실전에서는 “조건문 안 remember 금지” 규칙이 이 모델에서 나온다.
Recomposition이 일어나면 화면이 ‘다시 그려지는’ 건가? 성능이 걱정된다
Recomposition은 ‘@Composable을 다시 호출해 변경점을 반영’하는 단계이고, 항상 layout/draw까지 이어지지는 않는다. 텍스트 문자열만 바뀌면 measure 결과가 같을 수 있고, draw만 다시 될 수도 있다. 반대로 크기가 바뀌면 layout까지 영향을 준다. 성능 판단은 감이 아니라 측정이다. Layout Inspector의 recomposition count로 범위를 보고, Android Studio Profiler의 Allocation/CPU로 클릭 100회 같은 반복 입력에서 비용을 본다. 학습 키워드는 invalidation, measure/layout/draw separation, skipping이며, “state 읽기 위치”가 범위를 키우는 주요 원인이다.
Slot Table에는 정확히 무엇이 저장되나? View 트리 같은 걸 통째로 저장하나?
Slot Table은 ‘호출 위치 기반의 그룹 구조’와 ‘remember 값’, 그리고 노드 업데이트에 필요한 메타데이터를 저장하는 런타임 자료구조다. View 트리처럼 완성된 객체 그래프를 통째로 저장한다기보다, “이 위치에서 어떤 composable이 호출됐고, 그 내부에서 어떤 노드가 생성됐는지”를 재연결할 수 있는 형태로 저장한다. 그래서 호출 순서가 바뀌면 슬롯 매칭이 깨진다. 학습 키워드는 group, slot, applier, node이며, 실제로 UI 노드 생성은 applier를 통해 이루어진다.
왜 Modifier는 체이닝 방식인가? 파라미터로 한 번에 받으면 더 깔끔하지 않나?
Modifier는 단순 옵션 모음이 아니라 처리 순서가 있는 파이프라인이다. padding 뒤 clickable과 clickable 뒤 padding은 hit-test 영역이 달라지고, clip과 background 순서는 시각적 결과가 달라진다. 체이닝은 이 순서를 코드로 표현하게 만든다. 또한 Modifier는 여러 레이어(layout, draw, semantics, pointer input)를 한 줄로 합성할 수 있어 컴포넌트 외피를 재사용하기 쉽다. 학습 키워드는 Modifier chain, node, order, semantics이며, 실전에서는 클릭 영역/접근성 라벨이 의도대로 나오는지 손가락과 TalkBack으로 검증한다.
@Stable/@Immutable은 언제 필요하나? 붙이면 무조건 빨라지나?
이 어노테이션은 ‘스킵 판단을 위한 약속’이다. 무조건 빨라지는 스위치가 아니다. @Immutable은 내부가 절대 바뀌지 않는 데이터에만 맞고, @Stable은 외부에서 관찰 가능한 변경이 있으면 Compose가 감지할 수 있다는 조건이 필요하다(예: mutableStateOf로 노출). 약속을 어기면 UI가 갱신되지 않는 버그가 나온다. 먼저 프로파일링으로 병목을 확인하고, 타입이 실제로 불변인지, 변경이 snapshot state로 관찰 가능한지 점검한 뒤에 적용한다. 학습 키워드는 stability inference, skipping, snapshot state다.
Layout Inspector에서 recomposition count가 오르는데 UI는 안 바뀐다. 뭐가 다시 실행된 건가?
recomposition count는 대개 ‘재구성 시도’와 연결되어 보이지만, 내부에서 스킵이 일어나면 본문 실행이 줄어들 수 있다. 또한 recomposition은 UI 변경이 없어도 발생할 수 있다. 예를 들어 상위에서 state를 읽고 하위로 내려보내면 상위가 invalidation 되어 다시 호출된다. 하지만 changed 플래그와 안정성 추론이 만족되면 하위는 스킵될 수 있다. 확인 방법은 Logcat으로 특정 composable 본문에 로그를 넣고, 클릭/스크롤 시 어떤 로그가 실제로 찍히는지 보는 것이다. 학습 키워드는 skipping, changed flags, invalidation scope다.