Compose 기본2026년 03월 07일· 11 min read

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

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

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

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

검색창이 있는 리스트 화면을 처음 Compose로 만들면, 글자 한 번 입력할 때마다 리스트 전체가 다시 그려지는 느낌을 받는다. Layout Inspector에서 Recomposition Count가 급격히 오르고, 스크롤이 살짝 끊기기도 한다. 코드는 단순한데 왜 이런 일이 생기는지, 그리고 derivedStateOf를 넣는 위치 하나로 리컴포지션 범위를 어떻게 잘라내는지가 성능을 좌우한다. 나도 처음엔 "필터링 결과를 val로 빼두면 되겠지"라고 생각했다. 그런데 실제로는 입력 한 글자마다 LazyColumn의 아이템들이 연쇄적으로 다시 호출되었다. 3시간 정도 Slot Table과 스냅샷 읽기 추적을 디버깅하다가, 문제는 "계산"이 아니라 "어디에서 state를 읽었는지"라는 사실을 확인했다.

핵심 개념

derivedStateOf는 "상태로부터 파생된 값"을 Compose Runtime이 캐시하고, 그 값이 실제로 바뀌었을 때만 해당 값을 읽는 컴포저블을 invalidation 대상으로 만드는 장치이다. 핵심은 계산 비용 절감이 아니라, 스냅샷 읽기(read) 관계를 더 좁게 모델링해 리컴포지션 범위를 줄이는 데 있다.

View 시스템에서도 비슷한 문제가 있었다. TextWatcher에서 리스트를 필터링하고 notifyDataSetChanged를 호출하면 RecyclerView 전체가 흔들렸다. 차이는 Compose가 "읽은 state"를 자동 추적한다는 점이다. 자동 추적은 강력하지만, state를 넓은 범위에서 읽어버리면 넓은 범위가 다시 호출된다.

용어를 실제 사용 맥락으로 정의한다. - Snapshot: mutableStateOf 같은 상태가 속한 일관성 모델이다. 상태를 읽고 쓰는 시점이 기록된다. - State read tracking: 어떤 컴포저블이 어떤 state를 읽었는지 런타임이 기록하는 과정이다. 이 기록이 invalidation 범위를 정한다. - Invalidation: 상태 변경으로 "다시 호출되어야 하는" 컴포지션 구간을 표시하는 단계이다. - Slot Table: 컴포지션 결과(노드/그룹/remember 값 등)가 저장되는 테이블이다. 재구성 시 이전 호출과 매칭하는 기준이 된다. - derivedState: 다른 state를 읽어 계산한 값을 State로 감싼 것. 값 비교가 통과하면 하위는 건드리지 않는다.

derivedStateOf가 왜 remember와 함께 등장하는지도 중요하다. derivedStateOf 자체는 계산 규칙을 캡슐화하지만, 그 객체를 매 리컴포지션마다 새로 만들면 Slot Table에 저장된 이전 derivedState와 매칭되지 않아 캐시 이점이 사라진다. 그래서 remember { derivedStateOf { ... } } 형태가 기본이다.

DerivedStateBasics.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.derivedStateOf
3import androidx.compose.runtime.getValue
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.remember
6import androidx.compose.runtime.setValue
7
8@Composable
9fun DerivedStateBasics() {
10    var query by remember { mutableStateOf("") }
11
12    val isLongQuery by remember(query) {
13        derivedStateOf { query.length >= 10 }
14    }
15
16    // isLongQuery를 읽는 UI는 query가 바뀌어도
17    // true/false가 바뀌는 순간에만 의미 있는 변화가 생긴다.
18}

이 코드를 실행해서 확인할 포인트는 "query가 바뀌는 모든 순간"과 "isLongQuery가 바뀌는 순간"이 다르다는 점이다. derivedStateOf는 내부적으로 계산 블록에서 읽은 state(query)를 추적하지만, 외부에 노출되는 것은 Boolean 값이다. Runtime은 Boolean의 변경 여부로 하위 invalidation을 더 공격적으로 줄일 수 있다.

컴포넌트 해부

derivedStateOf는 UI 컴포넌트가 아니라 상태 컴포넌트에 가깝다. 그래서 해부 대상은 "어떤 파라미터/컨텍스트가 derivedStateOf의 동작을 결정하는가"이다. 실제 코드에서 성능을 가르는 것은 derivedStateOf 자체보다, 그것을 감싸는 remember/키/읽는 위치이다.

  • remember { derivedStateOf { ... } }: derived state 객체를 Slot Table에 고정한다. 없으면 매번 새 객체가 만들어져 관찰 연결이 흔들린다.
  • remember(key1, key2) { ... }: 파생 규칙 자체를 교체해야 할 때만 derived state를 재생성한다. 키가 바뀌면 이전 derived state는 폐기된다.
  • derivedStateOf { ... } 블록: 여기에서 읽은 state가 의존성으로 등록된다. 블록 밖에서 읽으면 의존성 범위가 넓어진다.
  • by 위임(getValue): State<T>.value 읽기 자체가 read tracking 지점이다. 읽는 컴포저블이 invalidation 대상이 된다.
  • 구조적 동등성 정책(기본): 계산 결과가 equals로 같으면 변경으로 간주하지 않는다. 리스트/맵은 참조가 바뀌면 equals 비용이 커질 수 있다.
  • snapshotFlow와의 관계: derivedStateOf는 Compose 내부에서 동작하고, snapshotFlow는 스냅샷 읽기를 Flow로 외부로 내보낸다. UI 내부 최적화는 derivedStateOf가 우선이다.
  • LazyColumn item key: 파생된 리스트가 바뀌어도 key가 안정적이면 재사용된다. derivedStateOf만으로는 아이템 재사용이 보장되지 않는다.
  • @Stable/@Immutable: 파생 값이 객체일 때 안정성 정보가 없으면 런타임이 보수적으로 처리해 더 자주 다시 호출될 수 있다.
  • rememberUpdatedState: 파생 값과는 목적이 다르다. long-lived effect에서 최신 람다를 잡기 위한 도구이다.
  • 읽기 위치(상위/하위): 상위에서 파생 값을 읽으면 상위가 invalidation 범위가 된다. 하위로 내리면 범위가 줄어든다.

