[Compose Internals] 3. 컴포즈 런타임

벼리·2026년 1월 25일

Compose

목록 보기
3/10

이 챕터에서는 컴포즈 런타임의 내부 동작 원리와 UI, 컴파일러, 런타임이 서로 어떻게 소통하는지를 깊이 있게 다룹니다.

핵심은 컴포저블 함수가 변경 사항을 시스템에 알리면, 컴파일러가 주입해 둔 composer 객체가 이를 받아 처리하는 과정입니다. 그동안 우리가 막연하게 컴포지션이라고 불렀던, 런타임이 상태를 저장하고 유지하는 구체적인 방법에 대해 데이터 구조 관점에서 살펴보겠습니다.

📌 슬롯 테이블과 변경 목록 (The Slot Table and the List of Changes)

많은 개발자가 헷갈려 하는 두 가지 핵심 데이터 구조, 슬롯 테이블과 변경 목록의 차이를 명확히 이해해야 합니다.

📍 슬롯 테이블 (The Slot Table)

: 런타임이 컴포지션의 현재 상태를 저장해 두는 최적화된 메모리 공간입니다.

앱이 처음 실행될 때 데이터가 채워지고, 화면을 다시 그리는 리컴포지션이 일어날 때마다 정보가 갱신됩니다. 소스 코드의 위치, 함수에 전달된 매개변수, remember로 기억된 값, CompositionLocal 등 컴포저블 함수 호출과 관련된 모든 기록이 여기에 담깁니다.

즉, 슬롯 테이블은 현재 화면이 어떻게 구성되어 있는지에 대한 모든 기록을 담고 있는 장부와 같습니다.

📍 변경 목록 (The List of Changes)

: 슬롯 테이블이 상태를 기록하는 역할이라면, 변경 목록은 실제 UI 트리를 수정하기 위한 작업 지시서입니다.

마치 소프트웨어 업데이트 패치 파일처럼, UI 트리에서 무엇을 바꿔야 할지를 목록으로 정리한 것입니다. 모든 변경 사항은 먼저 기록된 다음, 한꺼번에 트리에 적용됩니다.

📍 역할의 분담

이 두 구조를 바탕으로 런타임의 구성 요소들은 다음과 같이 역할을 나눕니다.

  • Applier (적용자)
    • 변경 목록을 넘겨받아 실제로 UI 트리를 업데이트하고 화면을 구체화하는 역할을 담당합니다.
  • Recomposer (재구성자)
    • 언제, 그리고 어떤 스레드에서 리컴포지션을 수행하고 변경 사항을 적용할지를 결정하는 관리자 역할을 합니다.

📌 슬롯 테이블 심층 분석 (The Slot Table in Depth)

이번에는 컴포지션의 상태가 실제로 메모리에 어떻게 저장되는지 알아보겠습니다.

슬롯 테이블은 데이터에 아주 빠르게 접근하기 위해 최적화된 자료구조입니다. 텍스트 에디터가 글자를 수정할 때 사용하는 갭 버퍼(Gap Buffer)라는 개념을 바탕으로 만들어졌습니다.

슬롯 테이블은 데이터를 효율적으로 관리하기 위해 두 개의 배열을 사용합니다.

  1. groups 배열 (IntArray): 컴포지션의 구조(그룹) 정보를 저장합니다.
  2. slots 배열 (Array<Any?>): 각 그룹에 속한 실제 데이터 값을 저장합니다.

📍 그룹과 슬롯의 역할

앞서 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)에 대한 데이터로 기존의 슬롯들을
모두 덮어씁니다.

📍 읽기(Reader)와 쓰기(Writer)

슬롯 테이블을 다루기 위해 SlotReader와 SlotWriter가 존재합니다.

구독자(SlotReader) 데이터를 읽는 역할을 합니다. 여러 구독자가 동시에 데이터를 읽는 것은 안전하므로 허용됩니다. 구독자는 배열을 순회하며 그룹의 위치, 부모-자식 관계, 저장된 값 등을 확인합니다.

작성자(SlotWriter) 데이터를 변경하는 역할을 합니다. 데이터 무결성을 위해 한 번에 단 하나의 작성자만 활동할 수 있습니다. 작성자가 작업 중일 때는 구독자도 접근할 수 없습니다.

작성자는 커서(갭)를 움직이며 그룹이나 슬롯을 추가, 삭제, 교체, 이동시킵니다. UI 트리에 새로운 노드를 추가하거나 조건문에 따라 내용을 바꿀 때 작성자가 바쁘게 움직입니다. 이때 데이터의 위치가 바뀌면, 앞서 말한 책갈피인 앵커들의 위치도 함께 업데이트하여 참조가 깨지지 않도록 관리합니다.

이 슬롯 테이블 덕분에 Compose는 복잡한 UI 상태를 매우 효율적으로 관리하고 업데이트할 수 있습니다. 이제 이 정보들이 어떻게 실제 변경 사항으로 이어지는지 변경 목록을 통해 알아보겠습니다.

📌 변경 목록 (The list of changes)

앞서 우리는 슬롯 테이블이 컴포지션의 현재 상태를 기록하는 장부라는 것을 배웠습니다. 그렇다면 변경 목록은 도대체 무엇이며, 언제 사용되는 걸까요?

이 섹션에서는 변경 사항이 기록되고, 잠시 보류되었다가, 실제로 적용되는 전체 과정을 살펴보겠습니다.

1. 방출(Emit)과 지연된 변경 사항

컴포지션(혹은 리컴포지션)이 발생하면 컴포저블 함수들이 실행되면서 방출(emit)이라는 과정이 일어납니다. 여기서 방출이란 화면을 즉시 고치는 것이 아닙니다. 슬롯 테이블을 갱신하고 최종적으로 화면(UI 트리)을 만들기 위해 해야 할 일들을 지연 중인 변경 사항 목록으로 정리하는 것을 말합니다.

중요한 점은 이 목록을 만들 때 슬롯 테이블에 저장된 기존 정보를 참고한다는 것입니다. 예를 들어 아이템의 순서를 바꾼다면, 기존에 어디에 있었는지(슬롯 테이블 확인)를 먼저 알아야 삭제하고 다시 배치할 수 있기 때문입니다.

2. 선 기록, 후 실행 (효율성의 핵심)

