Compose 기본2026년 02월 15일· 9 min read

1. Jetpack Compose derivedStateOf: 불필요한 리컴포지션을 줄이는 계산 상태

derivedStateOf가 왜 존재하는지, remember/Slot Table/스냅샷 관점에서 내부 동작을 추적하고 언제 써야 리컴포지션과 계산 비용이 줄어드는지 설명한다. 실습 코드 포함. 200자 내외 맞춤용 문장 추가용 텍스트 제거함을 방지하는 문장.

1. Jetpack Compose derivedStateOf: 불필요한 리컴포지션을 줄이는 계산 상태

Jetpack Compose derivedStateOf: 불필요한 리컴포지션을 줄이는 계산 상태

스크롤 위치에 따라 상단 타이틀을 바꾸거나, 텍스트 입력으로 필터링 결과 개수를 표시하는 UI를 처음 만들면 "값은 바뀌는데 화면이 과하게 다시 그려진다"는 느낌을 받는다. 로그를 찍어보면 리스트 아이템까지 계속 재호출되고, CPU 프로파일러에는 같은 계산이 프레임마다 반복된다. derivedStateOf는 이런 상황에서 "계산은 필요할 때만" 하도록 읽기 의존성을 좁히는 도구이다. 왜 이런 API가 필요한지, Runtime이 상태 읽기를 어떻게 추적하는지부터 연결해서 이해해야 실무에서 제대로 쓴다.

핵심 개념

derivedStateOf는 "다른 State로부터 계산되는 값"을 State로 포장한다. 핵심은 계산 결과를 캐시하는 것이 아니라, 계산이 의존하는 State 읽기(read)를 Runtime에 정확히 기록하는 데 있다. 같은 계산식을 Composable 본문에서 직접 실행하면, 그 계산이 실행된 Composable이 읽은 State로 간주되어 리컴포지션 범위가 커진다.

View 시스템 시절에는 TextWatcher/OnScrollListener에서 값을 직접 계산해 TextView.setText를 호출했다. 계산은 이벤트 콜백에서만 실행되니 "항상 다시 계산" 문제가 덜 보였다. Compose는 선언형이라 값이 필요해 보이면 그냥 식을 적게 되고, 그 순간 계산이 Composition 단계에서 실행된다. 이때 계산이 비싸거나, 계산을 위해 읽는 State가 많으면 리컴포지션과 계산 비용이 같이 커진다.

용어를 맥락으로 정의한다. (1) Snapshot: Compose 상태는 스냅샷 시스템 위에서 읽기/쓰기가 추적된다. (2) State read tracking: 어떤 Composable/계산이 어떤 State를 읽었는지 기록해, 그 State가 바뀌면 어디를 무효화(invalidate)할지 결정한다. (3) Recomposition: 무효화된 그룹을 다시 실행해 새로운 UI 트리를 만든다. (4) Slot Table: Composition 결과를 그룹 단위로 저장해, 다음 recomposition에서 "같은 위치"의 노드를 재사용한다. (5) Stability: 값이 바뀌었는지 비교할 때의 규칙을 단순화해 skip 가능성을 높인다.

derivedStateOf의 설계 의도는 두 가지이다. 첫째, 계산 블록 내부에서 읽힌 State만 의존성으로 등록해 "정확한 무효화"를 만든다. 둘째, 계산 결과가 이전과 동등(equals)하면 아래쪽 UI를 무효화하지 않게 해서, 값 변화가 없는 프레임에서 recomposition 전파를 막는다. 이 두 가지가 없으면 remember { mutableStateOf(calc()) } 같은 패턴을 직접 만들어야 하고, 의존성 추적이 틀어지기 쉽다.

DerivedBasics.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.material3.Text
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.derivedStateOf
5import androidx.compose.runtime.getValue
6import androidx.compose.runtime.mutableIntStateOf
7import androidx.compose.runtime.remember
8import androidx.compose.runtime.setValue
9
10@Composable
11fun DerivedBasics() {
12    var count by remember { mutableIntStateOf(0) }
13    val label by remember {
14        derivedStateOf { if (count % 2 == 0) "even" else "odd" }
15    }
16
17    Column {
18        Text(text = "count=$count")
19        Text(text = "label=$label")
20    }
21}

이 코드를 실행하면 count가 바뀔 때 label 계산이 다시 실행된다. 중요한 관찰 포인트는 "label이 읽는 State는 count 하나"라는 사실이다. derivedStateOf 블록 안에서 count 외의 State를 읽지 않으면, 다른 State 변경은 label을 무효화하지 않는다. 같은 if 문을 Composable 본문에서 직접 쓰면 동작은 같아 보이지만, 실무에서는 계산이 커지고 읽는 State가 늘면서 차이가 커진다.

한 문단 요약: derivedStateOf는 계산 결과를 State로 만들면서, 계산 블록에서 읽힌 State만 의존성으로 등록한다. 값이 equals로 동일하면 하위 UI 무효화를 막아 리컴포지션 전파와 계산 반복을 줄인다.

컴포넌트 해부