Surface 계층 관점에서 보면 derivedStateOf는 "Surface를 다시 만들지 않아도 되는 조건"을 만들어준다. 예를 들어 Scaffold 상단바, 리스트 컨테이너, 배경 Surface는 query 변화와 무관할 수 있다. 그런데 상단에서 query를 읽어버리면 그 Surface 그룹 전체가 invalidation 된다.

Content 계층에서는 반대다. 실제로 바뀌어야 하는 것은 필터 결과 텍스트, 필터된 아이템 목록, 비어있음 상태 같은 작은 조각이다. derivedStateOf는 이 작은 조각만 바뀌게 만들기 위한 '값의 경계' 역할을 한다.

파라미터를 안 쓰면 어떻게 되나도 자주 발생한다. remember를 빼면 derivedStateOf가 매번 새로 만들어져 계산 블록이 다시 등록되고, 이전 derivedState의 캐시가 의미를 잃는다. remember(key)를 잘못 잡으면 query가 바뀔 때마다 derivedState 자체가 재생성되어, 결국 query를 직접 읽는 것과 비슷한 비용으로 돌아간다.

EducationalDerivedStateContainer.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.State
3import androidx.compose.runtime.derivedStateOf
4import androidx.compose.runtime.getValue
5import androidx.compose.runtime.mutableStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.setValue
8
9@Composable
10private fun EducationalDerivedStateContainer() {
11    var query by remember { mutableStateOf("") }
12
13    // Surface(상위 컨테이너)에서 query를 직접 읽지 않고,
14    // 파생된 최소 정보만 아래로 전달하는 구조를 만든다.
15    val filteredCount: State<Int> = remember {
16        derivedStateOf {
17            // 실제 앱에서는 리스트 필터 결과의 크기 같은 값이 온다.
18            if (query.isBlank()) 0 else query.length
19        }
20    }
21
22    val count by filteredCount
23    // count를 필요로 하는 Content만 읽게 배치하는 게 핵심이다.
24}

이 재구성 코드는 "상위는 query를 모르고, 하위는 count만 안다"라는 형태를 의도한다. 실제 프로젝트에서 Slot Table을 보면, remember 블록이 하나의 그룹으로 들어가고 derivedState 객체는 그 그룹의 슬롯에 저장된다. 같은 컴포지션 경로로 다시 들어오면 그 슬롯에서 동일 객체를 꺼내 재사용한다.

FilterHeaderCard.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.PaddingValues
3import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Card
6import androidx.compose.material3.CardDefaults
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.derivedStateOf
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun FilterHeaderCard(
20    modifier: Modifier = Modifier,
21    contentPadding: PaddingValues = PaddingValues(16.dp)
22) {
23    var query by remember { mutableStateOf("") }
24
25    val headerText by remember {
26        derivedStateOf {
27            if (query.isBlank()) "검색어를 입력해라" else "검색 중: $query"
28        }
29    }
30
31    Card(
32        modifier = modifier.fillMaxWidth().padding(12.dp),
33        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
34    ) {
35        Column(Modifier.padding(contentPadding)) {
36            Text(text = headerText, style = MaterialTheme.typography.titleMedium)
37            Text(text = "query 길이: ${query.length}")
38        }
39    }
40}

여기서 일부러 query를 같이 출력했다. 실행하면 query는 매 입력마다 바뀌고, headerText도 매 입력마다 바뀐다. 이 예시는 derivedStateOf의 이점을 거의 못 얻는다. 이런 코드가 실무에 많다. 파생 값이 원본과 같은 빈도로 변하면, derivedStateOf는 "리컴포지션을 줄이는 도구"가 아니라 "읽기 위치를 조절하는 도구"로만 의미가 남는다. 진짜 효과는 "원본은 자주 바뀌는데 파생 값은 가끔만 바뀌는" 조건에서 나온다.

내부 동작 원리

Compose의 렌더링 파이프라인은 Composition → Layout → Drawing 순서다. derivedStateOf는 Composition 단계에서만 관여한다. Layout이나 Drawing을 직접 건드리지 않지만, Composition에서 invalidation 범위를 줄이면 Layout/Draw 호출도 연쇄적으로 줄어든다. 특히 LazyColumn은 아이템 컴포지션이 줄어드는 순간, measure/draw 호출 수가 같이 내려간다.

컴파일 관점에서 컴포저블은 Compose Compiler에 의해 (개념적으로) composer와 changed 플래그를 받는 함수로 변환된다. remember는 Slot Table의 특정 위치에 값을 저장하고, 다음 재구성에서 같은 위치로 들어오면 값을 재사용한다. derivedStateOf를 remember로 감싸는 이유는 이 Slot Table 위치에 derived state 객체를 고정하기 위해서다.

런타임 관점에서 state 읽기는 두 갈래로 기록된다. 첫째, 컴포저블이 State.value를 읽으면 "이 컴포지션 그룹은 이 state에 의존한다"가 기록된다. 둘째, derivedStateOf의 계산 블록에서 state를 읽으면 "이 derived state는 이 state에 의존한다"가 기록된다. 그 결과 query가 바뀌면, 직접 query를 읽는 그룹뿐 아니라 derived state도 invalidation 된다. 차이는 derived state의 결과가 같으면, derived state를 읽는 그룹은 invalidation 되지 않을 수 있다는 점이다.

