아티클 목록으로 가기

DerivedState: 의존성 추적 없이 해시 기반으로 무효화하는 메커니즘

skydovesJaewoong Eum (skydoves)||12분 소요

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: 의존성의 상태 레코드로부터 계산된 해시값입니다. 값 비교 없이 빠른 무효화 검사를 할 수 있게 해 주는 핵심 필드입니다.

validSnapshotIdvalidSnapshotWriteCount: 캐시 유효성 표지(marker)로, 이 결과가 어떤 스냅샷에서 계산되었는지와 당시 쓰기 횟수를 기록합니다. 이를 통해 빠른 경로(fast path) 검증이 가능해집니다.

의존성 추적: 계산 과정에서 읽기 캡처하기

파생 상태의 .value에 접근하면, 런타임은 먼저 캐시된 결과가 여전히 유효한지 판단해야 합니다. 유효하지 않으면 모든 상태 읽기를 관찰하면서 계산을 다시 실행합니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기