이 챕터에서는 컴포즈 런타임의 내부 동작 원리와 UI, 컴파일러, 런타임이 서로 어떻게 소통하는지를 깊이 있게 다룹니다.
핵심은 컴포저블 함수가 변경 사항을 시스템에 알리면, 컴파일러가 주입해 둔 composer 객체가 이를 받아 처리하는 과정입니다. 그동안 우리가 막연하게 컴포지션이라고 불렀던, 런타임이 상태를 저장하고 유지하는 구체적인 방법에 대해 데이터 구조 관점에서 살펴보겠습니다.
많은 개발자가 헷갈려 하는 두 가지 핵심 데이터 구조, 슬롯 테이블과 변경 목록의 차이를 명확히 이해해야 합니다.
: 런타임이 컴포지션의 현재 상태를 저장해 두는 최적화된 메모리 공간입니다.
앱이 처음 실행될 때 데이터가 채워지고, 화면을 다시 그리는 리컴포지션이 일어날 때마다 정보가 갱신됩니다. 소스 코드의 위치, 함수에 전달된 매개변수, remember로 기억된 값, CompositionLocal 등 컴포저블 함수 호출과 관련된 모든 기록이 여기에 담깁니다.
즉, 슬롯 테이블은 현재 화면이 어떻게 구성되어 있는지에 대한 모든 기록을 담고 있는 장부와 같습니다.
: 슬롯 테이블이 상태를 기록하는 역할이라면, 변경 목록은 실제 UI 트리를 수정하기 위한 작업 지시서입니다.
마치 소프트웨어 업데이트 패치 파일처럼, UI 트리에서 무엇을 바꿔야 할지를 목록으로 정리한 것입니다. 모든 변경 사항은 먼저 기록된 다음, 한꺼번에 트리에 적용됩니다.
이 두 구조를 바탕으로 런타임의 구성 요소들은 다음과 같이 역할을 나눕니다.
이번에는 컴포지션의 상태가 실제로 메모리에 어떻게 저장되는지 알아보겠습니다.
슬롯 테이블은 데이터에 아주 빠르게 접근하기 위해 최적화된 자료구조입니다. 텍스트 에디터가 글자를 수정할 때 사용하는 갭 버퍼(Gap Buffer)라는 개념을 바탕으로 만들어졌습니다.
슬롯 테이블은 데이터를 효율적으로 관리하기 위해 두 개의 배열을 사용합니다.
앞서 2장에서 컴파일러가 Composable 함수를 그룹으로 감싼다는 것을 배웠습니다. 이 그룹들은 메모리상에서 고유한 ID를 가지며, 함수의 실행 흐름(재시작, 이동, 교체 등)을 제어하는 기준이 됩니다.
groups 배열은 오직 구조적인 정보(메타데이터)만 담기 위해 Int 타입만 저장합니다. 트리 구조인 UI를 일렬로 쭉 펴서 저장하는데, 부모 그룹이 먼저 나오고 그 뒤에 자식 그룹들이 이어지는 방식입니다. 이렇게 일렬로 저장하면 순차적인 탐색은 빠르지만, 특정 위치로 바로 점프하기는 어렵습니다. 그래서 앵커(Anchor)라는 일종의 책갈피(포인터)를 사용하여 필요한 위치로 빠르게 이동합니다.
slots 배열은 실제 데이터를 저장합니다. 텍스트, 숫자, 객체 등 모든 타입의 정보를 담아야 하므로 Any? 타입을 사용합니다. groups 배열이 지도의 역할을 한다면, slots 배열은 그 위치에 있는 실제 보물(데이터)이라고 볼 수 있습니다.
슬롯 테이블의 핵심은 갭(Gap)입니다. 갭은 비어있는 공간을 가리키는 이동식 커서라고 생각하면 이해하기 쉽습니다. 데이터를 읽거나 쓸 때 이 커서가 이동하면서 위치를 잡습니다.