Slot Table 관점에서 derivedStateOf의 가장 중요한 효과는 "그룹 경계"를 만들어주는 것이다. query를 상위에서 읽으면 상위 그룹이 invalidation 되고, 그 아래 모든 호출이 재평가된다. 반대로 상위에서는 query를 읽지 않고 derived 값(예: Boolean, Int)만 하위로 내리면, 상위 그룹은 건드리지 않고 하위 일부만 다시 호출된다.

비교는 어디서 일어나나. Compose는 파라미터 변경 여부를 changed 플래그로 추적하고, State 변경은 invalidation으로 추적한다. derivedStateOf는 내부적으로 계산 결과를 저장하고, 다음 계산 결과와 equals로 비교해 "값이 바뀌었는지"를 결정한다. 값이 안 바뀌면 derived state를 읽는 컴포지션 그룹은 invalidation 큐에 올라가지 않는다.

내가 처음 헷갈렸던 지점은 "query가 바뀌었으니 어차피 다 다시 호출되는 거 아닌가"였다. 실제로는 query를 읽는 위치가 범위를 결정한다. Layout Inspector에서 recomposition highlight를 켜고, 상위 컨테이너가 계속 하이라이트되는지 확인하면 바로 감이 온다. 상위가 계속 깜빡이면 derivedStateOf가 아니라 "읽기 위치"가 문제다.

RecompositionProbe.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.fillMaxWidth
3import androidx.compose.foundation.layout.padding
4import androidx.compose.material3.Button
5import androidx.compose.material3.OutlinedTextField
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.SideEffect
9import androidx.compose.runtime.derivedStateOf
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.ui.Modifier
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun RecompositionProbe() {
20    var query by remember { mutableStateOf("") }
21    var clicks by remember { mutableIntStateOf(0) }
22
23    val showHint by remember {
24        derivedStateOf { query.length < 3 }
25    }
26
27    SideEffect {
28        // Logcat에서 "RecompositionProbe SideEffect"가 얼마나 찍히는지 확인한다.
29        android.util.Log.d("Probe", "RecompositionProbe SideEffect: query=$query clicks=$clicks")
30    }
31
32    Column(Modifier.fillMaxWidth().padding(16.dp)) {
33        OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("Query") })
34        Button(onClick = { clicks++ }) { Text("clicks=$clicks") }
35        if (showHint) Text("3글자 이상 입력하면 힌트가 사라진다")
36    }
37}

이 코드를 실행하면 query를 1글자씩 입력할 때마다 Logcat이 찍힌다. 중요한 관찰은 힌트 텍스트가 3글자 미만에서만 보이므로, 0→1→2글자 구간에서는 showHint 값이 계속 true라서 UI 변화가 없다. 그럼에도 SideEffect 로그는 계속 찍힌다. 이유는 showHint가 아니라 query를 OutlinedTextField에 전달하기 위해 같은 컴포지션 그룹에서 query를 읽기 때문이다. derivedStateOf는 "필요한 부분만" 읽게 배치했을 때만 효과가 난다.

ModifierAndSemanticsWithDerived.kt
1import androidx.compose.foundation.interaction.MutableInteractionSource
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.padding
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.derivedStateOf
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.rememberUpdatedState
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.semantics.contentDescription
13import androidx.compose.ui.semantics.semantics
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun ModifierAndSemanticsWithDerived(
18    enabled: Boolean,
19    label: String,
20    onClick: () -> Unit
21) {
22    val latestOnClick by rememberUpdatedState(onClick)
23    val interactionSource = remember { MutableInteractionSource() }
24
25    val semanticsLabel by remember(label) {
26        derivedStateOf { "action:$label" }
27    }
28
29    Column(Modifier.padding(16.dp)) {
30        Button(
31            onClick = { latestOnClick() },
32            enabled = enabled,
33            interactionSource = interactionSource,
34            modifier = Modifier.semantics { contentDescription = semanticsLabel }
35        ) {
36            Text(label)
37        }
38    }
39}

이 예시는 derivedStateOf가 Modifier/semantics와 만나는 지점을 보여준다. semantics는 접근성 트리로도 흘러가서, 값이 불필요하게 자주 바뀌면 TalkBack 이벤트나 테스트 매칭이 흔들릴 수 있다. label이 바뀔 때만 semanticsLabel이 바뀌게 만들면, enabled 같은 다른 state 변경과 분리된다. rememberUpdatedState는 onClick 람다 캡처를 최신으로 유지하는 용도라서 derivedStateOf와 목적이 다르다.

실습하기

실습 목표는 "입력은 매 키스트로크마다 변하지만, 리스트 필터 결과가 바뀌는 순간만 리스트가 다시 그려지게" 만드는 것이다. derivedStateOf를 잘못 쓰면 입력 한 글자마다 LazyColumn의 아이템 컴포지션이 반복된다. 제대로 쓰면 query 변화가 있어도 필터 결과가 동일한 구간에서는 리스트가 조용하다.

측정은 두 가지로 한다. 첫째 Layout Inspector의 Recomposition Count를 본다. 둘째 Logcat에서 아이템 컴포저블이 몇 번 호출되는지 찍는다. 눈으로 스크롤 끊김을 느끼는 것보다 숫자가 더 빨리 알려준다.

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

버전은 예시다. 핵심은 Compose BOM을 쓰고, ui-tooling을 debug에 넣어 Layout Inspector와 Preview를 안정적으로 쓰는 것이다. Recomposition Count를 보려면 디버그 빌드에서 툴링이 제대로 붙어야 한다.

1단계: naive 필터링으로 문제 재현

이 단계는 의도적으로 비효율을 만든다. query가 바뀔 때마다 filtered 리스트를 매번 새로 만들고, 그 리스트를 LazyColumn에 그대로 넘긴다. 실행하면 입력할 때마다 Logcat에 아이템 로그가 폭발한다.

