아티클 목록으로 가기

derivedStateOf 내부 구조: 관찰 비용의 실체와 왜 derivedStateOf가 비싼 연산인지 알아보기

skydovesJaewoong Eum (skydoves)||6분 소요

derivedStateOf 내부 구조: 관찰 비용의 실체와 왜 derivedStateOf가 비싼 연산인지 알아보기

Jetpack Compose의 derivedStateOf API는 기존 상태(state)로부터 파생된 메모이제이션 상태를 생성하는 편리한 메커니즘을 제공합니다. 의존하고 있는 상태가 변경될 때 자동으로 업데이트되므로 많은 시나리오에서 성능 최적화에 필수적이지만, 흔히 "비싼(expensive) 연산"이라는 평가를 받기도 합니다.

이 글에서는 DerivedSnapshotState의 내부 구현을 분석하여 이 비용의 실체를 파헤칩니다. 핵심은 derivedStateOf의 비용이 읽기(read) 연산 자체에 있는 것이 아니라, 의존성을 추적하고, 캐시된 값을 검증하며, 재계산을 수행하는 복잡한 내부 메커니즘에 있다는 점입니다. isValid, currentRecord, Snapshot.observe 호출 과정을 살펴보면서, 정교한 의존성 추적과 해싱, 트랜잭션 기반 레코드 관리가 derivedStateOf를 보편적으로 쓰는 도구가 아닌 신중하게 사용해야 할 정밀 도구로 만드는 이유를 함께 알아보겠습니다.

1. 서론: derivedStateOf가 약속하는 것과 그에 따른 비용

공개 API 자체는 매우 단순해 보입니다.

public fun <T> derivedStateOf(calculation: () -> T): State<T> =
    DerivedSnapshotState(calculation, null)

calculation 람다를 실행하고 결과를 캐시한 뒤, 내부에서 읽힌 State 객체 중 하나라도 변경되면 해당 계산을 다시 수행하겠다는 약속입니다. 간결한 API 뒤에 숨어 있는 복잡한 동작 원리를 이해하는 것이 이 글의 목표입니다.

다음 예제를 살펴보겠습니다.

// @Composable 함수 내부
val firstName by remember { mutableStateOf("John") }
val lastName by remember { mutableStateOf("Doe") }

// 직관적이지만 올바르지 않은 derivedStateOf 사용 예
val fullName by remember { derivedStateOf { "$firstName $lastName" } }

논리 자체는 맞습니다. fullNamefirstNamelastName으로부터 파생되니까요. 하지만 이 사용법은 올바르지 않으며 비효율적입니다. 왜 이렇게 단순한 연산에 derivedStateOf를 쓰면 안 되는지, 내부 구조를 이해하고 나면 자연스럽게 납득이 될 것입니다.

@Composable 함수 내부에서는 다음과 같이 작성하는 것이 올바르고 효율적인 방법입니다.

// @Composable 함수 내부에서 올바르고 효율적인 방식
val firstName by remember { mutableStateOf("John") }
val lastName by remember { mutableStateOf("Doe") }

val fullName = "$firstName $lastName" // 리컴포지션 시 자연스럽게 재계산됨

그렇다면 최적화를 위한 API인데 왜 "비싸다"고 하는 걸까요? 그 비용은 바로 약속을 이행하기 위해 필요한 정교한 내부 메커니즘에 있습니다. 지금부터 이 메커니즘을 하나씩 살펴보겠습니다.

2. 핵심 메커니즘: 캐싱과 의존성 추적

DerivedSnapshotState는 값을 직접 저장하지 않습니다. 대신 마지막으로 계산된 결과와 그 결과를 만들어 낸 의존성 목록을 레코드(record) 형태로 저장합니다. 이 구조를 이해하는 것이 전체 동작 원리를 파악하는 출발점입니다.

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

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

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