컴포저블 함수가 실행될 때마다 런타임은 다음 과정을 반복합니다.

  1. 슬롯 테이블 확인: 현재 상태가 어떤지 봅니다.
  2. 작업 생성: 무엇이 바뀌어야 하는지 판단하여 변경 사항을 만듭니다.
  3. 목록 추가: 이 변경 사항을 즉시 실행하지 않고 변경 목록에 차곡차곡 쌓아둡니다.

왜 바로 실행하지 않을까요? 실행을 잠시 미루고 목록만 작성하는 것이 훨씬 빠르기 때문입니다.

컴포지션 과정이 모두 끝나면, 그때 비로소 변경 목록에 적힌 내용들이 실제로 실행됩니다. 이때 슬롯 테이블의 정보가 최신 상태로 업데이트되고, 동시에 Applier(적용자)에게 신호를 보내 실제 화면(UI 노드 트리)을 구체화합니다.

3. 리컴포저(Recomposer)의 지휘

이 모든 과정의 지휘자는 리컴포저입니다.

리컴포저는 언제 화면을 다시 그릴지, 변경 목록을 만드는 계산은 어떤 스레드에서 할지, 그리고 만들어진 목록을 실제 화면에 적용하는 건 어떤 스레드에서 할지를 결정합니다. (참고로 변경 사항을 적용하는 스레드는 보통 LaunchedEffect가 실행되는 기본 컨텍스트가 됩니다.)

다시 정리하자면 다음과 같습니다.

컴포저블 함수가 실행되면 즉시 화면을 건드리는 것이 아니라, 변경 목록이라는 작업 지시서를 먼저 만듭니다. 이 지시서는 모든 계산이 끝난 후 한꺼번에 실행되어 슬롯 테이블과 실제 UI를 업데이트합니다. 이 방식 덕분에 Compose는 복잡한 UI 변경도 빠르고 효율적으로 처리할 수 있습니다.

이제 데이터가 어떻게 저장되고(슬롯 테이블), 어떻게 변경되는지(변경 목록) 알았으니, 이 모든 것을 관리하는 Composer에 대해 알아볼 차례입니다.

📌 Composer (The Composer)

앞서 2장에서 컴파일러가 우리 몰래 코드에 주입해 넣는 $composer라는 매개변수에 대해 배웠습니다. 이 $composer는 우리가 작성한 Composable 함수와 실제 Compose 런타임을 연결해 주는 다리 역할을 합니다.

📌 Composer 키우기 (Feeding the Composer)

화면을 구성하는 노드(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라는 객체에게 위임되어 있다는 것을 알 수 있습니다.

작업 순서는 다음과 같습니다.

  1. startReusableNode: 노드 작업을 시작한다고 알립니다.
  2. createNode: 팩토리 함수를 통해 실제 노드를 생성합니다.
  3. update: 속성들을 초기화합니다.
  4. startReplaceableGroup: 자식 내용물(content)을 담을 그룹을 엽니다.
  5. content(): 실제 자식 컴포저블들을 실행합니다.
  6. end...: 그룹과 노드 작업을 마무리합니다.

여기서 눈여겨볼 점은 content()가 교체 가능한 그룹으로 감싸진다는 것입니다. 덕분에 이 Layout 안에 들어가는 자식 컴포저블들은 안전하게 그룹화되어 저장됩니다.

다른 모든 Composable 함수에 대해서도 동일한 방출 작업이 수행됩니다. 예를 들어, remember를
살펴보겠습니다.

📍 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
}
  1. 슬롯 테이블 확인: 먼저 현재 위치(슬롯)에 저장된 값이 있는지 확인합니다.
  2. 값 계산 및 저장: 만약 값이 비어있거나(Empty), 강제로 갱신해야 한다면(invalid), 계산 블록을 실행해 값을 만들고 슬롯 테이블에 업데이트합니다. (invalid 하다면 저장되어 있어도 값을 갱신)
  3. 값 반환: 그게 아니라면, 기존에 저장해 두었던 값을 그대로 반환합니다.

결국 UI를 그리는 Layout이든, 값을 기억하는 remember든, 모든 작업은 Composer를 통해 슬롯 테이블에 정보를 기록하고 갱신하는 과정으로 귀결됩니다.

📌 변경 사항 모델링 (Modeling the Changes)

앞서 Composer가 수행하는 모든 방출 작업은 Change라는 단위로 변환되어 변경 목록에 저장됩니다.

Change는 단순한 데이터가 아니라, 나중에 실행될 수 있는 함수(람다)입니다. 코드를 보면 알 수 있듯이, 이 함수는 Applier와 SlotWriter 같은 핵심 도구들을 전달받아 실제 작업을 수행합니다.

internal typealias Change = (
	applier: Applier<*>,
	slots: SlotWriter,
	rememberManager: RememberManager
)> Unit

이 Change 객체들을 변경 목록에 차곡차곡 쌓는 과정을 우리는 방출이라고 부릅니다. 즉, 방출이란 노드를 추가하거나, 삭제하거나, 이동시키는 작업들을 당장 실행하는 것이 아니라 작업 대기열에 예약(스케줄링)해 두는 것입니다.

컴포지션 과정이 모두 끝나고 나면, Applier가 이 예약된 작업 목록을 넘겨받아 한꺼번에 실행하며 실제 화면을 갱신합니다.

요약하자면 방출, 기록, 스케줄링은 모두 같은 말입니다. 할 일을 목록에 적어두고, 나중에 한 번에 처리하여 효율성을 높이는 과정입니다.

📌 작성 시기 최적화 (Optimizing when to write)

기본적으로는 변경 사항을 목록에 적어두고 나중에 처리한다고 배웠습니다. 하지만 예외적으로 더 빠르게 처리하는 지름길이 있습니다.

바로 노드를 처음 생성해서 삽입하는 경우입니다.

Composer가 완전히 새로운 UI 노드를 만들 때는 굳이 작업을 나중으로 미룰 필요가 없습니다. 복잡한 비교 과정이 필요 없기 때문입니다. 그래서 이때는 변경 목록에 적는 과정을 생략하고, 곧바로 슬롯 테이블(메모리)에 데이터를 기록해 버립니다.

물론, 이미 존재하는 노드를 수정하거나 변경해야 할 때는 앞서 배운 대로 변경 목록에 작업을 기록하고 잠시 기다렸다가 적절한 시점에 처리합니다.

📌쓰기 및 읽기 그룹 (Writing and reading groups)

컴포지션 과정이 완료되면 applyChanges가 호출됩니다. 이때 변경 사항들이 메모리(슬롯 테이블)에 기록되고, 실제 화면(UI 트리)으로 구체화됩니다.