화면에는 텍스트 필드와 200개 아이템 리스트가 보인다. query에 "a"를 입력하면 리스트가 줄어든다. 중요한 관찰은 query가 "a"→"aa"로 바뀌는 동안에도, 필터 결과가 크게 달라지지 않는데 아이템들이 계속 다시 호출된다는 점이다.

NaiveFilterScreen.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.fillMaxSize
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.OutlinedTextField
12import androidx.compose.material3.Surface
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.tooling.preview.Preview
21import androidx.compose.ui.unit.dp
22
23class MainActivity : ComponentActivity() {
24    override fun onCreate(savedInstanceState: Bundle?) {
25        super.onCreate(savedInstanceState)
26        setContent { MaterialTheme { Surface { NaiveFilterScreen() } } }
27    }
28}
29
30@Composable
31fun NaiveFilterScreen() {
32    val all = remember { List(200) { i -> "Item #$i" } }
33    var query by remember { mutableStateOf("") }
34
35    val filtered = all.filter { it.contains(query, ignoreCase = true) }
36
37    Column(Modifier.fillMaxSize().padding(16.dp)) {
38        OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("Query") })
39        Text("filtered size=${filtered.size}")
40        LazyColumn {
41            items(filtered) { item ->
42                Log.d("Naive", "compose item=$item")
43                Text(item, modifier = Modifier.padding(vertical = 6.dp))
44            }
45        }
46    }
47}
48
49@Preview(showBackground = true)
50@Composable
51private fun PreviewNaive() {
52    MaterialTheme { Surface { NaiveFilterScreen() } }
53}

이 코드의 문제는 계산 비용이 아니다. 더 큰 문제는 filtered가 매번 새로운 List 인스턴스라는 점이다. LazyColumn은 items(filtered) 호출에서 리스트 참조가 바뀌면 아이템 구성이 다시 평가될 가능성이 커진다. 그리고 query를 상위 Column에서 읽기 때문에, Column 그룹 전체가 invalidation 된다.

2단계: derivedStateOf로 파생 값 경계 만들기

여기서는 filtered를 derivedStateOf로 감싸고, LazyColumn에는 State로부터 읽은 리스트를 넘긴다. 핵심은 derivedStateOf의 계산 블록이 all과 query를 읽는 유일한 장소가 되게 만드는 것이다.

실행하면 query가 바뀌어도 filtered 결과가 equals로 동일한 구간에서는, LazyColumn 아이템 로그가 덜 찍히는 구간이 생긴다. 특히 query가 공백이거나 동일 결과를 만드는 입력(예: 대소문자 차이)에서 차이가 눈에 들어온다.

DerivedFilterScreen.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.fillMaxSize
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.OutlinedTextField
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.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.tooling.preview.Preview
19import androidx.compose.ui.unit.dp
20
21@Composable
22fun DerivedFilterScreen() {
23    val all = remember { List(200) { i -> "Item #$i" } }
24    var query by remember { mutableStateOf("") }
25
26    val filtered by remember {
27        derivedStateOf {
28            if (query.isBlank()) all
29            else all.filter { it.contains(query, ignoreCase = true) }
30        }
31    }
32
33    Column(Modifier.fillMaxSize().padding(16.dp)) {
34        OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("Query") })
35        Text("filtered size=${filtered.size}")
36        LazyColumn {
37            items(filtered, key = { it }) { item ->
38                Log.d("Derived", "compose item=$item")
39                Text(item, modifier = Modifier.padding(vertical = 6.dp))
40            }
41        }
42    }
43}
44
45@Preview(showBackground = true)
46@Composable
47private fun PreviewDerived() {
48    MaterialTheme { Surface { DerivedFilterScreen() } }
49}

여기서 key = { it }를 넣은 이유는, 필터 결과 리스트가 바뀌더라도 동일 문자열 아이템은 Slot Table에서 같은 item 그룹으로 매칭되게 만들기 위해서다. derivedStateOf는 "리스트가 바뀌는 시점"을 줄여주고, key는 "바뀐 리스트 안에서 재사용"을 늘려준다. 둘 중 하나만 쓰면 체감이 반쪽짜리로 남는 경우가 많다.

3단계: 입력은 자주, 리스트는 가끔만 바뀌게 만들기

실무에서는 query 자체가 UI 여러 군데에서 필요하다. 그때 상위에서 query를 읽는 순간, derivedStateOf를 넣어도 상위가 계속 invalidation 된다. 해결은 "입력 컴포넌트"와 "리스트 컴포넌트"를 분리하고, 리스트에는 query 대신 파생된 최소 정보만 전달하는 것이다.

실행하면 TextField는 매 키 입력마다 반응하지만, 리스트는 특정 조건(예: query 길이가 2 이상)에서만 업데이트되도록 만들 수 있다. 이 패턴은 검색 자동완성, 서버 호출 디바운스, 필터 토글 같은 화면에서 특히 자주 쓴다.

SplitReadBoundaryScreen.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.fillMaxSize
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.OutlinedTextField
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.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.tooling.preview.Preview
19import androidx.compose.ui.unit.dp
20
21@Composable
22fun SplitReadBoundaryScreen() {
23    val all = remember { List(500) { i -> "User $i" } }
24    var query by remember { mutableStateOf("") }
25
26    val effectiveQuery by remember {
27        derivedStateOf {
28            query.trim().takeIf { it.length >= 2 } ?: ""
29        }
30    }
31
32    Column(Modifier.fillMaxSize().padding(16.dp)) {
33        QueryInput(query = query, onQueryChange = { query = it })
34        FilteredList(all = all, effectiveQuery = effectiveQuery)
35    }
36}
37
38@Composable
39private fun QueryInput(query: String, onQueryChange: (String) -> Unit) {
40    OutlinedTextField(value = query, onValueChange = onQueryChange, label = { Text("Query") })
41    Text("입력은 자유, 리스트는 2글자부터 갱신")
42}
43
44@Composable
45private fun FilteredList(all: List<String>, effectiveQuery: String) {
46    val filtered by remember(all, effectiveQuery) {
47        derivedStateOf {
48            if (effectiveQuery.isBlank()) all
49            else all.filter { it.contains(effectiveQuery, ignoreCase = true) }
50        }
51    }
52
53    Text("effectiveQuery='$effectiveQuery' size=${filtered.size}")
54    LazyColumn {
55        items(filtered, key = { it }) { item ->
56            Log.d("Split", "compose item=$item")
57            Text(item, modifier = Modifier.padding(vertical = 6.dp))
58        }
59    }
60}
61
62@Preview(showBackground = true)
63@Composable
64private fun PreviewSplit() {
65    MaterialTheme { Surface { SplitReadBoundaryScreen() } }
66}

