아티클 목록으로 가기

세 가지 페이즈: Composition, Layout, Drawing

skydovesJaewoong Eum (skydoves)||16분 소요

세 가지 페이즈: Composition, Layout, Drawing

Jetpack Compose는 선언적(declarative) UI 코드를 화면의 픽셀로 변환하기 위해 Composition, Layout, Drawing이라는 세 가지 고유한 페이즈로 구성된 파이프라인을 거칩니다. 상태(state) 변수를 변경하면 Compose는 모든 것을 다시 그리는 것이 아니라, 어떤 페이즈가 실행되어야 하는지를 판단한 뒤 필요한 작업만 수행합니다. 가령, 시각적 속성만 변경되는 경우 Composition과 Layout을 완전히 건너뛰고 Drawing만 실행할 수 있으며, 구조적인 변경이 발생하면 세 페이즈 모두 실행해야 할 수도 있습니다. 작성하는 코드가 어떤 페이즈를 트리거하는지 이해하면, 보다 효율적인 Compose 애플리케이션을 작성할 수 있습니다.

이 글에서는 세 가지 페이즈가 내부적으로 어떻게 동작하는지 살펴봅니다. Composition 페이즈에서 SlotTable과 Composer를 통해 UI 트리를 구성하고 업데이트하는 방법, Layout 페이즈에서 LayoutNode와 Constraints 전파(propagation)를 통해 노드를 측정하고 배치하는 방법, Drawing 페이즈에서 DrawScopeGraphicsLayer를 통해 콘텐츠를 렌더링하는 방법, 그리고 무효화(invalidation)가 시스템 전반에 어떻게 전파되는지를 다룹니다. 이 글은 Compose 사용법을 안내하는 가이드가 아니라, 컴포저블 함수를 렌더링된 UI로 변환하는 실행 파이프라인 자체를 탐구하는 글입니다.

실행 파이프라인: 상태에서 픽셀까지

Compose가 UI를 화면에 표시할 때, 세 가지 페이즈를 엄격한 순서대로 실행합니다. Composition은 컴포저블 함수를 실행하여 화면에 표시해야 할 내용을 기록함으로써 UI 트리를 구성합니다. Layout은 해당 트리를 받아 모든 요소의 크기와 위치를 결정합니다. Drawing은 배치가 완료된 요소를 화면에 렌더링합니다. 각 페이즈는 이전 페이즈가 완료되어야 실행될 수 있지만, 모든 상태 변경이 반드시 세 페이즈를 모두 거쳐야 하는 것은 아닙니다.

요소의 투명도(opacity)를 애니메이션하는 경우를 생각해 보겠습니다. 단순한 구현에서는 투명도 변경이 Composition(트리 재구성), Layout(재측정 및 재배치), Drawing(렌더링)을 모두 트리거할 것입니다. 하지만 투명도는 트리 구조나 요소 위치에 영향을 미치지 않으며, 순수하게 시각적인 속성입니다. Compose는 GraphicsLayer에서의 투명도 변경이 Drawing 페이즈만 트리거하도록 최적화하여, Composition과 Layout을 완전히 건너뛸 수 있게 합니다. 이러한 최적화는 페이즈가 분리되어 있기 때문에 비로소 가능한 것입니다.

페이즈 모델은 특정 패턴이 왜 문제가 되는지도 설명해 줍니다. 가령, Composition 도중 레이아웃 좌표를 읽으면, 시스템이 Composition을 완료하기 전에 Layout을 먼저 수행해야 하므로 정상적인 페이즈 순서가 깨집니다. 페이즈를 이해하면 시스템에 역행하지 않고, 시스템과 조화를 이루는 코드를 작성할 수 있습니다.

Composition 페이즈: UI 트리 구성

Composition 페이즈는 컴포저블 함수가 실행되는 단계입니다. Composer가 코드를 순회하면서, 호출된 함수를 추적하고, 이전 Composition과 비교하여, 변경 사항을 기록합니다. 이 페이즈에서는 픽셀이 생성되지 않으며, 후속 페이즈에서 처리할 노드 트리가 만들어집니다.

Composer의 역할

Composer는 컴포저블 함수를 실행하는 런타임 엔진입니다. 모든 컴포저블 함수는 컴파일러에 의해 주입되는 암시적 $composer 매개변수를 받습니다.

// 개발자가 작성하는 코드
@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

// 컴파일러가 생성하는 코드 (간소화)
fun Greeting(name: String, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(1234)
    if ($composer.changed(name) || !$composer.skipping) {
        Text("Hello, $name", $composer, 0)
    } else {
        $composer.skipToGroupEnd()
    }
    $composer.endRestartGroup()?.updateScope { $composer ->
        Greeting(name, $composer, $changed or 1)
    }
}

Composer는 세 가지 핵심 기능을 수행합니다. 첫째, remember 람다의 결과, 컴포저블 함수 매개변수, 호출 구조 등의 위치 정보를 기록합니다. 둘째, 현재 값을 이전 Composition 상태와 비교하여 변경 사항을 감지합니다. 셋째, 입력이 변경된 함수만 선별적으로 리컴포지션(Recomposition)하여 Composition을 점진적으로 평가합니다.