Composer는 데이터, 노드, 그룹 등 다양한 정보를 다루지만, 내부적으로는 이 모든 것을 그룹이라는 하나의 단위로 저장합니다. 각 그룹은 역할에 따라 서로 다른 꼬리표(필드)를 달고 있을 뿐입니다.

📍 읽기 모드와 쓰기 모드

Composer가 그룹을 시작한다 또는 종료한다고 할 때, 현재 상태가 읽기 모드인지 쓰기 모드인지에 따라 그 의미가 다릅니다.

쓰기 모드일 때 슬롯 테이블에 새로운 그룹을 생성하거나, 기존 그룹을 제거하는 작업을 수행합니다.

읽기 모드일 때 슬롯 리더(SlotReader)에게 데이터 읽기를 시작하거나 끝내라고 지시합니다. 즉, 읽기 포인터를 그룹 안으로 넣거나 밖으로 빼는 이동 작업을 의미합니다.

📍 삭제와 이동

화면의 구성 요소(노드)는 추가되는 것뿐만 아니라 삭제되거나 위치가 이동되기도 합니다.

그룹 삭제 그룹을 삭제한다는 것은 메모리(테이블)에서 해당 데이터를 지우고, 화면(Applier)에서도 해당 UI 요소를 제거한다는 뜻입니다. 이때 삭제된 그룹과 관련된 모든 대기 중인 작업(Invalidation)들도 함께 폐기됩니다.

이동 그룹이 삭제되지 않고 위치만 바뀐 경우, 데이터를 그대로 유지한 채 이동시킵니다.

참고로 모든 그룹이 화면을 다시 그리기 위한 용도는 아닙니다. 예를 들어 함수 매개변수의 기본값(default value)을 기억하기 위한 remember 블록처럼 특수한 목적을 가진 그룹들도 존재합니다.

📍 Composer가 그룹을 처리하는 5가지 시나리오

Composer가 그룹을 시작할 때, 상황에 따라 다음과 같이 다르게 행동합니다.

  1. 이미 삽입 작업을 하고 있을 때
    • 망설일 이유가 없습니다. 슬롯 테이블에 즉시 그룹을 기록합니다.
  2. 기존 그룹을 재사용할 때
    • 메모리에 이미 똑같은 그룹이 있다면, 새로 만들지 않고 그 그룹을 다시 사용합니다.
  3. 그룹의 위치가 바뀌었을 때
    • 그룹은 존재하지만 위치가 달라졌다면, 해당 그룹의 데이터를 새로운 위치로 옮기라고 기록합니다.
  4. 완전히 새로운 그룹일 때
    • 메모리에 없는 새로운 그룹이라면 삽입 모드로 전환합니다. 먼저 별도의 임시 테이블(insertTable)에 그룹과 자식들을 모두 작성한 뒤, 완성된 덩어리를 최종 테이블에 한 번에 집어넣습니다.
  5. 변경 사항이 없을 때
    • 아무런 쓰기 작업이 필요 없다면, 단순히 기존 데이터를 읽기만 합니다.

📍 재사용의 효율성

Compose는 가능하면 기존 그룹을 재사용하려고 노력합니다. 노드를 새로 만들고 초기화하는 것보다, 이미 있는 노드를 가져와서 위치만 옮기거나 속성만 살짝 바꾸는 것이 훨씬 빠르기 때문입니다.

결국 Compose는 화면을 효율적으로 그리기 위해 메모리 상의 그룹들을 끊임없이 재사용, 이동, 갱신하는 작업을 수행합니다.

📌 값 기억하기 (Remembering values)

우리는 앞서 Composer가 슬롯 테이블에 값을 저장하고, 필요할 때 업데이트하는 방법을 배웠습니다.

remember 함수가 호출될 때 내부적으로 어떤 순서로 일이 일어나는지 살펴보겠습니다.

  1. 즉시 비교
    • remember(key) { ... }가 호출되면, 괄호 안의 키(Key) 값이 이전과 달라졌는지 비교하는 작업은 그 자리에서 즉시 실행됩니다.
  2. 업데이트 예약
    • 비교 결과 값이 달라졌다면 새로운 값을 저장해야 합니다. 이때 런타임은 슬롯 테이블을 바로 건드리지 않고, 업데이트 작업을 변경 목록(Change List)에 예약해둡니다. (물론, 노드를 처음 생성해서 막 삽입하고 있는 중이라면 예약하지 않고 즉시 기록합니다.)
특별한 경우: RememberObserver

만약 기억해야 할 객체가 RememberObserver 인터페이스를 구현하고 있다면, Composer는 조금 더 세심하게 관리합니다.
이 객체의 수명 주기(Lifecycle)를 추적하기 위해, 우리 몰래 암시적인 변경 작업을 하나 더 기록해둡니다. 이렇게 별도로 기록해두어야 나중에 이 객체가 화면에서 사라지거나 더 이상 필요 없게 되었을 때, 잊어버리는 작업(onForgotten 등)을 안전하게 수행할 수 있기 때문입니다.

📌 ****재구성 범위 (Recompose scopes)

Composer의 또 다른 핵심 기능은 화면의 필요한 부분만 똑똑하게 다시 그리는 스마트 리컴포지션입니다. 이를 가능하게 하는 것이 바로 재구성 범위(RecomposeScope)입니다.

이 기능은 재시작 가능한 그룹(Restartable Group)과 짝을 이뤄 동작합니다. 재시작 가능한 그룹이 생성될 때마다 Composer는 그에 맞는 RecomposeScope를 생성하고, 이를 Composition에 대한 currentRecomposeScope로 설정합니다.

📍 독립적인 갱신 영역

재구성 범위는 화면 전체를 다시 그리지 않고도, 특정 부분만 독립적으로 갱신할 수 있는 영역을 의미합니다.

데이터가 변경되어 화면 갱신이 필요할 때, 해당 범위에 신호(invalidate)를 보냅니다. 그러면 Composer는 슬롯 테이블에서 해당 그룹이 시작되는 위치를 찾아가 저장해 둔 함수(람다)를 다시 실행합니다. 즉, 함수를 다시 호출하여 새로운 데이터를 반영하고 화면을 업데이트하는 것입니다.

Composer는 이렇게 갱신 대기 중인 범위들을 스택(Stack) 구조로 관리하며, 다음 리컴포지션 타이밍에 맞춰 순차적으로 처리합니다.

📍 활성화 조건: 상태(State)를 읽을 때만