effectiveQuery는 query가 바뀌어도 2글자 미만에서는 항상 빈 문자열이다. 이 구간에서 FilteredList는 effectiveQuery 값이 변하지 않으니 invalidation이 걸리지 않는다. TextField는 계속 바뀌지만 리스트는 조용하다. 이게 derivedStateOf가 만들어내는 "파생 값 경계"의 실전 형태다.

심화: Advanced 버전 만들기

실무 패턴 두 가지가 자주 나온다. 첫째는 "파생 값이 비싸고, 그 결과는 자주 변하지 않는다" 유형이다. 예를 들어 스크롤 위치로 헤더 축소 여부를 판단하거나, 다중 필터(카테고리/가격/정렬)를 합성해 서버 요청 파라미터를 만드는 경우다. 둘째는 "UI 이벤트는 자주 오지만, 실제로 화면이 바뀌는 조건은 좁다" 유형이다. 디바운스/로딩/중복 클릭 방지가 여기에 들어간다.

한 문단 요약: derivedStateOf는 계산 최적화 도구가 아니라 "state 읽기 범위를 잘라" 리컴포지션 전파를 줄이는 도구이다. 원본 state는 자주 변하고 파생 값은 드물게 변할 때, Slot Table 재사용과 invalidation 억제가 같이 걸려 체감이 크게 난다.

사례 1: 스크롤 기반 헤더 축소 + derivedStateOf

LazyListState.firstVisibleItemIndex/Offset은 스크롤 중 프레임마다 바뀐다. 이 값을 상위 Scaffold에서 직접 읽으면 상단바/콘텐츠 전체가 계속 invalidation 된다. 실제로 디버깅할 때 "Choreographer: Skipped 30 frames" 로그를 본 적이 있다. 원인은 리스트 아이템이 아니라 상단 전체가 리컴포지션되는 구조였다.

해결은 스크롤 값을 Boolean 같은 작은 파생 값으로 내리고, 그 Boolean을 읽는 UI만 바뀌게 만드는 것이다. 스크롤이 1px 움직일 때마다 상단바 텍스트가 바뀔 필요는 없다. 임계값을 넘는 순간만 바뀌면 된다.

CollapsingHeaderScreen.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.fillMaxSize
3import androidx.compose.foundation.lazy.LazyColumn
4import androidx.compose.foundation.lazy.rememberLazyListState
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.remember
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.tooling.preview.Preview
14
15@Composable
16fun CollapsingHeaderScreen() {
17    val listState = rememberLazyListState()
18
19    val collapsed by remember {
20        derivedStateOf {
21            listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 80
22        }
23    }
24
25    Column(Modifier.fillMaxSize()) {
26        Text(
27            text = if (collapsed) "Header (collapsed)" else "Header (expanded)",
28            style = MaterialTheme.typography.titleLarge
29        )
30        LazyColumn(state = listState) {
31            items(200, key = { it }) { Text("Row $it") }
32        }
33    }
34}
35
36@Preview(showBackground = true)
37@Composable
38private fun PreviewCollapsing() {
39    MaterialTheme { Surface { CollapsingHeaderScreen() } }
40}

이 코드를 실행하면 스크롤이 매 프레임 바뀌어도 헤더 텍스트는 임계값을 넘는 순간만 바뀐다. 내부적으로는 derivedStateOf 계산 블록이 listState의 스냅샷 값을 읽고, 결과 Boolean이 이전과 같으면 헤더를 읽는 그룹은 invalidation 되지 않는다. 스크롤 중에도 상단 전체가 깜빡이지 않는지 Layout Inspector에서 확인 가능하다.

사례 2: AdvancedButton 구현(loading, debounce, icon+text, long press, a11y)

버튼은 클릭 이벤트가 자주 들어오고, 상태는 로딩/활성/비활성이 섞인다. 여기서 derivedStateOf는 "지금 클릭을 받아도 되는가" 같은 파생 규칙을 고정해서, UI 트리의 넓은 부분이 onClick 람다 캡처 변화로 흔들리는 것을 막는 데 쓴다.