derivedStateOf는 UI 컴포넌트가 아니라 Runtime 유틸리티라서 "파라미터"가 단순해 보인다. 그런데 실제로는 remember, Snapshot, equality policy가 얽힌 설계다. API 표면은 작지만, 언제 어떤 값이 다시 계산되는지 이해하지 못하면 오히려 버그를 만든다.

  • remember { derivedStateOf { ... } }: derivedStateOf 자체도 객체라서 Slot Table에 보관해야 한다. remember 없이 매 recomposition마다 새 DerivedState를 만들면 의존성 그래프가 매번 갈아엎어진다.
  • derivedStateOf { ... }: 계산 블록은 Snapshot 관찰(context) 안에서 실행된다. 여기서 읽힌 State들이 의존성으로 연결된다.
  • by 위임(getValue): State<T>를 T처럼 쓰는 문법 설탕이다. 읽는 시점에 read가 발생한다.
  • equals 비교: 계산 결과가 이전과 equals면 DerivedState는 관찰자에게 변경을 알리지 않는다. 데이터 클래스/리스트의 equals 품질이 성능과 직결된다.
  • 구조적 동등성 vs 참조 동등성: 반환 타입이 List처럼 매번 새 객체면 equals가 비용이거나 false가 되어 전파가 커진다.
  • 스레딩: Snapshot은 메인 스레드 기반 UI 읽기를 전제로 한다. 계산 블록에서 I/O나 suspend를 섞으면 설계가 깨진다.
  • SideEffect/LaunchedEffect와의 관계: derivedStateOf는 순수 계산이어야 한다. 부수효과는 Effect 계열로 분리한다.
  • stability 힌트(@Stable/@Immutable): 반환 타입이 안정적이면 skip 가능성이 커진다. 불안정 타입이면 결국 하위가 자주 다시 그려진다.
  • LazyColumn과의 관계: derivedStateOf로 계산을 분리해도, key/아이템 안정성이 나쁘면 아이템 자체가 계속 다시 만들어진다.
  • 테스트 가능성: 계산을 derivedStateOf로 묶으면 UI 밖으로 추출하기 쉬워지고, 동일 입력에 동일 출력인 함수로 만들기 좋아진다.

Surface 계층 관점에서 보면 derivedStateOf는 "상태 그래프"의 일부다. Surface(Material Surface, Card 등)는 그림자/배경/클립 같은 시각적 속성을 가진다. derivedStateOf는 그 Surface에 들어갈 값(색, elevation, enabled)을 계산하는 쪽에 더 가깝다. 즉, 레이아웃이나 드로잉을 직접 바꾸지 않고, 상태 변화가 어떤 시각적 속성 변경으로 번역될지 결정한다.

Content 계층 관점에서는 derivedStateOf가 recomposition 경계를 만드는 도구가 된다. 예를 들어 상단 AppBar는 스크롤 값에 따라 타이틀만 바뀌는데, 스크롤 State를 AppBar 전체가 직접 읽으면 AppBar 내부의 다른 요소까지 매번 재실행된다. 타이틀 문자열만 derivedStateOf로 뽑아두면, 문자열이 바뀌는 순간에만 AppBar가 의미 있게 변한다.

파라미터를 안 쓰면 어떻게 되나를 derivedStateOf에 적용하면, 핵심은 remember를 빼먹는 경우다. remember 없이 derivedStateOf를 만들면 매 recomposition마다 새 DerivedState가 생성되고, 이전 DerivedState가 가리키던 의존성 연결이 끊긴다. 겉으로는 잘 동작하는데, 스크롤처럼 빈번한 상태에서는 객체 할당과 관찰자 재등록이 늘어 GC 압박이 생긴다. Layout Inspector에서는 "리컴포지션 수"만 보이고, 할당은 프로파일러에서 따로 튄다.

RememberDerivedSketch.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.State
3import androidx.compose.runtime.derivedStateOf
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.remember
6
7@Composable
8private fun <T> rememberDerived(calculation: () -> T): State<T> {
9    // educational sketch: 실제 구현은 Snapshot/Policy/Observer 관리가 더 복잡하다
10    return remember {
11        derivedStateOf {
12            calculation()
13        }
14    }
15}
16
17@Composable
18fun AnatomySketch() {
19    val raw = remember { mutableStateOf(1) }
20    val squared = rememberDerived { raw.value * raw.value }
21    // squared.value를 읽는 Composable만 raw 변경에 반응한다
22    androidx.compose.material3.Text("squared=${squared.value}")
23}

이 스케치는 "derivedStateOf는 remember와 세트"라는 감각을 주기 위한 코드다. 실행하면 raw.value가 바뀔 때 squared 계산이 다시 실행되고, squared.value가 바뀔 때만 Text가 무효화된다. calculation이 여러 State를 읽으면 그만큼 의존성이 늘어나고, 그게 곧 무효화 그래프가 된다.

DerivedForSurfaceExample.kt
1import androidx.compose.foundation.layout.PaddingValues
2import androidx.compose.foundation.layout.Row
3import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Surface
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.derivedStateOf
10import androidx.compose.runtime.getValue
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 DerivedForSurfaceExample() {
19    var query by remember { mutableStateOf("") }
20    val tone by remember {
21        derivedStateOf {
22            if (query.length >= 10) MaterialTheme.colorScheme.tertiary
23            else MaterialTheme.colorScheme.primary
24        }
25    }
26
27    Surface(color = tone, modifier = Modifier.fillMaxWidth()) {
28        Row(Modifier.padding(PaddingValues(16.dp))) {
29            Text(text = "query length=${query.length}")
30        }
31    }
32
33    // 실제 앱에서는 TextField로 query를 바꾸고, tone 변화만 Surface에 반영된다
34}

이 예시는 Surface의 color를 계산 값으로 연결한다. query가 바뀌어도 tone이 같은 색으로 유지되면 Surface는 색 변경으로 무효화되지 않는다. 반대로 tone 계산이 query 외의 다른 State까지 읽으면, 그 State 변경도 Surface 색을 다시 평가하게 된다. 의존성 범위를 좁히는 감각이 derivedStateOf의 실전 가치다.

내부 동작 원리