아래와 같은 조건부 코드를 예로 들어보겠습니다.
@Composable
@NonRestartableComposable
fun ConditionalText() {
if (a) {
Text(a)
} else {
Text(b)
}
}
만약 조건 a가 true에서 false로 바뀌면 어떻게 될까요? 슬롯 테이블의 커서(갭)가 그룹의 시작 위치로 되돌아갑니다. 그리고 기존에 저장되어 있던 Text(a)의 데이터를 덮어쓰면서 새로운 Text(b)의 데이터를 기록합니다. 마치 텍스트 에디터에서 글자를 지우고 새로 쓰는 것과 같습니다.
위의 Composable 함수는 재시작 불가능(non‑restartable) 으로 표시되어 있기 때문에, 재시작
가능한 그룹 대신에 교체 가능한 그룹이 삽입될 것입니다. 그룹은 테이블에 현재 “활성화된” 자식에
대한 데이터를 저장합니다. a가 true인 경우 Text(a)가 됩니다. If문의 조건이 전환되면, 갭은 그룹의
시작 위치로 되돌아가서 해당 위치에서 다시 쓰기 시작하여, Text(b)에 대한 데이터로 기존의 슬롯들을
모두 덮어씁니다.
슬롯 테이블을 다루기 위해 SlotReader와 SlotWriter가 존재합니다.
구독자(SlotReader) 데이터를 읽는 역할을 합니다. 여러 구독자가 동시에 데이터를 읽는 것은 안전하므로 허용됩니다. 구독자는 배열을 순회하며 그룹의 위치, 부모-자식 관계, 저장된 값 등을 확인합니다.
작성자(SlotWriter) 데이터를 변경하는 역할을 합니다. 데이터 무결성을 위해 한 번에 단 하나의 작성자만 활동할 수 있습니다. 작성자가 작업 중일 때는 구독자도 접근할 수 없습니다.
작성자는 커서(갭)를 움직이며 그룹이나 슬롯을 추가, 삭제, 교체, 이동시킵니다. UI 트리에 새로운 노드를 추가하거나 조건문에 따라 내용을 바꿀 때 작성자가 바쁘게 움직입니다. 이때 데이터의 위치가 바뀌면, 앞서 말한 책갈피인 앵커들의 위치도 함께 업데이트하여 참조가 깨지지 않도록 관리합니다.
이 슬롯 테이블 덕분에 Compose는 복잡한 UI 상태를 매우 효율적으로 관리하고 업데이트할 수 있습니다. 이제 이 정보들이 어떻게 실제 변경 사항으로 이어지는지 변경 목록을 통해 알아보겠습니다.
앞서 우리는 슬롯 테이블이 컴포지션의 현재 상태를 기록하는 장부라는 것을 배웠습니다. 그렇다면 변경 목록은 도대체 무엇이며, 언제 사용되는 걸까요?
이 섹션에서는 변경 사항이 기록되고, 잠시 보류되었다가, 실제로 적용되는 전체 과정을 살펴보겠습니다.
컴포지션(혹은 리컴포지션)이 발생하면 컴포저블 함수들이 실행되면서 방출(emit)이라는 과정이 일어납니다. 여기서 방출이란 화면을 즉시 고치는 것이 아닙니다. 슬롯 테이블을 갱신하고 최종적으로 화면(UI 트리)을 만들기 위해 해야 할 일들을 지연 중인 변경 사항 목록으로 정리하는 것을 말합니다.
중요한 점은 이 목록을 만들 때 슬롯 테이블에 저장된 기존 정보를 참고한다는 것입니다. 예를 들어 아이템의 순서를 바꾼다면, 기존에 어디에 있었는지(슬롯 테이블 확인)를 먼저 알아야 삭제하고 다시 배치할 수 있기 때문입니다.
컴포저블 함수가 실행될 때마다 런타임은 다음 과정을 반복합니다.
왜 바로 실행하지 않을까요? 실행을 잠시 미루고 목록만 작성하는 것이 훨씬 빠르기 때문입니다.
컴포지션 과정이 모두 끝나면, 그때 비로소 변경 목록에 적힌 내용들이 실제로 실행됩니다. 이때 슬롯 테이블의 정보가 최신 상태로 업데이트되고, 동시에 Applier(적용자)에게 신호를 보내 실제 화면(UI 노드 트리)을 구체화합니다.
이 모든 과정의 지휘자는 리컴포저입니다.
리컴포저는 언제 화면을 다시 그릴지, 변경 목록을 만드는 계산은 어떤 스레드에서 할지, 그리고 만들어진 목록을 실제 화면에 적용하는 건 어떤 스레드에서 할지를 결정합니다. (참고로 변경 사항을 적용하는 스레드는 보통 LaunchedEffect가 실행되는 기본 컨텍스트가 됩니다.)
다시 정리하자면 다음과 같습니다.
컴포저블 함수가 실행되면 즉시 화면을 건드리는 것이 아니라, 변경 목록이라는 작업 지시서를 먼저 만듭니다. 이 지시서는 모든 계산이 끝난 후 한꺼번에 실행되어 슬롯 테이블과 실제 UI를 업데이트합니다. 이 방식 덕분에 Compose는 복잡한 UI 변경도 빠르고 효율적으로 처리할 수 있습니다.
이제 데이터가 어떻게 저장되고(슬롯 테이블), 어떻게 변경되는지(변경 목록) 알았으니, 이 모든 것을 관리하는 Composer에 대해 알아볼 차례입니다.
앞서 2장에서 컴파일러가 우리 몰래 코드에 주입해 넣는 $composer라는 매개변수에 대해 배웠습니다. 이 $composer는 우리가 작성한 Composable 함수와 실제 Compose 런타임을 연결해 주는 다리 역할을 합니다.
화면을 구성하는 노드(Node)가 어떻게 메모리에 추가되는지, 가장 기초적인 UI 컴포넌트인 Layout을 예로 들어 살펴보겠습니다.
Layout 함수는 내부적으로 ReusableComposeNode라는 것을 사용합니다. 코드는 다음과 같습니다.
@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은 LayoutNode를 composition에 방출하기 위해 ReusableComposeNode를 사용합니다. 이
게 마치 즉각 노드를 생성하고 추가하는 것처럼 들릴 수 있지만, 실제로 하는 일은 적절한 시기에
composition의 현재 위치에서 노드를 생성, 초기화 및 삽입하는 방법을 런타임에게 가르치는 것
입니다.
아래 코드를 통해 살펴볼 수 있습니다.
@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()
}
ReusableComposeNode의 내부를 들여다보면, 모든 작업이 currentComposer라는 객체에게 위임되어 있다는 것을 알 수 있습니다.
작업 순서는 다음과 같습니다.
여기서 눈여겨볼 점은 content()가 교체 가능한 그룹으로 감싸진다는 것입니다. 덕분에 이 Layout 안에 들어가는 자식 컴포저블들은 안전하게 그룹화되어 저장됩니다.
다른 모든 Composable 함수에 대해서도 동일한 방출 작업이 수행됩니다. 예를 들어, remember를
살펴보겠습니다.
UI 노드뿐만 아니라, 값을 기억하는 remember 함수도 같은 방식으로 동작합니다.
@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () ‑> T): T =
currentComposer.cache(invalid = false, calculation)
remember는 currentComposer.cache라는 함수를 사용하여 값을 저장합니다. 이 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
}
결국 UI를 그리는 Layout이든, 값을 기억하는 remember든, 모든 작업은 Composer를 통해 슬롯 테이블에 정보를 기록하고 갱신하는 과정으로 귀결됩니다.
앞서 Composer가 수행하는 모든 방출 작업은 Change라는 단위로 변환되어 변경 목록에 저장됩니다.
Change는 단순한 데이터가 아니라, 나중에 실행될 수 있는 함수(람다)입니다. 코드를 보면 알 수 있듯이, 이 함수는 Applier와 SlotWriter 같은 핵심 도구들을 전달받아 실제 작업을 수행합니다.
internal typealias Change = (
applier: Applier<*>,
slots: SlotWriter,
rememberManager: RememberManager
) ‑> Unit
이 Change 객체들을 변경 목록에 차곡차곡 쌓는 과정을 우리는 방출이라고 부릅니다. 즉, 방출이란 노드를 추가하거나, 삭제하거나, 이동시키는 작업들을 당장 실행하는 것이 아니라 작업 대기열에 예약(스케줄링)해 두는 것입니다.
컴포지션 과정이 모두 끝나고 나면, Applier가 이 예약된 작업 목록을 넘겨받아 한꺼번에 실행하며 실제 화면을 갱신합니다.
요약하자면 방출, 기록, 스케줄링은 모두 같은 말입니다. 할 일을 목록에 적어두고, 나중에 한 번에 처리하여 효율성을 높이는 과정입니다.
기본적으로는 변경 사항을 목록에 적어두고 나중에 처리한다고 배웠습니다. 하지만 예외적으로 더 빠르게 처리하는 지름길이 있습니다.
바로 노드를 처음 생성해서 삽입하는 경우입니다.
Composer가 완전히 새로운 UI 노드를 만들 때는 굳이 작업을 나중으로 미룰 필요가 없습니다. 복잡한 비교 과정이 필요 없기 때문입니다. 그래서 이때는 변경 목록에 적는 과정을 생략하고, 곧바로 슬롯 테이블(메모리)에 데이터를 기록해 버립니다.
물론, 이미 존재하는 노드를 수정하거나 변경해야 할 때는 앞서 배운 대로 변경 목록에 작업을 기록하고 잠시 기다렸다가 적절한 시점에 처리합니다.
컴포지션 과정이 완료되면 applyChanges가 호출됩니다. 이때 변경 사항들이 메모리(슬롯 테이블)에 기록되고, 실제 화면(UI 트리)으로 구체화됩니다.
Composer는 데이터, 노드, 그룹 등 다양한 정보를 다루지만, 내부적으로는 이 모든 것을 그룹이라는 하나의 단위로 저장합니다. 각 그룹은 역할에 따라 서로 다른 꼬리표(필드)를 달고 있을 뿐입니다.
Composer가 그룹을 시작한다 또는 종료한다고 할 때, 현재 상태가 읽기 모드인지 쓰기 모드인지에 따라 그 의미가 다릅니다.
쓰기 모드일 때 슬롯 테이블에 새로운 그룹을 생성하거나, 기존 그룹을 제거하는 작업을 수행합니다.
읽기 모드일 때 슬롯 리더(SlotReader)에게 데이터 읽기를 시작하거나 끝내라고 지시합니다. 즉, 읽기 포인터를 그룹 안으로 넣거나 밖으로 빼는 이동 작업을 의미합니다.
화면의 구성 요소(노드)는 추가되는 것뿐만 아니라 삭제되거나 위치가 이동되기도 합니다.
그룹 삭제 그룹을 삭제한다는 것은 메모리(테이블)에서 해당 데이터를 지우고, 화면(Applier)에서도 해당 UI 요소를 제거한다는 뜻입니다. 이때 삭제된 그룹과 관련된 모든 대기 중인 작업(Invalidation)들도 함께 폐기됩니다.
이동 그룹이 삭제되지 않고 위치만 바뀐 경우, 데이터를 그대로 유지한 채 이동시킵니다.
참고로 모든 그룹이 화면을 다시 그리기 위한 용도는 아닙니다. 예를 들어 함수 매개변수의 기본값(default value)을 기억하기 위한 remember 블록처럼 특수한 목적을 가진 그룹들도 존재합니다.
Composer가 그룹을 시작할 때, 상황에 따라 다음과 같이 다르게 행동합니다.
Compose는 가능하면 기존 그룹을 재사용하려고 노력합니다. 노드를 새로 만들고 초기화하는 것보다, 이미 있는 노드를 가져와서 위치만 옮기거나 속성만 살짝 바꾸는 것이 훨씬 빠르기 때문입니다.
결국 Compose는 화면을 효율적으로 그리기 위해 메모리 상의 그룹들을 끊임없이 재사용, 이동, 갱신하는 작업을 수행합니다.
우리는 앞서 Composer가 슬롯 테이블에 값을 저장하고, 필요할 때 업데이트하는 방법을 배웠습니다.
remember 함수가 호출될 때 내부적으로 어떤 순서로 일이 일어나는지 살펴보겠습니다.
특별한 경우: RememberObserver
만약 기억해야 할 객체가 RememberObserver 인터페이스를 구현하고 있다면, Composer는 조금 더 세심하게 관리합니다.
이 객체의 수명 주기(Lifecycle)를 추적하기 위해, 우리 몰래 암시적인 변경 작업을 하나 더 기록해둡니다. 이렇게 별도로 기록해두어야 나중에 이 객체가 화면에서 사라지거나 더 이상 필요 없게 되었을 때, 잊어버리는 작업(onForgotten 등)을 안전하게 수행할 수 있기 때문입니다.
Composer의 또 다른 핵심 기능은 화면의 필요한 부분만 똑똑하게 다시 그리는 스마트 리컴포지션입니다. 이를 가능하게 하는 것이 바로 재구성 범위(RecomposeScope)입니다.
이 기능은 재시작 가능한 그룹(Restartable Group)과 짝을 이뤄 동작합니다. 재시작 가능한 그룹이 생성될 때마다 Composer는 그에 맞는 RecomposeScope를 생성하고, 이를 Composition에 대한 currentRecomposeScope로 설정합니다.
재구성 범위는 화면 전체를 다시 그리지 않고도, 특정 부분만 독립적으로 갱신할 수 있는 영역을 의미합니다.
데이터가 변경되어 화면 갱신이 필요할 때, 해당 범위에 신호(invalidate)를 보냅니다. 그러면 Composer는 슬롯 테이블에서 해당 그룹이 시작되는 위치를 찾아가 저장해 둔 함수(람다)를 다시 실행합니다. 즉, 함수를 다시 호출하여 새로운 데이터를 반영하고 화면을 업데이트하는 것입니다.
Composer는 이렇게 갱신 대기 중인 범위들을 스택(Stack) 구조로 관리하며, 다음 리컴포지션 타이밍에 맞춰 순차적으로 처리합니다.
중요한 점은 재구성 범위가 항상 작동하는 것은 아니라는 사실입니다.
Compose는 컴포저블 함수 내부에서 State 객체의 값을 읽는 작업이 감지될 때만 이 범위를 사용함(used) 상태로 표시합니다.
함수가 끝날 때 생성되는 코드인 endRestartGroup 뒤에 붙은 로직이 바로 이 역할을 합니다. 만약 상태를 읽어서 범위가 활성화되었다면, 이곳에서 나중에 다시 호출할 수 있도록 연결 고리(람다)를 만들어둡니다. 반대로 상태를 읽지 않았다면 null을 반환하여 불필요한 추적을 하지 않습니다.
// After compiler inserts boilerplate
@Composable
fun A(x: Int, $composer: Composer<*>, $changed: Int) {
$composer.startRestartGroup()
// ...
f(x)
$composer.endRestartGroup()?.updateScope { next ‑>
A(x, next, $changed or 0b1)
}
}
Composer는 화면을 그리는 것뿐만 아니라 사이드 이펙트(부수 효과)도 기록할 수 있습니다.
여기서 말하는 사이드 이펙트는 컴포지션 과정이 모두 끝나고, 실제 UI 트리에 변경 사항이 완벽하게 적용된 직후에 실행되는 함수들입니다.
중요한 점은 이 사이드 이펙트가 LaunchedEffect처럼 취소되거나 재시작되는 생명주기 관리 기능을 가지고 있지 않다는 것입니다. 이들은 슬롯 테이블에 영구적으로 저장되는 것이 아니기 때문에, 만약 컴포지션 과정이 도중에 실패하면 그냥 폐기되어 실행되지 않습니다. (이에 대한 더 자세한 내용은 6장 이펙트와 이펙트 핸들러에서 다룹니다.)
우리가 흔히 사용하는 CompositionLocal.current가 작동하는 원리도 Composer 덕분입니다. Composer는 데이터를 제공하는 Provider와 그 실제 값들을 슬롯 테이블에 그룹 형태로 저장해 두고, 필요할 때 꺼내 쓸 수 있게 해 줍니다.
안드로이드 스튜디오의 레이아웃 인스펙터 같은 도구들이 화면 구성을 분석할 수 있는 이유는, Composer가 컴포지션 과정에서 소스 코드의 위치나 구조 정보를 수집해서 CompositionData 형태로 소스 정보를 저장해 두기 때문입니다.
Compositions via CompositionContext)
CompositionContext는 메인 컴포지션과 그 하위의 서브 컴포지션(Subcomposition)을 이어주는 다리 역할을 합니다.
서브 컴포지션이란? 화면 전체 흐름과는 조금 다르게, 독자적으로 화면을 그리거나 갱신해야 할 때 만드는 별도의 컴포지션입니다. 예를 들어 Dialog, Popup, SubcomposeLayout, 그리고 AndroidView 같은 것들이 이에 해당합니다.
이들은 별도의 컴포지션이지만, 부모(메인) 컴포지션의 설정(CompositionLocal 값 등)이나 상태 변경 신호(Invalidation)를 그대로 물려받아야 합니다.
이때 CompositionContext가 부모와 자식을 하나의 트리처럼 투명하게 연결해 주는 탯줄 역할을 합니다. 덕분에 서브 컴포지션에서도 부모의 테마나 설정을 자연스럽게 공유받을 수 있습니다.
@Composable fun rememberCompositionContext(): CompositionContext {
return currentComposer.buildContext()
}
내부적으로는 rememberCompositionContext라는 함수를 통해 이 연결 고리(Context)를 새로 만들거나, 이미 만들어진 것을 가져와서 서브 컴포지션을 생성합니다.
Composer는 현재 시점의 데이터를 사진 찍듯이 보관하는 스냅샷에 대한 참조를 가지고 있습니다.
이 스냅샷은 현재 스레드에서 보이는 상태 값들을 그대로 유지합니다. 우리가 코드에서 State 객체의 값을 명시적으로 바꾸지 않는 한, 스냅샷 내부의 데이터는 처음 생성되었을 때와 똑같이 고정되어 있습니다. 이에 대한 더 깊은 내용은 5장 상태 스냅샷 시스템에서 자세히 다루겠습니다.
UI 트리 구조를 따라 부모에서 자식으로 이동하는 탐색 작업은 Applier가 담당합니다. 하지만 Applier가 그때그때 직접 이동하는 것은 아닙니다.
대신 Composer가 이동해야 할 경로를 downNodes라는 배열에 미리 기록해 둡니다. 나중에 이 기록을 Applier에게 전달하면, Applier가 그 지도를 보고 실제로 이동하는 방식입니다.
만약 자식 노드로 내려가라고 기록했다가(Down), 바로 다시 부모 노드로 올라오라고(Up) 한다면 어떻게 될까요? 굳이 내려갔다 올라오는 건 낭비이므로, downNodes 목록에서 아예 해당 경로를 지워버려 이동 경로를 최적화합니다.
이 부분은 Compose 내부의 아주 로우 레벨 동작 방식입니다.
데이터를 읽는 구독자(Reader)와 데이터를 수정하는 작성자(Writer)는 서로 다른 위치를 보고 있을 수 있습니다. 예를 들어 작성자가 중간에 새로운 그룹을 끼워 넣거나 삭제하면, 작성자의 현재 위치는 변하지만 구독자의 위치는 아직 그대로일 수 있기 때문입니다.
그래서 Compose 런타임은 이 둘 사이의 위치 차이(Delta)를 항상 계산하고 유지합니다. 즉, 작성자가 앞서 나가거나 뒤처진 거리만큼을 델타 값으로 저장해 둡니다.
// writersReaderDelta.kt
private var writersReaderDelta: Int = 0
fun moveReaderRelativeTo(location: Int) {
// Ensure the next skip will account for the distance we have already travelled.
writersReaderDelta += location ‑ reader.currentGroup
}
fun moveReaderToAbsolute(location: Int) {
writersReaderDelta = location
}
// realizeOperationLocation.kt
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
}
}
Runtime 라이브러리의 ComposerChangeListWriter 클래스에서 이 작업을 수행합니다.
이번 챕터에서 계속 언급했듯이, 계산된 변경 사항을 실제 화면(또는 데이터 트리)에 반영하는 역할은 Applier(적용자)가 담당합니다.
Composer는 변경 사항을 목록으로 정리만 해두고, 실제 적용(구체화, Materializing)은 Applier에게 전적으로 위임합니다. Applier는 전달받은 변경 목록을 하나씩 실행하면서 슬롯 테이블을 갱신하고, 최종적으로 우리가 눈으로 보는 결과물을 만들어냅니다.
재미있는 점은 Compose 런타임이 Applier가 구체적으로 무엇을 만드는지 전혀 신경 쓰지 않는다는 것입니다. 런타임은 단지 Applier가 지켜야 할 계약(인터페이스)만 정의해 둡니다.
즉, Applier를 어떻게 구현하느냐에 따라 안드로이드 뷰를 만들 수도 있고, 텍스트 파일을 만들 수도 있습니다. Applier는 플랫폼과 Compose 런타임을 연결해 주는 일종의 어댑터입니다.
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입니다.
이 N은 노드(Node)를 의미합니다. 이 타입을 유연하게 둔 덕분에 Compose는 안드로이드 UI뿐만 아니라 어떤 종류의 트리 구조와도 함께 동작할 수 있습니다.
Applier는 트리를 돌아다니며 노드를 추가(insert), 제거(remove), 이동(move)하는 기능만 제공할 뿐, 그 노드가 버튼인지 텍스트인지, 혹은 전혀 다른 데이터인지는 관여하지 않습니다.
Applier는 트리 전체를 돌아다니며(순회하며) 작업을 수행합니다.
결국 Applier는 변경 목록을 손에 쥐고 트리를 위아래로 누비며, 필요한 곳에 노드를 심거나 뽑아내는 정원사와 같은 역할을 수행합니다.
트리를 만들 때 위에서 아래로 만드는지(하향식), 아니면 아래에서 위로 만드는지(상향식)에 따라 성능이 크게 달라질 수 있습니다. R(루트) 밑에 B가 있고, B 밑에 A와 C가 있는 트리를 예로 들어 설명해 보겠습니다.