중요한 점은 재구성 범위가 항상 작동하는 것은 아니라는 사실입니다.

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와 사이드 이펙트 (SideEffects in the Composer)

Composer는 화면을 그리는 것뿐만 아니라 사이드 이펙트(부수 효과)도 기록할 수 있습니다.

여기서 말하는 사이드 이펙트는 컴포지션 과정이 모두 끝나고, 실제 UI 트리에 변경 사항이 완벽하게 적용된 직후에 실행되는 함수들입니다.

중요한 점은 이 사이드 이펙트가 LaunchedEffect처럼 취소되거나 재시작되는 생명주기 관리 기능을 가지고 있지 않다는 것입니다. 이들은 슬롯 테이블에 영구적으로 저장되는 것이 아니기 때문에, 만약 컴포지션 과정이 도중에 실패하면 그냥 폐기되어 실행되지 않습니다. (이에 대한 더 자세한 내용은 6장 이펙트와 이펙트 핸들러에서 다룹니다.)

📌 CompositionLocals 저장 (Storing CompositionLocals)

우리가 흔히 사용하는 CompositionLocal.current가 작동하는 원리도 Composer 덕분입니다. Composer는 데이터를 제공하는 Provider와 그 실제 값들을 슬롯 테이블에 그룹 형태로 저장해 두고, 필요할 때 꺼내 쓸 수 있게 해 줍니다.

📌 소스 정보 저장 (Storing source information)

안드로이드 스튜디오의 레이아웃 인스펙터 같은 도구들이 화면 구성을 분석할 수 있는 이유는, Composer가 컴포지션 과정에서 소스 코드의 위치나 구조 정보를 수집해서 CompositionData 형태로 소스 정보를 저장해 두기 때문입니다.

📌 CompositionContext를 이용한 Composition 연결 (Linking

Compositions via CompositionContext)

CompositionContext는 메인 컴포지션과 그 하위의 서브 컴포지션(Subcomposition)을 이어주는 다리 역할을 합니다.

서브 컴포지션이란? 화면 전체 흐름과는 조금 다르게, 독자적으로 화면을 그리거나 갱신해야 할 때 만드는 별도의 컴포지션입니다. 예를 들어 Dialog, Popup, SubcomposeLayout, 그리고 AndroidView 같은 것들이 이에 해당합니다.

이들은 별도의 컴포지션이지만, 부모(메인) 컴포지션의 설정(CompositionLocal 값 등)이나 상태 변경 신호(Invalidation)를 그대로 물려받아야 합니다.

이때 CompositionContext가 부모와 자식을 하나의 트리처럼 투명하게 연결해 주는 탯줄 역할을 합니다. 덕분에 서브 컴포지션에서도 부모의 테마나 설정을 자연스럽게 공유받을 수 있습니다.

@Composable fun rememberCompositionContext(): CompositionContext {
	return currentComposer.buildContext()
}

내부적으로는 rememberCompositionContext라는 함수를 통해 이 연결 고리(Context)를 새로 만들거나, 이미 만들어진 것을 가져와서 서브 컴포지션을 생성합니다.

📌 ****현재 상태 스냅샷에 접근 (Accessing the current State snapshot)

Composer는 현재 시점의 데이터를 사진 찍듯이 보관하는 스냅샷에 대한 참조를 가지고 있습니다.

이 스냅샷은 현재 스레드에서 보이는 상태 값들을 그대로 유지합니다. 우리가 코드에서 State 객체의 값을 명시적으로 바꾸지 않는 한, 스냅샷 내부의 데이터는 처음 생성되었을 때와 똑같이 고정되어 있습니다. 이에 대한 더 깊은 내용은 5장 상태 스냅샷 시스템에서 자세히 다루겠습니다.

📌 노드 탐색 (Navigating the nodes)

UI 트리 구조를 따라 부모에서 자식으로 이동하는 탐색 작업은 Applier가 담당합니다. 하지만 Applier가 그때그때 직접 이동하는 것은 아닙니다.

대신 Composer가 이동해야 할 경로를 downNodes라는 배열에 미리 기록해 둡니다. 나중에 이 기록을 Applier에게 전달하면, Applier가 그 지도를 보고 실제로 이동하는 방식입니다.

만약 자식 노드로 내려가라고 기록했다가(Down), 바로 다시 부모 노드로 올라오라고(Up) 한다면 어떻게 될까요? 굳이 내려갔다 올라오는 건 낭비이므로, downNodes 목록에서 아예 해당 경로를 지워버려 이동 경로를 최적화합니다.

📌 구독자와 작성자의 동기화 유지 (Keeping reader and writer in sync)

이 부분은 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 클래스에서 이 작업을 수행합니다.

  • writersReaderDelta
    • 작성자와 구독자 사이의 거리 차이를 저장하는 변수입니다.
  • moveReader
    • 구독자의 위치를 이동시키며 델타 값을 갱신합니다.
  • realizeOperationLocation
    • 작업을 수행하기 직전에 호출되는 함수입니다. 저장해 둔 델타 값을 이용해 구독자와 작성자의 위치를 정확하게 일치(동기화)시켜 엉뚱한 곳에 데이터를 쓰지 않도록 방지합니다.

📌 ****변경 사항 적용하기 (Applying the changes)

이번 챕터에서 계속 언급했듯이, 계산된 변경 사항을 실제 화면(또는 데이터 트리)에 반영하는 역할은 Applier(적용자)가 담당합니다.

Composer는 변경 사항을 목록으로 정리만 해두고, 실제 적용(구체화, Materializing)은 Applier에게 전적으로 위임합니다. Applier는 전달받은 변경 목록을 하나씩 실행하면서 슬롯 테이블을 갱신하고, 최종적으로 우리가 눈으로 보는 결과물을 만들어냅니다.

📍 플랫폼에 독립적인 Applier

재미있는 점은 Compose 런타임이 Applier가 구체적으로 무엇을 만드는지 전혀 신경 쓰지 않는다는 것입니다. 런타임은 단지 Applier가 지켜야 할 계약(인터페이스)만 정의해 둡니다.

즉, 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입니다.

이 N은 노드(Node)를 의미합니다. 이 타입을 유연하게 둔 덕분에 Compose는 안드로이드 UI뿐만 아니라 어떤 종류의 트리 구조와도 함께 동작할 수 있습니다.

Applier는 트리를 돌아다니며 노드를 추가(insert), 제거(remove), 이동(move)하는 기능만 제공할 뿐, 그 노드가 버튼인지 텍스트인지, 혹은 전혀 다른 데이터인지는 관여하지 않습니다.