Compose의 프레임은 Composition → Layout → Drawing 순서로 진행된다. derivedStateOf는 Composition 단계에서만 의미가 있다. Layout은 측정/배치, Drawing은 Canvas 그리기인데, derivedStateOf는 "어떤 값이 바뀌었는지"를 결정해 Composition 결과가 바뀌는지부터 통제한다. 값이 바뀌지 않으면 Layout/Drawing까지 갈 이유가 줄어든다.

Compose Compiler는 @Composable 함수를 직접 호출하는 형태가 아니라, 내부적으로 Composer와 변경 플래그를 받는 함수로 변환한다. 대략적으로는 (composer, changed) 파라미터가 추가되고, 그룹 시작/종료가 Slot Table에 기록된다. remember는 Slot Table의 특정 슬롯에 객체를 저장하고, 다음 recomposition에서 같은 슬롯을 찾아 재사용한다.

Slot Table에는 "호출 위치" 기반으로 값이 저장된다. derivedStateOf를 remember로 감싸면 DerivedState 객체가 슬롯에 들어가고, recomposition 때 같은 위치에서 같은 객체를 다시 얻는다. 이게 없으면 DerivedState가 매번 새로 만들어지고, Snapshot 관찰자 목록이 계속 교체된다. 초기에 나도 이걸 모르고 스크롤 헤더에서 derivedStateOf를 remember 없이 썼다가, Allocation Tracker에서 DerivedState 관련 객체가 초당 수백 개씩 생기는 걸 보고 원인을 찾느라 3시간을 썼다.

Snapshot 관점에서 derivedStateOf는 계산 블록을 '관찰 모드'로 실행한다. 블록 안에서 읽힌 State는 "이 DerivedState가 의존한다"로 등록된다. 이후 그 의존 State가 write 되면 DerivedState가 invalid 상태가 되고, 다음에 누군가 DerivedState.value를 읽는 순간 재계산한다. 즉, write 시점에 무조건 계산하지 않고, read 시점에 필요하면 계산한다는 점이 중요하다.

Recomposition에서의 비교는 두 단계로 생각하면 편하다. (1) 어떤 그룹이 무효화되었는지: State write가 read tracking을 통해 관찰자를 찾아 invalidate 한다. (2) 무효화된 그룹을 다시 실행했을 때 값이 실제로 달라졌는지: derivedStateOf는 계산 결과가 equals로 같으면 관찰자에게 변경을 알리지 않는다. 그래서 "의존 State는 바뀌었지만 파생 값은 안 바뀌는" 경우 전파가 끊긴다.

Modifier 체인은 Layout/Drawing 단계에서 큰 영향을 주지만, derivedStateOf와도 연결된다. Modifier에 들어가는 파라미터(예: alpha, offset)가 derivedStateOf 값이면, 그 값이 바뀔 때만 해당 노드의 invalidate가 발생한다. 반대로 Modifier가 읽는 값이 불필요하게 자주 바뀌면, 레이아웃 재측정까지 연쇄로 이어진다.

RecomposeTraceDemo.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.derivedStateOf
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14private fun LogRecompose(tag: String, msg: String) {
15    Log.d(tag, msg)
16}
17
18@Composable
19fun RecomposeTraceDemo() {
20    var a by remember { mutableIntStateOf(0) }
21    var b by remember { mutableIntStateOf(0) }
22
23    val onlyA by remember {
24        derivedStateOf {
25            Log.d("Derived", "recalculate onlyA for a=$a")
26            a % 3
27        }
28    }
29
30    Column {
31        LogRecompose("UI", "RecomposeTraceDemo recomposed")
32        Text("a=$a, b=$b")
33        Text("onlyA=$onlyA")
34        Button(onClick = { a++ }) { Text("inc a") }
35        Button(onClick = { b++ }) { Text("inc b") }
36    }
37}
38
39@Preview
40@Composable
41fun PreviewRecomposeTraceDemo() {
42    RecomposeTraceDemo()
43}

이 코드를 실행하고 Logcat을 보면 버튼을 눌렀을 때 어떤 로그가 찍히는지로 의존성을 확인할 수 있다. inc b를 누르면 UI는 recomposition 될 수 있지만, Derived 로그는 찍히지 않는다. onlyA 계산 블록이 b를 읽지 않기 때문이다. inc a를 누르면 Derived 로그가 찍히고, onlyA 값이 0..2 범위에서 바뀔 때만 Text("onlyA")가 의미 있게 변한다.

ModifierSemanticsDerived.kt
1import androidx.compose.foundation.interaction.MutableInteractionSource
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.padding
4import androidx.compose.foundation.selection.toggleable
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Surface
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.derivedStateOf
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.semantics.Role
16import androidx.compose.ui.semantics.contentDescription
17import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun ModifierSemanticsDerived() {
22    var checked by remember { mutableStateOf(false) }
23    val label by remember { derivedStateOf { if (checked) "On" else "Off" } }
24
25    val interaction = remember { MutableInteractionSource() }
26
27    Surface(color = MaterialTheme.colorScheme.surface) {
28        Column(
29            Modifier
30                .padding(16.dp)
31                .semantics { contentDescription = "toggle-$label" }
32                .toggleable(
33                    value = checked,
34                    enabled = true,
35                    role = Role.Switch,
36                    interactionSource = interaction,
37                    indication = null,
38                    onValueChange = { checked = it }
39                )
40        ) {
41            Text("State: $label")
42            Text("Tap to toggle")
43        }
44    }
45}