루트(R)에 B를 먼저 연결합니다. 그 다음 B에 A를 붙이고, 마지막으로 C를 붙이는 방식입니다. 즉, 이미 연결된 줄기에 새로운 가지를 계속 덧붙이는 순서입니다.

반대로 A와 C를 먼저 B에 붙여서 작은 덩어리를 만듭니다. B가 완성되면, 이 덩어리를 통째로 루트(R)에 갖다 붙입니다. 즉, 부품을 먼저 조립한 뒤 본체에 끼우는 방식입니다.
핵심은 변화를 알리는 비용 때문입니다. 어떤 Applier(적용자) 구현체는 자식 노드가 추가될 때마다 그 사실을 부모와 모든 조상 노드들에게 알려야 할 수도 있습니다.
하향식의 경우 이미 트리가 다 연결된 상태에서 끝부분에 노드를 추가합니다. A를 B에 붙이면, B뿐만 아니라 그 위의 R에게도 알림이 갈 수 있습니다. 트리가 깊어질수록 새로 추가할 때마다 줄줄이 보고해야 하므로 비용이 기하급수적으로 늘어납니다.
상향식의 경우 A를 B에 붙일 때, B는 아직 R에 연결되지 않은 상태입니다. 즉, B는 고립되어 있습니다. 그래서 A가 추가되었다는 사실을 R에게 알릴 필요가 없습니다. 그저 직속 부모인 B만 알면 됩니다. 나중에 B 덩어리를 R에 붙일 때 딱 한 번만 알리면 되므로 비용이 훨씬 절약됩니다.
결론적으로 사용하는 트리의 특성과 알림 방식에 따라 비용이 적게 드는 효율적인 전략을 하나 선택해서 사용해야 합니다.
앞서 설명한 대로 클라이언트 라이브러리는 Applier 인터페이스를 구현하여 실제 UI를 만듭니다. 안드로이드에서는 UiApplier라는 구현체를 사용합니다. 이 클래스는 노드 적용이라는 추상적인 개념을 안드로이드 화면에 보이는 실제 구성 요소로 바꾸는 핵심 역할을 합니다.
코드를 보면 생각보다 단순합니다.
internal class UiApplier(
root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
override fun insertTopDown(index: Int, instance: LayoutNode) {
// Ignored.
}
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()
}
}
UiApplier는 노드를 삽입, 제거, 이동하는 구체적인 방법을 직접 가지고 있지 않습니다. 대신 그 책임을 노드 자체인 LayoutNode에게 위임합니다.
변경 사항 적용이 모두 끝나면 onEndChanges가 호출됩니다. 이때 루트 노드의 주인(Owner)에게 작업이 끝났음을 알리고, 무효해진 관찰 객체들을 정리하여 화면 갱신을 준비합니다. 이 스냅샷(Snapshot) 관찰들은 읽고 의존하는 값이 변경될 때 자동으로 레이아웃이나 화면에 그리는 작업을 다시 호출하기 때문에 필요합니다.
그렇다면 트리에 노드를 붙이는 행위가 어떻게 실제 화면에 그림으로 나타날까요? 답은 간단합니다. 노드 스스로가 어떻게 연결되고 그려져야 하는지 알고 있기 때문입니다.
UiApplier가 LayoutNode에게 삽입 명령을 내리면 내부적으로 다음과 같은 일들이 일어납니다.

