
Jetpack Compose Internals를 읽고 내용을 정리하는 시리즈입니다.
이번 포스팅에서는 3장 컴포즈 런타임(The Compose runtime)을 다룹니다.
Composition의 상태를 저장하기 위한 데이터 구조로는 슬롯 테이블과 변경 목록이 있습니다.
이 두 데이터 구조의 차이를 명확히 구분하는 것이 중요합니다.
슬롯 테이블
런타임에서 컴포지션의 현재 상태를 기록하는 인메모리 구조로, 최초의 컴포지션 시 데이터가 채워지며 매 리컴포지션마다 업데이트됩니다.
슬롯 테이블은 모든 Composable 함수의 호출을 추적하며 소스 코드상 위치, 매개변수, remembered values, CompositionLocal 등 컴포지션 과정에서 일어나는 일들을 저장합니다.
트리에 변경이 일어나면 Composer가 이 슬롯 테이블에 기록되어있는 기존 정보와 새로운 정보를 비교해 바뀐 곳만 부분적으로 업데이트합니다.
변경 목록
변경 목록에는 트리에 적용될 예정인 변경 사항이 기록됩니다. 패치(patch) 파일과 비슷하다고 이해하면 됩니다.
변경 목록을 적용하는 책임은 Applier에게 있으며, Applier는 Compose 런타임이 트리를 구체화하기 위해 사용하는 추상체입니다.
슬롯 테이블이 어떻게 현재 컴포지션 상태를 저장하는지 알아봅니다.
슬롯 테이블의 데이터 구조
var groups = IntArray(0) // 그룹 정보 저장
private set
var slots = Array<Any?>(0) { null } // 각 그룹의 데이터 슬롯 저장
private set
슬롯 테이블은 두 개의 배열을 사용해 데이터들을 저장합니다.
하나는 컴포지션의 그룹 정보를 저장하고, 다른 하나는 각 그룹에 속한 데이터 슬롯을 저장합니다.
그룹 배열 (groups)
2장에서 배운 내용을 상기해보면, 컴파일러는 Composable 함수 본문을 그룹으로 감싸 방출합니다.
이 그룹에 대한 정보들은 그룹 필드로 Int 배열에 저장됩니다. 그룹 필드는 노드의 종류, 데이터 길이, 자식 노드 개수 등의 정보들을 비트 연산으로 나타낸 메타데이터 입니다.
이때 트리 구조를 일렬로 펴서 배열 형태로 만들기 위해 깊이 우선 탐색(DFS) 순서로 기록합니다.
따라서 아래 예시처럼 부모 그룹의 필드가 먼저 나오고 그 뒤에 자식들의 필드가 이어집니다.
// UI 트리 구조
Column (부모)
├── Text A (자식 1)
└── Button B (자식 2)
// 그룹 배열 구조
[[Column 시작 정보], [Text A 정보], [Button B 정보], [Column 끝 정보]]
이 구조는 자식 노드를 순차적으로 탐색하기 용이한 반면, 임의의 랜덤 위치 접근에서는 많은 비용이 듭니다.
이를 줄이기 위해 포인터 역할을 하는 앵커를 사용합니다.
슬롯 배열 (slots)
슬롯 배열은 실제 컴포지션 데이터를 저장하므로 모든 타입을 담을 수 있도록 Any? 타입을 사용합니다.
그룹 배열의 정보를 참고해 슬롯 배열의 데이터를 해석할 수 있습니다.
갭 버퍼
슬롯 테이블은 범위형 포인터인 갭(gap)으로 작업 위치를 지정해 데이터를 읽고 씁니다.
이해를 돕기 위해 아래 조건부 로직을 살펴봅시다.
@Composable
@NonRestartableComposable
fun ConditionalText() {
if (a) {
Text(a)
} else {
Text(b)
}
}
위 컴포저블은 @NonRestartableComposable로 리컴포지션이 불가능하므로 재시작 가능한 그룹 대신 교체 가능한 그룹이 삽입됩니다. 이 그룹은 if문에 따라 활성화된 자식의 데이터를 테이블에 저장합니다.
만약 a가 true라면 Text(a)가 저장됩니다. 이후 if 조건이 바뀌면, 갭은 그룹의 시작 위치로 되돌아가서 다시 쓰기 작업을 수행하며 기존 슬롯들을 Text(b)의 데이터로 덮어씁니다.
SlotReader(구독자) & SlotWriter(작성자)
슬롯 테이블을 다루는 도구로는 구독자와 작성자가 있으며, 작업이 끝나면 역할을 마치고 종료됩니다.
SlotReader
SlotWriter
슬롯 테이블에서 읽기 작업은 동시에 일어날 수 있지만, SlotWriter가 쓰기 작업 중일 때는 슬롯 테이블이 읽을 수 없는 상태가 됩니다. 이는 경쟁 상태(Race Condition)를 방지하기 위함 입니다.
앵커(Anchor)
배열에 데이터를 넣고 빼면 순서가 바뀌며 인덱스로 데이터를 찾지 못하게 됩니다. 이를 해결하기 위해 특정 데이터에 책갈피 역할을 하는 앵커를 붙여놓습니다.
앵커는 자신이 가리키는 위치보다 앞쪽에서 그룹이 이동, 교체, 삽입, 제거될 때마다 자동으로 업데이트됩니다. 덕분에 개발자나 런타임은 인덱스가 변해도 앵커를 통해 원하는 그룹을 즉시 찾아낼 수 있습니다. Writer는 이러한 앵커 목록을 관리해 테이블에 빠르게 접근할 수 있습니다.
앞서 슬롯 테이블이 컴포지션의 현재 상태를 저장한다는 것을 배웠습니다.
그렇다면 변경 목록은 무엇이고 왜 필요할까요?
방출의 의미
컴포지션이나 리컴포지션이 일어날 때마다 Composable 함수들이 방출됩니다.
여기서 방출이란 단순히 데이터를 내뱉는 것이 아니라, 다음 두 가지를 준비하는 과정입니다.
이러한 변경 사항들을 쌓아둔 것이 바로 변경 목록입니다.
현재 상태(슬롯 테이블)를 참고하는 이유
새로운 변경 목록을 만들 때, 런타임은 반드시 슬롯 테이블에 저장된 기존 값을 확인합니다.
트리의 모든 변경은 현재 상태를 기준으로 일어나기 때문입니다.
예를 들어 리스트의 순서가 바뀌어 노드를 이동해야 한다고 가정해 보겠습니다.
이처럼 현재 어디에 무엇이 있는지 알아야 효율적으로 옮기거나 지울 수 있습니다.
즉시 바꾸지 않고 지연시키는 이유
Composable 함수가 실행될 때마다 메모리에 반영하고 UI를 다시 그리면 앱이 매우 느려질 것입니다.
그래서 Compose는 방출 단계에서 변경할 내용을 빠르게 기록하고, 실제로 반영하는 작업은 미뤄둡니다.
목록에 적힌 내용이 실제로 실행되는 순간 슬롯 테이블이 최신 정보로 업데이트되고,
Applier를 통해 실제 UI 트리가 구체화됩니다.
Recomposer
이 모든 과정은 Recomposer에 의해 결정됩니다.
이때 변경 사항을 적용하는 스레드는 나중에 배울 LaunchedEffect 같은 사이드 이펙트가 돌아가는 기본 배경(Context)이 되기도 합니다.
코드에 주입된 $composer는 Compose 런타임의 핵심 엔진으로, 우리가 작성한 Composable 함수를 Compose 런타임과 연결해줍니다. 주입 과정은 2장 Compose 컴파일러에서 자세히 다루었습니다.
모든 컴포넌트의 기반인 Layout을 예시로 메모리 트리에 노드가 추가되는 과정을 구체적으로 살펴보겠습니다.
@Suppress("ComposableLambdaParameterPosition")
@Composable inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
*ReusableComposeNode<ComposeUiNode, Applier<Any>>*( //
factory = ComposeUiNode.Constructor,
update = {
set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
set(density, ComposeUiNode.SetDensity)
set(layoutDirection, ComposeUiNode.SetLayoutDirection)
},
skippableUpdate = materializerOf(modifier),
content = content
)
}
Layout은 노드를 컴포지션에 방출하기 위해 ReusableComposeNode를 사용합니다.
이름만 보면 마치 즉석에서 노드를 생성하고 추가하는 것처럼 보이지만, 실제로는 적절한 시점에 컴포지션의 현재 위치에서 노드를 생성, 초기화, 삽입하는 방법을 런타임에게 가르쳐주는 것에 가깝습니다.
이 과정은 ReusableComposeNode의 내부 코드로 확인할 수 있습니다.
핵심은 대부분의 작업이 currentComposer 인스턴스에 위임된다는 점입니다.
@Composable
inline fun <T, reified E : Applier<*>> ReusableComposeNode(
noinline factory: () -> T,
update: @DisallowComposableCalls Updater<T>.() -> Unit,
noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
content: @Composable () -> Unit
) {
// ...
*currentComposer*.startReusableNode()
// ...
*currentComposer*.createNode(factory)
// ...
Updater<T>(*currentComposer*).update() // initialization
// ...
*currentComposer*.startReplaceableGroup(0x7ab4aae9) // 교체 가능한 그룹 시작
content()
*currentComposer*.endReplaceableGroup() // 교체 가능한 그룹 끝
*currentComposer*.endNode()
}
또한 Composable 함수의 본문을 교체 가능한 그룹이 감싸는 것을 볼 수 있습니다.
content 내부에서 방출된 모든 자식 요소들은 이 교체 가능한 그룹의 자식으로 저장됩니다.
이러한 방출은 다른 모든 Composable 함수에서도 동일하게 수행됩니다.
대표적인 예로 remember 함수를 살펴보겠습니다.
@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
*currentComposer*.cache(invalid = false, calculation)
remember는 전달받은 람다의 반환값을 컴포지션에 캐싱하기 위해 currentComposer를 호출합니다.
이때 invalid 매개변수는 이미 저장된 값이 있더라도 강제로 업데이트할지 여부를 결정합니다.
내부적으로 호출되는 cache 함수는 다음과 같이 작성되어 있습니다.
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
return rememberedValue().let {
if (invalid || it === Composer.Empty) {
val value = block()
updateRememberedValue(value)
value
} else it
} as T
}
이 함수는, 먼저 rememberedValue()로 슬롯 테이블에서 값을 검색합니다.
값이 없거나(Composer.Empty) 유효하지 않다면(invalid), block에서 새로운 값을 계산한 뒤 해당 정보를 업데이트하도록 변경 사항을 방출(예약)합니다. 또한 이미 값이 존재한다면 저장된 값을 그대로 반환합니다.
즉, Composable 함수는 직접 UI를 그리는 것이 아니라 Composer에게 무엇을 어떻게 해야 할지 지시 사항을 전달하는 역할임을 알 수 있습니다.
이전 섹션에서 설명했듯이 currentComposer에 위임된 모든 방출 작업은 Change라는 람다 형태로 변경 목록에 추가됩니다. 결국 방출이란, Change 객체들을 생성하는 것을 의미합니다. 구조는 다음과 같습니다.
internal typealias Change = (
applier: Applier<*>,
slots: SlotWriter,
rememberManager: RememberManager
) -> Unit
Change란 슬롯 테이블에서 노드를 수정할 수 있는 지연된 람다식입니다. 현재 사용 중인 Applier와 SlotWriter에 접근할 수 있습니다. 모든 변경 사항은 방출된 후 Applier에 의해 일괄 적용됩니다.
컴포지션 자체는 Composition 클래스로 모델링됩니다. 이 부분은 챕터 후반부에서 컴포지션 과정을 다룰 때 자세히 살펴보겠습니다. 먼저 Composer에 대한 몇 가지 추가적인 세부 사항을 알아보겠습니다.
보통 Compose는 즉시 메모리를 수정하지 않고, 나중에 한꺼번에 처리하기 위해 변경 목록을 적어둡니다.
하지만 특정 상황에서는 굳이 변경 목록에 적지 않고 바로 슬롯 테이블을 수정해 단계를 건너뜁니다.
컴포지션 단계가 모두 완료되면 마침내 composition.applyChanges()가 호출됩니다. 이 시점에 실제 UI 트리가 구체화되고, 변경 사항들이 슬롯 테이블에 기록됩니다.
Composer는 데이터, 노드, 그룹 등 다양한 정보를 다루지만, 내부적으로는 이 모든 항목을 단순화하여 그룹이라는 형태로 저장합니다. 각 항목은 그룹 내의 필드 정보를 통해 서로를 구분할 뿐입니다.
Composer는 모든 그룹을 시작하고 종료할 수 있으며, 이는 현재 Composer가 무엇을 하고 있는지에 따라 의미가 달라집니다.
컴포저블 트리의 노드는 추가 뿐만 아니라 제거되거나 이동될 수도 있습니다. 그룹을 제거한다는 것은 해당 그룹과 그 안에 포함된 모든 슬롯 데이터를 테이블에서 완전히 삭제한다는 뜻입니다
이때 Composer는 SlotReader가 더 이상 존재하지 않는 위치를 읽지 않도록 재배치하고, Applier에게 명령하여 실제 UI 트리에서도 해당 노드를 제거합니다.
만약 어떤 그룹이 제거된다면, 사라진 그룹을 다시 그릴 이유는 없기 때문에 Composer는 해당 그룹에 대해 예정되어 있던 모든 재구성 요청(invalidation)을 폐기합니다.
참고로 그룹은 재시작, 교체, 이동, 재사용 가능한 형태만 존재하진 않습니다. 예를 들어 함수의 디폴트 매개변수 값을 기억하기 위한 model: Model = remember { DefaultModel() }처럼, 값을 안전하게 보관하기 위한 용도의 그룹들도 존재합니다.
Composer가 그룹을 시작할 때 일어나는 일
insertTable이라는 별도의 임시 슬롯 테이블에 먼저 작성해 나중에 최종 테이블에 삽입되도록 예약합니다.ReusableComposeNode처럼 기존 노드를 재활용할 수 있다면 노드를 새로 생성 및 초기화하는 복잡한 과정은 생략하고 노드의 위치를 이동시키는 작업만 기록합니다. 만약 노드의 속성만 바꿔야 한다면, 이 역시 Change라는 단위로 기록되고 추후에 일괄 적용합니다.
Composer가 값을 슬롯 테이블에 기록하고 나중에 업데이트하는 원리는 다음과 같습니다.
만약 저장하려는 값이 RememberObserver 인터페이스를 구현한 객체라면, Composer는 컴포지션의 기억 작업을 추적하기 위해 암시적 Change를 추가로 기록합니다. 이는 나중에 해당 컴포저블이 화면에서 사라지거나 컴포지션을 떠날 때, 기억하고 있던 값들을 안전하게 정리하기 위해 사용됩니다.
RecomposeScope는 스마트 리컴포지션의 핵심 원리로, 재시작 가능한 그룹과도 관련이 깊습니다.
Composer는 재시작 가능한 그룹이 생성될 때마다 이에 대응하는 RecomposeScope를 생성한 뒤 현재 컴포지션의 currentRecomposeScope로 설정합니다.
RecomposeScope는 컴포지션의 다른 부분들과 독립적으로 다시 계산될 수 있는 특정 영역을 모델링합니다.
이는 컴포저블의 리컴포지션을 수동으로 무효화(invalidate)하고 트리거하는 데 사용됩니다.
여기서 무효화란 쉽게 말하면 ‘이 화면은 이제 유효하지 않으니 다시 그려야 한다.’라고 표시해두는 것입니다.
덕분에 Compose는 변경되지 않은 부분은 건너뛰고 다시 그려야 하는 부분만 골라서 작업할 수 있습니다.
무효화 요청은 Composer의 composer.currentRecomposeScope().invalidate()에서 이루어집니다.
RecomposeScope에 무효화 표시가 붙습니다.Composer는 무효화된 모든 RecomposeScope들을 스택 구조로 유지합니다.
이 스택은 리컴포지션이 필요한 보류 상태임을 의미하며, 다음 리컴포지션 단계에서 트리거됩니다.
우리가 사용하는 currentRecomposeScope는 바로 이 재구성 범위 스택을 통해 관리됩니다.
중요한 점은 RecomposeScope가 항상 활성화되는 것은 아니라는 사실입니다.
Compose가 컴포저블 함수 내부에서 상태 스냅샷을 읽는 동작을 감지했을 때만 해당 범위를 활성 상태로 표시합니다. 범위가 used로 표시되면, 컴포저블 함수 끝에 삽입된 end 호출이 더 이상 null을 반환하지 않고 리컴포지션 람다를 활성화하게 됩니다. 아래 코드에서 endRestartGroup 호출 뒤에 붙은 물음표(?) 연산자의 동작을 살펴보세요.
// 컴파일러에 의해 보일러플레이트 코드가 삽입된 모습
@Composable
fun A(x: Int, $composer: Composer<*>, $changed: Int) {
$composer.startRestartGroup()
// ...
f(x)
$composer.endRestartGroup()?.updateScope { next ->
A(x, next, $changed or 0b1)
}
}
재구성이 시작되면 Composer는 현재 상위 그룹 내에서 무효화된 하위 그룹들을 모두 다시 실행할 수 있습니다. 반대로 무효화되지 않은 그룹들은 SlotReader가 내용을 읽지 않고 끝까지 건너뛸 수 있는데, 이에 대한 자세한 내용은 2장의 비교 전파 섹션을 참고하시기 바랍니다.
Composer는 사이드 이펙트도 기록할 수 있습니다.
사이드 이펙트는 항상 컴포지션이 끝난 이후에 실행되며, 트리의 모든 변경 사항이 적용된 후 호출할 수 있도록 함수의 형태로 기록됩니다.
이러한 이펙트는 컴포저블 함수 외부에서 발생하는 작용을 나타내며, 컴포저블의 라이프사이클과는 직접적인 연관이 없습니다. 따라서 컴포지션을 떠날 때 진행 중인 작업을 자동으로 취소하거나, 리컴포지션 시 이펙트를 다시 시도하는 등의 작업은 지원하지 않습니다.
이런 특성을 갖는 이유는 사이드 이펙트 정보가 슬롯 테이블에 저장되지 않기 때문입니다. 만약 컴포지션 과정이 중간에 실패하면, 기록되었던 사이드 이펙트들은 간단히 폐기됩니다. 이펙트와 이펙트 핸들러의 구체적인 목적과 활용법은 6장에서 더 자세히 학습하게 됩니다. Composer가 이러한 외부 작업을 어떻게 기록하고 관리하는지 이해하는 것은 매우 흥미로운 주제입니다.
Composer는 CompositionLocal을 등록하고, 고유 키를 통해 해당 값을 가져오는 기능을 제공합니다.
우리가 흔히 사용하는 CompositionLocal.current 호출은 바로 이 메커니즘을 기반으로 동작합니다. CompositionLocal을 제공하는 공급자(Provider)와 실제 공급되는 값들은 모두 슬롯 테이블 내부에 그룹 형태로 저장됩니다.
Composer는 레이아웃 인스펙터와 같은 각종 Compose 도구에서 활용할 수 있도록 컴포지션 과정 중에 수집된 소스 정보를 CompositionData 형태로 저장합니다. 이를 통해 개발 도구들이 컴포지션의 세부 정보를 검사하고 시각적으로 표시할 수 있게 됩니다.
CompositionContext는 개별 컴포지션들을 하나의 트리로 연결합니다. 이때 composition과 subcomposition을 부모-자식 관계로 묶으며, subcomposition은 현재 컨텍스트 내에서 독립적인 무효화를 위해 별도의 단위를 구성하는 특수한 컴포지션입니다.
subcomposition은 상위 CompositionContext 참조를 통해 부모 컴포지션과 연결됩니다. 덕분에 CompositionLocal이나 무효화 신호가 트리를 따라 전파될 수 있습니다. 또한 CompositionContext 자체도 슬롯 테이블에 하나의 그룹 유형으로 기록됩니다.
하위 컴포지션을 생성할 때는 일반적으로 rememberCompositionContext 함수를 사용합니다.
@Composable fun rememberCompositionContext(): CompositionContext {
return currentComposer.buildContext()
}
위 함수는 슬롯 테이블의 현재 위치에 새로운 컴포지션 컨텍스트를 기억하고, 이미 저장된 정보가 있다면 이를 반환합니다. 이 방식은 별도의 컴포지션 주기가 필요한 VectorPainter, Dialog, SubcomposeLayout, Popup 등에서 하위 컴포지션을 만들 때 사용됩니다. 또한 안드로이드 뷰를 컴포저블 트리에 포함할 때 사용하는 AndroidView 역시 이 메커니즘을 동일하게 활용합니다.
Composer는 현재 스냅샷에 대한 참조를 보유합니다.
이는 현재 스레드에서의 가변 상태 및 여러 상태 객체들이 반환하는 값들의 스냅샷과 유사한 개념입니다.
모든 상태 객체는 스냅샷 내에서 명시적으로 변경되지 않는 한, 해당 스냅샷이 생성된 시점과 동일한 값을 유지합니다. 이에 대한 상세한 메커니즘은 5장 상태 스냅샷 시스템에서 자세히 다룹니다.
UI 노드 트리를 탐색하는 작업은 최종적으로 Applier가 수행하지만, 직접적으로 노드 사이를 이동하지는 않습니다. 대신 구독자(Reader)가 탐색하는 노드의 모든 위치를 추적하여 downNodes 배열에 기록하는 방식을 취합니다.
실제 노드 탐색이 일어나는 시점이 되면, 이 downNodes에 저장된 모든 하향 노드 정보가 Applier에게 전달됩니다. 만약 하향 탐색이 실제로 실행되기 전에 상향 탐색이 먼저 수행되어야 하는 상황이라면, 단순히 downNodes 스택에서 해당 항목을 제거함으로써 불필요한 탐색 경로를 단축합니다.
구독자(SlotReader)와 작성자(SlotWriter)의 동기화를 유지하는 것은 시스템 로우 레벨에서 이루어지는 정밀한 작업입니다. 특정 그룹을 삽입, 삭제, 이동하는 과정에서 작성자가 가리키는 그룹의 위치는 변경 사항이 완전히 적용되기 전까지 구독자의 위치와 일시적으로 다를 수 있습니다.
이러한 위치 차이를 관리하기 위해 델타라는 값을 관리하고 데이터의 삽입, 삭제, 이동이 발생할 때마다 업데이트합니다. Compose 소스 코드 문서의 설명처럼, 이 델타는 작성자와 구독자의 현재 슬롯 위치를 정확하게 일치시키기 위해 이동해야 하는 실현되지 않은 거리를 의미합니다.
구독자-작성자 간 동기화는 런타임 라이브러리의 ComposerChangeListWriter 클래스에서 담당합니다.
해당 클래스 내부에는 writersReaderDelta라는 변수가 존재하며, 구독자와 작성자 간의 슬롯 위치를 지속적으로 계산하기 위해 다음과 같은 함수들이 사용됩니다.
private var writersReaderDelta: Int = 0
fun moveReaderRelativeTo(location: Int) {
// 이미 이동한 거리를 고려하여 다음 건너뛰기 작업이 정확하게 계산되도록 합니다.
writersReaderDelta += location - reader.currentGroup
}
fun moveReaderToAbsolute(location: Int) {
writersReaderDelta = location
}
위 함수들은 구독자와 작성자 사이의 격차를 추적하기 위해 Composer에 의해 지속적으로 호출됩니다.
그리고 그룹의 생성이 시작되거나 값에 업데이트가 발생할 때마다 아래와 같은 realizeOperationLocation 함수를 실행해 구독자와 작성자 사이의 거리 간격을 정확하게 일치시킵니다.
private fun realizeOperationLocation(forParent: Boolean = false) {
val location = if (forParent) reader.parent else reader.currentGroup
val distance = location - writersReaderDelta
runtimeCheck(distance >= 0) {
"Tried to seek backward"
}
if (distance > 0) {
changeList.pushAdvanceSlotsBy(distance)
writersReaderDelta = location
}
}
위 코드을 설명하면 다음과 같습니다.
location을 결정합니다.location과 이전에 기록해 둔 델타 값(writersReaderDelta) 사이의 거리(distance)를 계산합니다.changeList.pushAdvanceSlotsBy(distance)를 호출해 수행됩니다. 이후 최종적으로 현재 위치를 다시 델타 값에 반영하여 동기화를 완료합니다.이 과정을 통해 Compose 런타임은 슬롯 테이블을 읽는 위치와 실제로 변경 사항을 적용할 위치를 항상 정확하게 맞출 수 있습니다.
기록된 변경 사항을 실제로 적용하는 과정을 구체화(Materializing)라고 하며, 이는 Applier가 수행합니다.
이 프로세스는 변경 사항 목록을 순차적으로 실행하여 슬롯 테이블을 업데이트하고, 테이블에 저장된 데이터를 해석해 최종적인 결과물을 만들어냅니다.
Applier는 플랫폼과의 접점 역할을 하는 인터페이스로, Compose 런타임은 Applier의 구현을 알지 못합니다.
Applier는 사용 사례에 따라 구체적인 구현 내용이 완전히 달라질 수 있으며 인터페이스 명세는 다음과 같습니다.
interface Applier<N> {
val current: N
fun onBeginChanges() {}
fun onEndChanges() {}
fun down(node: N)
fun up()
fun insertTopDown(index: Int, instance: N)
fun insertBottomUp(index: Int, instance: N)
fun remove(index: Int, count: Int)
fun move(from: Int, to: Int, count: Int)
fun clear()
}
인터페이스 선언에 있는 제네릭 타입 매개변수 N은 우리가 트리에 적용하려는 노드의 타입을 결정합니다.
덕분에 Compose는 특정한 노드 트리 구조에 종속되지 않고 범용적으로 동작할 수 있습니다.
Applier는 전체 트리를 탐색하며 모든 노드를 수정할 수 있지만, 해당 노드의 구체적인 타입이나 최종적으로 어떻게 삽입되는지까지는 관여하지 않습니다. 이러한 세부 처리는 노드 자체의 역할로 위임됩니다.
Applier의 주요 함수들
onBeginChanges, onEndChanges : Composer가 변경 사항 적용을 시작하고 끝낼 때 호출된다.insertTopDown : 상단에서 하단으로 노드를 삽입한다.insertBottomUp : 하단에서 상단으로 노드를 삽입한다.down : 현재 노드의 자식 노드로 이동한다.(하향 순회)up : 부모 노드로 이동한다.(상향 순회)remove : 현재 노드에서 지정된 범위의 하위 항목을 제거한다.move : 하위 항목의 위치를 이동시킨다.clear : 루트를 가리키고 트리에서 모든 노드를 제거한다.Applier는 현재 방문 중인 노드에 대한 참조를 항상 유지하며, 트리는 상황에 따라 하향(Top-down) 또는 상향(Bottom-up) 방식으로 순회할 수 있습니다.
트리를 구축하는 방식에는 하향식과 상향식이 있습니다. 아래 예시로 각 방식의 성능 차이를 살펴보겠습니다.
하향식 삽입 (Inserting top‑down)
위 트리를 하향식으로 구축하려면 먼저 B를 R에 삽입합니다. 그 다음 A를 B에 삽입하고, 마지막으로 C를 B에 삽입합니다. 즉, 트리의 뿌리부터 시작해 잎사귀 방향으로 노드를 하나씩 붙여나가는 순서로 동작합니다.
상향식 삽입 (Inserting bottom-up)
반대로 상향식 구축은 아래에서 위로 올라가는 방식입니다. 먼저 자식들인 A와 C를 부모인 B에 삽입하여 작은 가지를 먼저 완성합니다. 그 다음, 이렇게 완성된 B 트리를 통째로 루트인 R에 삽입합니다.
성능의 차이와 전략
트리를 구축하는 과정의 성능은, 트리의 변화를 감지하고 알림을 받아야 하는 노드가 몇 개인가에 달려 있습니다.
하향식 노드처럼 새 노드가 삽입될 때마다 모든 상위 노드(조상 노드)에게 알리는 대신, 상향식 노드처럼 변경 사항을 좁은 범위에만 알리는 전략을 취하면 노드 삽입 비용을 획기적으로 줄일 수 있습니다.
하지만 결론적으로 어떤 전략이 더 유리한지는 Applier의 구현 방식이나 구축하려는 트리의 성격, 변경 사항이 위아래로 어떻게 전파되어야 하는지에 따라 달라집니다.
핵심은 성능을 위해 ‘한 가지 노드 삽입 전략만을 선택’해야 하며 두 방식을 혼용해서는 안 된다는 점입니다.
앞서 보았듯 클라이언트 라이브러리는 Applier 인터페이스를 직접 구현해 런타임에 제공해야 합니다.
안드로이드의 경우, UiApplier라는 구현체로 노드 방출이란 개념을 실제 화면에 UI를 그리는 동작으로 변환합니다.
internal class UiApplier(
root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
override fun insertTopDown(index: Int, instance: LayoutNode) {
// 사용하지 않으므로 무시합니다.
}
override fun insertBottomUp(index: Int, instance: LayoutNode) {
current.insertAt(index, instance)
}
override fun remove(index: Int, count: Int) {
current.removeAt(index, count)
}
override fun move(from: Int, to: Int, count: Int) {
current.move(from, to, count)
}
override fun onClear() {
root.removeAll()
}
override fun onEndChanges() {
super.onEndChanges()
(root.owner as? AndroidComposeView)?.clearInvalidObservations()
}
}
제네릭 타입 N인 LayoutNode는 화면에 렌더링할 UI 노드를 나타냅니다.
또한 UiApplier가 상속받는 AbstractApplier는 대부분의 Applier에서 공통으로 필요한 기본 기능을 제공함으로써 중복을 방지하는 추상 클래스입니다. 특히, 트리를 내려가며 새 노드를 방문할 때마다 스택에 추가하고 다시 위로 이동할 때마다 스택 맨 위의 노드를 제거하는 기능을 제공합니다.
또한 안드로이드의 경우 새 자식 노드가 삽입될 때마다 발생하는 중복 알림을 방지하고 성능을 최적화하기 위해 상향식 전략을 택했습니다. 따라서 UiApplier에서 insertTopDown 함수는 아무 동작도 하지 않게 비워둡니다.
노드를 삽입, 제거, 이동하는 구체적인 방법은 모두 노드 객체인 LayoutNode에 위임됩니다. LayoutNode는 Compose UI에서 UI 노드를 모델링하는 핵심 객체로, 부모와 자식 노드에 대한 모든 정보를 알고 있습니다.
변경 사항 적용을 시작할 때는 항상 onBeginChanges가 호출되고, 모든 변경 사항 적용이 완료되면 마지막으로 onEndChanges가 호출되어 루트 노드의 Owner에게 최종 마무리 작업을 요청합니다.
또한 onEndChanges 호출 시점에는, 보류 중이던 무효화된 관찰(Invalid Observations)들이 모두 해제됩니다. 이는 상태 값이 변경되면 레이아웃 계산이나 그리기를 자동으로 다시 호출해야 하기 때문입니다.
트리에 노드를 삽입하고 부모에 연결하는 동작이 어떻게 우리 실제 화면 UI로 그려지게 될까요?
답은 간단합니다. 노드 스스로가 자신을 부모와 연결하고 그리는 방법을 알고 있기 때문입니다.
자세한 내용은 4장 Compose UI에서 다루므로, 여기서는 그 과정을 아주 간략하게 요약하겠습니다.
지금은 꼬리에 꼬리를 무는 궁금증을 잠시 멈추고, 런타임의 동작 모델을 완성하는 데 집중해 봅시다.
안드로이드 UI를 위해 채택된 노드 유형인 LayoutNode를 예로 들어보겠습니다.
UiApplier가 LayoutNode에게 노드 삽입 작업을 위임하면 다음과 같은 순서로 작업이 진행됩니다.
여기서 Owner는 트리의 최상단 루트에 존재하며, 우리가 만든 컴포저블 트리와 안드로이드의 기본 뷰 시스템을 연결해 주는 구현체입니다. 안드로이드 프레임워크와 Compose 세상을 잇는 통합 지점이라고 생각하면 이해가 쉽습니다. 실제로 이는 안드로이드 표준 뷰인 AndroidComposeView에 의해 구현됩니다.
화면에 보여지는 모든 레이아웃 계산, 실제 그리기 작업, 사용자 입력 처리, 접근성 기능은 모두 이 Owner를 통해 연결됩니다. 따라서 LayoutNode가 화면에 나타나려면 반드시 Owner와 연결되어야 하며, 이때 연결되는 Owner는 부모 노드가 가진 Owner와 동일해야 합니다. 노드를 연결한 후에는 Owner를 통해 무효화를 호출하여 최종적으로 컴포저블 트리를 화면에 렌더링하게 됩니다.
Owner가 설정되는 순간이 시스템이 하나로 합쳐지는 통합 지점입니다.
예를 들어 액티비티, 프래그먼트 또는 ComposeView에서 setContent를 호출하면 AndroidComposeView가 생성되며 안드로이드의 기존 뷰 계층에 연결됩니다.
생성된 AndroidComposeView는 트리의 Owner 역할을 맡아 화면 업데이트가 필요할 때마다 시스템의 요구에 맞춰 무효화 작업을 수행합니다.
이로써 우리는 Compose UI가 안드로이드용 노드 트리를 어떻게 구체화하고 화면에 실현하는지 핵심 원리를 파악했습니다. 이와 관련된 더 깊은 세부 사항들은 다음 챕터인 4장 Compose UI에서 자세히 다루게 됩니다.
컴포지션과 컴포저의 관계
많은 사람이 Composer가 Composition의 참조를 소유한다고 착각하지만, 사실은 그 반대입니다. 컴포지션 객체가 먼저 생성되며 이 과정에서 컴포저가 스스로 구축됩니다.
생성된 Composer는 currentComposer로 접근할 수 있고, Composition이 관리하는 트리를 실제로 생성하고 업데이트하는 도구로 사용됩니다.
런타임으로 들어가는 두 가지 진입점
클라이언트 라이브러리는 두 가지 경로를 거쳐야 컴포즈 런타임에 연결될 수 있습니다.
사례 1. 안드로이드
Android의 경우 ViewGroup.setContent 호출로 새로운 Composition을 생성합니다. 내부는 이렇습니다.
internal fun ViewGroup.setContent(
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
// ...
val composeView = // ...
return doSetContent(composeView, parent, content)
}
private fun doSetContent(
owner: AndroidComposeView,
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
// ...
val original = Composition(UiApplier(owner.root), parent) // 여기서 생성
val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
as? WrappedComposition ?: WrappedComposition(owner, original).also {
owner.view.setTag(R.id.wrapped_composition_tag, it)
}
wrapped.setContent(content)
return wrapped
}
doSetContent 함수 내의 Composition(UiApplier(owner.root), parent)로 실제 컴포지션 객체가 탄생합니다.WrappedCompositionAndroidComposeView에 연결하는 데코레이터입니다.Context, LifecycleOwner, SavedStateRegistryOwner 등을 하위 컴포저블에 CompositionLocal형태로 제공합니다.UiApplier가 트리의 최상단 노드인 LayoutNode를 가리키며 작업을 시작합니다.사례 2. VectorPainter
컴포지션은 일반적인 UI 구성 외에도 다양한 곳에서 독립적으로 생성되어 사용됩니다.
화면에 벡터를 그리는 VectorPainter도 자신만의 독립적인 Composition을 생성하고 유지하는 예시입니다.
여기서는 VNode를 다루는 VectorApplier가 사용됩니다. 이를 통해 Compose가 일반 UI뿐만 아니라 벡터 그래픽 등 다양한 노드 트리를 관리할 수 있음을 알 수 있습니다.
@Composable
internal fun RenderVector(
name: String,
viewportWidth: Float,
viewportHeight: Float,
content: @Composable (viewportWidth: Float, viewportHeight: Float) -> Unit
) {
// ...
val composition = composeVector(rememberCompositionContext(), content)
DisposableEffect(composition) {
onDispose {
composition.dispose() // 컴포지션은 마지막에 반드시 폐기되어야 함!
}
}
}
private fun composeVector(
parent: CompositionContext,
composable: @Composable (viewportWidth: Float, viewportHeight: Float) -> Unit
): Composition {
val existing = composition
val next = if (existing == null || existing.isDisposed) {
Composition(VectorApplier(vector.root), parent) // 여기서 생성!
} else {
existing
}
composition = next
next.setContent {
composable(vector.viewportWidth, vector.viewportHeight)
}
return next
}
사례 3. SubcomposeLayout
측정 단계에서 자식 노드를 Subcompose하기 위해 자체적인 Composition을 유지하는 특수한 레이아웃입니다.
| 사례 | 사용되는 Applier | 특징 |
|---|---|---|
| 일반 Android UI | UiApplier | LayoutNode 트리를 관리하고 실제 뷰 시스템과 연결합니다. |
| VectorPainter | VectorApplier | 화면에 벡터 그래픽을 그리기 위해 자신만의 컴포지션을 유지하며, VNode를 다룹니다. |
| SubcomposeLayout | 자체 컴포지션 | 측정(Measure) 단계에서 자식 노드를 하위 구성(Subcompose)하기 위해 사용됩니다. |
Composition 연결과 관리의 핵심 특징
CompositionContext를 전해줄 수 있습니다. 이를 통해 무효화 신호나 CompositionLocal이 전달되며 서로 다른 컴포지션들이 논리적으로 하나처럼 연결됩니다.Recomposer의 기본 문맥을 따르며, 안드로이드에서는 일반적으로 AndroidUiDispatcher.Main입니다.composition.dispose()를 호출하여 자원을 해제해야 합니다. 이는 해당 UI 요소의 생명주기와 같으며, 메모리 누수를 방지하는 중요한 작업입니다.초기 Composition은 컴포즈 앱이 처음 실행될 때 슬롯 테이블에 데이터를 채우는 핵심 절차입니다.
composition.setContent(content)는 Composition이 처음 생성되는 곳으로, 초기 composition을 트리거하고 상위 composition에 위임됩니다.
서브컴포지션은 루트 컴포지션에 도달할 때까지 상위 요소에 composeInitial 호출을 계속 위임하며 최종적으로는 Recomposer가 이 로직을 수행합니다.
override fun setContent(content: @Composable () -> Unit) {
// ...
this.composable = content
parent.composeInitial(this, composable) // 현재 컴포지션 정보를 상위로 전달
}
Recomposer와 스냅샷 시스템의 역할
최종적으로 Recomposer가 composeInitial을 처리할 때의 작업들은 다음과 같습니다.
snapshot.enter(block: () ‑> T)의 블록 안에서만 수정 가능합니다.composition.composeContent(content)와 같은 블록에 snapshot.enter(block: () ‑> T)을 전달함으로써 실제 컴포지션 프로세스를 시작합니다.snapshot.apply()를 통해 스냅샷의 변경 사항을 전역 상태에 전파합니다.Composer가 수행하는 실제 컴포지션 단계
Composer에게 위임된 실제 composition 과정은 아래의 순서로 발생합니다.
isComposing 플래그를 true로 변경합니다.startRoot()를 호출하여 루트 그룹을 시작한 뒤 startGroup을 호출하여 콘텐츠 그룹을 시작합니다.content 람다식을 호출하여 모든 변경 사항을 방출합니다.endGroup()과 endRoot()를 호출하여 마친 뒤, isComposing 플래그를 false로 돌리고 임시 데이터를 삭제합니다.초기 composition이 완료되면, Applier는 composition.applyChanges() 프로세스 동안 기록된 모든 변경 사항을 적용하라는 알림을 받습니다.
applier.onBeginChanges()를 호출해 변경 사항을 반영하기 시작함을 알립니다.applier.onEndChanges()를 호출합니다.RememberedObserver 알림LaunchedEffect나 DisposableEffect 등이 이 인터페이스를 구현하여 컴포저블 수명 주기에 따라 이펙트 실행 및 중지 시점을 관리합니다.isComposing 플래그와 Invalidation 관리
컴포지션은 현재 자신이 작업 수행 중인지를 나타내는 isComposing 플래그를 갖고 있습니다.
이 플래그는 아래처럼 리컴포지션을 효율적으로 제어하는 데 사용됩니다.
isComposing 플래그를 통해 현재 컴포지션이 진행 중이라면 무효화를 즉시 적용하고,true)일 때 불필요한 리컴포지션 작업을 버리는 판단 근거로 활용합니다.제어 가능한 컴포지션 (ControlledComposition)
런타임은 일반적인 컴포지션을 확장한 ControlledComposition이라는 변형 객체에 의존합니다.
이는 외부에서 컴포지션을 제어할 수 있는 추가 기능을 제공하여, Recomposer가 무효화와 추가 리컴포지션 과정을 세밀하게 조율할 수 있게 합니다.
composeContent나 recompose와 같은 함수들이 대표적인 예시이며 Recomposer는 필요에 따라 컴포지션에서 이러한 작업을 직접 트리거할 수 있습니다.
컴포지션은 자신이 관찰하는 객체들의 변경을 감지하고 리컴포지션을 강제할 수 있는 메커니즘을 갖고 있습니다.
또한 컴포지션 중 예상치 못한 오류가 발견되는 경우, 시스템의 안정성을 위해 프로세스를 중단할 수 있습니다.
이는 Composer와 그에 연결된 각종 참조, 스택 정보 등을 모두 초기 상태로 재설정(Reset)하는 과정과 유사합니다.
Recomposer는 ControlledComposition을 제어하며, 변경 사항을 적용해야 할 때 리컴포지션을 트리거하는 핵심 주체입니다. 또한 컴포지션을 수행할 스레드와 변경 사항을 적용할 스레드를 결정하는 역할도 담당합니다.
루트 컴포지션이 생성될 때 그 부모로서 Recomposer가 필요합니다. 안드로이드의 경우 Compose UI 라이브러리가 플랫폼과 컴포즈 런타임을 연결하며 이 Recomposer를 생성합니다.
안드로이드의 ViewGroup.setContent는 내부적으로 WindowRecomposerFactory에게 상위 컨텍스트 생성 작업을 위임합니다. 명세는 다음과 같습니다.
fun interface WindowRecomposerFactory {
fun createRecomposer(windowRootView: View): Recomposer
companion object {
val LifecycleAware: WindowRecomposerFactory = WindowRecomposerFactory { rootView ->
rootView.createLifecycleAwareViewTreeRecomposer()
}
}
}
Recomposer는 ViewTreeLifecycleOwner에 연결되어 뷰 트리의 수명 주기를 인식합니다. 이는 뷰 트리가 연결 해제될 때 Recomposer를 취소하여 리컴포지션 프로세스의 누수를 방지하기 위함입니다
컴포즈 UI의 모든 활동은 AndroidUiDispatcher를 통해 조정됩니다.
Choreographer 인스턴스 및 메인 Looper와 연결되어 애니메이션 프레임 단계에 맞춰 이벤트를 전달합니다.PausableMonotonicFrameClock을 사용하여 윈도우가 보이지 않을 때(예: onStop) 프레임 생성을 일시 중지할 수 있습니다.Recomposer를 생성할 때는 스레드 컨텍스트와 프레임 클록이 결합된 CoroutineContext가 필요합니다.
val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
val recomposer = Recomposer(effectCoroutineContext = contextWithClock)
이 contextWithClock은 다음 역할을 합니다.
LaunchedEffect가 이펙트를 실행하는 기본 컨텍스트가 되며, 보통 안드로이드에서는 메인 스레드에서 실행됩니다.실제 리컴포지션 작업은 코루틴 스코프 내에서 recomposer.runRecomposeAndApplyChanges()를 호출함으로써 시작됩니다.
viewTreeLifecycleOwner.lifecycle.addObserver(
object : LifecycleEventObserver {
override fun onStateChanged(lifecycleOwner: LifecycleOwner, event: Lifecycle.Event) {
val self = this
when (event) {
Lifecycle.Event.ON_CREATE ->
runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
try {
// 무효화를 기다리고 리컴포지션 후 변경 사항을 적용하는 루프 시작
recomposer.runRecomposeAndApplyChanges()
} finally {
// 완료 또는 취소 후 관찰자 제거
lifecycleOwner.lifecycle.removeObserver(self)
}
}
Lifecycle.Event.ON_START -> pausableClock?.resume()
Lifecycle.Event.ON_STOP -> pausableClock?.pause()
Lifecycle.Event.ON_DESTROY -> {
recomposer.cancel()
}
}
}
}
)
recomposer.runRecomposeAndApplyChanges() 함수가 호출되면 시스템은 무효화를 기다리기 시작하며, 무효화가 발생하면 자동으로 리컴포지션을 수행합니다. 이 프로세스의 구체적인 단계는 다음과 같습니다.
recomposer.runRecomposeAndApplyChanges() 호출 시 가장 먼저 snapshot.apply()를 통한 전역 상태 전파 프로세스에 관찰자를 등록합니다. 이 관찰자는 상태 변경 시 모든 변경 사항을 스냅샷 무효화 목록에 추가하고 모든 Composer에게 전파하여 리컴포지션을 트리거하는 기축 역할을 합니다.RecomposeScope 등에서 오는 무효화 작업이 준비될 때까지 지연됩니다.parentFrameClock.withFrameNanos {}를 호출하고 다음 프레임을 기다려 변경 사항을 통합합니다.withFrameNanos 블록 내에서 애니메이션 등 프레임을 기다리는 요소들에게 신호를 전달하며, 이 과정에서 새로운 무효화가 발생할 수 있습니다.composition.invalidate() 등으로 무효화된 각 컴포지션에 대해 리컴포지션을 수행합니다. 이는 슬롯 테이블(상태)과 Applier(구체화된 트리)에 필요한 모든 Change를 다시 계산하는 것을 의미합니다.CompositionLocal 업데이트와 같이 하위 컴포지션에 영향을 줄 수 있는 잠재적 리컴포지션 사례를 찾아내어 작업으로 예약합니다.composition.applyChanges()를 호출하여 최종적으로 반영한 뒤, Recomposer의 상태를 업데이트합니다.Recomposer는 현재 Compose UI가 이 기능을 직접 사용하고 있지는 않지만, 리컴포지션을 동시에 수행할 수 있는 기능을 내장하고 있습니다. 이는 다른 클라이언트 라이브러리가 필요에 따라 활용할 수 있는 확장성을 제공합니다.
Recomposer는 runRecomposeConcurrentlyAndApplyChanges 함수를 통해 동시 리컴포지션을 지원합니다. 이 함수는 상태 스냅샷의 무효화를 기다리고 자동으로 리컴포지션을 트리거하는 중단(suspend) 함수라는 점에서 일반적인 runRecomposeAndApplyChanges와 유사합니다. 가장 큰 차이점은 외부에서 제공되는 CoroutineContext를 사용하여 무효화된 컴포지션들의 리컴포지션을 개별적인 하위 작업으로 병렬 실행한다는 점입니다.
suspend fun runRecomposeConcurrentlyAndApplyChanges(
recomposeCoroutineContext: CoroutineContext
) { /* ... */ }
이 함수는 전달받은 컨텍스트를 사용해 자체적인 CoroutineScope를 생성합니다. 이 스코프 내에서 필요한 모든 리컴포지션 작업을 수행하기 위한 하위 작업들을 생성하고 조율함으로써 병렬 처리를 실현합니다.
Recomposer는 자신의 수명 주기를 나타내는 일련의 상태를 가지며, 이는 State 열거형 클래스로 정의되어 있습니다.
enum class State {
ShutDown,
ShuttingDown,
Inactive,
InactivePendingWork,
Idle,
PendingWork
}
각 상태의 구체적인 의미는 다음과 같습니다:
runRecomposeAndApplyChanges 등을 호출해야 합니다.