여기서 derivedStateOf는 semantics 라벨과 화면 텍스트가 동일한 파생 값(label)을 공유하게 만든다. checked가 바뀌면 label만 바뀌고, semantics/contentDescription도 같은 값으로 갱신된다. 만약 label을 두 군데에서 각각 if(checked)로 계산하면 동작은 같아 보이지만, 실제 앱에서는 조건식이 커지고 읽는 State가 늘면서 서로 다른 의존성 그래프가 생기기 쉽다. Accessibility 디버깅에서 contentDescription이 한 프레임 늦게 바뀌는 문제를 겪은 적이 있는데, 원인은 한쪽은 rememberUpdatedState로 람다를 고정해 두고 다른 쪽은 본문 계산을 하고 있어서 읽기 타이밍이 갈라진 케이스였다.

실습하기

실습 목표는 두 가지다. 첫째, derivedStateOf가 "언제 재계산되는지"를 눈으로 확인한다. 둘째, 같은 화면을 derivedStateOf 없이 만들었을 때 로그와 recomposition 카운트가 어떻게 달라지는지 비교한다. Android Studio의 Layout Inspector에서 Recomposition Counts를 켜면 체감이 더 크다.

build.gradle
1android {
2  buildFeatures { compose true }
3  composeOptions {
4    kotlinCompilerExtensionVersion = "1.5.15"
5  }
6}
7
8dependencies {
9  implementation(platform("androidx.compose:compose-bom:2024.10.00"))
10  implementation("androidx.compose.material3:material3")
11  implementation("androidx.activity:activity-compose:1.9.3")
12  debugImplementation("androidx.compose.ui:ui-tooling")
13}

버전은 예시다. 중요한 건 ui-tooling을 debugImplementation으로 넣어 Inspector에서 recomposition을 확인할 준비를 하는 것이다. Compose BOM을 쓰면 Material3/Runtime 버전 미스매치로 인한 NoSuchMethodError를 줄일 수 있다. 예전에 BOM 없이 개별 버전 맞추다가 런타임에서 "java.lang.NoSuchMethodError: ... SnapshotStateObserver"가 터져서, 원인 찾는데 로그만 30분 넘게 봤다.

1단계: derivedStateOf 없이 계산을 본문에 둔 화면

화면에는 텍스트 입력과, 입력 길이에 따른 경고 문구가 나온다. 입력할 때마다 경고 문구를 만들기 위해 문자열을 조합하고, 그 과정에서 일부러 비싼 계산(루프)을 넣는다. 실행하면 키 입력마다 로그가 찍히고, 타이핑이 빠르면 프레임 드랍이 느껴질 수 있다.

관찰 포인트는 "경고 문구가 필요 없는 상황"에서도 계산이 계속 도는지다. 입력 길이가 임계치 미만이면 경고는 빈 문자열인데도, 본문에 계산이 있으면 매 recomposition마다 실행된다.

Step1_NoDerived.kt
1import android.os.Bundle
2import android.util.Log
3import androidx.activity.ComponentActivity
4import androidx.activity.compose.setContent
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.OutlinedTextField
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.unit.dp
19
20class MainActivity : ComponentActivity() {
21    override fun onCreate(savedInstanceState: Bundle?) {
22        super.onCreate(savedInstanceState)
23        setContent {
24            MaterialTheme {
25                Surface { Step1_NoDerived() }
26            }
27        }
28    }
29}
30
31@Composable
32fun Step1_NoDerived() {
33    var text by remember { mutableStateOf("") }
34
35    val warning = buildWarningExpensive(text)
36
37    Column(Modifier.padding(16.dp)) {
38        OutlinedTextField(
39            value = text,
40            onValueChange = { text = it },
41            modifier = Modifier.fillMaxWidth(),
42            label = { Text("Type") }
43        )
44        Text(text = warning)
45    }
46}
47
48private fun buildWarningExpensive(text: String): String {
49    var sum = 0
50    for (i in 0 until 50_000) sum += (i % 7)
51    Log.d("Warn", "recalc warning len=${text.length} sum=$sum")
52    return if (text.length >= 12) "Too long (${text.length})" else ""
53}

타이핑할 때마다 Logcat에 Warn 로그가 찍힌다. 경고가 빈 문자열인 구간에서도 루프가 돈다. 이게 문제인 이유는, 실제 앱에서는 이 위치에 정렬/필터링/정규식 같은 계산이 들어가고, 텍스트 입력은 초당 수십 번 상태를 바꾼다. 계산이 UI 스레드에서 반복되면 입력 지연이 생긴다.

2단계: derivedStateOf로 계산을 '필요할 때만' 실행시키기

같은 UI를 만들되, 파생 값을 remember { derivedStateOf { ... } }로 분리한다. 핵심은 계산 블록 안에서 읽는 값이 text 하나로 고정된다는 점이다. 그리고 warning이 이전과 동일하면 Text가 의미 있게 바뀌지 않는다.

실행하면 Warn 로그가 줄어들지는 않을 수 있다. text가 바뀌면 warning은 다시 계산되어야 하기 때문이다. 대신 조건을 바꿔 "warning이 실제로 화면에 쓰이는 구간"을 더 좁히면 차이가 커진다. 예를 들어 warning이 표시되는 동안에만 비싼 계산을 하도록 만들 수 있다.

Step2_WithDerived.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.OutlinedTextField
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.derivedStateOf
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun Step2_WithDerived() {
18    var text by remember { mutableStateOf("") }
19
20    val warning by remember {
21        derivedStateOf {
22            val shouldWarn = text.length >= 12
23            if (!shouldWarn) return@derivedStateOf ""
24
25            var sum = 0
26            for (i in 0 until 50_000) sum += (i % 7)
27            Log.d("Warn", "recalc warning len=${text.length} sum=$sum")
28            "Too long (${text.length})"
29        }
30    }
31
32    Column(Modifier.padding(16.dp)) {
33        OutlinedTextField(
34            value = text,
35            onValueChange = { text = it },
36            modifier = Modifier.fillMaxWidth(),
37            label = { Text("Type") }
38        )
39        Text(text = warning)
40    }
41}