여기서 Owner는 컴포즈 트리와 안드로이드의 기본 뷰 시스템을 이어주는 다리 역할을 합니다. 실제 구현체는 AndroidComposeView입니다.
우리가 액티비티에서 setContent를 호출하는 순간, 이 AndroidComposeView가 생성되어 안드로이드 뷰 계층에 붙습니다. 그리고 이 뷰가 컴포즈 트리의 최상위 Owner가 됩니다.
모든 LayoutNode는 화면에 그려지기 위해 반드시 이 Owner와 연결되어야 합니다. 그래야 레이아웃 측정, 그리기, 터치 입력 같은 안드로이드 시스템의 이벤트를 전달받을 수 있기 때문입니다.
이제 우리는 Compose UI가 어떻게 노드 트리를 만들고 안드로이드 화면에 띄우는지 알게 되었습니다. Applier는 변경 사항을 수행하고, LayoutNode는 스스로를 관리하며, Owner는 이를 안드로이드 시스템과 연결합니다.
구조적인 원리는 파악했으니, 이제 실제 컴포지션 프로세스가 어떻게 흘러가는지 더 깊이 알아보겠습니다.
이전 섹션까지는 Composer가 어떻게 변경 사항을 기록하고 슬롯 테이블을 관리하는지, 그리고 이 변경 사항들이 어떻게 적용되는지에 대해 자세히 배웠습니다. 하지만 정작 이 모든 과정의 무대인 Composition이 누가, 언제, 어떻게 만드는지에 대해서는 아직 다루지 않았습니다. 이제 그 놓쳤던 퍼즐 조각을 맞춰볼 차례입니다.
많은 분들이 Composer가 Composition을 생성하고 소유한다고 오해하곤 합니다. 하지만 실제로는 정반대입니다. Composition이 생성되면, 그 Composition이 작업을 수행하기 위해 Composer를 만듭니다. 즉, Composition이 주인이고 Composer는 트리를 만들고 갱신하기 위한 도구일 뿐입니다.
우리가 Compose 런타임을 사용하는 방법은 크게 두 가지로 나뉩니다.
Composable 함수만으로는 아무 일도 일어나지 않습니다. setContent와 같은 진입점을 통해 플랫폼과 연결되고 Composition이 생성되어야 비로소 작동합니다.
안드로이드 앱을 만들 때 우리는 보통 ViewGroup.setContent를 호출하여 Composition을 시작합니다. 내부적으로 어떤 일이 일어나는지 코드를 통해 살펴보겠습니다.
// Wrapper.android.kt
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) // Here!
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
}
이 객체 덕분에 키보드 상태나 접근성 기능을 추적할 수 있고, 우리가 흔히 쓰는 안드로이드의 Context, Lifecycle, SavedStateHandle 같은 정보들이 CompositionLocal을 통해 Composable 함수들로 자연스럽게 흘러 들어옵니다.
주목할 점은 Composition을 생성할 때 UiApplier를 사용하고, 그 시작점으로 트리의 루트 노드를 지정한다는 것입니다. 이는 Compose가 어떤 종류의 트리를 다룰지(여기서는 UI 트리) 결정하는 순간입니다.
마지막에 호출되는 composition.setContent(content)는 우리가 작성한 UI 코드로 Composition을 채우고 업데이트하는 역할을 합니다.
Composition을 생성하는 또 다른 좋은 예는 Compose UI의 일부이며 화면에 벡터를 그리는 데
사용되는 VectorPainter입니다. VectorPainter는 자신만의 Composition을 생성하고 유지합니다.
@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) // 벡터를 위한 별도의 Applier를 사용합니다.
} else {
existing
}
composition = next
next.setContent {
composable(vector.viewportWidth, vector.viewportHeight)
}
return next
}
또한 SubcomposeLayout이라는 것도 있습니다. 이는 자식 요소의 크기를 측정하기 위해 별도의 Composition을 생성하여 관리하는 레이아웃입니다.
Composition을 생성할 때 부모의 CompositionContext를 전달할 수 있습니다. 이렇게 하면 새로 만든 Composition이 기존 Composition과 논리적으로 연결됩니다. 덕분에 부모 쪽에서 발생한 데이터 변경(invalidation)이나 테마 정보(CompositionLocal)가 자식 Composition까지 투명하게 전달될 수 있습니다.
또한 변경 사항을 적용할 스레드를 결정하는 CoroutineContext도 전달할 수 있습니다. 따로 지정하지 않으면 안드로이드에서는 기본적으로 메인 스레드(AndroidUiDispatcher.Main)를 사용합니다.
생성된 Composition은 영원히 존재하지 않습니다. 화면이 닫히거나 더 이상 필요 없게 되면 dispose()를 호출하여 메모리에서 정리해야 합니다. 안드로이드의 setContent를 사용할 때는 수명 주기(Lifecycle)에 맞춰 자동으로 정리되도록 생명주기 관찰자 뒤에 숨겨져 있지만, 내부적으로는 반드시 이 정리 과정이 존재합니다.
새로운 Composition이 만들어질 때마다 setContent 함수가 호출됩니다. 바로 이 시점에서 Composition이 처음으로 구축되며, 슬롯 테이블이 관련 데이터로 채워지기 시작합니다.
이 과정은 초기 설정을 위해 자신의 부모 Composition에게 작업을 위임하는 방식으로 진행됩니다.
setContent 내부에서는 composeInitial이라는 함수를 통해 부모에게 초기화를 요청합니다.
만약 Subcomposition이라면 부모 Composition이 그 요청을 받겠지만, 최상위 루트 Composition이라면 그 부모는 바로 Recomposer입니다. 결국, 중간 과정이 어떻든 초기화 로직의 최종 책임자는 항상 Recomposer가 됩니다. 모든 요청은 결국 가장 위에 있는 Recomposer까지 전달되기 때문입니다.
Recomposer가 초기 Composition을 구축할 때 수행하는 핵심 작업은 다음과 같습니다.
위에서는 큰 흐름을 보았고, 이제 실무자인 Composer가 내부적으로 어떤 순서로 작업을 처리하는지 살펴보겠습니다.
초기 컴포지션 단계가 끝나면, Applier는 composition.applyChanges()를 통해 그동안 기록된 변경 사항들을 실제 화면(또는 노드)에 반영하라는 신호를 받습니다.
구체적인 진행 순서는 다음과 같습니다.
about the Composition)
컴포지션은 단순히 화면을 그리는 것 외에도 현재 상태를 파악하고 제어하는 똑똑한 기능들을 가지고 있습니다.
상태 인식과 스케줄링 컴포지션은 현재 자신이 무언가를 그리고 있는 중인지(isComposing 플래그), 혹은 다시 그려야 할 대기열(invalidation)이 있는지 알고 있습니다. 이를 통해, 이미 그리는 중이라면 중복 작업을 피하거나, 상황에 맞춰 다시 그리기 작업을 즉시 수행할지 미룰지 결정합니다.
런타임은 ControlledComposition이라는 특수한 형태를 사용하여 컴포지션 과정을 외부에서 정밀하게 제어합니다. Recomposer는 이 기능을 통해 언제 화면을 갱신할지(composeContent, recompose) 조율합니다.
컴포지션은 자신이 관찰하고 있는 객체들의 변화를 감지합니다. 예를 들어, 부모 컴포지션의 CompositionLocal 값이 바뀌면, 이를 감지하여 연결된(CompositionContext로 이어진) 자식 컴포지션까지 강제로 다시 그리게 만듭니다.
만약 컴포지션 수행 중 오류가 발생하면, 작업을 즉시 중단하고 관련 정보를 초기화하여 안전을 확보합니다. 또한 불필요한 작업을 줄이는 최적화 기능도 있습니다. 변경된 데이터가 없거나 갱신할 필요가 없다고 판단되면, 리컴포지션 단계를 똑똑하게 건너뛰어 성능을 높입니다.
우리는 앞서 초기 Composition이 어떻게 만들어지는지, 그리고 화면 갱신을 위해 RecomposeScope와 Invalidation이 어떻게 동작하는지 배웠습니다. 하지만 정작 이 모든 과정을 총괄하는 지휘자, Recomposer에 대해서는 아직 자세히 다루지 않았습니다.
도대체 Recomposer는 언제 만들어지고, 어떻게 변경 사항을 감지하여 Recomposition을 시작할까요?
Recomposer의 핵심 역할은 ControlledComposition을 관리하는 것입니다. 변경 사항이 발생했을 때 언제 Recomposition을 수행할지 결정하고, 어떤 스레드에서 화면을 갱신할지 통제합니다.
Android에서 Compose를 시작하는 가장 일반적인 방법은 setContent를 호출하는 것입니다. 이때 내부적으로 새로운 Composition이 만들어지는데, 이 Composition은 반드시 자신의 부모(Parent)를 가져야 합니다. 바로 이 부모 역할을 하는 것이 Recomposer입니다.
안드로이드 Compose UI는 플랫폼과 연결되기 위해 자체적으로 Composition과 Recomposer를 모두 생성합니다. 이 과정은 주로 WindowRecomposerFactory라는 팩토리를 통해 이루어집니다.
WindowRecomposerFactory 안드로이드의 뷰(View) 시스템 위에서 동작하는 Recomposer를 만드는 공장입니다.
fun interface WindowRecomposerFactory {
fun createRecomposer(windowRootView: View): Recomposer
companion object {
val LifecycleAware: WindowRecomposerFactory
= WindowRecomposerFactory { rootView ‑>
rootView.createLifecycleAwareViewTreeRecomposer()
}
}
}
위 코드에서 볼 수 있듯이, 팩토리는 루트 뷰(rootView)를 받아 생명주기(Lifecycle)를 인식하는 Recomposer를 생성합니다. 이는 매우 중요한 포인트입니다. 만약 뷰가 화면에서 사라졌는데도 Recomposer가 계속 동작한다면 메모리 누수가 발생할 수 있기 때문입니다. 따라서 뷰의 생명주기에 맞춰 Recomposer도 함께 종료되도록 설계되어 있습니다.
Compose UI의 모든 동작은 AndroidUiDispatcher에 의해 조정됩니다. 이 디스패처는 안드로이드의 메인 루퍼(Main Looper) 및 화면 주사율을 담당하는 Choreographer와 연결되어 있습니다. 덕분에 애니메이션이나 화면 갱신이 시스템의 프레임 속도에 맞춰 부드럽게 동작합니다.
팩토리 함수가 Recomposer를 만들 때 가장 먼저 하는 일은 PausableMonotonicFrameClock을 생성하는 것입니다. 이름에서 알 수 있듯이 필요할 때 일시 정지할 수 있는 시계입니다. 예를 들어 앱이 백그라운드로 내려가서 화면이 보이지 않을 때는 굳이 화면을 갱신할 필요가 없으므로 시계를 멈춰 불필요한 연산을 막습니다.
Recomposer는 생성될 때 CoroutineContext를 필요로 합니다. 이때 AndroidUiDispatcher의 스레드 정보(주로 메인 스레드)와 위에서 만든 일시 정지 가능한 시계를 합쳐서 컨텍스트를 만듭니다.
val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
val recomposer = Recomposer(effectCoroutineContext = contextWithClock)
이렇게 만들어진 컨텍스트는 Recomposer가 종료될 때 관련된 모든 작업(LaunchedEffect 등)을 함께 취소하는 데 사용됩니다. 또한 UI 변경 사항을 적용하거나 이펙트를 실행할 때 기본적으로 이 컨텍스트(메인 스레드)를 사용하게 됩니다.
Recomposer가 생성된 후에는 뷰의 생명주기를 관찰하는 옵저버(Observer)가 등록됩니다.
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 {
// After completion or cancellation
lifecycleOwner.lifecycle.removeObserver(self)
}
}
Lifecycle.Event.ON_START ‑> pausableClock?.resume()
Lifecycle.Event.ON_STOP ‑> pausableClock?.pause()
Lifecycle.Event.ON_DESTROY ‑> {
recomposer.cancel()
}
}
}
}
)
이 코드는 Recomposer의 동작 원리를 명확하게 보여줍니다.
결론적으로 setContent를 호출할 때 전달되는 parent 매개변수가 바로 이 Recomposer입니다. 안드로이드 시스템과 Compose 세계를 연결하고, 생명주기에 맞춰 화면 갱신을 지휘하는 것이 바로 Recomposer의 역할입니다.
Recomposer의 runRecomposeAndApplyChanges 함수가 호출되면, 시스템은 데이터의 변화(invalidation)를 감지하기 위해 대기 상태로 들어갑니다. 데이터가 변경되면 자동으로 화면을 다시 그리는 Recomposition이 시작되는데, 구체적으로 어떤 과정을 거치는지 살펴보겠습니다.
일반적인 안드로이드 UI는 메인 스레드에서 동작하지만, Recomposer는 기술적으로 동시에 여러 작업을 처리할 수 있는 기능을 가지고 있습니다. (안드로이드 UI가 아닌 다른 라이브러리에서 유용할 수 있습니다.)
runRecomposeConcurrentlyAndApplyChanges라는 함수를 사용하면 되는데, 기본 동작은 앞서 설명한 것과 거의 같습니다. 차이점은 외부에서 전달받은 CoroutineContext를 사용하여 별도의 스레드(코루틴 스코프)에서 invalidation 된 Composition의 recomposition을 수행한다는 점입니다. 이를 통해 복잡한 계산이 필요한 경우 백그라운드에서 처리하는 구조를 만들 수 있습니다.
Recomposer는 생명주기에 따라 다음과 같은 상태를 가집니다.