내 삽질 경험 하나를 적는다. 예전에 debounce를 remember 없이 구현했다가, 리컴포지션 때마다 마지막 클릭 시간이 초기화되어 중복 클릭이 그대로 통과했다. 서버에서 429 Too Many Requests가 터지고, 로그에는 같은 요청이 50ms 간격으로 3번 찍혔다. 원인은 "상태를 remember로 Slot Table에 고정하지 않았다"였다.

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.padding
6import androidx.compose.foundation.layout.size
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.Alignment
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.semantics.contentDescription
21import androidx.compose.ui.semantics.semantics
22import androidx.compose.ui.unit.Dp
23import androidx.compose.ui.unit.dp
24
25@Composable
26fun AdvancedButton(
27    text: String,
28    modifier: Modifier = Modifier,
29    icon: (@Composable (() -> Unit))? = null,
30    loading: Boolean = false,
31    enabled: Boolean = true,
32    debounceMs: Long = 600L,
33    progressSize: Dp = 18.dp,
34    accessibilityLabel: String = text,
35    onClick: () -> Unit,
36    onLongClick: (() -> Unit)? = null
37) {
38    var lastClickAt by remember { mutableLongStateOf(0L) }
39
40    val clickable by remember(loading, enabled, debounceMs) {
41        derivedStateOf {
42            enabled && !loading && debounceMs >= 0
43        }
44    }
45
46    val semanticsLabel by remember(accessibilityLabel, loading) {
47        derivedStateOf {
48            if (loading) "$accessibilityLabel, loading" else accessibilityLabel
49        }
50    }
51
52    Surface(
53        modifier = modifier
54            .semantics { contentDescription = semanticsLabel }
55            .combinedClickable(
56                enabled = clickable,
57                onClick = {
58                    val now = SystemClock.elapsedRealtime()
59                    if (now - lastClickAt >= debounceMs) {
60                        lastClickAt = now
61                        onClick()
62                    }
63                },
64                onLongClick = onLongClick
65            ),
66        color = if (clickable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
67        contentColor = if (clickable) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
68        tonalElevation = 2.dp
69    ) {
70        Row(
71            verticalAlignment = Alignment.CenterVertically,
72            modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
73        ) {
74            if (loading) {
75                CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(progressSize))
76                Spacer(Modifier.size(10.dp))
77            } else if (icon != null) {
78                icon()
79                Spacer(Modifier.size(10.dp))
80            }
81            Text(text)
82        }
83    }
84}

여기서 derivedStateOf는 두 군데다. clickable은 로딩/활성/디바운스 설정이 바뀌는 순간에만 의미가 있다. semanticsLabel도 로딩 상태가 바뀔 때만 변경되면 된다. 반대로 lastClickAt은 이벤트 처리용 내부 상태라 derivedStateOf로 감싸지 않는다. 클릭 가능 여부를 파생 값으로 분리해두면, 버튼 외부에서 enabled/loading이 바뀔 때 Surface/semantics/interaction이 한 덩어리로 매번 흔들리는 상황을 줄일 수 있다.

AdvancedButtonDemo.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.Spacer
3import androidx.compose.foundation.layout.height
4import androidx.compose.material3.Icon
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Surface
7import androidx.compose.material3.Text
8import androidx.compose.material3.icons.Icons
9import androidx.compose.material3.icons.filled.Favorite
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.runtime.getValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.tooling.preview.Preview
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun AdvancedButtonDemo() {
21    var loading by remember { mutableStateOf(false) }
22
23    Column {
24        AdvancedButton(
25            text = if (loading) "Saving" else "Save",
26            icon = { Icon(Icons.Default.Favorite, contentDescription = null) },
27            loading = loading,
28            accessibilityLabel = "Save item",
29            onClick = { loading = true },
30            onLongClick = { loading = !loading }
31        )
32        Spacer(Modifier.height(12.dp))
33        Text("짧게 클릭하면 loading=true, 길게 누르면 토글")
34    }
35}
36
37@Preview(showBackground = true)
38@Composable
39private fun PreviewAdvancedButtonDemo() {
40    MaterialTheme { Surface { AdvancedButtonDemo() } }
41}

두 번째 삽질 경험도 남긴다. accessibilityLabel을 매 프레임 바뀌는 값(예: 현재 시간)으로 넣어두고, UI 테스트에서 onNodeWithContentDescription이 계속 실패한 적이 있다. 테스트 로그에는 "Failed to assert that any node exists"가 찍혔다. 원인은 semantics 트리가 계속 바뀌었기 때문이다. derivedStateOf로 로딩 같은 의미 있는 변화에만 라벨이 바뀌게 만들면, 접근성/테스트 안정성도 같이 올라간다.

자주 하는 실수

실수 1: remember 없이 derivedStateOf를 생성함

증상: 입력 한 번 할 때마다 파생 값이 새로 만들어지고, 기대했던 캐시가 전혀 먹지 않는다. Logcat에 derived 계산 로그를 찍으면 리컴포지션마다 항상 실행된다.

원인: derivedStateOf가 Slot Table에 고정되지 않는다. 매 리컴포지션마다 새 derived state 객체가 생기면, 이전 객체가 가지고 있던 의존성 그래프와 캐시된 결과가 버려진다.

해결: remember { derivedStateOf { ... } }로 객체를 고정한다. 파생 규칙 자체가 바뀌는 경우에만 remember(key)로 재생성한다.

실수 2: remember(query) { derivedStateOf { query ... } }로 키를 잘못 잡음

증상: query가 바뀔 때마다 derived state가 재생성되어, query를 직접 읽는 것과 거의 같은 리컴포지션 패턴이 나온다. Layout Inspector에서 derived state를 읽는 하위가 계속 하이라이트된다.

원인: 파생 규칙은 고정인데, 입력값(query)을 키로 넣어버려 derivedState 객체 자체가 매번 교체된다. 교체 순간마다 값 비교 최적화가 사라진다.

해결: 보통 remember { derivedStateOf { query ... } }가 맞다. 키는 "규칙"이 바뀌는 조건(예: ignoreCase 플래그, threshold 값)이어야 한다.

실수 3: 파생 값이 원본과 같은 빈도로 변하는데도 기대함

증상: derivedStateOf를 넣었는데도 Recomposition Count가 거의 줄지 않는다. 오히려 코드만 복잡해졌다고 느낀다.

원인: query를 그대로 문자열로 노출하거나, 파생 값이 매번 다른 리스트 인스턴스가 되는 구조다. equals 비교가 매번 true가 아니라면 invalidation 억제가 불가능하다.

해결: 파생 값을 Boolean/Int/enum처럼 변화 빈도가 낮은 형태로 만든다. 리스트는 key를 안정적으로 주고, 필요하면 "effectiveQuery"처럼 임계값/정규화를 적용해 변화 횟수를 줄인다.

