아티클 목록으로 가기

Recompose Scopes: Compose는 어떻게 업데이트할 대상을 파악하는가

skydovesJaewoong Eum (skydoves)||19분 소요

Recompose Scopes: Compose는 어떻게 업데이트할 대상을 파악하는가

Jetpack Compose의 선언적(declarative) UI 패러다임은 간결함을 약속합니다. 상태의 함수로 UI를 선언하면, 프레임워크가 자동으로 업데이트를 처리합니다. 하지만 이 우아한 추상화 뒤에는 Compose를 놀라울 만큼 효율적으로 만들어 주는 정교한 선택적 리컴포지션(Recomposition) 시스템이 숨어 있습니다. 단 하나의 상태 변수가 변경되더라도 Compose는 전체 UI 트리를 다시 실행하지 않습니다. 해당 상태를 읽은 특정 컴포저블 함수만 정밀하게 리컴포지션합니다. 이러한 정밀도를 가능하게 하는 핵심이 바로 Recompose Scope입니다. Recompose Scope는 상태 읽기와 컴포저블 함수를 연결하고, 최소한의 UI 업데이트를 조율하는 런타임 추적 메커니즘입니다.

이번 아티클에서는 "Recompose Scope"가 어떻게 동작하는지 깊이 있게 살펴봅니다. 구체적으로는 RecomposeScopeImpl이 어떤 컴포저블이 어떤 상태를 읽었는지 추적하는 방식, 무효화(invalidation)가 컴포지션 계층을 통해 전파되는 과정, 컴파일러가 생성하는 restart lambda를 통해 정밀한 리컴포지션이 이루어지는 원리, 시스템이 리컴포지션을 완전히 건너뛰는 시점을 판단하는 기준, 그리고 비트 패킹 플래그와 토큰 기반 추적이 메모리와 성능을 최적화하는 방법을 다루게 됩니다. 이 글은 효율적인 컴포저블 작성법을 안내하는 가이드가 아니라, 선택적 리컴포지션을 가능하게 하는 런타임 내부 동작을 탐구하는 글입니다.

근본적인 문제: 무엇을 리컴포지션해야 하는지 어떻게 알 수 있을까?

다음과 같은 간단한 Compose 코드를 살펴보겠습니다.

@Composable
fun UserProfile(userId: String) {
    val user by viewModel.userState.collectAsState()
    val settings by viewModel.settingsState.collectAsState()

    Column {
        UserHeader(user.name)
        UserAvatar(user.avatarUrl)
        SettingsPanel(settings)
    }
}

user가 변경되면 UserHeaderUserAvatar만 리컴포지션되어야 합니다. SettingsPaneluser를 읽지 않았으므로 리컴포지션할 필요가 없습니다. 그런데 Compose는 이 사실을 어떻게 파악할까요? 단순한 접근 방식은 모든 것을 다시 실행한 뒤 결과를 비교하는 것이지만, 비용이 매우 높습니다. Compose는 런타임에서 어떤 컴포저블이 어떤 상태를 읽었는지 추적해야 하며, 상태가 변경되면 영향을 받는 컴포저블만 다시 실행해야 합니다.

이를 위해서는 다음과 같은 복잡한 문제들을 해결해야 합니다.

  1. 의존성 추적(dependency tracking): 어떤 컴포저블 함수가 어떤 상태 객체를 읽었는가?
  2. 무효화(invalidation): 상태가 변경되면 어떤 스코프를 리컴포지션 대상으로 표시해야 하는가?
  3. 정밀한 재시작(precise restart): 동일한 매개변수로 하나의 컴포저블 함수만 다시 실행하려면 어떻게 해야 하는가?
  4. 스킵(skipping): 의존하는 것이 아무것도 변경되지 않았을 때 함수 실행을 어떻게 건너뛸 수 있는가?
  5. 메모리(memory): 과도한 메모리 오버헤드 없이 의존성을 어떻게 추적할 수 있는가?

Recompose Scope는 컴파일러 협력과 런타임 추적의 조합을 통해 이 문제들을 해결합니다.

RecomposeScopeImpl: 추적 메커니즘의 핵심

리컴포지션이 필요할 수 있는 모든 컴포저블 함수에는 RecomposeScopeImpl 인스턴스가 연결됩니다. Compose 런타임에 정의된 이 클래스는 선택적 리컴포지션을 위한 핵심 장부(bookkeeping) 구조체입니다.

RecomposeScopeImpl 클래스는 컴포저블 함수를 추적하고 재시작하는 데 필요한 모든 정보를 캡슐화합니다.

internal class RecomposeScopeImpl(internal var owner: RecomposeScopeOwner?) :
    ScopeUpdateScope, RecomposeScope, IdentifiableRecomposeScope

비트 플래그 기반의 컴팩트한 상태 저장

RecomposeScopeImpl은 여러 개의 boolean 필드를 사용하는 대신, 하나의 정수에 비트 마스크를 적용하여 상태를 저장합니다. 이 방식은 Compose 런타임 전반에서 자주 사용되는 최적화 기법입니다.

private var flags: Int = 0