SlotTable: 영속적 메모리

Composition 상태는 SlotTable에 저장됩니다. SlotTable은 점진적(incremental) 업데이트에 최적화된 평탄화된(flattened) 형식으로 UI 트리를 저장하는 데이터 구조입니다. 내부적으로 그룹 메타데이터용 배열과 슬롯 값용 배열, 두 개의 배열을 사용합니다.

테이블의 각 그룹은 다음과 같은 정보를 담고 있습니다.

| Key  | Flags  | Nodes | Size  | Parent | Slots |
|------|--------|-------|-------|--------|-------|
| 1234 | <none> |   0   |   3   |   -1   |   0   |
| 4567 | <none> |   0   |   1   |    0   |   0   |

Key는 호출 위치를 식별하기 위해 컴파일러가 생성하는 정수 값입니다. Flags는 노드 유형을 나타냅니다. Nodes는 해당 그룹이 생성하는 UI 노드 수를 카운트합니다. Size는 자식 그룹의 전체 수를 추적합니다. Parent는 상위 그룹을 가리킵니다. Slots는 remember 결과 등 저장된 값을 참조합니다.

리컴포지션이 발생하면, Composer는 SlotTable을 순회하면서 현재 실행 상태를 저장된 상태와 비교합니다. 그룹에 변경이 없으면 skipToGroupEnd()를 사용하여 해당 그룹을 완전히 건너뜁니다. 이것이 바로 Compose가 효율적인 점진적 업데이트를 달성하는 원리이며, 변경되지 않은 트리 부분은 단순히 스킵됩니다.

Composition 트리거

Composition은 시스템이 UI 변경 가능성을 감지할 때 실행됩니다. 주요 트리거는 크게 세 가지입니다. 상태 변경의 경우, Composition 도중 mutableStateOf 값을 읽으면 의존성이 생성되며, 해당 상태가 변경되면 읽기 스코프가 무효화됩니다. 구조적 변경은 조건부 콘텐츠가 나타나거나 사라질 때 발생하며, 트리를 수정해야 합니다. 명시적 무효화는 리컴포즈 스코프에서 invalidate()를 직접 호출하는 경우입니다.

Recomposer가 이러한 무효화를 조정합니다.

private fun deriveStateLocked(): CancellableContinuation<Unit>? {
    val newState = when {
        compositionInvalidations.isNotEmpty() ||
        snapshotInvalidations.isNotEmpty() -> State.PendingWork
        else -> State.Idle
    }
}

무효화가 존재하면 Recomposer는 Composition 작업을 스케줄링합니다. 이때 배칭(batching)이 중요한 역할을 하는데, 단일 프레임 내에서 여러 상태가 변경되더라도 Composition 패스는 한 번만 실행됩니다. 여러 번 실행되는 것이 아닙니다.

스킵(Skipping)과 재시작(Restarting)

Composer는 스킵과 재시작을 구분합니다. 스킵은 컴포저블의 입력이 변경되지 않아 해당 함수를 완전히 우회할 수 있는 경우에 발생합니다. 생성된 코드는 $composer.skipping을 확인하고, 안전한 경우 skipToGroupEnd()를 호출합니다.

if ($composer.changed(person) || !$composer.skipping) {
    // 콘텐츠 실행
} else {
    $composer.skipToGroupEnd()
}

재시작은 더 세밀한 메커니즘입니다. startRestartGroupendRestartGroup으로 감싸진 함수는 관찰 중인 상태가 변경될 때 개별적으로 리컴포지션될 수 있습니다. endRestartGroup() 호출은 해당 함수를 다시 호출하는 방법을 캡처한 스코프를 반환합니다.

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

이 재시작 람다가 바로 선택적 리컴포지션을 가능하게 하는 요소입니다. 상태가 변경되면, 해당 상태를 읽은 스코프만 다시 실행하면 되며, 전체 Composition을 다시 수행할 필요가 없습니다.

Layout 페이즈: 측정과 배치

Composition이 완료되면 Compose에는 UI 구조를 나타내는 LayoutNode 트리가 생성됩니다. Layout 페이즈에서는 크기를 측정하고 위치를 계산하여 각 노드가 화면 어디에 나타나야 하는지를 결정합니다.

LayoutNode: 레이아웃 조정자

Compose의 모든 UI 요소는 LayoutNode를 기반으로 합니다. 이 내부 클래스는 측정, 배치, 그리기를 조정하는 역할을 합니다.

internal class LayoutNode(
    private val isVirtual: Boolean = false,
) : Remeasurement, OwnerScope, LayoutInfo {

    override var measurePolicy: MeasurePolicy = ErrorMeasurePolicy
    override var modifier: Modifier = Modifier
    internal val layoutDelegate = LayoutNodeLayoutDelegate(this)
}

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

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

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