실수 4: derivedStateOf 안에서 불안정 객체를 만들어 매번 equals 비용을 폭발시킴

증상: 프레임 드랍은 줄지 않는데 CPU 사용률이 올라간다. 프로파일러에서 equals/컬렉션 연산이 상위에 뜬다.

원인: derivedStateOf 결과가 큰 List/Map이고, 매번 새로 만들어 equals 비교가 O(n)으로 돈다. 값이 같아도 비교 비용이 비싸다.

해결: 파생 값으로 큰 컬렉션을 직접 노출하기보다, 화면에 필요한 최소 요약값(count, empty 여부, 페이지 키)로 경계를 만든다. 컬렉션은 구조 공유가 가능한 데이터 구조를 쓰거나, 변경 지점을 줄인다.

실수 5: 상위 컴포저블에서 원본 state를 읽어 경계를 무너뜨림

증상: derivedStateOf를 썼는데도 상단 Scaffold/Surface가 계속 리컴포지션된다. 리스트만 최적화하고 싶었는데 화면 전체가 깜빡인다.

원인: 상위에서 query 같은 원본 state를 읽는 순간, 그 그룹이 state 의존성을 가진다. 하위에서 derivedStateOf로 잘라놔도 상위 invalidation은 그대로 전파된다.

해결: 입력/표시/리스트를 컴포저블로 분리하고, 상위에는 원본 state를 되도록 노출하지 않는다. 하위로 내려보내는 값은 파생된 최소 정보로 제한한다.

WrongKeyExample.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.derivedStateOf
3import androidx.compose.runtime.getValue
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.remember
6import androidx.compose.runtime.setValue
7
8@Composable
9fun WrongKeyExample() {
10    var query by remember { mutableStateOf("") }
11
12    // 나쁜 예: query가 바뀔 때마다 derivedState 객체가 새로 생긴다.
13    val flagBad by remember(query) { derivedStateOf { query.length >= 2 } }
14
15    // 좋은 예: 규칙이 고정이면 키 없이 고정한다.
16    val flagGood by remember { derivedStateOf { query.length >= 2 } }
17
18    // UI는 생략. Layout Inspector에서 flagBad/flagGood를 읽는 위치로 차이를 확인한다.
19}

이 코드에서 flagBad는 query가 바뀔 때마다 derivedState 객체가 교체된다. flagGood는 객체는 고정이고 값만 갱신된다. 차이가 미묘해 보여도, 큰 화면에서 파생 값이 여러 군데로 퍼지면 invalidation 패턴이 달라진다.

성능 최적화 체크리스트

  • derivedStateOf는 항상 remember로 감싸 Slot Table에 고정했는가
  • remember의 key는 입력값이 아니라 파생 규칙이 바뀌는 조건으로 잡았는가
  • 파생 값이 원본보다 덜 자주 변하도록 정규화(trim, threshold, enum화)를 적용했는가
  • 상위 컨테이너(Scaffold/Surface)에서 원본 state를 읽지 않도록 컴포저블 경계를 분리했는가
  • LazyColumn에는 items(key=...)를 제공해 Slot Table 재사용이 가능하게 했는가
  • derivedStateOf 결과가 큰 컬렉션이면 equals 비용(O(n))이 병목이 아닌지 확인했는가
  • 파생 값으로 컬렉션 전체를 내보내기보다 count/empty/selectedId 같은 최소 정보로 쪼갰는가
  • 불필요한 객체 할당(새 List, 새 data class)을 입력 이벤트마다 만들지 않도록 했는가
  • Layout Inspector에서 Recomposition Count와 highlight로 실제 범위를 확인했는가
  • Logcat 또는 tracing으로 아이템 컴포저블 호출 횟수를 숫자로 확인했는가
  • @Stable/@Immutable이 필요한 타입(파라미터로 자주 전달되는 모델)에 안정성 표시를 검토했는가
  • snapshotFlow/LaunchedEffect를 쓰는 경우, UI 내부 최적화(derivedStateOf)와 외부 스트림(Flow)을 역할로 분리했는가

자주 묻는 질문

derivedStateOf는 언제 효과가 크고, 언제 의미가 거의 없나?

효과가 큰 경우는 원본 state가 자주 변하지만, UI가 실제로 필요로 하는 파생 값은 드물게 변할 때다. 예를 들어 스크롤 오프셋(Int)은 프레임마다 바뀌지만, 헤더 축소 여부(Boolean)는 임계값을 넘을 때만 바뀐다. 이때 derivedStateOf는 Boolean이 이전과 같은 동안 하위 컴포지션 그룹을 invalidation 큐에 올리지 않을 수 있다. 반대로 query 문자열을 그대로 보여주는 텍스트처럼 원본과 파생 값이 같은 빈도로 변하면, derivedStateOf는 리컴포지션을 줄이지 못한다. 이때는 읽기 위치를 분리하거나(입력/리스트 분리), 파생 값을 threshold/정규화로 덜 변하게 만들어야 한다. 검색 키워드로는 state read tracking, invalidation, recomposition scope를 같이 잡는 게 좋다.

remember { derivedStateOf { ... } }에서 remember key는 언제 넣어야 하나?

키는 파생 규칙이 바뀌는 경우에만 필요하다. 예를 들어 ignoreCase 옵션, 필터 임계값, 사용자 설정에 따라 파생 계산식 자체가 달라지는 경우다. 입력값(query)을 키로 넣으면 query가 바뀔 때마다 derived state 객체가 새로 만들어져 Slot Table에 저장된 객체가 교체된다. 그러면 이전 결과 캐시와 의존성 그래프가 버려지고, 값 비교 기반의 invalidation 억제가 깨진다. 대부분의 UI에서는 규칙은 고정이고 입력만 바뀌므로 키 없이 remember { derivedStateOf { query ... } }가 맞다. 학습 키워드는 remember key semantics, slot table reuse, derivedState lifecycle이다.

