https://proandroiddev.com/recomposition-all-in-one-5bd1f4aedf8b
Reducing Unnecessary Recompositions: 3 Practical Optimization Techniques for Jetpack Compose
This guide explains how to reliably eliminate unnecessary recompositions.
proandroiddev.com
컴포즈는 선언적 UI구조기 때문에
상태 변화 -> 재구성 -> UI 업데이트 흐름을 따릅니다.
하지만 상태를 잘못 다루면 불필요한 Recompositions이 발생하게 되고,
이는 성능 저하로 직결됩니다.
이 글에서는 잘못된 예제와 올바른 예제를 비교하며 Recomposition을 줄이는 기법을 설명합니다.
상태는 가능한 좁은 범위에서만 읽기
잘못된 예제
@Composable
private fun Parent() {
var count by remember { mutableIntStateOf(0) }
// Parent reads state directly → Parent undergoes full recomposition
ChildA(count = count)
}
@Composable
private fun ChildA(count: Int) {
Text("Count: $count")
}
Compose 에서는 상태를 선언할 때 by를 자주사용합니다.
by 키워드는 프로퍼티 위임을 의미하며
내부적으로 State 객체에 value 접근을 위임합니다.
즉 변수를 단순히 참조하는 것만으로도 내부적으로 state.value를 읽게됩니다.
Compose는 이러한 value 읽기를 추적하며,
상태가 변경 되면 그 값을 읽은 컴포저블만 자동으로 재구성합니다.
따라서 부모 컴포저블이 상태를 직접 사용하지않으면서 자식에게 내려보내는 경우
부모가 해당 상태를 읽지 않도록 구조를 잡아야합니다.
그렇지 않으면 부모 내부에 모든 컴포저블이 불필요한 재구성의 영향을 받을 수 있습니다.
정확한 예제
@Composable
fun Parent() {
val count = remember { mutableStateOf(0) }
// Pass a lambda that reads the state
ChildB(count = { count.value })
// Pass without reading the state
ChildC(count = count)
}
@Composable
fun ChildB(count: () -> Int) {
Text("Count: ${count()}") // Here, the state is actually read.
}
@Composable
fun ChildC(count: State<Int>) {
Text("Count: ${count.value}") // Here, the state is actually read.
}
이 문제를 피하는 방법은 두 가지가 있습니다.
첫째는 상태를 직접 읽지 않고 State<T> 객체 자체를 자식에게 전달하는 방식입니다.
부모가 state.value를 호출하지 않는다면 부모는 상태를 읽은 것으로 추적되지 않으며
오직 자식 컴포저블이 필요할 때만 state.value를 읽고 재구성이 발생합니다.
둘째, 상태 접근을 람다로 전달하는 방식입니다.
람다는 값이 아니라 코드 이므로, 람다 내부에서 state.value를 일는 동작은 실제 호출 시점에만 실행됩니다.
즉 상태 읽기 타이밍이 호출시점으로 지연되며
실제로 필요한 위치에서만 읽게되는 장점이 있습니다.
왜 이러한 방식이 가능한가요?
컴포즈의 상태는 SnapShot System에 의해 관리되며,
모든 상태 읽기와 쓰기 연산은 스냅샷 단위로 추적됩니다.
SnapShot 시스템은 상태 변화가 발생하면 이를 감지하고,
그 상태를 읽었던 컴포저블 (Composable 함수의 실행 범위)를 재실행해야합니다.
즉 스냅샷은 상태 변화로 인해 발생하는 재구성 트리거를 추적하는 역할을 합니다.
이러한 추적이 가능하려면,
상태가 읽힐 때 해당 read 연산이 전역 스냅샷에 등록되어야 합니다.
이 과정을 통해 Compose는 "어떤 컴포저블이 어떤 상태를 읽었는지" 기록할 수 있고,
나중에 그 상태가 변경되면 정확한 범위만 재 구성할 수 있게 됩니다.
// Inside the mutableStateOf implementation
override var value: T
get() = next.readable(this).value
set(value) =
next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
이 코드에서 value라는 프로퍼티를 볼 수 있습니다.
이 value가 우리가 사용해온 state.value에 해당합니다.
이제 이 getter를 조금 더 자세히 살펴봅시다.
public fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
// This is where the snapshot system indicates that the state has been read.
snapshot.readObserver?.invoke(state)
return readable(this, snapshot.snapshotId, snapshot.invalid)
?: sync {
val syncSnapshot = Snapshot.current
@Suppress("UNCHECKED_CAST")
readable(state.firstStateRecord as T, syncSnapshot.snapshotId, syncSnapshot.invalid)
?: readError()
}
}
state.value가 호출되면 스냅샷의 readObserver가 실행되며,
이 readObserver는 현재 어떤 위치에서 이 상태가 읽히고 있는지" 를 스냅샷 시스템에 전달합니다.
이 과정을 통해
컴포저는 "어떤 컴포저블이 어떤 상태를 읽었는지"를 기록할 수 있고,
해당 상태가 변경 될 때 그 부분만 정확히 재 구성 할 수 있습니다.
따라서 불필요한 재구성을 방지하려면
상태는 정말 필요한 시점에서만 읽어야합니다.
즉 단순히 상태를 읽는 위치만 조정하는것만으로도 재구성 범위를 효과적으로 최소화할 수 있습니다.
빠르게 변하는 State를 다루는 방법
잘못된 예
val showTopBar = lazyListState.firstVisibleItemIndex > 20
우리는 때때로 한 상태로부터 다른 상태를 파생 해 사용합니다.
그런데 LazyListState처럼 사용되는 상태가 매우 빠르게 변하는 경우,
재 구성이 매우 자주 발생할 수 있습니다.
올바른 예
// case of derivedStateOf
val showTopBar by remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 20 }
}
// case of snapshotFlow
var showTopBar by remember { mutableStateOf(false) }
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.firstVisibleItemIndex }
.map { it > 20 }
.distinctUntilChanged()
.collect { visible -> showTopBar = visible }
}
빠르게 변하는 상태를 다룰 때에는 두 가지 방법을 사용할 수 있습니다.
첫 번째는 derivedStateOf를 사용하는 것이고
두 번째는 snapShotFlow를 사용하는 것입니다.
derivedStateOf를 사용하면 내부에 계산 블록을 정의할 수 있으며
그 블록에서 사용되는 값이 변경 될 때 해당 상태가 자동으로 갱신됩니다.
즉 상태 변화에 따라 필요한 계산 결과가 자동으로 생성되는 방식입니다.
반면 snapshotFlow는 상태를 Flow로 변환 해
우리가 원하는 방식으로 처리할 수 있게 해줍니다.
계산 로직을 직접 정의 할 수 있기 때문에
상태 변화에 따라 다양한 처리 단계를 유연하게 구현할 수 있습니다.
어떤 상황에서 어떤 방식을 사용해야 할까?
단순히 계산을 통해 파생된 상태가 필요하다면
derivedStateOf를 사용하는 것이 좋습니다.
반면 상태 변화에 따라 비동기 처리가 필요하거나,
토스트 같은 추가적인 효과를 발생시켜야 한다면
snapShotFlow를 사용하는 것이 더 적합합니다.
즉 파생 상태만 필요하다면 derivedStateOf를,
상태 변화로 인해 부수 효과 까지 처리해야 한다면 snapShotFlow를 선택하면 됩니다.
상태 읽기를 가능한 늦추는 방법
잘못된 예
Text(
modifier = Modifier
.align(Alignment.Center)
.offset(y = 60.dp * animatedValue) // The state is read at the time of composition phase.
.scale(animatedValue), // The state is read at the time of composition phase.
text = "Animate"
)
우리는 컴포저블의 위치, 크기, 투명도 등을 동적으로 변경하기 위해 상태와 Modifier를 자주 사용합니다
하지만 빠르게 변하는 상태를 modifier를 통해 컴포지션 단계에서 읽게되면,
해당 상태가 급격히 변할 때마다 불필요한 재구성이 자주 발생할 수 있습니다.
올바른 예
Text(
modifier = modifier
.offset { IntOffset(0, (60 * animatedValue).toInt()) } // read state on layout phase
.drawWithContent { // read state on draw phase
scale(scale = animatedValue) {
this@drawWithContent.drawContent()
}
},
text = "Animate"
)
// or
Text(
modifier = modifier
.offset { IntOffset(0, (60 * animatedValue).toInt()) } // read state on layoutphase
.graphicsLayer { // read state on draw phase
scaleX = animatedValue
scaleY = animatedValue
},
text = "Animate"
)
레이아웃 단계에서 상태를 읽는 람다 형태의 Modifier,
또는 drawWithContent, graphicsLayout 처럼 드로우 단계에서 상태를 읽는 방법들을 활용할 수 있습니다.
왜 상태를 Composition 이후 단계에서 읽어야 할까 ?
컴포즈에서 UI가 화면에 렌더링 되는 과정은 Composition, Layout, Draw의 세 단계로 구성됩니다.
여기서 중요한 점은 Composition 단계에서 상태가 읽히고 그 상태가 변하면 즉시 재구성이 발생한다는 것입니다.
컴포지션 단계는 상태를 기반으로 UI 트리를 구성하는 단계입니다.
UI 트리가 한번 구성되면,
Layout 단계에서 각 컴포넌트의 배치를 계산하고
Draw 단계에서 화면에 실제로 렌더링 합니다.
따라서 Composition 단계에서 상태를 읽으면,
그 상태가 바뀔 때마다 UI 트리를 다시 만들어야 하므로
재구성이 발생합니다.
반면 Layout 또는 Draw 단계에서 상태를 읽으면
위치, 크기, 투명도 같은 UI 속성만 갱신 할 수 있어 재구성을 발생시키지 않습니다.
마무리하며
재 구성이 생각만큼 큰 성능 저하를 유발하는 것은 아닙니다.
그럼에도 불구하고 재구성에 신경을 쓰면
향후 발생 할 수 있는 성능 저하의 잠재적 원인을 하나 이상 제거할 수 있습니다.
'영어 데일리' 카테고리의 다른 글
| RemoteCompose: 컴포즈에서 서버 주도 UI를 위한 또 다른 패러다임 (0) | 2025.12.02 |
|---|---|
| Bosch 안드로이드 개발자 인터뷰 경험 (0) | 2025.12.01 |
| withContext(Dispatchers.IO)와 launch(Dispatchers.IO)의 실제 차이 (0) | 2025.11.24 |
| 핫한 안드로이드 스킬 2025 (0) | 2025.11.18 |
| “Kotlin 2.2.0: 개발 워크플로우를 혁신적으로 바꿀 게임 체인저급 기능들 (0) | 2025.11.11 |