Compose의 3단계 렌더링 파이프라인
Compose의 3단계 렌더링 파이프라인
Compose에서 모든 프레임은 Composition, Layout, Drawing이라는 세 단계를 순서대로 거칩니다. Composition 단계에서는 컴포저블(composable) 함수를 재실행하여 노드 트리를 생성하거나 업데이트하고, Layout 단계에서는 각 노드를 측정한 뒤 위치를 할당하며, Drawing 단계에서는 최종 픽셀을 캔버스에 렌더링합니다. 대부분의 개발자가 공식 문서를 통해 이 세 단계의 존재를 알고 있지만, 실제 면접에서는 보다 깊은 수준의 질문이 나올 수 있습니다. 런타임이 이 순서를 어떻게 강제하는지, 각 단계가 수행할 작업을 어떻게 결정하는지, 그리고 Drawing 단계에서 읽은 상태(state) 변경이 리컴포지션(Recomposition)을 트리거하지 않도록 상태 읽기를 어떻게 스코핑하는지가 핵심입니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
dispatchDraw()가 Composition → Layout → Drawing 순서를 어떻게 강제하는지 설명할 수 있습니다.- Recomposer가 프레임 내에서 리컴포지션을 어떻게 스케줄링하고 수행하는지 파악할 수 있습니다.
MeasureAndLayoutDelegate가 더티(dirty) 노드를 깊이 순서로 측정하고 배치하는 과정을 추적할 수 있습니다.NodeCoordinator.draw()가 Modifier 체인을 따라가며 자식을 렌더링하는 방식을 이해할 수 있습니다.OwnerSnapshotObserver가 상태 읽기를 특정 단계에 스코핑하여 대상별 무효화(invalidation)를 수행하는 원리를 파악할 수 있습니다.
프레임 루프와 단계 순서
안드로이드에서 각 프레임의 진입점(entry point)은 AndroidComposeView.dispatchDraw()입니다. 안드로이드 뷰 시스템은 Compose 계층 구조를 그릴 시점이 되면 이 메서드를 호출합니다. dispatchDraw() 내부에서는 단계 순서가 코드로 직접 강제됩니다.
override fun dispatchDraw(canvas: android.graphics.Canvas) {
if (!isAttachedToWindow) {
invalidateLayers(root)
}
measureAndLayout()
Snapshot.notifyObjectsInitialized()
isDrawingContent = true
canvasHolder.drawInto(canvas) {
root.draw(canvas = this, graphicsLayer = null)
}
}
measureAndLayout() 호출은 Layout 단계를 실행하며, Composition에서 더티로 표시된 노드를 측정하고 배치합니다. Layout이 완료되면 root.draw()가 트리를 순회하며 모든 노드를 캔버스에 렌더링합니다.
Composition은 dispatchDraw()가 호출되기 전에 이미 완료된 상태입니다. Recomposer는 UI 스레드의 코루틴에서 실행되며, Choreographer 프레임 콜백에 동기화되어 있습니다. 뷰 시스템이 dispatchDraw()를 호출하는 시점에는 Composition이 이미 끝나 노드 트리가 최신 상태이므로, Layout과 Drawing이 같은 메서드 내에서 순차적으로 실행됩니다.
Composition: 노드 트리 재구성
Recomposer가 Composition을 주도합니다. 무효화 신호를 기다리고, 다음 Choreographer 프레임에 맞춰 정렬한 뒤, 상태 의존성이 변경된 컴포저블 함수를 재실행하는 장기 실행 코루틴을 운영합니다.
핵심 루프는 runRecomposeAndApplyChanges() 안에 있습니다. 작업이 가능해질 때까지 대기한 후, 프레임 콜백 안에서 실제 리컴포지션을 수행합니다.
while (shouldKeepRecomposing) {
awaitWorkAvailable()
if (!recordComposerModifications()) continue
parentFrameClock.withFrameNanos { frameTime ->
// Recomposer 클록에서 대기 중인 모든 대상에 프레임 시간 전파
if (hasBroadcastFrameClockAwaiters) {
broadcastFrameClock.sendFrame(frameTime)
Snapshot.sendApplyNotifications()
}
// 스냅샷 변경으로 인한 Composer 무효화를 수집하고
// 작업할 Composer 목록 기록
recordComposerModifications()
synchronized(stateLock) {
compositionInvalidations.forEach { toRecompose += it }
compositionInvalidations.clear()
}
// 무효화된 Composer에 대해 리컴포지션 수행
toRecompose.fastForEach { composition ->
performRecompose(composition, modifiedValues)?.let {
toApply += it
}
}
toRecompose.clear()
// 리컴포지션의 변경 사항을 노드 트리에 적용
toApply.fastForEach { composition ->
composition.applyChanges()
}
toApply.clear()
}
}
awaitWorkAvailable()은 스냅샷 상태 변경이 최소 하나의 Composition을 무효화할 때까지 일시 중단됩니다. recordComposerModifications()는 작업이 필요한 Composition을 수집하고, parentFrameClock.withFrameNanos가 실행을 다음 Choreographer 프레임에 맞춰 정렬합니다. 프레임 콜백 내부에서 Recomposer는 먼저 애니메이션 대기자에게 프레임 시간을 브로드캐스트한 후, 무효화된 각 Composition에 대해 performRecompose()를 호출합니다.
performRecompose() 자체는 단순합니다. composition.recompose()를 호출하여 무효화된 컴포저블 함수를 재실행하고, 노드 트리의 변경 사항을 기록합니다.
private fun performRecompose(
composition: ControlledComposition,
modifiedValues: MutableScatterSet<Any>?,
): ControlledComposition? {
if (composition.isComposing || composition.isDisposed) return null
return if (composing(composition, modifiedValues) {
composition.recompose()
}) composition else null
}
모든 Composition이 완료되면, 변경 사항이 기반이 되는 LayoutNode 트리에 적용됩니다. 이 과정에서 노드가 추가되거나 제거될 수 있으며, 프로퍼티가 업데이트되기도 합니다. 측정이나 레이아웃에 영향을 받는 노드는 measurePending 또는 layoutPending으로 표시되며, 이어지는 Layout 단계에서 처리됩니다.
Layout: 노드 측정과 배치
Layout 단계는 MeasureAndLayoutDelegate가 주도하며, relayoutNodes라는 깊이 순 정렬 집합을 관리합니다. 이 집합에는 재측정이나 재배치가 필요한 모든 LayoutNode가 포함됩니다. 깊이 순 정렬이 핵심 설계 결정인 이유는, 부모 노드의 제약 조건(constraint)이 자식 노드의 측정 방식을 결정하므로 부모가 항상 자식보다 먼저 처리되어야 하기 때문입니다.
measureAndLayout()은 이 집합을 순회하며 각 노드를 처리합니다.
fun measureAndLayout(onLayout: (() -> Unit)? = null): Boolean {
var rootNodeResized = false
performMeasureAndLayout(fullPass = true) {
if (relayoutNodes.isNotEmpty()) {
relayoutNodes.popEach { layoutNode, affectsLookahead, relayoutNeeded ->
val sizeChanged = if (relayoutNeeded) {
remeasureAndRelayoutIfNeeded(layoutNode, affectsLookahead)
} else {
remeasureIfNeeded(layoutNode, affectsLookahead)
}
if (layoutNode === root && sizeChanged) {
rootNodeResized = true
}
}
onLayout?.invoke()
}
}
return rootNodeResized
}
깊이 순 정렬 집합에서 꺼낸 각 노드에 대해 delegate는 remeasureAndRelayoutIfNeeded()를 호출합니다. 이 메서드는 해당 노드에 실제로 어떤 작업이 필요한지 확인합니다.
private fun remeasureAndRelayoutIfNeeded(layoutNode: LayoutNode): Boolean {
var sizeChanged = false
if (layoutNode.measurePending) {
sizeChanged = doRemeasure(layoutNode, constraints)
}
if (layoutNode.layoutPending) {
if (layoutNode === root) {
layoutNode.place(0, 0)
} else {
layoutNode.replace()
}
}
return sizeChanged
}
measurePending이 true이면 doRemeasure()를 통해 노드를 재측정합니다. 측정 결과 크기가 변경되었고, 부모가 자신의 measure 블록에서 이 자식을 측정한 경우 requestRemeasure()를 통해 부모 자체가 relayoutNodes에 다시 추가됩니다. 이러한 상향 전파는 더 이상 영향을 받는 부모가 없을 때까지 계속됩니다.
측정 후 layoutPending이 true이면 노드를 배치합니다. 루트 노드는 (0, 0) 위치에 배치되고, 나머지 노드는 replace()를 호출하여 해당 자식에 대한 부모의 배치 로직을 재실행합니다.
Drawing: 캔버스에 렌더링
Drawing은 LayoutNode.draw()에서 시작되며, 가장 바깥쪽 NodeCoordinator에 위임됩니다.
internal fun draw(canvas: Canvas, graphicsLayer: GraphicsLayer?) =
outerCoordinator.draw(canvas, graphicsLayer)
NodeCoordinator.draw()는 좌표 변환을 처리하고 그래픽 레이어(graphics layer) 사용 여부를 결정합니다.
fun draw(canvas: Canvas, graphicsLayer: GraphicsLayer?) {
val layer = layer
if (layer != null) {
layer.drawLayer(canvas, graphicsLayer)
} else {
val x = position.x.toFloat()
val y = position.y.toFloat()
canvas.translate(x, y)
drawContainedDrawModifiers(canvas, graphicsLayer)
canvas.translate(-x, -y)
}
}
노드에 그래픽 레이어(Modifier.graphicsLayer로 생성)가 있으면 그리기를 레이어에 위임하며, 레이어는 콘텐츠를 디스플레이 리스트(display list)로 캐싱할 수 있습니다. 레이어가 없는 경우에는 coordinator가 캔버스를 노드 위치로 이동시킨 뒤 drawContainedDrawModifiers()를 호출합니다.
drawContainedDrawModifiers()는 체인에서 draw Modifier 노드가 있는지 확인합니다. 존재한다면 실행을 위해 LayoutNodeDrawScope에 전달하고, draw Modifier가 없으면 performDraw()로 넘어갑니다. 체인 끝에 위치한 InnerNodeCoordinator가 performDraw()를 구현하며, 노드의 자식을 순회합니다.
override fun performDraw(canvas: Canvas, graphicsLayer: GraphicsLayer?) {
layoutNode.zSortedChildren.forEach { child ->
if (child.isPlaced) {
child.draw(canvas, graphicsLayer)
}
}
}
자식은 z 순서로 순회되며, 배치된 자식만 그려집니다. 각 자식의 draw() 호출은 다시 NodeCoordinator.draw()로 재귀하여, 전체 트리를 깊이 우선(depth-first)으로 순회하게 됩니다.
단계별 스코핑 무효화
3단계 시스템에서 가장 중요한 최적화는 상태 읽기가 발생한 단계에 스코핑된다는 점입니다. OwnerSnapshotObserver는 어떤 단계에서 상태를 읽느냐에 따라 서로 다른 콜백을 등록합니다.
internal class OwnerSnapshotObserver(onChangedExecutor: (callback: () -> Unit) -> Unit) {
private val observer = SnapshotStateObserver(onChangedExecutor)
private val onCommitAffectingMeasure: (LayoutNode) -> Unit = { layoutNode ->
if (layoutNode.isValidOwnerScope) {
layoutNode.requestRemeasure()
}
}
private val onCommitAffectingLayout: (LayoutNode) -> Unit = { layoutNode ->
if (layoutNode.isValidOwnerScope) {
layoutNode.requestRelayout()
}
}
}
측정 중 상태 객체를 읽으면 onCommitAffectingMeasure 콜백이 등록됩니다. 이후 해당 상태가 변경되면 콜백이 requestRemeasure()를 호출하여 다음 프레임에서 노드를 재측정 및 재배치하도록 표시합니다. 부모 노드로 전파될 수 있기 때문에 가장 비용이 큰 무효화 형태입니다.
Layout(배치) 중 상태를 읽으면 대신 onCommitAffectingLayout 콜백이 등록됩니다. 해당 상태가 변경되면 requestRelayout()을 호출하여 측정을 완전히 건너뛰고 배치만 재실행합니다.
Drawing은 다르게 동작합니다. draw Modifier 블록은 snapshotObserver.observeReads() 내부에서 레이어 수준 콜백과 함께 실행됩니다. Drawing 중 읽힌 상태가 변경되면 그래픽 레이어만 무효화되며, 노드를 재측정하거나 재배치하지 않습니다. 레이어가 다음 프레임에서 콘텐츠만 다시 그릴 뿐이므로, 가장 비용이 적은 무효화 형태입니다.
이러한 스코핑 덕분에 Modifier.drawWithContent {} 안에서 Animatable 값을 읽어도 리컴포지션이나 재배치가 트리거되지 않습니다. 상태 읽기가 Drawing 수준에서만 관찰되므로 무효화도 Drawing 레이어에만 영향을 미칩니다. Composition과 Layout 단계는 완전히 건너뛰게 되므로, 애니메이션 성능을 최적화할 때 이 원리를 잘 활용하는 것이 중요합니다.
요약
Compose는 각 프레임을 세 단계로 순차 렌더링합니다. Composition이 먼저 Recomposer의 프레임 동기화 코루틴을 통해 실행되며, 무효화된 컴포저블 함수를 재실행하여 노드 트리를 업데이트합니다. 이어서 dispatchDraw() 내부에서 Layout이 실행되어, MeasureAndLayoutDelegate가 더티 노드를 깊이 순서로 처리하며 재측정 및 재배치를 수행합니다. 마지막으로 Drawing이 실행되어 NodeCoordinator가 Modifier 체인을 따라가고, InnerNodeCoordinator가 z 순서로 정렬된 자식을 재귀적으로 순회합니다. OwnerSnapshotObserver는 상태 읽기를 발생한 단계에 스코핑하므로, Drawing 중 관찰된 상태 변경은 레이어만 무효화하는 반면, 측정 중 관찰된 상태 변경은 전체 측정 → 배치 → 그리기 시퀀스를 트리거합니다.
실전 질문
Compose의 세 렌더링 단계가 단일 프레임 내에서 어떤 순서로 실행되는지 설명하고, Drawing 단계에서 상태를 읽어도 리컴포지션이 트리거되지 않는 이유를 설명해 주세요.
세 단계는 Composition, Layout, Drawing이라는 고정된 순서로 실행됩니다. Composition이 먼저 실행되는 이유는 Recomposer가 Choreographer 프레임 콜백에 동기화되어 있기 때문입니다. 스냅샷 상태가 변경되면 Recomposer의 runRecomposeAndApplyChanges() 루프가 awaitWorkAvailable()을 통해 무효화를 감지한 후, parentFrameClock.withFrameNanos로 다음 프레임까지 대기합니다. 프레임 콜백 내부에서 무효화된 각 Composition에 대해 performRecompose()를 호출하여 영향을 받은 컴포저블 함수를 재실행하고, LayoutNode 트리에 변경 사항을 적용합니다.
Composition이 완료되고 변경 사항이 적용된 후, 안드로이드 뷰 시스템이 AndroidComposeView.dispatchDraw()를 호출합니다. dispatchDraw()가 가장 먼저 수행하는 작업은 measureAndLayout() 호출을 통한 Layout 단계 실행입니다. MeasureAndLayoutDelegate는 깊이 순 정렬된 relayoutNodes 집합을 순회하며, measurePending이 true인 노드를 재측정하고 layoutPending이 true인 노드를 재배치합니다. 깊이 순 정렬 덕분에 부모가 항상 자식보다 먼저 처리되며, 이는 부모의 제약 조건이 자식의 크기를 결정하기 때문에 반드시 필요합니다.
Layout이 완료된 후 dispatchDraw()는 root.draw()를 호출하여 Drawing 단계를 시작합니다. NodeCoordinator.draw()가 각 노드의 Modifier 체인을 따라가며 그래픽 레이어 또는 직접 캔버스 변환을 적용하고, InnerNodeCoordinator.performDraw()가 z 순서로 정렬된 자식을 재귀적으로 순회합니다.
Drawing 중 상태를 읽어도 리컴포지션이 트리거되지 않는 이유는 OwnerSnapshotObserver의 단계별 스코핑 콜백 때문입니다. 컴포저블 함수가 Composition 중 상태를 읽으면 Recomposer가 해당 읽기를 관찰하고 Composition 수준 무효화를 등록합니다. 반면 draw Modifier가 상태를 읽으면, snapshotObserver.observeReads()가 레이어 수준 콜백으로 해당 읽기를 관찰합니다. 상태가 변경될 때 그래픽 레이어만 더티로 표시되며, Composition이나 Layout을 거치지 않고 다음 프레임에서 레이어만 다시 그려집니다. Modifier.drawWithContent {} 안에서 값을 애니메이션하는 것이 효율적인 이유가 바로 이 때문입니다. 가장 비용이 큰 두 단계를 완전히 건너뛸 수 있기 때문입니다.
후속 질문: MeasureAndLayoutDelegate는 부모 노드가 항상 자식보다 먼저 측정되도록 어떻게 보장하며, 자식의 크기 변경이 부모에 어떤 영향을 미치나요?
MeasureAndLayoutDelegate는 트리에서의 노드 깊이를 기준으로 정렬된 relayoutNodes 컬렉션을 관리합니다. measureAndLayout()이 이 컬렉션을 순회할 때 가장 얕은 깊이부터 가장 깊은 순서로 노드를 처리합니다. 따라서 부모가 항상 자식보다 먼저 측정되며, 이는 부모가 측정 시 제약 조건을 아래로 전달하기 때문에 반드시 필요합니다.
자식이 doRemeasure()를 통해 재측정되어 크기가 달라지면, delegate는 부모가 해당 자식을 원래 어떻게 측정했는지 확인합니다. 자식의 measuredByParent 프로퍼티를 확인하는데, 이 프로퍼티는 자식이 부모의 measure 블록에서 측정되었는지 layout 블록에서 측정되었는지를 추적합니다.
measuredByParent가 InMeasureBlock이면, 자식이 부모의 MeasureScope.measure() 호출의 일부로 측정된 것입니다. 크기 변경은 부모 자체의 측정이 유효하지 않다는 의미이므로, delegate가 parent.requestRemeasure()를 호출하여 부모를 relayoutNodes에 다시 추가합니다. 부모의 깊이가 더 얕으므로 다른 자식보다 먼저 다시 처리됩니다.
measuredByParent가 InLayoutBlock이면, 자식이 부모의 measure 단계가 아닌 layout 단계에서 측정된 것입니다. 이 경우 부모 자체의 크기에는 영향이 없으므로 재배치만 필요합니다. delegate는 대신 parent.requestRelayout()을 호출하며, 부모의 재측정을 완전히 건너뛰기 때문에 비용이 더 적습니다.
이러한 깊이 순 처리와 상향 전파를 통해 트리는 일관된 상태로 수렴합니다. 변경이 계층 구조를 통해 위로 전파되더라도, 모든 노드의 제약 조건, 크기, 위치가 상위 노드의 최종 값을 반영하게 됩니다.

