DerivedState: 의존성 추적 없이 해시 기반으로 무효화하는 메커니즘
DerivedState: 의존성 추적 없이 해시 기반으로 무효화하는 메커니즘
Compose의 derivedStateOf는 계산된 결과가 실제로 변경되었을 때만 리컴포지션(Recomposition)을 트리거하는 파생 상태(derived state)를 생성하는 수단을 제공합니다. val fullName by remember { derivedStateOf { "${firstName.value} ${lastName.value}" } }와 같이 작성하면, Compose는 계산 과정에서 읽힌 상태 객체를 자동으로 추적하고 재계산이 필요한 시점을 지능적으로 판단합니다. derivedStateOf가 중간 상태 변경으로 인한 불필요한 리컴포지션을 방지한다는 사실은 대부분의 개발자가 알고 있지만, 더 근본적인 질문이 남아 있습니다. 명시적으로 의존성을 추적하지 않으면서 어떻게 재계산 시점을 알 수 있을까요? 그리고 단순한 remember { 계산값 } 방식과는 무엇이 다를까요?
이 글에서는 derivedStateOf의 내부 메커니즘을 깊이 있게 탐구합니다. Snapshot.observe() 메커니즘이 계산 과정에서 의존성을 캡처하는 방식, 중첩 레벨(nesting level) 시스템이 직접 읽기와 간접 읽기를 구분하는 원리, 해시 기반 검증이 값 비교 없이 무효화를 판별하는 과정, ResultRecord 구조가 스냅샷 간에 결과를 캐시하는 방법, 그리고 동등성 정책(equivalence policy)이 값이 동일할 때 할당 없이 업데이트를 처리하는 원리를 살펴봅니다. 이 글은 derivedStateOf의 사용법을 안내하는 가이드가 아니라, 지능적인 상태 파생을 가능하게 만드는 런타임 내부 구조를 깊이 탐구하는 글입니다.
근본적인 문제: 지나치게 자주 리컴포지션되는 계산값
필터 기능이 있는 검색 화면을 상상해 보겠습니다.
@Composable
fun SearchScreen() {
var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<Category?>(null) }
var items by remember { mutableStateOf(listOf<Item>()) }
val filteredItems = items.filter { item ->
item.name.contains(searchQuery, ignoreCase = true) &&
(selectedCategory == null || item.category == selectedCategory)
}
LazyColumn {
items(filteredItems) { item ->
ItemCard(item)
}
}
}
위 코드에서는 어떤 상태든 변경될 때마다 filteredItems가 매번 재계산됩니다. 더 문제가 되는 것은, 필터 결과가 동일하더라도 Compose가 새로운 리스트 객체로 인식하기 때문에 리컴포지션이 발생한다는 점입니다. 항목이 10,000개라면 성능 문제로 직결됩니다.
가장 먼저 떠오르는 해결책은 remember를 활용한 메모이제이션(memoization)입니다.
val filteredItems = remember(searchQuery, selectedCategory, items) {
items.filter { ... }
}
이 방식도 도움이 되지만, 모든 의존성을 수동으로 지정해야 합니다. 의존성을 하나라도 빠뜨리면 오래된 결과가 반환되고, 불필요한 의존성을 추가하면 과도한 재계산이 발생합니다.
derivedStateOf는 이 두 가지 문제를 모두 해결합니다.
val filteredItems by remember {
derivedStateOf {
items.filter { item ->
item.name.contains(searchQuery, ignoreCase = true) &&
(selectedCategory == null || item.category == selectedCategory)
}
}
}
의존성은 자동으로 추적됩니다. 의존성이 변경될 때만 재계산이 수행됩니다. 결과가 변경될 때만 리컴포지션이 발생합니다. 그렇다면 명시적으로 의존성을 선언하지 않고도 어떻게 이러한 동작이 가능한 것일까요?
DerivedState 아키텍처: 캐시된 결과를 가진 StateObject
derivedStateOf 함수는 DerivedSnapshotState를 생성하며, Compose의 스냅샷 시스템과 통합하면서 캐시된 계산 결과를 관리합니다.
private class DerivedSnapshotState<T>(
private val calculation: () -> T,
override val policy: SnapshotMutationPolicy<T>?,
) : StateObjectImpl(), DerivedState<T> {
private var first: ResultRecord<T> = ResultRecord(currentSnapshot().snapshotId)
}
다른 상태 객체와 마찬가지로 DerivedSnapshotState는 서로 다른 스냅샷에 대한 레코드의 연결 리스트를 유지합니다. mutableStateOf와 다른 점은, 단순히 값만 저장하는 것이 아니라 재계산이 필요한 시점을 판단하기 위한 메타데이터도 함께 보관한다는 것입니다.
ResultRecord 구조
각 ResultRecord는 하나의 완전한 계산 스냅샷을 담고 있습니다.
class ResultRecord<T>(snapshotId: SnapshotId) :
StateRecord(snapshotId), DerivedState.Record<T> {
companion object {
val Unset = Any()
}
var validSnapshotId: SnapshotId = SnapshotIdZero
var validSnapshotWriteCount: Int = 0
override var dependencies: ObjectIntMap<StateObject> = emptyObjectIntMap()
var result: Any? = Unset
var resultHash: Int = 0
}
네 가지 필드가 긴밀하게 협력하여 지능적인 캐싱을 구현합니다.
dependencies: ObjectIntMap<StateObject> 타입으로, 계산 과정에서 읽힌 각 상태 객체를 해당 중첩 레벨과 함께 매핑합니다. 자동으로 추적된 의존성 목록이 바로 이 필드에 저장됩니다.
result: 캐시된 계산 결과이며, 아직 계산되지 않은 경우에는 Unset 센티널 값을 가집니다.
resultHash: 의존성의 상태 레코드로부터 계산된 해시값입니다. 값 비교 없이 빠른 무효화 검사를 할 수 있게 해 주는 핵심 필드입니다.
validSnapshotId와 validSnapshotWriteCount: 캐시 유효성 표지(marker)로, 이 결과가 어떤 스냅샷에서 계산되었는지와 당시 쓰기 횟수를 기록합니다. 이를 통해 빠른 경로(fast path) 검증이 가능해집니다.
의존성 추적: 계산 과정에서 읽기 캡처하기
파생 상태의 .value에 접근하면, 런타임은 먼저 캐시된 결과가 여전히 유효한지 판단해야 합니다. 유효하지 않으면 모든 상태 읽기를 관찰하면서 계산을 다시 실행합니다.