derivedStateOf가 Slot Table에 저장된다는 게 실제로 무슨 의미인가?

Compose는 컴포저블 호출 트리를 그룹 단위로 Slot Table에 기록한다. remember는 해당 그룹의 슬롯에 값을 저장하고, 다음 재구성에서 같은 호출 경로로 들어오면 그 슬롯에서 값을 그대로 꺼낸다. derivedStateOf를 remember로 감싸면 derived state 객체가 재구성 간에 동일 인스턴스로 유지된다. 이 인스턴스는 (1) 계산 블록에서 읽은 state 의존성, (2) 마지막 계산 결과, (3) 결과 변경 여부 판단을 위한 메타데이터를 가진다. 동일 인스턴스를 유지해야만 "이전 결과와 비교"가 가능하고, 결과가 같을 때 하위 invalidation을 억제할 수 있다. 반대로 매번 새 인스턴스면 비교 기준이 없어져 최적화가 사라진다. 키워드는 SlotTable, remember slot, derived state caching이다.

derivedStateOf를 써도 LazyColumn이 계속 다시 그려지는 이유는 뭔가?

대표 원인은 두 가지다. 첫째, LazyColumn에 전달되는 리스트가 매번 새 인스턴스로 만들어져 items 블록이 다시 평가되는 경우다. derivedStateOf 안에서 filter를 하면 리스트는 새로 만들어지므로, key를 안정적으로 주지 않으면 아이템 그룹 매칭이 흔들린다. 둘째, 상위에서 원본 state를 읽어 LazyColumn이 포함된 큰 그룹이 invalidation 되는 구조다. 예를 들어 Column에서 query를 읽고 그 아래에 LazyColumn이 있으면 query 변경이 Column 그룹을 invalidation 한다. 해결은 items(key=...) 적용, 입력/리스트 컴포저블 분리, effectiveQuery 같은 파생 값으로 리스트 갱신 조건을 좁히는 방식이다. 디버깅은 Layout Inspector recomposition highlight와 Logcat 아이템 호출 로그를 같이 쓰면 원인이 바로 드러난다.

derivedStateOf와 rememberUpdatedState는 어떤 관계이고, 같이 쓰는 이유가 있나?

둘은 목적이 다르다. derivedStateOf는 스냅샷 상태로부터 파생된 값을 캐시하고, 그 결과가 바뀔 때만 파생 값을 읽는 컴포지션을 invalidation 대상으로 만들기 위한 도구다. rememberUpdatedState는 LaunchedEffect, DisposableEffect 같은 long-lived effect가 최신 람다/값을 참조하게 만드는 도구다. 예를 들어 버튼의 onClick 람다는 외부 상태에 따라 바뀔 수 있는데, effect 내부에서 오래 잡고 있으면 오래된 람다를 호출하는 버그가 생긴다. 이때 rememberUpdatedState로 최신 onClick을 보장하고, UI 쪽에서는 derivedStateOf로 clickable/label 같은 파생 값을 안정적으로 만든다. 학습 키워드는 stale lambda, effect capture, derived state vs updated state이다.

derivedStateOf 결과가 List 같은 컬렉션이면 equals 비교 비용이 문제 되지 않나?

문제가 될 수 있다. derivedStateOf는 결과가 바뀌었는지 판단하기 위해 equals를 사용한다. List의 equals는 요소를 순차 비교하므로 O(n)이다. 입력 이벤트가 자주 발생하고 리스트가 크면, 리컴포지션은 줄어도 equals 비교로 CPU를 태울 수 있다. 해결 방향은 (1) 파생 값으로 컬렉션 전체를 노출하지 말고 size, empty 여부, 선택된 id 같은 요약값을 먼저 파생해 읽기 경계를 만든다, (2) 컬렉션 생성 자체를 줄이도록 effectiveQuery 임계값을 두거나 서버/DB 계층에서 페이징한다, (3) LazyColumn key를 안정적으로 줘서 아이템 재구성을 최소화한다. 프로파일링은 Android Studio CPU profiler에서 equals/hot path를 확인하고, 필요하면 tracing(Perfetto)을 붙여 프레임 구간을 본다.

Compose Compiler가 changed 플래그를 만든다는데, derivedStateOf 최적화와 어떤 식으로 겹치나?

changed 플래그는 주로 "파라미터가 이전과 달라졌는지"를 빠르게 판단해 스킵 가능한 그룹을 건너뛰는 데 쓰인다. State 변경은 별도의 invalidation 메커니즘으로 컴포지션 그룹을 다시 호출하게 만든다. derivedStateOf는 State 변경이 발생해도, 파생 결과가 이전과 같으면 derivedState.value를 읽는 그룹을 invalidation에서 제외할 수 있는 여지를 만든다. 즉, derivedStateOf는 invalidation 입력을 더 좁게 만들고, changed 플래그는 재호출된 뒤 스킵을 더 많이 만들 수 있다. 둘은 레이어가 다르다. 디버깅 관점에서는 (1) 어떤 state를 어디서 읽었는지(읽기 추적), (2) 재호출된 그룹이 어떤 이유로 스킵되지 않았는지(파라미터 변경/불안정 타입)를 분리해서 보는 게 효율적이다. 키워드는 composer changed flags, group skipping, stability inference이다.

관련 글

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

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

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

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

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

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

25. Compose State와 MutableState: remember가 UI를 자동 갱신하는 이유
Compose 기본2026.03.05

25. Compose State와 MutableState: remember가 UI를 자동 갱신하는 이유

Jetpack Compose의 State/MutableState, remember가 필요한 이유, Slot Table 저장 방식과 리컴포지션 범위를 내부 동작 관점에서 설명한다. 초보가 흔히 겪는 버그까지 다룬다. 2026-03 기준 실무 팁 포함.