29. Compose derivedStateOf: 불필요한 리컴포지션을 줄이는 이유와 내부 동작
Compose derivedStateOf(derivedState)로 파생 상태를 캐싱하고 읽기 추적을 제어해 리컴포지션을 줄이는 원리, Slot Table 관점 동작, 실전 패턴을 설명한다. (Compose 초심자용)
Compose derivedStateOf: 불필요한 리컴포지션을 줄이는 이유와 내부 동작
스크롤 리스트에서 검색 필터를 걸고, 상단에 "결과 N개" 텍스트를 띄웠다. 입력 한 글자마다 화면이 버벅이고 Layout Inspector에서 생각보다 많은 컴포저블이 계속 리컴포지션된다. 초보 시절에는 "어차피 값 하나 계산하는데 뭐가 문제지"라고 넘겼는데, 실제로는 파생 값 계산이 어디서 읽혔는지가 리컴포지션 범위를 키우는 트리거가 된다. derivedStateOf는 이 문제를 런타임 레벨에서 다루기 위한 API다.
핵심 개념
derivedStateOf는 "다른 State들로부터 계산되는 값"을 State로 포장하되, (1) 읽기 추적(read tracking)과 (2) 값 캐싱(caching)을 결합해 리컴포지션 범위를 좁히는 도구다. 단순히 계산 결과를 remember에 저장하는 것과 다르게, 런타임이 "이 파생 값이 어떤 원본 State를 읽었는지"를 스냅샷 시스템에서 추적한다.
왜 별도 API가 필요할까. Compose에서 리컴포지션은 "State를 읽은 위치"를 기준으로 구독이 걸린다. 파생 값을 그냥 함수로 계산하면, 그 함수가 읽는 원본 State가 호출 지점(부모/자식 어디든)에 구독을 만든다. 그러면 실제로 화면에 영향을 주는 작은 부분만 바뀌어도, 필요 이상으로 큰 범위가 다시 호출된다.
View 시스템에서는 보통 (a) 값이 바뀔 때마다 직접 setText/setVisibility를 호출하거나, (b) LiveData/Flow를 구독해 콜백에서 필요한 뷰만 갱신했다. "어디를 다시 그릴지"를 개발자가 직접 쪼갰다. Compose는 선언형이라 런타임이 이 역할을 대신하는데, derivedStateOf는 런타임이 더 잘 쪼개도록 힌트를 주는 장치다.
핵심 용어를 사용 맥락으로 정의한다. Snapshot은 상태 읽기/쓰기의 일관성을 보장하는 시스템이다. State<T>는 읽기 시 현재 컴포지션 스코프에 구독을 등록하고, 쓰기 시 구독자들을 무효화(invalidate)한다. Recomposition은 무효화된 구독 범위만 다시 실행하는 단계다. Slot Table은 컴포지션 트리의 구조와 remember 값들을 저장하는 테이블이다. derivedStateOf는 Slot Table에 저장된 객체(remember로 유지됨)가 Snapshot 읽기를 통해 의존성을 기록한다.
1package com.example.derivedstate
2
3import androidx.compose.foundation.layout.Column
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
14fun DerivedStateBasic() {
15 var a by remember { mutableIntStateOf(1) }
16 var b by remember { mutableIntStateOf(2) }
17
18 val sum by remember {
19 derivedStateOf { a + b }
20 }
21
22 Column {
23 Text(text = "a=$a, b=$b")
24 Text(text = "sum=$sum")
25 }
26}
27
28@Preview
29@Composable
30private fun DerivedStateBasicPreview() {
31 DerivedStateBasic()
32}이 코드에서 중요한 지점은 derivedStateOf가 remember 안에 있다는 점이다. remember가 없으면 recomposition마다 새 DerivedState 객체가 만들어져 의존성 추적이 매번 초기화된다. 실행 시 a 또는 b를 바꾸면 sum을 읽는 위치만 무효화된다. 반대로 sum 계산을 그냥 val sum = a + b로 두면, sum을 계산한 호출 지점이 a와 b를 읽은 것으로 기록돼 그 범위 전체가 무효화 대상이 된다.
컴포넌트 해부
derivedStateOf 자체는 UI 컴포넌트가 아니라 런타임 상태 도구다. 하지만 실제 사용은 대부분 "UI 컴포넌트가 받는 파라미터"를 만들 때 발생한다. 예를 들어 리스트 필터 결과, 버튼 enabled, 스크롤에 따른 앱바 타이틀 같은 값이 대표적이다. 파라미터로 내려갈수록 리컴포지션 경계가 생기므로, 파생 값을 어디서 계산하고 어디서 읽을지 설계가 성능을 좌우한다.
API를 해부하면 의도가 보인다. derivedStateOf { ... }는 람다를 받아 State<T>를 만든다. 여기서 람다는 "원본 State 읽기"를 포함할 수 있고, 런타임이 그 읽기를 트래킹한다. remember { derivedStateOf { ... } }는 이 State 객체를 Slot Table에 고정시킨다. by 위임은 State.value 읽기를 문법적으로 간단히 만들 뿐이며, 내부적으로는 value getter를 호출한다.
- remember: DerivedState 객체를 Slot Table에 저장해 recomposition 간 동일 인스턴스를 유지한다
- derivedStateOf: 계산 람다를 snapshot read tracking으로 감싸 의존성 집합을 만든다
- State<T>.value: 읽는 순간 현재 컴포지션 스코프에 구독이 걸린다
- by 위임(getValue): value 접근을 위임 연산자로 바꾸는 문법 설탕이다
- 구독(invalidation): 원본 State가 바뀌면 파생 State는 무효화되고, 파생 State를 읽는 스코프가 다시 무효화된다
- 동등성 정책(equality policy): 파생 값이 이전과 같으면(구조적 동등성) 읽는 쪽을 다시 그리지 않게 막는다
- 계산 비용: 파생 값 계산이 비싸면 derivedStateOf 캐싱이 직접적인 CPU 절감으로 이어진다
- 읽기 위치: 파생 값을 어디서 읽느냐가 리컴포지션 범위를 결정한다
- key(remember key): 파생 계산의 전제가 바뀌면 DerivedState를 교체해야 한다
- 스냅샷 일관성: 여러 State를 읽어도 동일 스냅샷에서 계산되어 찢어진 값(tear)을 줄인다
Surface 계층 관점에서 보면 derivedStateOf는 "외부 입력(원본 State)"과 "표면에 드러나는 속성" 사이의 변환기다. 예를 들어 enabled, alpha, 색상, 텍스트 같은 표면 속성은 자주 파생된다. 이 변환을 컴포저블 외부에서 매번 계산하면, 상위 노드가 원본 State 구독을 잡아 Surface 전체가 다시 호출되는 상황이 생긴다.
Surface 계층의 또 다른 문제는 Modifier 체인이다. Modifier는 값 객체를 이어 붙이는 구조라, 파생 값이 Modifier에 들어가면 재생성/비교가 빈번해진다. derivedStateOf는 Modifier에 들어갈 값을 캐싱해 "같은 값이면 같은 결과"를 유지시키는 데 유리하다. 특히 alpha나 padding처럼 숫자 기반 값은 동등성 비교가 명확하다.
Surface 계층에서 derivedStateOf를 안 쓰면 어떤 일이 생기나. 상위 컴포저블이 원본 State를 읽고, 그 결과를 여러 자식에게 내려주면, 원본 State 변경마다 상위 스코프 전체가 무효화된다. 자식들이 실제로는 일부만 영향을 받아도, 호출 자체는 다시 일어난다. 호출이 가벼워도 트리 깊이가 크면 프레임 예산(16.6ms)을 쉽게 잡아먹는다.
Content 계층 관점에서는 "content slot"이 파생 값의 영향을 받지 않게 격리하는 게 중요하다. content 람다는 보통 큰 서브트리를 포함한다. enabled 같은 파생 값이 바뀔 때 content까지 같이 리컴포지션되면 손해가 커진다. derivedStateOf는 content 바깥에서 파생 값을 만들고, 필요한 최소 지점에서만 읽게 해 격리를 돕는다.
Content 계층에서 자주 하는 실수는 파생 값을 content 람다 안에서 계산하는 것이다. 그러면 content 전체가 원본 State를 읽게 된다. 반대로 파생 State를 상위에서 만들고, content에는 이미 계산된 결과만 넘기면 content는 원본 State를 모른다. 이 차이가 Slot Table의 invalidation 범위를 바꾼다.
Content 계층에서 derivedStateOf를 안 쓰면, 비싼 계산(필터링/정렬/문자열 포맷)이 recomposition마다 실행될 수 있다. 특히 LazyList에서 item마다 파생 계산을 하면 GC 압박이 커진다. derivedStateOf는 계산 결과를 캐싱하고, 원본이 바뀔 때만 다시 계산하게 만든다.
1package com.example.derivedstate
2
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Surface
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.State
9import androidx.compose.runtime.derivedStateOf
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.remember
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun CounterHeader(
17 countState: State<Int>,
18 modifier: Modifier = Modifier
19) {
20 val label by remember {
21 derivedStateOf { "count=${countState.value}" }
22 }
23
24 Surface(modifier = modifier) {
25 Row(Modifier.padding(12.dp)) {
26 Text(text = label)
27 }
28 }
29}이 재구성 코드는 "Surface + Content" 구조에서 파생 값을 어디에 두는지 보여준다. label은 CounterHeader 내부에서 derivedStateOf로 만들어지고, Text가 label만 읽는다. countState.value를 Text가 직접 읽게 만들면 큰 차이가 없어 보이지만, 실제 코드에서 label이 여러 곳에 쓰이거나 포맷 비용이 커지면 invalidation과 CPU 비용이 눈에 띄게 갈린다.
1package com.example.derivedstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Surface
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.runtime.derivedStateOf
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.tooling.preview.Preview
16
17@Composable
18fun DerivedStateUsageDemo(modifier: Modifier = Modifier) {
19 var count by remember { mutableIntStateOf(0) }
20 val enabled by remember { derivedStateOf { count % 2 == 0 } }
21
22 MaterialTheme {
23 Surface(modifier = modifier) {
24 Column {
25 Text(text = "count=$count")
26 Button(enabled = enabled, onClick = { count++ }) {
27 Text(text = if (enabled) "even: click" else "odd: disabled")
28 }
29 }
30 }
31 }
32}
33
34@Preview
35@Composable
36private fun DerivedStateUsageDemoPreview() {
37 DerivedStateUsageDemo()
38}실행하면 버튼은 0,2,4…에서만 활성화되고 1,3,5…에서 비활성화된다. 여기서 관찰 포인트는 enabled 계산이 count를 읽지만, enabled 값이 바뀔 때만 Button의 enabled 경로가 의미 있게 변한다는 점이다. count가 바뀌어도 enabled가 같은 값으로 유지되는 구간이 있다면(예: count>0 같은 조건), derivedStateOf는 값 동등성으로 불필요한 무효화를 줄인다.
내부 동작 원리
Compose는 Composition → Layout → Drawing 단계로 프레임을 만든다. derivedStateOf는 이 중 Composition 단계에서만 관여한다. Layout/Draw는 파라미터가 바뀌지 않으면 스킵될 수 있는데, 파라미터의 변경 여부를 판단하는 출발점이 바로 "어떤 State를 읽었고, 그 값이 바뀌었는가"다.
컴파일러 관점에서 Composable 함수는 추가 파라미터(Composer, changed 플래그 등)를 받는 형태로 변환된다. 런타임은 이 changed 정보를 이용해 스킵 가능성을 판단하고, remember는 Slot Table의 특정 슬롯에 값을 저장/재사용한다. derivedStateOf는 remember로 저장된 객체가 snapshot 읽기 추적을 수행하도록 만든다. 즉, Slot Table에는 DerivedState 객체 레퍼런스가 들어가고, 그 객체 내부에는 "의존성(읽은 State들)"과 "마지막 계산 결과"가 들어간다.
Slot Table에 저장되는 것은 UI 트리만이 아니다. remember 값도 노드 순서에 맞춰 슬롯에 저장된다. derivedStateOf를 remember 밖에 두면, recomposition 때마다 새로운 DerivedState가 생성되고 이전 객체가 Slot Table에서 교체된다. 이때 의존성 집합이 매번 새로 만들어지고, 구독 관계가 흔들리면서 불필요한 invalidation이 늘 수 있다.
스냅샷 시스템에서 State 읽기는 "현재 스냅샷"을 기준으로 일어난다. derivedStateOf의 계산 람다는 보통 Snapshot.observeReads 같은 메커니즘으로 감싸져, 람다 실행 중 읽힌 State들을 기록한다(교육용 표현이며 실제 구현은 더 복잡하다). 이후 원본 State가 바뀌면 파생 State는 자신이 기록한 의존성 중 변경된 것이 있는지 확인하고, 필요할 때만 자신을 무효화한다.
Recomposition 시 비교는 두 단계로 나뉜다. (1) 원본 State 쓰기 → 해당 State를 읽은 스코프 무효화. (2) derivedStateOf는 원본 State 변경을 감지하면 자신의 캐시를 무효화하고, derivedState.value를 읽는 스코프를 무효화한다. 여기서 파생 값이 이전과 동등하면(구조적 동등성) derivedState는 읽는 쪽 무효화를 전파하지 않을 수 있다. 이게 "count는 바뀌었지만 enabled는 그대로" 같은 케이스에서 차이를 만든다.
처음에 나도 derivedStateOf를 "계산 캐시" 정도로만 이해했다가, 3시간 삽질 끝에 원인이 "읽기 위치"라는 걸 디버깅으로 확인했다. LazyColumn 상단 헤더에서 items를 필터링한 결과 size를 계산했는데, 필터링 함수가 상위 Column에서 실행되면서 Column 전체가 items State를 읽어버렸다. Layout Inspector의 Recomposition Count가 헤더뿐 아니라 리스트 전체에서 같이 증가했고, 로그에는 "Skipped 0" 같은 흔적이 계속 남았다.
1package com.example.derivedstate
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.SideEffect
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.runtime.derivedStateOf
14import androidx.compose.ui.tooling.preview.Preview
15
16@Composable
17fun RecompositionTraceDemo() {
18 var clicks by remember { mutableIntStateOf(0) }
19 val label by remember { derivedStateOf { if (clicks < 5) "warmup" else "ready" } }
20
21 SideEffect {
22 Log.d("Trace", "Recomposed: clicks=$clicks label=$label")
23 }
24
25 Column {
26 Text(text = "clicks=$clicks")
27 Text(text = "label=$label")
28 Button(onClick = { clicks++ }) { Text("+1") }
29 }
30}
31
32@Preview
33@Composable
34private fun RecompositionTraceDemoPreview() {
35 RecompositionTraceDemo()
36}이 코드를 실행하면 Logcat에 매 클릭마다 "Recomposed"가 찍힌다. 관찰 포인트는 clicks가 0→4로 변하는 동안 label은 계속 "warmup"이라서, label을 읽는 별도 컴포저블로 경계를 만들면(label만 읽는 작은 컴포저블) 무효화 전파를 줄일 여지가 생긴다는 점이다. derivedStateOf는 label 값이 바뀌지 않으면 읽는 쪽을 덜 흔들 수 있다.
1package com.example.derivedstate
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.derivedStateOf
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.semantics.contentDescription
16import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun ModifierAndSemanticsDerived() {
22 var taps by remember { mutableIntStateOf(0) }
23 val desc by remember { derivedStateOf { "taps=$taps" } }
24 val interaction = remember { MutableInteractionSource() }
25
26 Column(
27 Modifier
28 .padding(16.dp)
29 .semantics { contentDescription = desc }
30 .clickable(interactionSource = interaction, indication = null) { taps++ }
31 ) {
32 Text(text = "Tap this area")
33 Text(text = desc)
34 }
35}
36
37@Preview
38@Composable
39private fun ModifierAndSemanticsDerivedPreview() {
40 ModifierAndSemanticsDerived()
41}여기서는 파생 값 desc가 semantics에도 들어간다. semantics는 접근성 트리로도 전파되므로, desc가 바뀌지 않는 구간에서는 접근성 노드 업데이트를 줄이는 편이 유리하다. Modifier 체인은 오른쪽에서 왼쪽으로 적용되는 게 아니라, 연결 리스트처럼 누적되고 최종적으로 노드에 적용된다. 파생 값을 Modifier 입력으로 만들면 매 recomposition마다 Modifier 인스턴스가 새로 만들어질 수 있는데, derivedStateOf로 값 자체를 안정화하면 체인 비교가 단순해진다.
실습하기
실습 목표는 "derivedStateOf를 쓰지 않았을 때 리컴포지션 범위가 커지는 상황"을 눈으로 확인하는 것이다. 화면에는 검색어 입력, 필터된 리스트, 그리고 상단 카운터가 나온다. 입력 한 글자마다 필터링이 실행되고, derivedStateOf 적용 전후로 Logcat과 Layout Inspector의 recomposition 카운트를 비교한다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4}
5
6android {
7 namespace = "com.example.derivedstate"
8 compileSdk = 34
9
10 defaultConfig {
11 minSdk = 24
12 targetSdk = 34
13 }
14
15 buildFeatures { compose = true }
16 composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
17}
18
19dependencies {
20 implementation(platform("androidx.compose:compose-bom:2024.06.00"))
21 implementation("androidx.activity:activity-compose:1.9.0")
22 implementation("androidx.compose.material3:material3")
23 implementation("androidx.compose.ui:ui-tooling-preview")
24 debugImplementation("androidx.compose.ui:ui-tooling")
25}Compose BOM을 쓰면 material3/ui 버전 정합성을 맞추기 쉽다. 컴파일러 확장 버전은 프로젝트의 Kotlin 버전과 호환이 맞아야 한다. 버전이 안 맞으면 빌드 에러로 "This version of the Compose Compiler requires Kotlin version ..."가 뜬다. 초보 때 이 메시지로 반나절을 날린 적이 있다.
1단계: 파생 값을 그냥 계산하기
첫 단계에서는 필터링 결과를 매번 계산하고, 그 결과 size를 상단에서 읽는다. 실행하면 TextField에 입력할 때마다 Logcat에 필터링 실행 로그가 찍힌다. 중요한 관찰은 "카운터만 바뀌어도" 리스트까지 같이 리컴포지션되는지 여부다.
Logcat 태그를 두 개로 나눠 둔다. Header는 상단 카운터의 리컴포지션, List는 리스트 영역의 리컴포지션이다. Layout Inspector의 Recomposition Count와 로그가 같이 움직이면, 읽기 위치가 넓게 잡힌 상태다.
1package com.example.derivedstate
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.items
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.OutlinedTextField
13import androidx.compose.material3.Surface
14import androidx.compose.material3.Text
15import androidx.compose.runtime.Composable
16import androidx.compose.runtime.SideEffect
17import androidx.compose.runtime.getValue
18import androidx.compose.runtime.mutableStateOf
19import androidx.compose.runtime.remember
20import androidx.compose.runtime.setValue
21import androidx.compose.ui.Modifier
22import androidx.compose.ui.tooling.preview.Preview
23
24class MainActivity : ComponentActivity() {
25 override fun onCreate(savedInstanceState: Bundle?) {
26 super.onCreate(savedInstanceState)
27 setContent { MaterialTheme { Surface(Modifier.fillMaxSize()) { Step1PlainDerived() } } }
28 }
29}
30
31@Composable
32fun Step1PlainDerived() {
33 val all = remember { List(200) { "Item #$it" } }
34 var query by remember { mutableStateOf("") }
35
36 val filtered = all.filter { it.contains(query, ignoreCase = true) }
37
38 SideEffect { Log.d("Header", "recomposed header, query='$query', count=${filtered.size}") }
39
40 Column {
41 OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("search") })
42 Text(text = "count=${filtered.size}")
43 LazyColumn {
44 items(filtered) { item ->
45 SideEffect { Log.d("List", "recomposed item=$item") }
46 Text(text = item)
47 }
48 }
49 }
50}
51
52@Preview
53@Composable
54private fun Step1PlainDerivedPreview() {
55 Step1PlainDerived()
56}2단계: derivedStateOf로 필터 결과를 파생 State로 만들기
두 번째 단계에서는 filtered 리스트 자체를 derivedStateOf로 만든다. 실행하면 입력할 때마다 필터링은 여전히 실행되지만, 필터 결과가 이전과 동일하면(예: 같은 query 입력 반복, 혹은 대소문자 변화 없는 상황) 리스트 쪽 무효화가 줄어드는 걸 관찰할 수 있다.
또 다른 관찰 포인트는 "필터 결과를 어디서 읽는가"다. filtered.size를 헤더에서만 읽고, LazyColumn은 filtered를 읽는다. derivedStateOf는 두 읽기 지점이 같은 원본(query)을 직접 읽지 않게 만들어, 구독이 filtered로 모이게 한다.
1package com.example.derivedstate
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.material3.OutlinedTextField
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.SideEffect
11import androidx.compose.runtime.derivedStateOf
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.tooling.preview.Preview
17
18@Composable
19fun Step2DerivedState() {
20 val all = remember { List(200) { "Item #$it" } }
21 var query by remember { mutableStateOf("") }
22
23 val filtered by remember {
24 derivedStateOf {
25 Log.d("Filter", "filter executed for query='$query'")
26 all.filter { it.contains(query, ignoreCase = true) }
27 }
28 }
29
30 SideEffect { Log.d("Header", "recomposed header, query='$query', count=${filtered.size}") }
31
32 Column {
33 OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("search") })
34 Text(text = "count=${filtered.size}")
35 LazyColumn {
36 items(filtered) { item ->
37 Text(text = item)
38 }
39 }
40 }
41}
42
43@Preview
44@Composable
45private fun Step2DerivedStatePreview() {
46 Step2DerivedState()
47}3단계: 커스터마이징과 경계 쪼개기(헤더 분리)
세 번째 단계에서는 헤더를 별도 컴포저블로 분리하고, 헤더가 읽는 값은 filtered.size만 받게 만든다. 실행하면 query 입력 시 헤더와 리스트가 각각 필요한 만큼만 다시 호출되는지 확인할 수 있다. 특히 리스트 아이템이 복잡한 UI일수록 체감이 커진다.
초보가 자주 놓치는 지점은 "분리만 하면 된다"가 아니라 "무엇을 읽는지"다. 헤더가 query를 읽으면 헤더는 query 변경마다 무효화된다. 헤더가 filtered.size만 읽으면, 헤더는 query를 모른다. derivedStateOf는 이 경계를 더 선명하게 만든다.
1package com.example.derivedstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.material3.OutlinedTextField
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.tooling.preview.Preview
15
16@Composable
17fun Step3SplitBoundary() {
18 val all = remember { List(200) { "Item #$it" } }
19 var query by remember { mutableStateOf("") }
20
21 val filtered by remember {
22 derivedStateOf { all.filter { it.contains(query, ignoreCase = true) } }
23 }
24
25 Column {
26 OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("search") })
27 ResultHeader(count = filtered.size)
28 LazyColumn { items(filtered) { Text(it) } }
29 }
30}
31
32@Composable
33private fun ResultHeader(count: Int) {
34 Text(text = "count=$count")
35}
36
37@Preview
38@Composable
39private fun Step3SplitBoundaryPreview() {
40 Step3SplitBoundary()
41}심화: Advanced 버전 만들기
실무에서는 derivedStateOf가 단독으로 쓰이기보다, 사용자 입력/인터랙션/비동기 로딩과 섞인다. 버튼 하나만 예로 들어도 loading이면 클릭을 막고, debounce로 연타를 제어하고, 아이콘+텍스트 조합을 만들고, long press를 지원하고, 접근성 라벨을 상태에 따라 바꾸는 요구가 같이 온다. 이때 파생 값이 여러 개 생기고, 읽기 위치가 얽히면 리컴포지션 폭발이 난다.
한 문단 요약: derivedStateOf는 파생 값을 State로 만들면서 의존성(State read)과 결과를 캐싱한다. 값이 변할 때만 무효화를 전파해, 같은 원본 변화라도 실제로 UI에 영향이 없는 구간에서 리컴포지션 범위를 줄인다.
사례 1: loading + debounce + enabled 파생 값 분리
버튼 enabled는 보통 여러 조건의 AND다. loading 중이면 false, 입력이 비었으면 false, 최근 클릭이 너무 가까우면 false 같은 조건이 합쳐진다. 이걸 버튼 컴포저블 내부에서 매번 계산하면, 내부가 읽는 원본 State가 많아지고 버튼 서브트리 전체가 그 State들에 구독된다.
enabled를 derivedStateOf로 만들면, 원본 State 변화가 있어도 enabled가 같은 값이면 버튼의 enabled 경로 업데이트를 줄일 수 있다. 또한 접근성 라벨도 enabled/loading 상태에 따라 바뀌므로, contentDescription을 파생 값으로 분리하면 semantics 업데이트를 필요한 때만 만들 수 있다.
1package com.example.derivedstate
2
3import android.os.SystemClock
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Icon
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.derivedStateOf
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.mutableLongStateOf
16import androidx.compose.runtime.remember
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.semantics.contentDescription
20import androidx.compose.ui.semantics.semantics
21import androidx.compose.ui.unit.dp
22
23@Composable
24fun AdvancedButton(
25 text: String,
26 loading: Boolean,
27 icon: (@Composable () -> Unit)? = null,
28 debounceMs: Long = 600L,
29 onClick: () -> Unit,
30 onLongClick: (() -> Unit)? = null,
31 modifier: Modifier = Modifier
32) {
33 var lastClickAt by remember { mutableLongStateOf(0L) }
34
35 val enabled by remember {
36 derivedStateOf {
37 val now = SystemClock.elapsedRealtime()
38 val debounced = now - lastClickAt >= debounceMs
39 !loading && debounced
40 }
41 }
42
43 val a11y by remember {
44 derivedStateOf {
45 when {
46 loading -> "$text, loading"
47 !enabled -> "$text, disabled"
48 else -> text
49 }
50 }
51 }
52
53 Surface(
54 modifier = modifier
55 .semantics { contentDescription = a11y }
56 .combinedClickable(
57 enabled = enabled,
58 onClick = {
59 lastClickAt = SystemClock.elapsedRealtime()
60 onClick()
61 },
62 onLongClick = onLongClick
63 ),
64 color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
65 ) {
66 Row(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
67 if (loading) {
68 CircularProgressIndicator(strokeWidth = 2.dp)
69 } else {
70 icon?.invoke()
71 }
72 Text(text = text, modifier = Modifier.padding(start = 10.dp))
73 }
74 }
75}여기서 derivedStateOf가 해결하는 문제는 두 가지다. 첫째, enabled/a11y 계산이 여러 원본 값(loading, lastClickAt, 시간)에 의존해도, 버튼 내부에서 읽는 지점을 제한한다. 둘째, semantics(contentDescription) 업데이트는 접근성 트리에 영향을 주는데, a11y가 같은 문자열이면 업데이트를 덜 일으킨다. 실제 기기에서 TalkBack을 켜고 탭하면, loading 상태에서 라벨이 "loading"으로 읽히는 걸 확인할 수 있다.
사례 2: 아이콘+텍스트 슬롯과 리컴포지션 격리
content slot을 많이 쓰는 디자인 시스템에서는 버튼 내부 content가 커진다. 예를 들어 아이콘이 애니메이션을 갖고 있고, 텍스트가 실시간 카운터를 포함하면 서브트리가 커진다. enabled 같은 파생 값이 바뀔 때 content까지 같이 호출되면 손해가 크다.
해결책은 파생 값은 버튼 외곽(Surface/interaction/semantics)에서만 읽고, content는 가능한 한 원본 State를 직접 읽지 않게 만드는 것이다. derivedStateOf는 외곽이 읽는 값을 안정화하는 역할을 하고, content는 값 파라미터만 받는다.
1package com.example.derivedstate
2
3import androidx.compose.material.icons.Icons
4import androidx.compose.material.icons.filled.Favorite
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.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15import androidx.compose.runtime.derivedStateOf
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.tooling.preview.Preview
18
19@Composable
20fun AdvancedButtonUsage() {
21 var loading by remember { mutableStateOf(false) }
22 var clicks by remember { mutableIntStateOf(0) }
23
24 val label by remember { derivedStateOf { if (loading) "Saving..." else "Like ($clicks)" } }
25
26 Surface(color = MaterialTheme.colorScheme.background) {
27 AdvancedButton(
28 text = label,
29 loading = loading,
30 icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
31 onClick = {
32 clicks++
33 loading = true
34 },
35 onLongClick = { loading = !loading }
36 )
37 }
38}
39
40@Preview
41@Composable
42private fun AdvancedButtonUsagePreview() {
43 AdvancedButtonUsage()
44}실행하면 탭 시 clicks가 증가하고 loading이 true로 바뀌며 텍스트가 "Saving..."으로 바뀐다. 롱프레스는 loading 토글이다. 관찰 포인트는 label이 derivedStateOf라서 clicks 증가가 loading 상태에서는 label 변화로 이어지지 않는다는 점이다(loading이면 항상 "Saving..."). 이 구간에서 label을 읽는 곳의 무효화 전파가 줄어든다.
내가 이거 잘못 써서 생긴 흑역사가 있다. 예전에는 debounce를 remember 없이 val enabled = derivedStateOf { ... }로 만들었다. 증상은 연타 방지가 랜덤하게 풀리는 것이었고, Logcat을 찍어보니 recomposition마다 lastClickAt을 잡고 있는 객체가 교체돼 의존성 추적이 흔들렸다. UI는 멀쩡해 보여서 더 위험했다.
교정은 단순했다. derivedStateOf는 remember로 고정하고, debounce 기준 시간이 외부에서 바뀌면 remember 키로 교체한다. 이때 실제로 본 에러 메시지는 아니지만, 디버깅 중에 "왜 enabled가 false로 남지?"라는 의문을 풀기 위해 lastClickAt과 now를 매 프레임 로깅했고, recomposition 타이밍에 객체가 바뀌는 걸 확인했다. Slot Table에 저장될 값이 무엇인지 떠올리면 바로 납득된다.
자주 하는 실수
remember 없이 derivedStateOf를 생성함
증상: recomposition마다 파생 상태가 새로 만들어져, 캐싱 효과가 사라지고 의존성 추적이 흔들린다. 디버깅 시 같은 입력인데도 계산 로그가 과도하게 찍힌다.
원인: derivedStateOf는 객체다. remember 없이 선언하면 Composable이 다시 호출될 때마다 새 인스턴스가 생성돼 Slot Table에 저장되지 않는다.
해결: remember { derivedStateOf { ... } }로 고정한다. 파생 계산의 전제가 바뀌는 값이 있으면 remember(key1, key2)로 교체 조건을 명시한다.
파생 값 계산에서 불필요한 State를 읽음
증상: 실제로는 필요 없는 변화에도 파생 값이 무효화되고, 연쇄적으로 많은 컴포저블이 다시 호출된다. Layout Inspector에서 예상보다 넓은 범위가 리컴포지션된다.
원인: derivedStateOf 람다 안에서 읽는 모든 State가 의존성 집합에 들어간다. 디버그용으로 state를 읽어 로그를 찍는 것조차 의존성에 포함될 수 있다.
해결: 람다 안에서는 결과 계산에 필요한 읽기만 남긴다. 로그가 필요하면 SideEffect에서 출력하고, 람다에서는 값을 읽지 않게 분리한다.
derivedStateOf로 리스트를 만들고 매번 새 List를 반환함
증상: 필터 결과가 내용상 같아도 매번 새 List 인스턴스가 만들어져, 구조적 동등성 비교 비용이 커지거나(큰 리스트) 하위가 다시 그려진다.
원인: all.filter(...)는 새 리스트를 만든다. derivedStateOf는 계산 횟수를 줄이지만, query가 바뀌는 동안에는 여전히 새 인스턴스가 나온다.
해결: 필요하면 immutable 컬렉션/키 기반 diff(LazyColumn key)로 하위 재사용을 돕는다. 결과가 큰 경우에는 필터링을 백그라운드로 옮기고 UI에는 스냅샷된 결과만 공급한다.
파생 값을 너무 상위에서 읽어 경계를 다시 넓힘
증상: derivedStateOf를 썼는데도 상위 Column/Scaffold가 계속 리컴포지션된다. "썼는데도 느리다"라는 느낌이 든다.
원인: 파생 State를 만들어도, 그 value를 상위에서 읽으면 상위가 구독을 잡는다. 파생 값이 아니라 읽기 위치가 경계를 결정한다.
해결: 파생 값을 읽는 지점을 최소화한다. 예를 들어 헤더 텍스트만 필요하면 헤더 컴포저블로 분리하고 그 안에서만 value를 읽는다.
시간/랜덤 같은 비결정적 값과 섞어 derivedStateOf를 사용함
증상: recomposition마다 값이 바뀌어 계속 무효화가 전파된다. 배터리/CPU가 튀고, 애니메이션이 없는 화면인데도 프레임 드랍이 난다.
원인: derivedStateOf는 "원본 State 변화"에 반응하는 게 기본인데, SystemClock이나 Random을 람다에서 직접 읽으면 매번 다른 결과가 나온다. 이건 상태 기반이 아니라 시간 기반 업데이트다.
해결: 시간 기반 값은 LaunchedEffect + delay로 tick State를 만들고, 그 tick을 의존성으로 삼아 업데이트 주기를 통제한다. 또는 animation APIs를 사용해 프레임워크가 관리하게 둔다.
1package com.example.derivedstate
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.derivedStateOf
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.mutableIntStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.runtime.setValue
10import kotlinx.coroutines.delay
11
12@Composable
13fun TimeDrivenDerivedSample() {
14 var tick by remember { mutableIntStateOf(0) }
15
16 LaunchedEffect(Unit) {
17 while (true) {
18 delay(1000)
19 tick++
20 }
21 }
22
23 val label by remember {
24 derivedStateOf { "seconds=$tick" }
25 }
26
27 androidx.compose.material3.Text(text = label)
28}성능 최적화 체크리스트
- derivedStateOf는 remember로 고정되어 Slot Table에서 같은 인스턴스를 재사용하는가
- 파생 계산 람다에서 읽는 State가 최소화되어 있는가(디버그 로그용 읽기 제거)
- 파생 값이 실제로 UI 파라미터(텍스트/enabled/색/semantics)에 직접 연결되는가
- 파생 State.value를 읽는 위치가 최소 범위 컴포저블로 제한되어 있는가
- 상위 Scaffold/Column에서 파생 값을 읽어 전체 리컴포지션을 유발하지 않는가
- 리스트 파생 결과는 LazyColumn key를 지정해 아이템 재사용을 돕는가
- 파생 결과가 큰 컬렉션이면 계산 비용(필터/정렬)과 할당량을 프로파일링했는가
- 동등성 정책이 기대대로 동작하는가(값이 같으면 무효화 전파가 줄어드는지)
- Modifier 입력에 들어가는 파생 값(alpha/padding/semantics)이 불필요하게 자주 바뀌지 않는가
- 시간/랜덤 등 비결정적 값은 derivedStateOf에 직접 넣지 않고 tick State로 통제하는가
- Layout Inspector의 Recomposition Count로 변경 전/후를 비교했는가(스크린샷 기록)
- Logcat/trace로 파생 계산 실행 횟수를 계측했는가(태그 분리)
자주 묻는 질문
derivedStateOf와 remember의 역할이 왜 분리되어 있나?
derivedStateOf는 "파생 계산 + 의존성 추적 + 캐싱"을 제공하는 State 생성기이고, remember는 "그 객체를 Slot Table에 저장해 recomposition 간 동일 인스턴스를 유지"하는 메커니즘이다. 둘을 분리하면 같은 derivedStateOf를 ViewModel 수준에서 만들 수도 있고(권장되진 않지만), Composable 내부에서는 remember로 수명을 컴포지션에 맞출 수 있다. 기억해야 할 포인트는 derivedStateOf 자체가 상태 변경을 트리거하는 게 아니라, 내부에서 읽은 원본 State의 변경이 파생 State를 무효화한다는 점이다. 학습 키워드는 Slot Table, remember slot, snapshot read tracking이다.
val x = a + b 와 derivedStateOf { a + b }는 리컴포지션에서 뭐가 다른가?
표면적으로는 둘 다 같은 값을 만든다. 차이는 "어디에 구독이 걸리느냐"와 "값이 같을 때 무효화 전파를 막을 수 있느냐"다. 단순 계산식은 그 줄이 실행되는 컴포저블 스코프가 a와 b를 읽은 것으로 기록된다. 그러면 a/b 변경 시 그 스코프가 무효화된다. derivedStateOf는 a/b 읽기를 파생 State 내부로 옮기고, UI는 파생 State.value만 읽는다. 또한 파생 값이 이전과 동등하면(예: 조건식 결과가 계속 true) 파생 State가 읽는 쪽 무효화를 덜 전파할 수 있다. 학습 키워드는 invalidation propagation, equality policy, read scope다.
derivedStateOf를 쓰면 무조건 성능이 좋아지나?
무조건이 아니다. 파생 계산이 매우 싸고, 읽기 위치가 이미 잘게 쪼개져 있으며, 값이 매번 바뀌는 상황이라면 derivedStateOf는 이득이 작다. 오히려 파생 State 객체를 추가로 만들고, 의존성 추적을 수행하는 오버헤드가 생긴다. 다만 실무에서 문제는 대개 "값이 자주 바뀌지만 결과는 자주 같음"(예: enabled, show/hide) 또는 "계산이 비쌈"(필터/정렬/포맷) 또는 "읽기 위치가 상위에 있음"이다. 이 세 가지 중 하나라도 해당하면 derivedStateOf는 효과가 있다. 확인 방법은 Layout Inspector의 Recomposition Count, Logcat으로 계산 실행 횟수, 그리고 Android Studio Profiler의 CPU 샘플링이다.
derivedStateOf가 Slot Table에 저장된다는 말이 무슨 뜻인가?
derivedStateOf 자체는 단순 함수 호출이지만, 일반적으로 remember { derivedStateOf { ... } }로 사용한다. remember는 컴파일러가 생성한 슬롯 인덱스를 기준으로 Slot Table에 값을 저장한다. recomposition이 일어나도 같은 호출 위치라면 같은 슬롯에서 값을 꺼내므로 DerivedState 객체 인스턴스가 유지된다. 이 인스턴스 내부에 마지막 계산 값과, 스냅샷 시스템이 기록한 의존성(State 읽기 목록)이 남는다. 그래서 다음 recomposition에서는 "새로 의존성을 수집"하는 게 아니라, 변경된 의존성이 있을 때만 캐시를 무효화한다. 학습 키워드는 Composer, remember slots, slot index stability다.
derivedStateOf를 ViewModel에 두고 StateFlow로 노출하는 게 더 낫지 않나?
UI 파생 값이 순수한 도메인 상태 변환이라면 ViewModel에서 Flow/StateFlow로 만드는 편이 맞는 경우가 많다. 하지만 derivedStateOf는 Compose Snapshot 기반이라, Composable 수명과 스냅샷 일관성에 맞춘 도구다. ViewModel에서 derivedStateOf를 쓰면 스냅샷과 코루틴/스레드 경계가 섞여 복잡해질 수 있다. 실전 처방은 이렇다. (1) 비즈니스 규칙 기반 파생은 ViewModel에서 Flow map으로 만든다. (2) UI 전용 파생(예: enabled, 색상, 접근성 라벨, 스크롤에 따른 타이틀)은 Composable에서 derivedStateOf로 만든다. (3) 리스트 필터링처럼 비용이 큰 변환은 백그라운드 처리 후 UI에는 결과만 공급한다. 학습 키워드는 snapshot vs Flow, collectAsState, UI-only derived state다.
derivedStateOf가 값이 같으면 리컴포지션을 막는다는 게 정확히 어떤 의미인가?
Compose의 무효화는 "State가 바뀌었다"가 아니라 "State를 읽는 스코프를 다시 실행해야 한다"로 이어진다. derivedStateOf는 원본 State 변경을 감지해도, 재계산 결과가 이전과 동등하면 파생 State의 value 변경으로 간주하지 않을 수 있다. 그 경우 파생 State.value를 읽는 컴포저블 스코프는 무효화되지 않거나, 무효화되더라도 빠르게 스킵될 수 있다. 다만 UI가 직접 원본 State를 읽고 있다면 이 최적화는 적용되지 않는다. 그래서 "파생 값을 만들었는데도 느리다"는 상황은 대개 파생 값을 읽는 위치가 넓거나, 원본을 여전히 읽고 있기 때문이다. 학습 키워드는 structural equality, state change vs invalidation, skipping recomposition이다.
derivedStateOf와 rememberUpdatedState는 어떻게 다른가? 같이 쓰는 경우가 있나?
derivedStateOf는 "여러 State로부터 계산되는 파생 값"을 캐싱하고 의존성 추적을 한다. rememberUpdatedState는 "람다/콜백이 최신 값을 캡처하도록" 만드는 도구로, 주로 LaunchedEffect/DisposableEffect 내부에서 오래 살아있는 코루틴이 최신 값을 참조하게 할 때 쓴다. 같이 쓰는 경우도 있다. 예를 들어 스크롤 상태로부터 파생된 enabled를 derivedStateOf로 만들고, 클릭 콜백은 rememberUpdatedState로 최신 ViewModel 함수를 캡처해 코루틴에서 호출한다. 실전 처방은 "파생 계산은 derivedStateOf, 오래 살아있는 이펙트의 최신 참조는 rememberUpdatedState"로 역할을 분리하는 것이다. 학습 키워드는 effect lifecycle, stale capture, snapshot reads in effects다.