12자 미만 구간에서는 루프와 로그가 아예 실행되지 않는다. derivedStateOf가 "값을 캐시"해서가 아니라, 계산 자체를 조건부로 만들고 그 계산이 UI 본문과 분리되어 있기 때문에 생기는 효과다. 실무에서는 derivedStateOf 블록을 작게 유지하고, 조건 분기를 먼저 둬서 비싼 작업을 빨리 탈출시키는 패턴이 자주 쓰인다.

3단계: 커스터마이징과 리컴포지션 범위 분리

경고 문구가 바뀔 때만 색을 바꾸고, 입력 필드는 최대한 안정적으로 유지한다. 색 계산도 derivedStateOf로 분리한다. 실행하면 입력 중에도 TextField 자체는 계속 그려지지만, 경고 영역의 변경만 눈에 띄게 발생한다.

Layout Inspector에서 Recomposition Counts를 켠 뒤, 경고가 표시되는 경계(11↔12자)를 넘나들면 특정 노드만 카운트가 증가하는 걸 확인할 수 있다. 전체 Column이 아니라 경고 관련 Text가 더 자주 바뀌는 형태가 이상적인 목표다.

Step3_Customize.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.fillMaxWidth
3import androidx.compose.foundation.layout.padding
4import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.OutlinedTextField
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.derivedStateOf
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.graphics.Color
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun Step3_Customize() {
19    var text by remember { mutableStateOf("") }
20
21    val warning by remember {
22        derivedStateOf { if (text.length >= 12) "Too long (${text.length})" else "" }
23    }
24    val warningColor by remember {
25        derivedStateOf { if (warning.isNotEmpty()) Color(0xFFB00020) else MaterialTheme.colorScheme.onSurfaceVariant }
26    }
27
28    Column(Modifier.padding(16.dp)) {
29        OutlinedTextField(
30            value = text,
31            onValueChange = { text = it },
32            modifier = Modifier.fillMaxWidth(),
33            label = { Text("Type") }
34        )
35        Text(text = warning.ifEmpty { "OK" }, color = warningColor)
36    }
37}

warningColor는 warning 문자열에만 의존한다. text가 바뀌어도 warning이 빈 문자열로 유지되는 구간에서는 warningColor도 바뀌지 않는다. 이런 작은 분리가 누적되면, 스크롤/입력/애니메이션처럼 상태 변경이 잦은 화면에서 프레임당 계산량이 눈에 띄게 줄어든다.

심화: Advanced 버전 만들기

실무에서는 derivedStateOf를 단독으로 쓰기보다, 이벤트(Effect)와 입력 안정화(rememberUpdatedState), 상호작용(InteractionSource)과 같이 엮는다. 목표는 "UI는 순수하게 그리고, 비싼 계산은 의존성 좁혀서, 부수효과는 Effect로"라는 분리다.

사례 1: LazyColumn 헤더의 스크롤 기반 UI (계산 위치를 잘못 두면 전부 다시 돈다)

스크롤 위치로 헤더 투명도를 계산하는 코드를 LazyColumn 아이템 내부에 두면, 스크롤할 때 아이템들이 연쇄로 재실행되는 느낌을 받는다. 실제로는 LazyColumn이 최적화를 해도, 헤더 계산이 아이템마다 중복되면 CPU가 올라간다. derivedStateOf로 헤더 파생 값만 분리하면, 아이템은 자신의 데이터 변경에만 반응하게 만들기 쉽다.

Advanced_ScrollHeader.kt
1import androidx.compose.foundation.layout.Box
2import androidx.compose.foundation.layout.fillMaxWidth
3import androidx.compose.foundation.layout.height
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.rememberLazyListState
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.derivedStateOf
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.remember
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun Advanced_ScrollHeader(items: List<String>) {
16    val listState = rememberLazyListState()
17
18    val collapsed by remember {
19        derivedStateOf {
20            val first = listState.firstVisibleItemIndex
21            val offset = listState.firstVisibleItemScrollOffset
22            first > 0 || offset > 40
23        }
24    }
25
26    LazyColumn(state = listState) {
27        item {
28            Box(Modifier.fillMaxWidth().height(56.dp)) {
29                Text(if (collapsed) "Collapsed" else "Expanded")
30            }
31        }
32        items(items.size) { idx ->
33            Text(text = items[idx], modifier = Modifier.height(48.dp))
34        }
35    }
36}

collapsed는 LazyListState의 두 값만 읽는다. 스크롤 중에도 collapsed가 false로 유지되는 구간에서는 헤더 텍스트가 바뀌지 않는다. 헤더가 복잡한 컴포넌트(이미지/애니메이션/측정)라면 이 차이가 커진다. 여기서 핵심은 "스크롤 값 전체"가 아니라 "헤더가 진짜로 필요한 boolean"으로 축약하는 데 있다.

사례 2: AdvancedButton 요구사항(loading, debounce, icon+text, long press, a11y) + derivedStateOf 역할

버튼은 클릭 이벤트가 많고 상태가 섞인다. loading이면 클릭이 막혀야 하고, debounce면 연타가 무시되어야 하며, long press는 별도 콜백이어야 한다. 이때 enabled 계산이 여기저기 흩어지면, 한쪽은 enabled인데 다른 쪽은 ripple이 살아있는 식의 불일치가 생긴다. derivedStateOf로 enabled/label 같은 파생 값을 하나로 모으면 일관성이 생긴다.