📍 트리 탐색과 수정

Applier는 트리 전체를 돌아다니며(순회하며) 작업을 수행합니다.

  • current
    • 현재 작업 중인 노드가 무엇인지 항상 기억하고 있습니다.
  • down(node) / up()
    • 트리의 아래(자식)로 내려가거나 위(부모)로 올라가는 이동 기능을 제공합니다.
  • insertTopDown / insertBottomUp
    • 노드를 위에서 아래로 붙일지, 아래에서 위로 붙일지 결정합니다.
  • onBeginChanges / onEndChanges
    • 변경 작업의 시작과 끝을 알리는 신호입니다.
  • clear
    • 트리의 모든 노드를 지우고 초기화하여 새로운 컴포지션을 준비합니다.

결국 Applier는 변경 목록을 손에 쥐고 트리를 위아래로 누비며, 필요한 곳에 노드를 심거나 뽑아내는 정원사와 같은 역할을 수행합니다.

📌 노드 트리 구축 시 성능 (Performance when building the node tree)

트리를 만들 때 위에서 아래로 만드는지(하향식), 아니면 아래에서 위로 만드는지(상향식)에 따라 성능이 크게 달라질 수 있습니다. R(루트) 밑에 B가 있고, B 밑에 A와 C가 있는 트리를 예로 들어 설명해 보겠습니다.

📍 하향식 삽입 (Top-down)

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

📍 상향식 삽입 (Bottom-up)

반대로 A와 C를 먼저 B에 붙여서 작은 덩어리를 만듭니다. B가 완성되면, 이 덩어리를 통째로 루트(R)에 갖다 붙입니다. 즉, 부품을 먼저 조립한 뒤 본체에 끼우는 방식입니다.

📍 왜 성능 차이가 날까요?

핵심은 변화를 알리는 비용 때문입니다. 어떤 Applier(적용자) 구현체는 자식 노드가 추가될 때마다 그 사실을 부모와 모든 조상 노드들에게 알려야 할 수도 있습니다.

하향식의 경우 이미 트리가 다 연결된 상태에서 끝부분에 노드를 추가합니다. A를 B에 붙이면, B뿐만 아니라 그 위의 R에게도 알림이 갈 수 있습니다. 트리가 깊어질수록 새로 추가할 때마다 줄줄이 보고해야 하므로 비용이 기하급수적으로 늘어납니다.

상향식의 경우 A를 B에 붙일 때, B는 아직 R에 연결되지 않은 상태입니다. 즉, B는 고립되어 있습니다. 그래서 A가 추가되었다는 사실을 R에게 알릴 필요가 없습니다. 그저 직속 부모인 B만 알면 됩니다. 나중에 B 덩어리를 R에 붙일 때 딱 한 번만 알리면 되므로 비용이 훨씬 절약됩니다.

결론적으로 사용하는 트리의 특성과 알림 방식에 따라 비용이 적게 드는 효율적인 전략을 하나 선택해서 사용해야 합니다.

📌 변경 사항이 적용되는 방식 (How changes are applied)

앞서 설명한 대로 클라이언트 라이브러리는 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()
	}
}
  1. LayoutNode 사용
    • 가장 먼저 눈에 띄는 점은 제네릭 타입 N이 LayoutNode로 지정되었다는 것입니다.
    • LayoutNode는 Compose UI가 화면에 그릴 요소를 표현하는 기본 단위입니다.
  2. AbstractApplier 상속
    • UiApplier는 AbstractApplier를 상속받습니다.
    • 이 부모 클래스는 트리 구조를 탐색할 때 현재 위치(스택)를 관리하는 기능을 이미 구현해 두었습니다. 덕분에 UiApplier는 노드 관리의 핵심 로직에만 집중할 수 있습니다.
  3. 상향식(Bottom-up) 전략 선택
    • 코드에서 insertTopDown 함수는 비어있고, insertBottomUp 함수에만 구현 내용이 있는 것을 볼 수 있습니다.
    • 이는 앞서 성능 최적화 파트에서 다룬 것처럼, 안드로이드 UI 트리에서는 자식이 추가될 때마다 부모에게 알림이 가는 비용을 줄이기 위해 상향식 구축 전략을 선택했기 때문입니다. 그래서 하향식 삽입은 무시(Ignored)하도록 설계되었습니다.

📍 노드 조작의 위임

UiApplier는 노드를 삽입, 제거, 이동하는 구체적인 방법을 직접 가지고 있지 않습니다. 대신 그 책임을 노드 자체인 LayoutNode에게 위임합니다.

  • 삽입
    • 노드를 새로운 부모에 연결합니다.
  • 이동
    • 부모 노드 안에서 자식들의 순서를 바꿉니다.
  • 제거
    • 자식 목록에서 해당 노드를 뺍니다.

변경 사항 적용이 모두 끝나면 onEndChanges가 호출됩니다. 이때 루트 노드의 주인(Owner)에게 작업이 끝났음을 알리고, 무효해진 관찰 객체들을 정리하여 화면 갱신을 준비합니다. 이 스냅샷(Snapshot) 관찰들은 읽고 의존하는 값이 변경될 때 자동으로 레이아웃이나 화면에 그리는 작업을 다시 호출하기 때문에 필요합니다.

📌 노드 연결 및 그리기 (Attaching and drawing the nodes)

그렇다면 트리에 노드를 붙이는 행위가 어떻게 실제 화면에 그림으로 나타날까요? 답은 간단합니다. 노드 스스로가 어떻게 연결되고 그려져야 하는지 알고 있기 때문입니다.

UiApplier가 LayoutNode에게 삽입 명령을 내리면 내부적으로 다음과 같은 일들이 일어납니다.

  1. 유효성 검사: 부모 노드가 존재하는지 등 연결 조건을 확인합니다.
  2. Z-Index 정렬 및 갱신: 화면에 그려지는 순서(앞뒤 관계)를 결정하는 Z-Index 목록을 갱신(invalidate)하여, 자식들이 올바른 순서로 그려지도록 준비합니다.
  3. 부모 및 Owner 연결: 새 노드를 부모 노드에 연결하고, 동시에 최상위 관리자인 Owner와도 연결합니다.
  4. 화면 갱신 요청: 마지막으로 변경 사항이 생겼으니 화면을 다시 그려달라고 요청(invalidate)합니다.

📍 Owner: 안드로이드와의 연결 고리