private const val UsedFlag = 0x001              // 컴포지션 중 이 스코프가 사용되었는지 여부
private const val DefaultsInScopeFlag = 0x002   // 기본 매개변수 계산이 포함되어 있는지 여부
private const val DefaultsInvalidFlag = 0x004   // 기본 매개변수 계산이 변경되었는지 여부
private const val RequiresRecomposeFlag = 0x008 // 직접적인 무효화가 발생했는지 여부
private const val SkippedFlag = 0x010           // 스코프가 스킵되었는지 여부
private const val RereadingFlag = 0x020         // 추적된 인스턴스를 다시 읽고 있는지 여부
private const val ForcedRecomposeFlag = 0x040   // 강제 리컴포지션 여부
private const val ForceReusing = 0x080          // 강제 재사용 상태 여부
private const val Paused = 0x100                // 일시 중단 가능한 컴포지션의 일시 정지 여부
private const val Resuming = 0x200              // 일시 정지에서 재개 중인지 여부
private const val ResetReusing = 0x400          // 재사용 상태 초기화 여부

이 컴팩트한 표현 방식은 상당한 메모리 절약 효과를 제공합니다. 11개의 boolean 플래그가 11바이트(패딩을 고려하면 그 이상)를 차지하는 대신, 하나의 32비트 정수에 모두 담기기 때문입니다. getter와 setter는 비트 연산을 활용합니다.

private inline fun getFlag(flag: Int) = flags and flag != 0

private inline fun setFlag(flag: Int, value: Boolean) {
    flags = if (value) {
        flags or flag
    } else {
        flags and flag.inv()
    }
}

이 비트 패킹 패턴은 고성능 Compose 코드 전반에서 반복적으로 등장합니다. 자주 할당되는 객체에서는 개별 boolean 필드 대신 비트 패킹을 선호하는 것이 성능에 유리합니다.

앵커(Anchor): 컴포지션 내 위치 식별

스코프의 anchor 필드는 위치를 식별하는 데 핵심적인 역할을 합니다.

var anchor: Anchor? = null

Anchor는 슬롯 테이블(slot table) 내 위치에 대한 안정적인 참조입니다. 슬롯 테이블은 컴포지션 상태를 저장하는 Compose의 내부 데이터 구조입니다. 슬롯 테이블이 수정되어 그룹이 삽입, 제거, 이동되더라도, 앵커는 논리적 위치를 유지하도록 자동으로 조정됩니다.

앵커는 스코프가 처음 사용될 때 생성됩니다.

if (scope.anchor == null) {
    scope.anchor = if (inserting) {
        writer.anchor(writer.parent)
    } else {
        reader.anchor(reader.parent)
    }
}

앵커는 두 가지 목적으로 사용됩니다.

  1. 유효성 검사: 앵커가 무효화되면 해당 컴포저블이 컴포지션에서 제거된 것입니다.
  2. 위치 추적: 스코프가 슬롯 테이블에서 해당 컴포저블이 위치한 곳을 파악할 수 있습니다.

스코프는 소유자(owner)와 유효한 앵커가 모두 존재할 때만 유효합니다.

val valid: Boolean
    get() = owner != null && anchor?.valid ?: false

Restart Lambda: 컴포지션 재실행의 핵심

가장 중요한 필드는 restart lambda입니다.

private var block: ((Composer, Int) -> Unit)? = null

이 람다는 컴파일러가 생성한 코드에 의해 설정되며, 동일한 매개변수로 컴포저블을 다시 실행하는 방법을 캡처합니다. 리컴포지션이 필요하면 이 람다가 호출됩니다.

fun compose(composer: Composer) {
    block?.invoke(composer, 1) ?: error("Invalid restart scope")
}

핵심은 이 람다가 생성되는 방식에 있습니다. 컴파일러는 모든 컴포저블 함수가 끝에서 updateScope를 호출하도록 변환합니다.

@Composable
fun UserHeader(name: String, $composer: Composer) {
    $composer.startRestartGroup(12345)
    // ... 실제 컴포저블 본문 ...
    $composer.endRestartGroup()?.updateScope { $composer ->
        UserHeader(name, $composer)
    }
}

람다가 name을 값으로 캡처하는 것에 주목하세요. 이렇게 하면 동일한 매개변수로 정확히 같은 컴포지션을 나중에 재실행할 수 있는 클로저가 생성됩니다. 스코프가 무효화되어 리컴포지션이 필요하면, Compose는 단순히 이 람다를 호출합니다.

컴파일러가 생성하는 패턴

스코프의 동작 방식을 이해하려면 컴파일러가 생성하는 코드를 살펴봐야 합니다. 다음은 간략화된 변환 과정입니다.

// 원본 소스
@Composable
fun Greeting(person: Person) {
    Text("Hello, ${person.name}!")
}

// 컴파일러 변환 후 (간략화)
fun Greeting(person: Person, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(67890)

    val changed = $composer.changed(person)
    if (changed || !$composer.skipping) {
        Text("Hello, ${person.name}!", $composer, 0)
    } else {
        $composer.skipToGroupEnd()
    }

    $composer.endRestartGroup()?.updateScope { $composer ->
        Greeting(person, $composer, $changed or 1)
    }
}

이 변환에는 다음과 같은 요소가 추가됩니다.

  1. startRestartGroup\(key\): recompose scope를 생성하거나 기존 스코프를 가져옵니다
  2. changed\(parameter\): 매개변수가 이전 컴포지션 이후 변경되었는지 확인합니다
  3. 스킵 검사: 아무것도 변경되지 않았고 스킵이 활성화되어 있으면 본문을 건너뜁니다
  4. endRestartGroup\(\): 스코프가 사용된 경우 해당 스코프를 반환합니다
  5. updateScope { }: 스코프가 사용된 경우 restart lambda를 설정합니다

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

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

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