Compose 성능·안정성 — recomposition·@Stable·skippable
왜 recomposition이 성능의 거의 전부인가
Compose는 상태가 바뀌면 그 상태를 읽는 컴포저블 함수를 다시 실행해 UI를 갱신한다 — 이게 recomposition이다([03]). 여기서 성능 문제의 근원이 나온다 — 바뀌지 않아도 될 부분까지 자꾸 다시 실행되면, 매 프레임 안에(60Hz면 16ms) 일을 못 끝내 *렉(jank)*이 보인다. 즉 **Compose가 느린 거의 모든 이유는 "과한 recomposition"**이다. 그래서 성능 최적화의 핵심은 — Compose가 "무엇을 다시 그리고 무엇을 건너뛸지(skippable)"를 어떻게 결정하는지, 그 규칙인 **안정성(stability)**을 이해하는 것이다. 이 장은 [12 성능]의 Compose 심화다. (출처: Android — Compose performance / Stability.)
recomposition 범위 — 읽는 곳만 다시 그린다
좋은 소식은 — Compose가 상태를 실제로 읽는 컴포저블만 recompose한다는 것이다. 그래서 상태를 읽는 범위를 좁히면 영향 범위가 줄어든다. 흔한 실수는 상태를 너무 높은 곳에서 읽어, 그 아래 큰 트리가 통째로 다시 그려지는 것이다.
// ❌ 상위에서 읽음 → count가 바뀌면 형제인 HeavyList까지 recompose
@Composable fun Bad(vm: VM) {
val count by vm.count.collectAsState()
Column { Header(); Text("$count"); HeavyList() } // count 변할 때마다 HeavyList도 재실행
}
// ✅ 읽기를 필요한 곳까지 내림(또는 람다로 지연) → Text만 영향
@Composable fun Good(count: () -> Int) {
Column { Header(); Text("${count()}"); HeavyList() }
}
상태를 읽는 위치가 곧 recompose의 경계다 — 값을 직접 읽는 대신 람다(() -> Int)로 넘겨 읽는 시점을 실제로 그 값이 필요한 가장 안쪽으로 미루면, 무거운 형제(HeavyList)가 불필요하게 다시 그려지지 않는다.
안정성(stability) — skippable의 조건
Compose 컴파일러는 어떤 컴포저블의 파라미터가 모두 "안정(stable)"하면, 그 컴포저블을 skippable로 만든다 — 입력이 안 바뀌었으면 recompose를 건너뛴다. 그럼 "안정"이란 무엇인가? — 값이 바뀌었는지 Compose가 신뢰할 수 있게 판단할 수 있는 타입이다(원시 타입·String·@Stable/@Immutable로 표시한 타입·모든 프로퍼티가 안정인 data class).
문제는 불안정(unstable) 타입이다. *대표적으로 List/Map/Set*이 불안정으로 취급된다 — 왜? List는 읽기 전용 인터페이스지만 실제 구현이 가변 리스트일 수도 있어서, Compose는 "이 List의 내용이 그대로인지" 확신할 수 없다. 그래서 안전하게 "바뀌었을 수도 있다"고 보고 매번 recompose한다. 불안정 파라미터가 하나라도 있으면 그 컴포저블은 skippable이 깨져 입력이 같아도 매번 다시 그려진다.
// ❌ List는 불안정 → skippable 깨져 매번 recompose
@Composable fun MemberList(members: List<Member>) { ... }
// ✅ 불변 컬렉션으로 '진짜 안 바뀜'을 보장(kotlinx.collections.immutable)
@Composable fun MemberList(members: ImmutableList<Member>) { ... }
// 또는 @Immutable로 명시 — '이 타입은 절대 안 바뀐다'는 약속(실제로 불변일 때만!)
@Immutable data class UiState(val items: List<Member>)
읽기 지연 — 람다와 derivedStateOf
*자주 바뀌는 상태(스크롤 오프셋처럼 매 프레임 변하는 것)*를 컴포지션 단계에서 직접 읽으면 — 매 프레임 recompose가 일어난다. 두 가지로 늦춘다. 람다로 넘겨 읽기를 layout/draw 단계로 미루거나(Modifier.offset { ... }는 그 안에서 읽어 recompose 없이 위치만 바꾼다), derivedStateOf로 "결과가 바뀔 때만" recompose하게 한다([04]).
// ❌ firstVisibleItemIndex는 매 스크롤마다 변함 → 매번 recompose
val show = state.firstVisibleItemIndex > 0
// ✅ 불리언 결과가 바뀔 때만 recompose
val show by remember { derivedStateOf { state.firstVisibleItemIndex > 0 } }
Strong Skipping·진단
- Strong Skipping Mode(Compose 컴파일러 2.x) — 불안정 파라미터가 있어도 인스턴스가 같으면 건너뛰고, 람다를 자동 기억해 상당수 skippable 문제를 완화했다. 그래도 불변 컬렉션·안정 모델은 여전히 권장된다(인스턴스가 바뀌면 여전히 재실행되므로).
- 진단 — 추측 말고 Layout Inspector의 recomposition 카운트로 어디가 과하게 다시 그려지는지 본다. Compose 컴파일러 메트릭으로 어떤 컴포저블이 왜 skippable이 아닌지 리포트를 받는다.
- Baseline Profile([12])로 첫 실행 jank를 줄인다.
정리 — 안정성을 지키고 읽기를 좁힌다
Compose 성능의 핵심 — 느림의 원인은 과한 recomposition, 읽는 곳만 recompose되니 상태 읽기를 필요한 곳까지 좁히고(람다로 지연), 파라미터를 안정(stable)하게(불변 컬렉션·@Immutable·안정 data class) 해서 skippable을 유지, 자주 바뀌는 값은 derivedStateOf로 결과가 바뀔 때만, Layout Inspector·컴파일러 메트릭으로 진단·Baseline Profile로 시작 jank 완화. 과한 recomposition을 막는 것이 Compose 성능의 거의 전부다.
한 줄 요약 — 느림=과한 recomposition(바뀌지 않아도 될 게 재실행). 읽는 곳만 recompose→읽기를 좁히고 람다로 지연(
Modifier.offset { }). skippable은 파라미터가 모두 안정일 때 —List/람다는 불안정(내용 변화를 확신 못 해)이라 ImmutableList·@Immutable·안정 data class로 안정화. 자주 바뀌는 값은derivedStateOf. Strong Skipping(컴파일러 2.x)·Layout Inspector 카운트·Baseline Profile.
(출처: Android — Compose performance / Stability / Strong skipping / Diagnose recomposition.)