여기서 Owner는 컴포즈 트리와 안드로이드의 기본 뷰 시스템을 이어주는 다리 역할을 합니다. 실제 구현체는 AndroidComposeView입니다.

우리가 액티비티에서 setContent를 호출하는 순간, 이 AndroidComposeView가 생성되어 안드로이드 뷰 계층에 붙습니다. 그리고 이 뷰가 컴포즈 트리의 최상위 Owner가 됩니다.

모든 LayoutNode는 화면에 그려지기 위해 반드시 이 Owner와 연결되어야 합니다. 그래야 레이아웃 측정, 그리기, 터치 입력 같은 안드로이드 시스템의 이벤트를 전달받을 수 있기 때문입니다.

📍 결론

이제 우리는 Compose UI가 어떻게 노드 트리를 만들고 안드로이드 화면에 띄우는지 알게 되었습니다. Applier는 변경 사항을 수행하고, LayoutNode는 스스로를 관리하며, Owner는 이를 안드로이드 시스템과 연결합니다.

구조적인 원리는 파악했으니, 이제 실제 컴포지션 프로세스가 어떻게 흘러가는지 더 깊이 알아보겠습니다.

📌 Composition (Composition)

이전 섹션까지는 Composer가 어떻게 변경 사항을 기록하고 슬롯 테이블을 관리하는지, 그리고 이 변경 사항들이 어떻게 적용되는지에 대해 자세히 배웠습니다. 하지만 정작 이 모든 과정의 무대인 Composition이 누가, 언제, 어떻게 만드는지에 대해서는 아직 다루지 않았습니다. 이제 그 놓쳤던 퍼즐 조각을 맞춰볼 차례입니다.

많은 분들이 Composer가 Composition을 생성하고 소유한다고 오해하곤 합니다. 하지만 실제로는 정반대입니다. Composition이 생성되면, 그 Composition이 작업을 수행하기 위해 Composer를 만듭니다. 즉, Composition이 주인이고 Composer는 트리를 만들고 갱신하기 위한 도구일 뿐입니다.

우리가 Compose 런타임을 사용하는 방법은 크게 두 가지로 나뉩니다.

  1. Composable 함수 작성: UI를 정의하고 런타임에 필요한 정보를 제공하는 함수를 작성하는 것.
  2. setContent 호출: 작성한 함수들을 실제로 실행시킬 Composition 프로세스를 시작하는 것.

Composable 함수만으로는 아무 일도 일어나지 않습니다. setContent와 같은 진입점을 통해 플랫폼과 연결되고 Composition이 생성되어야 비로소 작동합니다.