AdvancedButton.kt
1import android.os.SystemClock
2import androidx.compose.foundation.combinedClickable
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.width
6import androidx.compose.material3.CircularProgressIndicator
7import androidx.compose.material3.Icon
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Surface
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.derivedStateOf
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.mutableLongStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Alignment
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.semantics.Role
20import androidx.compose.ui.semantics.contentDescription
21import androidx.compose.ui.semantics.semantics
22import androidx.compose.ui.unit.dp
23
24@Composable
25fun AdvancedButton(
26    text: String,
27    loading: Boolean,
28    enabled: Boolean,
29    debounceMs: Long,
30    icon: (@Composable (() -> Unit))? = null,
31    a11yLabel: String = text,
32    onClick: () -> Unit,
33    onLongPress: (() -> Unit)? = null,
34    modifier: Modifier = Modifier
35) {
36    var lastClickAt by remember { mutableLongStateOf(0L) }
37
38    val clickableEnabled by remember {
39        derivedStateOf { enabled && !loading }
40    }
41    val showSpinner by remember {
42        derivedStateOf { loading }
43    }
44
45    Surface(
46        modifier = modifier
47            .semantics { contentDescription = a11yLabel }
48            .combinedClickable(
49                enabled = clickableEnabled,
50                role = Role.Button,
51                onClick = {
52                    val now = SystemClock.elapsedRealtime()
53                    if (now - lastClickAt < debounceMs) return@combinedClickable
54                    lastClickAt = now
55                    onClick()
56                },
57                onLongClick = if (onLongPress != null) ({ onLongPress() }) else null
58            ),
59        color = if (clickableEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
60    ) {
61        Row(verticalAlignment = Alignment.CenterVertically) {
62            if (icon != null) {
63                icon()
64                Spacer(Modifier.width(8.dp))
65            }
66            Text(text = text)
67            if (showSpinner) {
68                Spacer(Modifier.width(12.dp))
69                CircularProgressIndicator(strokeWidth = 2.dp)
70            }
71        }
72    }
73}

여기서 derivedStateOf는 enabled/loading 조합을 clickableEnabled로 축약한다. combinedClickable과 Surface 색, 스피너 표시가 같은 파생 값에 묶여서 불일치 가능성이 줄어든다. debounce는 State가 아니라 로컬 시간 기록이라 remember로만 유지한다. 이런 분리는 "무효화 그래프"를 단순하게 만들고, 디버깅 시 원인을 좁히기 좋다.

AdvancedButtonUsage.kt
1import androidx.compose.material.icons.Icons
2import androidx.compose.material.icons.filled.Favorite
3import androidx.compose.material3.Icon
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.mutableStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.setValue
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.tooling.preview.Preview
11
12@Preview
13@Composable
14fun PreviewAdvancedButtonUsage() {
15    var loading by remember { mutableStateOf(false) }
16
17    AdvancedButton(
18        text = if (loading) "Sending" else "Send",
19        loading = loading,
20        enabled = true,
21        debounceMs = 600,
22        icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
23        a11yLabel = "send-button",
24        onClick = { loading = !loading },
25        onLongPress = { loading = false },
26        modifier = Modifier
27    )
28}

프리뷰에서 클릭하면 loading이 토글되고, debounce 때문에 빠르게 연타하면 상태가 덜 바뀐다. long press는 loading을 끈다. 내가 한 번 크게 삽질한 케이스는 enabled 계산을 두 군데에서 따로 하다가 생겼다. 버튼은 비활성처럼 보이는데 클릭은 먹는 상황이었다. Layout Inspector에서는 recomposition이 정상이라 더 헷갈렸고, 실제 원인은 clickable의 enabled에 loading을 반영하지 않았던 단순 실수였다. derivedStateOf로 clickableEnabled를 하나로 만들고 나서 이런 류의 불일치가 거의 사라졌다.

또 다른 흑역사는 derivedStateOf 블록 안에서 네트워크 결과를 기다리려고 suspend를 섞으려다 난리 난 적이다. 당시 실제로 본 에러는 "IllegalStateException: Snapshot apply failed" 비슷한 형태였고, 원인은 계산 블록에서 부수효과를 만들어 스냅샷 읽기/쓰기 경계를 깨뜨린 것이었다. derivedStateOf는 순수 계산만 둬야 하고, 비동기는 LaunchedEffect로 분리해야 한다.

자주 하는 실수

remember 없이 derivedStateOf를 생성함

증상: 스크롤/입력 같은 고빈도 상태에서 프레임이 불안정해지고, Allocation Tracker에서 DerivedState 관련 객체가 계속 늘어난다. UI는 정상이라 더 늦게 발견된다.

원인: recomposition마다 새 DerivedState가 만들어져 Slot Table에 저장되지 않는다. 의존성 관찰자 재등록이 반복되고, 이전 객체가 끊기면서 불필요한 할당과 해제가 발생한다.

해결: remember { derivedStateOf { ... } } 형태로 슬롯에 고정한다. derivedStateOf를 래핑한 헬퍼를 만들어 실수 가능성을 줄인다.

derivedStateOf 안에서 mutableStateOf에 쓰기(부수효과)를 함

증상: 상태가 튀거나 무한 재구성이 걸린다. 드물게 스냅샷 관련 IllegalStateException이 발생한다. 디버깅 시 재현이 들쑥날쑥하다.

원인: derivedStateOf는 읽기 기반 의존성 그래프를 만들기 위한 순수 계산이어야 한다. 계산 중에 write를 하면 "읽기 추적"과 "쓰기 적용"이 얽혀 런타임이 가정한 순서가 깨진다.

해결: derivedStateOf는 값 계산만 담당하고, 상태 변경은 onClick/LaunchedEffect/SideEffect로 분리한다. 필요하면 snapshotFlow로 변환해 코루틴에서 처리한다.

반환 타입이 매번 새 컬렉션이라 equals가 계속 실패함

증상: 파생 값이 논리적으로는 같아 보이는데도 하위 UI가 계속 무효화된다. 리스트 필터링 화면에서 아이템이 계속 재컴포즈된다.

원인: derivedStateOf는 결과가 equals로 같으면 변경을 전파하지 않는다. 그런데 매번 새 List를 만들고 equals가 참조 기반이거나 내용이 달라지면 매번 변경으로 간주된다.

해결: 결과를 불변 리스트로 유지하거나, 필요한 요약값(Int count, Boolean flags)으로 축약한다. 컬렉션이 필요하면 stable한 데이터 구조(예: persistent list)나 memoization을 고려한다.

의존성을 넓게 읽어 derivedStateOf가 자주 무효화됨

증상: derivedStateOf를 썼는데도 기대만큼 변화가 없다. 스크롤과 무관한 상태 변경에도 헤더/필터 값이 계속 재계산된다.

원인: 계산 블록 안에서 불필요한 State를 읽어 의존성 그래프가 커진다. 예를 들어 테마 색, locale, density까지 같이 읽으면 그 변경도 파생 값 무효화로 연결된다.

해결: 계산 블록은 최소 입력만 읽게 만든다. 필요한 값은 파라미터로 주입하거나, 상위에서 이미 계산된 값을 전달한다.

derivedStateOf로 모든 것을 해결하려고 함 (Effect와 역할 혼동)

증상: 타이머, 애니메이션, 네트워크 같은 비동기 로직을 derivedStateOf에 넣고 싶어진다. 코드가 꼬이고 테스트가 어려워진다.

원인: derivedStateOf는 "State -> 값" 변환기다. 시간/외부 이벤트는 State가 아니라 side-effect 영역이다. 역할이 다른 도구를 섞으면 Compose의 단계(Composition/Layout/Drawing) 가정이 무너진다.

해결: 비동기는 LaunchedEffect/produceState, 이벤트 스트림은 snapshotFlow로 분리한다. derivedStateOf는 순수한 파생 값에만 사용한다.

MistakeFix_EffectSeparation.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.LaunchedEffect
3import androidx.compose.runtime.derivedStateOf
4import androidx.compose.runtime.getValue
5import androidx.compose.runtime.mutableIntStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.setValue
8import kotlinx.coroutines.delay
9
10@Composable
11fun MistakeFix_EffectSeparation() {
12    var tick by remember { mutableIntStateOf(0) }
13
14    // 파생 값은 순수 계산
15    val label by remember { derivedStateOf { "tick=$tick" } }
16
17    // 시간은 Effect로
18    LaunchedEffect(Unit) {
19        while (true) {
20            delay(1000)
21            tick++
22        }
23    }
24
25    androidx.compose.material3.Text(label)
26}

이 코드는 derivedStateOf와 Effect의 역할 분리를 보여준다. tick 증가라는 외부 이벤트(시간)는 LaunchedEffect가 담당하고, label은 순수 계산만 한다. 이 경계를 지키면 스냅샷 관련 예외와 무한 재구성 가능성이 크게 줄어든다.

성능 최적화 체크리스트

  • derivedStateOf는 remember로 감싸 Slot Table에 고정했는가
  • derivedStateOf 블록은 순수 계산만 포함하고 State write/로그인/네트워크 호출이 없는가
  • 계산 블록에서 읽는 State를 최소화했는가(불필요한 theme/density/locale read 제거)
  • 반환 타입의 equals 비용을 점검했는가(대형 리스트/맵을 그대로 반환하지 않음)
  • 파생 값이 컬렉션이면 stable한 구조를 쓰거나 요약값(Int/Boolean)으로 축약했는가
  • 타이핑/스크롤처럼 고빈도 상태에서 계산이 조건부로 빠르게 탈출하도록 분기 순서를 잡았는가
  • derivedStateOf가 실제로 전파를 줄이는지 Layout Inspector의 Recomposition Counts로 확인했는가
  • Allocation Tracker에서 recomposition 중 DerivedState/람다 할당이 폭증하지 않는지 확인했는가
  • LazyColumn에서는 key를 안정적으로 주고, 아이템 모델이 @Immutable/@Stable에 맞는지 점검했는가
  • Modifier 파라미터(예: offset/alpha)에 들어가는 값이 불필요하게 자주 바뀌지 않는지 확인했는가
  • 파생 값이 이벤트 핸들러에 캡처될 때 rememberUpdatedState가 필요한지 검토했는가
  • 측정이 비싼 레이아웃(ConstraintLayout 등)에서 파생 값 변화가 측정 invalidation으로 이어지는지 프로파일링했는가

자주 묻는 질문

derivedStateOf는 remember 없이 써도 동작하는데 왜 문제가 되나?

UI가 정상으로 보이는 건 "값 계산" 자체는 매번 새 DerivedState로도 가능하기 때문이다. 문제는 런타임이 의존성 그래프를 재사용하지 못한다는 점이다. remember가 없으면 recomposition마다 DerivedState 객체가 새로 만들어지고, Snapshot 관찰자 등록이 매번 새로 일어난다. 스크롤처럼 초당 수십 번 recomposition이 발생하면 객체 할당과 해제가 늘고, GC가 끼어들어 프레임 타임이 흔들린다. Android Studio Profiler의 Allocation Tracker에서 DerivedState/Observer 관련 할당을 확인하고, remember { derivedStateOf { ... } }로 슬롯에 고정하는 패턴을 기본값으로 둔다.

derivedStateOf는 언제 쓰고, 언제 그냥 if/when으로 계산해도 되나?

계산이 싸고 의존성이 단순하면 Composable 본문에서 바로 if/when을 써도 된다. derivedStateOf가 필요한 시점은 (1) 계산이 비싸거나(필터링/정렬/정규식/큰 루프) (2) 계산이 읽는 State가 많아서 무효화 범위를 넓히거나 (3) 같은 파생 값을 여러 곳(semantics, modifier, text)에서 공유해야 일관성이 생길 때다. 특히 LazyListState 기반 헤더, 입력 필터 결과 카운트, 애니메이션 트리거 boolean 같은 "요약값"을 만들 때 효과가 크다. 학습 키워드는 state read tracking, Snapshot, recomposition scope이다.

derivedStateOf가 캐시라면, 값이 바뀌지 않을 때 계산도 안 하나?

derivedStateOf는 "의존 State가 바뀌면 무조건 재계산"이 아니라, 의존 State write로 invalid가 된 뒤에 value가 다시 읽힐 때 재계산하는 성격이 강하다. 그래서 화면에서 실제로 그 값이 읽히지 않는 프레임이면 계산이 실행되지 않을 수 있다. 다만 일반적인 Composable은 recomposition 때 값을 읽으니 결국 자주 계산된다. 계산을 더 줄이고 싶으면 derivedStateOf 내부에서 먼저 cheap한 조건을 검사해 빠르게 반환하거나, 아예 계산이 필요한 UI가 조건부로 포함될 때만 값을 읽게 구조를 바꾼다. 학습 키워드는 invalidation, lazy recalculation, read-triggered compute이다.

derivedStateOf 결과가 List면 성능이 나빠지는 이유가 뭔가?

derivedStateOf는 결과가 이전과 equals로 같으면 변경 전파를 막는다. List를 매번 새로 만들면 equals가 내용 비교를 하느라 O(n) 비용이 들거나, 커스텀 타입이 equals를 제대로 구현하지 않으면 매번 false가 되어 하위가 계속 무효화된다. 또 List가 새 인스턴스면 LazyColumn 아이템이 같은 데이터라도 stability가 깨져 재컴포지션이 늘 수 있다. 처방은 파생 값을 List 자체로 두지 말고 count, hasAny, firstId 같은 요약값으로 축약하거나, 불변 컬렉션을 재사용하는 방식으로 만든다. 키워드는 structural equality, stable collections, LazyColumn key이다.

derivedStateOf 안에서 다른 Composable을 호출하면 왜 안 되나?

derivedStateOf 블록은 Composable 컨텍스트가 아니라 Snapshot 관찰 컨텍스트에서 실행되는 순수 계산 영역이다. 여기서 Composable을 호출하면 Composer/Slot Table 그룹이 열려야 하는데, 그 환경이 없다. 설령 컴파일이 되도록 우회해도 Composition 단계의 규칙(그룹 시작/종료, 변경 플래그)이 깨져 런타임 오류나 예측 불가능한 무효화가 생긴다. UI 트리는 @Composable 함수가 만들고, derivedStateOf는 UI가 사용할 값만 계산한다는 경계를 유지해야 한다. 키워드는 composable context, composer, slot table group이다.

derivedStateOf와 rememberUpdatedState는 어떤 관계인가? 같이 써야 하나?

둘은 문제 영역이 다르다. derivedStateOf는 파생 값을 만들고 의존성 그래프를 좁힌다. rememberUpdatedState는 LaunchedEffect나 콜백이 오래 유지될 때, 그 안에서 참조하는 최신 람다/값을 안전하게 업데이트한다. 예를 들어 derivedStateOf로 만든 label을 onClick에서 캡처해 로그를 남기는데, onClick이 remember된 객체에 저장되어 오래 살아있으면 stale 값이 찍힐 수 있다. 이때 rememberUpdatedState로 최신 label을 전달한다. 처방은 "파생 값은 derivedStateOf", "오래 사는 effect/콜백의 최신 참조는 rememberUpdatedState"로 나누는 것이다. 키워드는 stale capture, effect lifetime, updated state holder이다.

리컴포지션이 줄었는지 어떻게 측정하나? 체감만으로는 모르겠다.

측정은 세 단계로 한다. 1) Layout Inspector에서 Recomposition Counts를 켜고, 상태 변경(스크롤/입력/클릭) 시 어떤 노드 카운트가 증가하는지 본다. 2) Logcat으로 특정 Composable 진입 로그와 derivedStateOf 재계산 로그를 분리해 찍어 "무효화는 되지만 값 전파는 안 되는" 상황을 확인한다. 3) Android Studio Profiler에서 CPU와 Allocation을 본다. derivedStateOf를 잘못 쓰면 recomposition 수는 비슷해도 할당이 늘 수 있다. 학습 키워드는 recomposition counts, tracing, allocation tracker이며, 필요하면 Macrobenchmark로 프레임 타임을 수치화한다.

관련 글

30. derivedStateOf로 불필요한 Recomposition 줄이는 실전 패턴
Compose 기본2026.03.07

30. derivedStateOf로 불필요한 Recomposition 줄이는 실전 패턴

Jetpack Compose에서 derivedStateOf가 왜 필요한지, Slot Table·스냅샷·재구성 비교까지 따라가며 불필요한 recomposition을 줄이는 패턴을 구현한다. 초보도 동작 이유를 이해한다. ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ

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

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

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

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

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

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