Composition 생성하기 (Creating a 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 연결하기

Composition을 생성할 때 부모의 CompositionContext를 전달할 수 있습니다. 이렇게 하면 새로 만든 Composition이 기존 Composition과 논리적으로 연결됩니다. 덕분에 부모 쪽에서 발생한 데이터 변경(invalidation)이나 테마 정보(CompositionLocal)가 자식 Composition까지 투명하게 전달될 수 있습니다.

또한 변경 사항을 적용할 스레드를 결정하는 CoroutineContext도 전달할 수 있습니다. 따로 지정하지 않으면 안드로이드에서는 기본적으로 메인 스레드(AndroidUiDispatcher.Main)를 사용합니다.

📍 정리 (Disposal)

생성된 Composition은 영원히 존재하지 않습니다. 화면이 닫히거나 더 이상 필요 없게 되면 dispose()를 호출하여 메모리에서 정리해야 합니다. 안드로이드의 setContent를 사용할 때는 수명 주기(Lifecycle)에 맞춰 자동으로 정리되도록 생명주기 관찰자 뒤에 숨겨져 있지만, 내부적으로는 반드시 이 정리 과정이 존재합니다.

📌 초기 Composition 과정 (The initial Composition process)

새로운 Composition이 만들어질 때마다 setContent 함수가 호출됩니다. 바로 이 시점에서 Composition이 처음으로 구축되며, 슬롯 테이블이 관련 데이터로 채워지기 시작합니다.

이 과정은 초기 설정을 위해 자신의 부모 Composition에게 작업을 위임하는 방식으로 진행됩니다.

📍 부모에게 위임하기

setContent 내부에서는 composeInitial이라는 함수를 통해 부모에게 초기화를 요청합니다.

만약 Subcomposition이라면 부모 Composition이 그 요청을 받겠지만, 최상위 루트 Composition이라면 그 부모는 바로 Recomposer입니다. 결국, 중간 과정이 어떻든 초기화 로직의 최종 책임자는 항상 Recomposer가 됩니다. 모든 요청은 결국 가장 위에 있는 Recomposer까지 전달되기 때문입니다.

📍 Recomposer가 하는 일

Recomposer가 초기 Composition을 구축할 때 수행하는 핵심 작업은 다음과 같습니다.

cs.android.com

  1. 상태 스냅샷 생성
    • 모든 State 객체의 현재 값을 사진 찍듯이 스냅샷으로 저장합니다. 이 스냅샷은 다른 곳에서 일어나는 변경 사항과 분리되어 있어, 동시성 문제로부터 안전합니다. 이곳에서 일어나는 변경은 오직 이 State 객체 안에서만 발생합니다. 이후 단계에서는 모든 변경 사항을 전역 공유 상태와 원자적으로 동기화하므로 다른 기존 State 스냅샷에 영향을 주지 않고 안정하게 수정할 수 있습니다.
  2. 관찰자 설정
    • 스냅샷을 만들 때 Recomposer는 어떤 State 객체를 읽거나 쓰는지 감시할 관찰자들을 함께 전달합니다. 덕분에 나중에 데이터가 변경되었을 때, Composition에게 다시 그려야 한다고(Recomposition) 알려줄 수 있습니다.
  3. Composition 실행
    • 이제 실제로 코드를 실행할 차례입니다. snapshot.enter 블록 안에서 여러분이 작성한 content(UI 코드)를 실행합니다. 이 블록 안에서 실행됨으로써, Recomposer는 UI를 그리는 동안 사용된 모든 State 객체를 추적할 수 있게 됩니다.
  4. 변경 사항 적용
    • Composition 과정이 무사히 끝나면, 스냅샷 내부에서 변경된 내용들을 실제 전역 상태에 반영(snapshot.apply)합니다.

📍 Composer의 상세 작업 흐름

위에서는 큰 흐름을 보았고, 이제 실무자인 Composer가 내부적으로 어떤 순서로 작업을 처리하는지 살펴보겠습니다.

  1. 중복 실행 방지
    • 이미 Composition이 진행 중이라면 새로 시작하지 않고 예외를 발생시킵니다. 즉, 동시에 두 번 진입하는 것을 막습니다.
  2. 변경 사항 대기
    • 처리해야 할 변경 사항(invalidation)이 있다면 목록으로 가져와서 처리 순서를 기다립니다.
  3. 시작 플래그 설정
    • 이제 작업을 시작하므로 isComposing 상태를 true로 변경합니다.
  4. 루트 및 그룹 시작
    • startRoot를 호출하여 슬롯 테이블의 뿌리(Root)를 만들고, 이어서 startGroup을 호출하여 실제 content가 들어갈 그룹을 시작합니다.
  5. 내용 실행 (핵심)
    • 드디어 content 람다식을 실행합니다. 이때 여러분이 작성한 UI 코드들이 실행되며 변경 사항들이 방출됩니다.
  6. 그룹 및 루트 종료
    • 코드 실행이 끝나면 endGroup으로 그룹을 닫고, endRoot로 전체 Composition을 마무리합니다.
  7. 정리
    • 작업이 끝났으므로 isComposing 상태를 다시 false로 돌려놓고, 임시로 사용했던 데이터들을 정리합니다.

📌 초기 Composition 후 변경 사항 적용 (Applying changes after initial Composition)

초기 컴포지션 단계가 끝나면, Applier는 composition.applyChanges()를 통해 그동안 기록된 변경 사항들을 실제 화면(또는 노드)에 반영하라는 신호를 받습니다.

구체적인 진행 순서는 다음과 같습니다.

  1. 변경 시작 알림
    • Applier의 onBeginChanges()를 호출하여 변경 작업이 시작됨을 알립니다.
  2. 변경 사항 실행
    • 기록해 둔 모든 변경 사항을 하나씩 실행합니다. 이때 필요한 Applier와 SlotWriter 인스턴스를 함께 전달하여 작업을 수행합니다.
  3. 변경 종료 알림
    • 모든 적용이 끝나면 applier.onEndChanges()를 호출하여 작업을 마무리합니다.
  4. 이펙트(Effect) 생명주기 관리
    • 변경 사항 적용이 완료되면, 등록된 RememberObserver들에게 알림을 보냅니다.
      우리가 자주 쓰는 LaunchedEffect나 DisposableEffect가 바로 이 RememberObserver를 구현한 것들입니다. 덕분에 이들은 자신이 컴포지션에 진입했는지, 혹은 나갔는지를 파악하고 그에 맞춰 동작할 수 있습니다.
  5. 사이드 이펙트 실행
    • 마지막으로, 기록해 두었던 사이드 이펙트(SideEffect)들을 순서대로 실행합니다.

📌 Composition에 대한 추가 정보 (Additional information

about the Composition)

컴포지션은 단순히 화면을 그리는 것 외에도 현재 상태를 파악하고 제어하는 똑똑한 기능들을 가지고 있습니다.

상태 인식과 스케줄링 컴포지션은 현재 자신이 무언가를 그리고 있는 중인지(isComposing 플래그), 혹은 다시 그려야 할 대기열(invalidation)이 있는지 알고 있습니다. 이를 통해, 이미 그리는 중이라면 중복 작업을 피하거나, 상황에 맞춰 다시 그리기 작업을 즉시 수행할지 미룰지 결정합니다.

📍 외부 제어 (ControlledComposition)

런타임은 ControlledComposition이라는 특수한 형태를 사용하여 컴포지션 과정을 외부에서 정밀하게 제어합니다. Recomposer는 이 기능을 통해 언제 화면을 갱신할지(composeContent, recompose) 조율합니다.

📍 부모-자식 간의 데이터 동기화

컴포지션은 자신이 관찰하고 있는 객체들의 변화를 감지합니다. 예를 들어, 부모 컴포지션의 CompositionLocal 값이 바뀌면, 이를 감지하여 연결된(CompositionContext로 이어진) 자식 컴포지션까지 강제로 다시 그리게 만듭니다.

📍 오류 처리와 최적화 (스마트 리컴포지션)

만약 컴포지션 수행 중 오류가 발생하면, 작업을 즉시 중단하고 관련 정보를 초기화하여 안전을 확보합니다. 또한 불필요한 작업을 줄이는 최적화 기능도 있습니다. 변경된 데이터가 없거나 갱신할 필요가 없다고 판단되면, 리컴포지션 단계를 똑똑하게 건너뛰어 성능을 높입니다.

📌 Recomposer (The Recomposer)

우리는 앞서 초기 Composition이 어떻게 만들어지는지, 그리고 화면 갱신을 위해 RecomposeScope와 Invalidation이 어떻게 동작하는지 배웠습니다. 하지만 정작 이 모든 과정을 총괄하는 지휘자, Recomposer에 대해서는 아직 자세히 다루지 않았습니다.

도대체 Recomposer는 언제 만들어지고, 어떻게 변경 사항을 감지하여 Recomposition을 시작할까요?

Recomposer의 핵심 역할은 ControlledComposition을 관리하는 것입니다. 변경 사항이 발생했을 때 언제 Recomposition을 수행할지 결정하고, 어떤 스레드에서 화면을 갱신할지 통제합니다.

📌 Recomposer 생성 (Spawning the Recomposer)

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도 함께 종료되도록 설계되어 있습니다.

📍 AndroidUiDispatcher와 프레임 클록

Compose UI의 모든 동작은 AndroidUiDispatcher에 의해 조정됩니다. 이 디스패처는 안드로이드의 메인 루퍼(Main Looper) 및 화면 주사율을 담당하는 Choreographer와 연결되어 있습니다. 덕분에 애니메이션이나 화면 갱신이 시스템의 프레임 속도에 맞춰 부드럽게 동작합니다.

팩토리 함수가 Recomposer를 만들 때 가장 먼저 하는 일은 PausableMonotonicFrameClock을 생성하는 것입니다. 이름에서 알 수 있듯이 필요할 때 일시 정지할 수 있는 시계입니다. 예를 들어 앱이 백그라운드로 내려가서 화면이 보이지 않을 때는 굳이 화면을 갱신할 필요가 없으므로 시계를 멈춰 불필요한 연산을 막습니다.

📍 Recomposer 초기화

Recomposer는 생성될 때 CoroutineContext를 필요로 합니다. 이때 AndroidUiDispatcher의 스레드 정보(주로 메인 스레드)와 위에서 만든 일시 정지 가능한 시계를 합쳐서 컨텍스트를 만듭니다.

val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
val recomposer = Recomposer(effectCoroutineContext = contextWithClock)

이렇게 만들어진 컨텍스트는 Recomposer가 종료될 때 관련된 모든 작업(LaunchedEffect 등)을 함께 취소하는 데 사용됩니다. 또한 UI 변경 사항을 적용하거나 이펙트를 실행할 때 기본적으로 이 컨텍스트(메인 스레드)를 사용하게 됩니다.

📍 생명주기 감지와 Recomposition 실행

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의 동작 원리를 명확하게 보여줍니다.

  • ON_CREATE
    • recomposer.runRecomposeAndApplyChanges()를 호출합니다.
    • 이 함수는 무한 루프처럼 동작하며, 변경 사항(invalidation)이 생길 때까지 기다렸다가 Recomposition을 수행하고 결과를 화면에 반영하는 핵심 역할을 합니다.
  • ON_START / ON_STOP
    • 화면이 보이면 시계(pausableClock)를 다시 동작시키고, 화면이 가려지면 시계를 멈춥니다.
  • ON_DESTROY
    • Recomposer를 취소(cancel)하여 모든 작업을 정리합니다.

결론적으로 setContent를 호출할 때 전달되는 parent 매개변수가 바로 이 Recomposer입니다. 안드로이드 시스템과 Compose 세계를 연결하고, 생명주기에 맞춰 화면 갱신을 지휘하는 것이 바로 Recomposer의 역할입니다.

📌 Recomposition 프로세스 (Recomposition process)

Recomposer의 runRecomposeAndApplyChanges 함수가 호출되면, 시스템은 데이터의 변화(invalidation)를 감지하기 위해 대기 상태로 들어갑니다. 데이터가 변경되면 자동으로 화면을 다시 그리는 Recomposition이 시작되는데, 구체적으로 어떤 과정을 거치는지 살펴보겠습니다.

  1. 변경 감지 시작
    • 가장 먼저 하는 일은 데이터 변경을 감시할 관찰자를 등록하는 것입니다. 앞서 배운 것처럼 상태(State) 값의 변경은 스냅샷 내부에서 일어나고, 나중에 전역 상태로 적용됩니다. 이때 관찰자가 활성화되어 변경된 내역을 스냅샷 변경 목록에 추가하고, 연결된 모든 Composer에게 이 사실을 알립니다. 즉, 이 관찰자는 데이터가 바뀌면 자동으로 화면 갱신을 시작하게 만드는 방아쇠 역할을 합니다.
  2. 초기화 및 대기
    • 관찰자 등록 직후, Recomposer는 일단 모든 것이 변경되었다고 가정하고 초기화 작업을 수행합니다. 그 후 실제로 다시 그려야 할 작업(보류 중인 스냅샷 변경이나 컴포지션의 변경 요청)이 생길 때까지 잠시 대기합니다.
  3. 프레임 대기 (Frame Sync)
    • 작업할 준비가 되면, Recomposer는 무작정 그리는 것이 아니라 화면의 주사율(프레임)에 맞춰 동작하기 위해 다음 프레임 신호를 기다립니다. 이때 모노리틱 클럭(Monolithic Frame Clock)을 사용합니다.
  4. 애니메이션 및 변경 사항 수집
    • 프레임 신호가 오면 먼저 애니메이션 같은 대기 중인 작업들에게 시간을 알려줍니다. 이 과정에서 애니메이션 값이 변하면서 새로운 변경 사항이 추가로 발생할 수 있습니다.
    • 이제 진짜 작업을 시작합니다. 마지막으로 화면을 그린 이후 수정된 모든 상태 값을 확인하고, 이를 바탕으로 다시 그려야 할 컴포저블들을 추려냅니다. 만약 부모 쪽 데이터(예: CompositionLocal)가 바뀌어 자식 컴포지션까지 영향을 받는 경우, 이 연쇄적인 작업들도 함께 예약합니다.
  5. 변경 사항 적용
    • Recomposition은 기존 코드를 재사용하여 필요한 부분만 다시 계산하는 과정입니다. 계산이 끝나면 변경 사항이 있는 모든 Composition에 대해 applyChanges를 호출하여 실제 화면(노드 트리)에 반영합니다. 마지막으로 Recomposer 자신의 상태도 업데이트합니다.

📌 ****Recomposition의 동시성 (Concurrent recomposition)

일반적인 안드로이드 UI는 메인 스레드에서 동작하지만, Recomposer는 기술적으로 동시에 여러 작업을 처리할 수 있는 기능을 가지고 있습니다. (안드로이드 UI가 아닌 다른 라이브러리에서 유용할 수 있습니다.)

runRecomposeConcurrentlyAndApplyChanges라는 함수를 사용하면 되는데, 기본 동작은 앞서 설명한 것과 거의 같습니다. 차이점은 외부에서 전달받은 CoroutineContext를 사용하여 별도의 스레드(코루틴 스코프)에서 invalidation 된 Composition의 recomposition을 수행한다는 점입니다. 이를 통해 복잡한 계산이 필요한 경우 백그라운드에서 처리하는 구조를 만들 수 있습니다.

📌 Recomposer의 상태 (Recomposer states)

Recomposer는 생명주기에 따라 다음과 같은 상태를 가집니다.

  • ShutDown
    • 완전히 종료되고 정리까지 끝난 상태입니다. 더 이상 사용할 수 없습니다.
  • ShuttingDown
    • 종료 신호를 받아 취소되었지만, 아직 내부 정리 작업이 진행 중인 상태입니다.
  • Inactive
    • 비활성 상태입니다. 데이터가 변경되어도 무시하며 화면을 다시 그리지 않습니다. Recomposer가 처음 생성되었을 때의 초기 상태이며, 활성화하려면 실행 함수를 호출해야 합니다.
  • InactivePendingWork
    • 비활성 상태이지만, 처리해야 할 작업(예: 보류 중인 이펙트)이 남아있는 상태입니다. Recomposer가 실행되자마자 프레임을 생성하여 작업을 시작할 것입니다.
  • Idle
    • 유휴 상태입니다. 데이터 변경을 감시하고 있지만, 현재는 처리할 작업이 없어 쉬고 있는 상태입니다.
  • PendingWork
    • 작업 대기 중인 상태입니다. 처리해야 할 작업이 있다는 알림을 받았으며, 현재 작업을 수행 중이거나 수행하기 위해 순서를 기다리고 있습니다.
profile
코딩일기

0개의 댓글