[Jetpack Compose Internals] 4장 Compose UI

빙티·2025년 12월 28일

Jetpack Compose

목록 보기
5/6
post-thumbnail

Compose UI와 런타임의 통합

Compose UI는 컴포저블 함수로 UI를 생성하는 코틀린 멀티플랫폼 프레임워크입니다.
안드로이드와 데스크톱은 공통 소스셋을 공유하지만, 웹은 별도의 DOM을 기반으로 합니다.

레이아웃 트리를 생성하고 갱신하는 작업은 런타임과 UI가 소통하며 이루어집니다.
이때 런타임은 트리를 구성하는 각 노드의 구체적인 타입을 알지 못합니다.
이는 플랫폼별로 사용하는 노드 타입이 다르기 때문입니다.
따라서 런타임은 노드의 삽입, 제거, 이동, 교체 등의 구체적인 구현을 Compose UI에 위임합니다.

초기 컴포지션은 레이아웃 트리를 처음 생성하며 리컴포지션은 상태나 매개변수 변경 시 트리를 갱신합니다.
이 과정에서 생성된 변경 사항 목록은 Applier를 통해 실제 UI 트리에 반영됩니다.

트리의 생성과 컴포지션

런타임이 코드를 실행하여 가상의 트리(예약된 변경 목록)를 만드는 단계입니다.

예약된 변경 목록을 실제 트리의 변경 목록으로 대응 (Mapping scheduled changes to actual changes to the tree)

컴포저블 함수가 실행되면 변경 사항이 방출됩니다.
이때 Composition이라는 객체가 사용되는데, 이는 컴포지션과 구분되는 별도의 개념입니다.

Composition 객체는 런타임이 예약한 변경 목록을 실제 노드 트리의 변경으로 매핑해줍니다.
하나의 앱은 필요한 노드 트리의 수만큼 여러 개의 Composition 객체를 가질 수 있습니다.

Compose UI 관점에서의 Composition (Composition from the point of view of Compose UI)

안드로이드 환경에서 Compose UI 라이브러리와 런타임이 만나는 가장 주된 진입점은 setContent 호출입니다. 보통 액티비티에서 이를 호출하여 UI를 구성합니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Text("Hello Compose!")
            }
        }
    }
}

하지만 setContent는 액티비티뿐만 아니라 뷰 계층 구조 중간에서도 호출할 수 있습니다.
기존 뷰 시스템과 컴포즈를 혼용하는 경우 ComposeView를 사용하여 다음과 같이 구현합니다.

ComposeView(requireContext()).apply {
    setContent {
        MaterialTheme {
            Text("Hello Compose!")
        }
    }
}

독립적인 루트 컴포지션

setContent 함수가 호출될 때마다 독자적인 컴포저블 트리를 관리하는 새로운 루트 컴포지션이 생성됩니다.
이렇게 생성된 여러 컴포지션들은 서로 연결되지 않고 독립적으로 존재하므로, 하나의 앱 내에 다수의 루트 컴포지션이 공존할 수 있습니다. 예를 들어 3개의 프래그먼트가 있는 앱을 가정해 봅시다.

  1. 프래그먼트 1: setContent 호출
  2. 프래그먼트 2: XML 레이아웃 내에 여러 개의 ComposeView를 배치하고 각각 setContent 호출
  3. 프래그먼트 3: setContent 호출

이 경우 프래그먼트 1과 3은 각각 1개의 루트 컴포지션을 갖고, 프래그먼트 2는 ComposeView의 개수만큼 루트 컴포지션을 갖습니다. 결과적으로 총 5개의 독립적인 루트 컴포지션을 유지하게 됩니다.

LayoutNode와 ReusableComposeNode

컴포지션 과정이 실행되면 컴포저블 함수들은 변경 사항을 방출하여 UI 노드를 생성하거나 조작합니다.
Box, Column, LazyColumn 등 대부분의 UI 구성 요소는 내부적으로 Layout 컴포저블을 사용하며,
결과적으로 LayoutNode라는 동일한 타입의 노드를 방출합니다.

Layout 컴포저블은 노드를 방출할 때 ReusableComposeNode를 사용합니다.

@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    val viewConfiguration = LocalViewConfiguration.current

    // LayoutNode를 방출한다.
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = { LayoutNode() },
        update = {
            set(measurePolicy, { this.measurePolicy = it })
            set(density, { this.density = it })
            set(layoutDirection, { this.layoutDirection = it })
            set(viewConfiguration, { this.viewConfiguration = it })
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

재사용 가능한 노드의 최적화

ReusableComposeNode는 런타임 최적화 기능입니다.
일반적인 노드는 키가 변경되면 기존 노드를 버리고 새로 생성하지만,
재사용 가능한 노드는 내용을 폐기하는 대신 새 데이터를 덮어씌워 업데이트(recompose)합니다.

이 최적화는 노드 내부에 숨겨진 상태가 없고, 오직 setupdate 동작만으로 상태를 완전히 제어할 수 있는 경우에만 가능합니다.

LayoutNode는 이에 해당하므로 최적화가 적용되지만, AndroidView는 내부에 자체적인 상태를 가지므로 이 최적화를 사용할 수 없어 일반적인 ComposeNode를 사용합니다.
ReusableComposeNode는 다음 과정을 거칩니다.

  1. factory 람다를 통해 노드 인스턴스를 생성합니다.
  2. update 람다를 통해 속성을 초기화하거나 변경된 값만 업데이트합니다.
  3. 교체 가능 그룹을 생성하고 고유 키를 할당하여 관리합니다.
  4. content 람다를 실행하여 자식 노드들을 구성합니다.

Compose UI는 대부분 LayoutNode로 구성되지만,
런타임에 내용을 추가하는 방식에는 다른 종류의 노드 타입과 컴포지션도 존재합니다.

Compose UI 관점에서의 Subcomposition

컴포지션은 루트 레벨뿐 아니라 컴포저블 트리의 내부 깊은 곳에서도 생성될 수 있습니다.
이를 Subcomposition이라고 부릅니다. 서브컴포지션은 부모 컴포지션과 트리 형태로 연결되며, 부모의 컨텍스트인 CompositionContext를 참조합니다. 참고로 루트 컴포지션의 부모는 Recomposer입니다.

Compose UI에서 서브컴포지션을 사용하는 이유는 크게 두 가지입니다.

  1. 초기 컴포지션 실행을 특정 정보가 준비될 때까지 미루기 위해
  2. 하위 트리에서 다른 타입의 노드를 사용하기 위해

초기 Composition 과정의 지연 (Deferring initial composition process)

일반적인 레이아웃과 달리 SubcomposeLayout은 독립적인 서브컴포지션을 생성하여 컴포지션을 레이아웃(측정) 단계까지 지연시킵니다. 이를 통해 자식 컴포저블이 부모의 ‘크기 제약’ 같은 ‘계산된 값’에 의존하여 UI를 구성할 수 있게 합니다.

대표적인 예가 BoxWithConstraints입니다. 이 컴포저블은 부모로부터 받은 제약 조건인 constraints을 콘텐츠 블록에 제공합니다. 아래 예시를 보면 maxHeight 값에 따라 어떤 컴포저블을 표시할지 동적으로 결정하는 것을 볼 수 있습니다.

BoxWithConstraints {
    val rectangleHeight = 100.dp
    if (maxHeight < rectangleHeight * 2) {
        Box(Modifier.size(50.dp, rectangleHeight).background(Color.Blue))
    } else {
        Column {
            Box(Modifier.size(50.dp, rectangleHeight).background(Color.Blue))
            Box(Modifier.size(50.dp, rectangleHeight).background(Color.Gray))
        }
    }
}

SubcomposeLayout은 컴포지션 시점을 제어하여 루트가 실행될 때가 아닌, 레이아웃 단계에서 초기 컴포지션을 수행합니다. 또한 부모 컴포지션과 독립적으로 리컴포지션할 수 있습니다.
레이아웃 단계에서 매개변수가 변경될 때는 서브컴포지션 내부만 리컴포지션되며, 반대로 내부 상태가 변경되면 부모에게 리컴포지션을 예약하는 방식으로 동작합니다.

SubcomposeLayout 자체는 부모와 동일한 LayoutNode를 사용합니다.
기술적으로 하나의 컴포지션 내에서 여러 노드 타입을 섞는 것이 가능할 수도 있지만,
Compose UI의 Applier는 단일 노드 타입만 처리하도록 설계되어 있습니다.
따라서 서브트리에서 완전히 다른 노드 타입을 사용하려면 서브컴포지션이 필요합니다.

서브 트리의 노드 타입 변경 (Changing the node type in a subtree)

서브컴포지션을 통해 노드 타입을 변경하는 가장 좋은 사례는 벡터 그래픽 처리입니다.
벡터 컴포저블은 UI 레이아웃 노드와 다른 VNode라는 타입을 사용합니다.
VNode는 경로(Paths)나 그룹을 모델링하는 재귀 구조를 가집니다.

@Composable
fun MenuButton(onMenuClick: () -> Unit) {
    Icon(
        painter = rememberVectorPainter(image = Icons.Rounded.Menu),
        contentDescription = "Menu button",
        modifier = Modifier.clickable { onMenuClick() }
    )
}

위 코드에서 Icon이나 Image 컴포저블은 LayoutNode를 방출하는 UI 요소입니다.
하지만 내부에서 호출되는 rememberVectorPainter는 벡터를 그리기 위해 별도의 서브컴포지션을 생성하고,
이 서브컴포지션은 LayoutNode가 아닌 VNode로 구성된 트리를 만듭니다.

image.png

이 구조 덕분에 벡터 그래픽이라는 완전히 다른 시스템을 기존 UI 트리 안에 통합할 수 있습니다.
또한 서브컴포지션으로 연결되어 있기 때문에 테마 색상이나 density 같은 부모의 CompositionLocal 정보를 벡터 내부에서도 자연스럽게 공유받을 수 있습니다.

벡터 서브컴포지션은 해당 VectorPainter나 이를 포함하는 컴포저블이 부모 컴포지션을 떠날 때 함께 폐기됩니다. 이처럼 서브컴포지션은 서로 다른 노드 시스템을 유연하게 연결하고 수명 주기를 함께 관리합니다.


변경 사항의 구체화 (Materialization via Appliers)

생성된 변경 사항을 LayoutNode실제 노드에 반영하고 안드로이드 뷰 시스템과 연결하는 단계입니다.

UI에 변경사항 반영하기 (Reflecting changes in the UI)

초기 컴포지션과 리컴포지션을 통해 UI 노드가 방출되는 과정을 배웠습니다.
이제 런타임에서 감지한 변경 사항을 실제 UI에 반영하여 사용자가 볼 수 있게 만들어야 합니다.
이 과정을 구체화(Materialization)라고 하며, 이는 Compose UI와 같은 클라이언트 라이브러리가 담당합니다.

다양한 타입의 Applier들 (Different types of Appliers)

Applier는 런타임이 플랫폼에 종속되지 않고 트리를 업데이트할 수 있게 해주는 추상화 인터페이스입니다. Compose UI는 이 Applier를 구현해 안드로이드나 데스크톱 같은 특정 플랫폼에 맞는 노드 타입을 제어합니다.

image.png

런타임은 기본적으로 AbstractApplier라는 구현체를 제공합니다.
이 클래스는 스택을 사용한 노드 탐색 로직을 관리합니다.

  • 아래로 내려갈 때: down(node)을 호출하여 노드를 스택에 push
  • 위로 올라갈 때: up()을 호출하여 스택에서 pop

예를 들어 Column > Row > Text 구조가 있을 때,
Applier는 Column 진입(down) > Row 진입(down) > Text 작업 수행 > Row 탈출(up) 순서로 탐색하며 작업을 수행합니다. 이 방식 덕분에 노드 타입에 상관없이 일관된 탐색 로직을 사용할 수 있습니다.

물론 LayoutNode처럼 컴포지션 외부(e.g. 그리기 단계)에서 부모를 찾아야 하는 경우,
Applier의 스택과는 별개로 자체적인 부모-자식 참조를 유지하기도 합니다.

UiApplierVectorApplier의 전략 차이

안드로이드에서 사용되는 두 가지 주요 Applier 구현체는 각각 다른 트리 구축 전략을 사용합니다.

1. 상향식(Bottom-up) : UiApplier

  • 목적: 안드로이드 UI를 렌더링
  • 전략: 자식 노드들을 먼저 조립한 뒤, 완성된 서브 트리를 부모 노드에 결합
  • 이유: 안드로이드 UI는 계층이 깊은 경우가 많은데, 하향식의 경우 노드가 추가될 때마다 모든 상위 부모에게 알림이 갈 수 있다. 따라서 결합 순간에 직계 부모에게만 알림을 보내는 상향식 전략으로 중복 알림을 방지하고 성능을 최적화한다.

2. 하향식(Top-down) : VectorApplier

  • 목적: 벡터 그래픽을 렌더링
  • 전략: 부모 노드를 먼저 트리에 넣고, 그 아래에 자식 노드들을 순차적으로 삽입
  • 이유: 벡터 그래픽에서는 자식 노드가 변경되거나 추가되어도 상위 노드로 복잡한 알림을 전파할 필요가 없다. 따라서 두 방식의 성능 차이가 거의 없으므로, 둘 중 구현이 자연스러운 하향식 방식을 채택한다.

결과적으로 우리가 화면을 볼 때, 전체 UI 트리는 UiApplier가 관리하는 LayoutNode 트리와 VectorApplier가 관리하는 VNode 서브트리가 함께 구체화되어 나타나는 것입니다.

새로운 LayoutNode를 구체화 하기(Materializing a new LayoutNode)

Compose UI에서 사용하는 UiApplier의 간소화된 구현으로 노드가 어떻게 처리되는지 살펴보겠습니다.

internal class UiApplier(
    root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {

    override fun insertTopDown(index: Int, instance: LayoutNode) {
        // Ignored. (The tree is built bottom‑up with this one).
    }

    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()
    }
    // ...
}

이 구현을 보면 UiApplierLayoutNode 타입만 다루도록 고정되어 있으며,
삽입이나 삭제 같은 실제 작업은 현재 방문 중인 노드인 current에 위임하고 있습니다.
LayoutNode는 스스로를 구체화할 수 있는 능력이 있으므로 런타임은 플랫폼의 세부 사항을 알 필요가 없습니다.

LayoutNode와 Owner의 관계

LayoutNode는 안드로이드 의존성이 없는 순수 코틀린 클래스입니다.
이는 안드로이드뿐만 아니라 데스크톱 등 여러 플랫폼에서 공통으로 사용되는 UI 모델이기 때문입니다.

LayoutNode들은 서로 부모-자식으로 연결되어 트리 구조를 형성하며, 모든 노드는 같은 Owner에 연결됩니다.

Owner는 플랫폼 통합을 위한 추상화 개념입니다. 안드로이드 환경에서 OwnerAndroidComposeView로 구현되며, 이것이 LayoutNode 트리와 안드로이드 뷰 시스템을 연결하는 다리 역할을 합니다.
노드에 변화가 생기면 Owner를 통해 안드로이드의 invalidate가 호출되고, 다음 그리기 단계에서 화면이 갱신됩니다.

LayoutNode 삽입 과정 (insertAt)

새로운 노드가 트리에 삽입되는 과정을 이해하기 위해 간소화된 insertAt 함수를 살펴보겠습니다.
이 함수는 아래 순서로 동작합니다.

  1. 유효성 검사: 노드가 이미 다른 부모나 Owner를 가지고 있지 않은지 확인합니다.
  2. 부모 설정 및 추가: 현재 노드를 새 노드의 부모로 설정하고 자식 목록에 추가합니다.
  3. Z 인덱스 무효화: 자식들의 그리기 순서를 다시 계산하기 위해 정렬된 목록을 무효화합니다.
    이는 Modifier.zIndex() 등으로 그리기 순서가 변경될 수 있기 때문입니다.
  4. 래퍼 연결: LayoutNodeWrapper(현재는 NodeCoordinator로 명칭 변경됨)를 서로 연결합니다.
    이는 측정 및 그리기와 관련된 중요한 부분입니다.
  5. Owner 연결: 부모에게 Owner가 있다면 새 노드도 해당 Owner에 연결(attach)합니다.
internal fun insertAt(index: Int, instance: LayoutNode) {
    check(instance._foldedParent == null) {
        "Cannot insert, it already has a parent!"
    }
    check(instance.owner == null) {
        "Cannot insert, it already has an owner!"
    }

    instance._foldedParent = this
    _foldedChildren.add(index, instance)
    onZSortedChildrenInvalidated()

    instance.outerLayoutNodeWrapper.wrappedBy = innerLayoutNodeWrapper

    val owner = this.owner
    if (owner != null) {
        instance.attach(owner)
    }
}

LayoutNode 연결 과정 (attach)

노드가 Owner에 연결되는 과정은 재귀적으로 일어납니다.
이를 통해 전체 서브 트리가 동일한 Owner를 공유하게 됩니다.

internal fun attach(owner: Owner) {
    check(_foldedParent == null || _foldedParent?.owner == owner) {
        "Attaching to a different owner than the parent’s owner"
    }
    val parent = this.parent // [this] is the node being attached

    this.owner = owner

    if (outerSemantics != null) {
        owner.onSemanticsChange()
    }
    owner.onAttach(this)
    _foldedChildren.forEach { child ->
        child.attach(owner)
    }

    requestRemeasure()
    parent?.requestRemeasure()
}

attach 함수가 실행되면 다음과 같은 일들이 발생합니다.

  1. Owner 할당: 현재 노드와 모든 자식 노드들에게 Owner를 할당합니다.
  2. 의미론(Semantics) 업데이트: 노드에 의미론적 메타데이터(접근성 정보 등)가 있다면 Owner에게 변경 사항을 알립니다. 안드로이드에서는 이를 받아 접근성 API와 연동합니다.
  3. 재측정 요청: 노드가 구체화되어 화면에 표시될 수 있도록 자신과 부모에 대해 재측정(requestRemeasure)을 요청합니다.

이 재측정 요청은 Owner를 거쳐 안드로이드 뷰의 requestLayout이나 invalidate를 유발하고, 최종적으로 변경된 내용이 사용자의 화면에 나타나게 됩니다. 이것이 Compose UI가 레이아웃 트리의 변경을 실제 화면의 픽셀로 변환하는 핵심 과정입니다.

전체 과정의 마무리 (Closing the circle)

마지막으로 Owner가 실제 안드로이드 뷰 계층에 연결되는 과정을 정리해보겠습니다.
Activity나 Fragment에서 setContent가 호출되면 Owner 구현체인 AndroidComposeView가 생성되고, 이것이 안드로이드 뷰 계층에 연결됩니다.

이 전체 과정을 구체적인 예시를 통해 순서대로 살펴보겠습니다.
루트 노드 A와 그 자식인 노드 B가 이미 존재하는 상황에서, Applier가 새로운 노드 C를 삽입한다고 가정해 봅시다. 이때 모든 노드는 항상 측정(Measure), 배치(Place), 그리기(Draw)의 순서를 따릅니다.

image.png

  1. 삽입 및 재측정 요청
    Applier가 insertAt을 호출하여 노드 C를 트리에 삽입합니다. 노드 C는 연결과 동시에 Owner를 통해 자기 자신과 새로운 부모가 된 노드 B에 대해 재측정을 요청합니다.

image.png

  1. 뷰 무효화 (Invalidation)
    재측정 요청은 AndroidComposeViewinvalidate를 호출하게 됩니다.
    이때 현재 노드와 부모 노드가 동시에 무효화를 요청하더라도 성능상 문제는 없습니다.
    무효화는 뷰를 dirty 상태로 표시할 뿐, 실제 그리기는 다음 프레임에서 한 번만 수행되기 때문입니다.
  2. 측정 및 배치 (Measure & Place)
    그리기 단계가 되면 AndroidComposeViewdispatchDraw가 호출됩니다.
    Compose UI는 이 시점에서 변경이 필요한 노드들의 측정과 배치를 수행합니다.
  3. 루트 크기 변경 시 처리
    만약 재측정 과정에서 루트 노드의 크기가 변경된다면 AndroidComposeViewrequestLayout을 호출합니다. 이는 안드로이드 뷰 시스템의 onMeasure를 다시 트리거하여, ComposeView와 형제 관계에 있는 다른 뷰들의 레이아웃까지 올바르게 갱신되도록 합니다.
  4. 그리기 (Draw)
    측정과 배치가 모두 끝나면 루트 LayoutNodedraw 함수가 호출됩니다.
    루트 노드는 자신을 캔버스에 그리는 방법을 알고 있으며, 재귀적으로 자식 노드들의 그리기를 요청합니다.

노드 제거를 위한 변경 사항 구체화 (Materializing a change to remove nodes)

자식 노드를 제거하는 과정은 삽입과 유사하게 위임 패턴을 따릅니다.
UiAppliercurrent.removeAt(index, count)을 호출하여 현재 노드에게 자식 제거 처리를 맡깁니다.

부모 노드는 삭제할 자식들을 마지막 인덱스부터 역순으로 순회하며 다음과 같은 정리 작업을 수행합니다.

  1. 내부 자식 목록에서 해당 노드를 제거합니다.
  2. Z 인덱스 정렬 목록을 무효화하여 다시 정렬되도록 합니다.
  3. 해당 자식과 그 하위 트리에 속한 모든 자식들을 분리(detach)하고,
    갖고 있던 Owner 참조를 null로 초기화합니다.
  4. 변경 사항이 반영될 수 있도록 부모 노드에 대해 재측정(remeasure)을 요청합니다.

노드 삽입 때와 마찬가지로, 노드가 제거되어 의미론적(semantics) 구조에 변경이 생기면 Owner에게 이를 알립니다.

노드 이동을 위한 변경 사항 구체화 (Materializing a change to move nodes)

노드 이동은 자식들의 순서를 재배치하는 작업입니다.
UiApplier가 move를 호출하면 시스템은 내부적으로 해당 노드들을 제거(removeAt)한 뒤,
새로운 위치에 다시 추가하는 방식으로 동작합니다.
이동이 완료되면 부모 노드에 대해 재측정을 요청합니다.

모든 노드를 지우는 변경 사항 구체화 (Materializing a change to clear all the nodes)

모든 노드를 지우는 작업은 앞서 설명한 노드 제거 로직을 전체 자식에게 적용하는 것과 동일합니다.
마지막 자식부터 역순으로 순회하며 모든 노드를 분리(detach)한 뒤, 빈 상태가 된 부모 노드에게 재측정을 요청합니다.

측정과 배치 시스템 (Measurement & Layout)

생성된 노드들의 크기를 계산(Measure)하고 위치를 잡는(Place) 단계입니다.

Compose UI에서의 측정 (Measuring in Compose UI)

재측정이 요청되면 뷰(Owner)는 더티(dirty) 상태로 표시되고, 해당 노드는 재측정 및 재배치 대기 목록에 추가됩니다. 이후 그리기 시점이 되면 AndroidComposeView의 dispatchDraw가 호출되면서 대기 목록에 있는 노드들을 순회하며 작업을 수행합니다.

재측정 및 재배치가 예정된 각 노드는 다음 3단계를 순차적으로 거칩니다.

  1. 노드가 재측정이 필요한지 확인하고, 필요하다면 수행합니다.
  2. 측정 후 노드가 재배치가 필요한지 확인하고, 필요하다면 수행합니다.
  3. 이 과정에서 파생된(연기된) 측정 요청이 있는지 확인합니다. 있다면 해당 노드들을 대기 목록에 추가하고 다시 1단계로 돌아갑니다.

LayoutNodeWrapper와 체이닝

각 노드의 측정은 LayoutNodeWrapper에게 위임됩니다. 앞서 보았던 insertAt 함수를 다시 살펴보면 래퍼가 할당되는 코드를 볼 수 있습니다.

internal fun insertAt(index: Int, instance: LayoutNode) {
    // ...
    instance.outerLayoutNodeWrapper.wrappedBy = innerLayoutNodeWrapper
    // ...
}

기본적으로 LayoutNode는 외부(Outer) 래퍼와 내부(Inner) 래퍼를 가집니다. 외부 래퍼는 현재 노드의 측정과 그리기를 담당하고, 내부 래퍼는 자식 노드들에 대해 같은 작업을 수행합니다.

하지만 실제로는 Modifier가 존재하기 때문에 구조가 더 복잡해집니다. 패딩이나 배경색 같은 Modifier는 측정값이나 그리기 영역에 영향을 주며, 상태를 유지해야 할 수도 있습니다. 이를 위해 LayoutNode는 외부 및 내부 래퍼 사이에 적용된 각 Modifier에 대한 래퍼들을 생성하고 이들을 체인 형태로 연결합니다.

측정 시 래퍼들의 연결 및 호출 순서는 다음과 같습니다.

  1. 부모 LayoutNode는 측정 정책(measurePolicy)을 사용하여 자식의 외부 래퍼를 측정합니다.
  2. 자식의 외부 래퍼는 체인의 첫 번째 Modifier 래퍼를 호출합니다.
  3. 첫 번째 래퍼는 두 번째를, 두 번째는 세 번째를 감싸며 순차적으로 호출합니다.
  4. 마지막 Modifier 래퍼는 내부 래퍼를 호출합니다.
  5. 내부 래퍼는 다시 현재 노드의 측정 정책을 사용하여 자신의 자식들의 외부 래퍼를 측정합니다.

이러한 체이닝 구조 덕분에 측정과 Modifier 적용이 순서대로 보장됩니다. 그리기 과정도 동일한 체인을 따르며, 마지막 내부 래퍼 단계에서 Z 인덱스 순서대로 자식들의 draw를 호출합니다.

래퍼의 생명주기와 상태 감지

새 노드가 연결(attach)되면 모든 LayoutNodeWrapper가 알림을 받습니다. Focus Modifier처럼 이벤트를 전송하거나 초기화가 필요한 경우 이 시점에 처리합니다.

재측정이 요청되면 외부 래퍼부터 시작해 체인을 따라 내려가며 재측정이 수행됩니다. 이 과정에서 측정 람다(측정 정책) 내에서 읽히는 모든 상태(State)는 기록됩니다. 덕분에 특정 상태 값이 변경되면 람다가 다시 실행되면서 자동으로 재측정이 트리거됩니다.

측정 완료 후, 이전 크기와 현재 크기를 비교하여 값이 달라졌다면 부모 노드에게 재측정을 요청하여 변경 사항을 전파합니다.

측정 정책 (Measuring policies)

노드가 측정될 때 LayoutNodeWrapper는 해당 노드가 생성될 때 제공된 측정 정책(Measure Policy)을 따릅니다.

@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    // ...
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = { LayoutNode() },
        update = {
            set(measurePolicy, { this.measurePolicy = it })
            // ...
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

LayoutNode는 자체적인 측정 로직을 가지고 있지만, Compose UI는 유연성을 위해 이를 무시하고 외부에서 제공된 측정 정책을 사용합니다. 측정 정책이 변경되면 재측정이 요청됩니다. 커스텀 레이아웃을 만들 때 전달하는 람다가 바로 이 측정 정책입니다.

간단한 예시: Spacer

Spacer의 측정 정책은 매우 단순합니다. 자식 노드가 없기 때문에 전달받은 제약 조건(Constraints)에 따라 자신의 크기를 결정합니다.

@Composable
fun Spacer(modifier: Modifier) {
    Layout({}, modifier) { _, constraints ->
        with(constraints) {
            val width = if (hasFixedWidth) maxWidth else 0
            val height = if (hasFixedHeight) maxHeight else 0
            layout(width, height) {}
        }
    }
}

Spacer는 고정된 크기 제약(min == max)이 있으면 그 값을 사용하고, 그렇지 않으면 0을 사용합니다. 즉, Spacer의 크기는 부모나 Modifier에 의해 외부에서 결정됩니다.

심화 예시: Box

Box는 자식 노드의 정렬 방식과 부모의 제약 조건 전파 여부에 따라 동작이 달라집니다.
Box의 측정 정책은 다음과 같은 순서로 처리됩니다.

1. 자식이 없는 경우
부모가 제공한 최소 크기 제약에 맞춰 자신의 크기를 결정합니다.

MeasurePolicy { measurables, constraints ->
    if (measurables.isEmpty()) {
        return@MeasurePolicy layout(
            constraints.minWidth,
            constraints.minHeight
        ) {}
    }
    // ...

2. 자식이 1개인 경우
제약 조건을 준비한 뒤, 자식이 matchParentSize 옵션을 사용하는지에 따라 두 가지로 나뉩니다.

val contentConstraints = if (propagateMinConstraints) {
        constraints
    } else {
        constraints.copy(minWidth = 0, minHeight = 0)
    }

    if (measurables.size == 1) {
        val measurable = measurables[0]
        // ...
        if (!measurable.matchesParentSize) {
            // Case 1: 콘텐츠 크기에 맞춤
            placeable = measurable.measure(contentConstraints)
            boxWidth = max(constraints.minWidth, placeable.width)
            boxHeight = max(constraints.minHeight, placeable.height)
        } else {
            // Case 2: 부모 크기에 맞춤
            boxWidth = constraints.minWidth
            boxHeight = constraints.minHeight
            placeable = measurable.measure(
                Constraints.fixed(constraints.minWidth, constraints.minHeight)
            )
        }
        return@MeasurePolicy layout(boxWidth, boxHeight) {
            placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
        }
    }
  • 콘텐츠에 맞춤: 자식을 먼저 측정하여 그 크기에 맞게 Box의 크기를 결정합니다. 단, Box는 최소 제약 조건보다 작아질 수 없습니다.
  • 부모 크기에 맞춤: Box의 크기를 부모의 최소 제약 조건으로 먼저 고정한 뒤, 자식을 그 크기에 맞춰 측정합니다.

3. 자식이 여러 개인 경우
먼저 matchParentSize가 아닌 자식들을 측정하여 Box가 가져야 할 최대 크기를 계산합니다.

val placeables = arrayOfNulls<Placeable>(measurables.size)

    var hasMatchParentSizeChildren = false
    var boxWidth = constraints.minWidth
    var boxHeight = constraints.minHeight

    measurables.fastForEachIndexed { index, measurable ->
        if (!measurable.matchesParentSize) {
            val placeable = measurable.measure(contentConstraints)
            placeables[index] = placeable
            boxWidth = max(boxWidth, placeable.width)
            boxHeight = max(boxHeight, placeable.height)
        } else {
            hasMatchParentSizeChildren = true
        }
    }

그다음, 확정된 Box 크기를 기준으로 matchParentSize인 자식들을 측정합니다.

if (hasMatchParentSizeChildren) {
        val matchParentSizeConstraints = Constraints(
            minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
            minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
            maxWidth = boxWidth,
            maxHeight = boxHeight
        )
        measurables.fastForEachIndexed { index, measurable ->
            if (measurable.matchesParentSize) {
                placeables[index] = measurable.measure(matchParentSizeConstraints)
            }
        }
    }

마지막으로 결정된 boxWidthboxHeight를 사용하여 레이아웃을 생성하고 자식들을 배치합니다.

layout(boxWidth, boxHeight) {
        placeables.forEachIndexed { index, placeable ->
            placeable as Placeable
            val measurable = measurables[index]
            placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
        }
    }

이 과정을 통해 Box는 자식들의 크기와 설정에 따라 유연하게 자신의 크기를 결정하고 자식들을 배치합니다.

고유 크기 측정 (Intrinsic measurements)

MeasurePolicy에는 레이아웃의 고유 크기를 계산하는 메서드들이 포함되어 있습니다. 고유 크기란 엄격한 제약 조건(Constraints)이 주어지지 않았을 때 레이아웃이 이상적으로 생각하는 추정 크기를 의미합니다.

이 기능은 본격적인 측정(measure) 단계 이전에 자식의 대략적인 크기를 알아야 할 때 유용합니다. 예를 들어, 여러 자식 중 가장 키가 큰 자식의 높이에 맞춰 다른 자식들의 높이를 통일하고 싶은 경우가 있습니다.

하지만 Compose는 성능을 위해 단 한 번의 측정 패스만 허용합니다. 만약 크기를 알기 위해 두 번 측정을 시도하면 런타임 예외가 발생합니다. 서브컴포지션을 사용하는 방법도 있지만 비용이 큽니다. 이때 고유 크기 측정이 훌륭한 대안이 됩니다.

고유 크기 측정 메서드

LayoutNode의 고유 크기 정책은 다음 4가지 값을 계산할 수 있습니다. 값을 계산하기 위해서는 항상 반대쪽 치수를 입력값으로 제공해야 합니다. 제약 조건이 없는 상태에서 적절한 크기를 유추하려면 적어도 하나의 단서(높이 또는 너비)가 필요하기 때문입니다.

  1. minIntrinsicWidth: 주어진 높이 내에서 콘텐츠를 잘리지 않고 표시할 수 있는 최소 너비.
  2. minIntrinsicHeight: 주어진 너비 내에서 콘텐츠를 잘리지 않고 표시할 수 있는 최소 높이.
  3. maxIntrinsicWidth: 너비를 아무리 더 늘려도 높이를 더 이상 줄일 수 없는 상태의 최소 너비(가장 이상적인 너비).
  4. maxIntrinsicHeight: 높이를 아무리 더 늘려도 너비를 더 이상 줄일 수 없는 상태의 최소 높이.

Modifier.width(IntrinsicSize) 활용

이 개념을 가장 잘 보여주는 예시가 Modifier.width(intrinsicSize: IntrinsicSize)입니다. 이 수정자는 고정된 값을 사용하는 대신, 노드 자신이 계산한 최소 또는 최대 고유 너비를 선호하는 너비로 사용하겠다고 선언하는 것입니다.

@Stable
fun Modifier.width(intrinsicSize: IntrinsicSize) = when (intrinsicSize) {
    IntrinsicSize.Min -> this.then(MinIntrinsicWidthModifier)
    IntrinsicSize.Max -> this.then(MaxIntrinsicWidthModifier)
}

예를 들어 Modifier.width(IntrinsicSize.Max)를 호출하면 내부적으로 MaxIntrinsicWidthModifier가 적용됩니다. 이 수정자는 들어오는 제약 조건(예: maxHeight)을 바탕으로 계산된 최대 고유 너비를 구하고, 이를 콘텐츠의 고정 너비로 강제합니다.

private object MaxIntrinsicWidthModifier : IntrinsicSizeModifier {
    override fun MeasureScope.calculateContentConstraints(
        measurable: Measurable,
        constraints: Constraints
    ): Constraints {
        val width = measurable.maxIntrinsicWidth(constraints.maxHeight)
        return Constraints.fixedWidth(width)
    }

    override fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ) = measurable.maxIntrinsicWidth(height)
}

실제 사용 사례: DropdownMenu

이 기능이 UI에서 어떻게 보이는지 이해하기 위해 DropdownMenu를 살펴보겠습니다. 드롭다운 메뉴는 여러 항목 중 가장 긴 텍스트를 가진 항목의 너비에 맞춰 전체 메뉴의 너비가 결정되어야 합니다.

@Composable
fun DropdownMenuContent(...) {
    // ...
    Column(
        modifier = modifier
            .padding(vertical = DropdownMenuVerticalPadding)
            .width(IntrinsicSize.Max)
            .verticalScroll(rememberScrollState()),
        content = content
    )
    // ...
}

위 코드에서 Column에 width(IntrinsicSize.Max)를 적용했습니다. 이렇게 하면 Column은 자식들(메뉴 항목) 중 가장 넓은 너비를 가진 자식(최대 고유 너비)을 기준으로 자신의 너비를 설정합니다. 결과적으로 모든 메뉴 항목이 가장 긴 항목의 너비와 동일하게 맞춰지게 됩니다.

이처럼 고유 크기 측정은 부모가 자식들의 크기를 미리 파악하고 그에 맞춰 레이아웃을 구성해야 할 때 필수적인 기능입니다.

레이아웃 제약 조건 (Layout Constraints)

레이아웃 제약 조건(Constraints)은 부모 LayoutNode나 Modifier로부터 전달됩니다. 이는 너비와 높이에 대한 최소값과 최대값의 범위이며, 자식 레이아웃은 반드시 이 범위 내에서 자신의 크기를 결정해야 합니다.

측정된 자식의 너비와 높이는 다음 조건을 만족해야 합니다.
minWidth <= chosenWidth <= maxWidth
minHeight <= chosenHeight <= maxHeight

제약 조건의 완화와 무한대

대부분의 레이아웃은 부모로부터 받은 제약 조건을 그대로 자식에게 전달하거나, 최소값을 0으로 완화하여 자식이 크기를 자유롭게 결정하도록 합니다.

때로는 부모가 자식에게 본래 크기를 묻기 위해 최대값을 무한대(Constraints.Infinity)로 설정하여 전달하기도 합니다. 예를 들어 Box(Modifier.fillMaxHeight())는 일반적으로 사용 가능한 모든 높이를 채우려 합니다.

Box(Modifier.fillMaxHeight()) {
    Text("test")
}

하지만 이 Box가 높이 제약이 무한대인 LazyColumn 내부에 배치된다면 동작이 달라집니다. 무한대를 가득 채우는 것은 불가능하므로, Box는 확장하는 대신 내용을 감싸는 크기(wrap content)로 동작하여 Text의 높이만큼만 차지하게 됩니다. 레이아웃 컴포넌트들은 무한대 제약 조건이 들어오면 이처럼 내용을 감싸도록 크기를 조정하는 것이 일반적입니다.

LazyColumn과 무한 제약 조건

LazyColumn은 내부적으로 LazyList를 사용하며 SubcomposeLayout을 통해 화면에 보이는 아이템만 지연 구성합니다. 이때 스크롤 방향인 주축(Main axis)에 대해 무한대 제약 조건을 사용합니다.

// The main axis is not restricted
val childConstraints = Constraints(
    maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity,
    maxHeight = if (!isVertical) constraints.maxHeight else Constraints.Infinity
)

LazyList 측정 단계에서 아이템의 내용을 서브컴포지션하면 측정 가능한(measurable) 자식 목록이 생성됩니다. 이때 위 코드에서 생성된 childConstraints가 사용되는데, 높이 제약(수직 스크롤인 경우)이 무한대이므로 자식 아이템들은 높이 제한 없이 자신의 크기를 스스로 결정할 수 있게 됩니다.

고정 크기 제약 조건

반대로 부모나 Modifier가 자식에게 정확한 크기를 강제해야 할 때는 최소값과 최대값을 동일하게 설정합니다(min == max).

구버전의 LazyVerticalGrid(현재는 LazyLayout으로 변경됨) 구현을 예로 들어보겠습니다. 이 컴포넌트는 그리드의 열 너비를 고정하기 위해 계산된 너비 값을 고정 제약 조건으로 변환하여 자식에게 전달했습니다.

val width = span * columnSize + remainderUsed + spacing * (span - 1)
measurable.measure(Constraints.fixedWidth(width))

이렇게 하면 너비는 고정되고 높이는 0에서 무한대 사이인 제약 조건이 생성됩니다.

minWidth = width,
maxWidth = width,
minHeight = 0,
maxHeight = Infinity

참고로 Constraints는 성능 최적화를 위해 인라인 클래스로 구현되어 있습니다. 4가지 값(min/max 너비/높이)을 단일 Long 값에 비트마스크를 사용하여 패킹하는 방식으로 메모리와 성능 효율을 높였습니다.

LookaheadLayout

Lookahead는 미리 보기 또는 사전 계산을 의미합니다.
참고로 LookaheadLayoutLookaheadLayoutScope는 Compose 1.6부터 LookaheadScope로 완전히 대체되었습니다. 본문은 원저자의 설명을 따르지만, 최신 버전에서는 API가 변경되었다는 점을 유념해 주세요.

LookaheadLayout은 측정과 배치에 깊게 관여하는 기능으로, 공유 요소 전환(Shared Element Transition)과 같은 애니메이션 구현에 핵심적인 역할을 합니다. 예를 들어 라디오 버튼을 눌러 목록 형태가 1열에서 2열로 바뀔 때, 각 아이템이 자연스럽게 이동하는 애니메이션을 생각해보면 이해가 쉽습니다.

필요성: 매직 넘버 없는 애니메이션

클릭 시 Row와 Column 레이아웃을 전환하는 간단한 컴포저블을 예로 들어보겠습니다.

@Composable
fun SmartBox() {
    var vertical by remember { mutableStateOf(false) }

    Box(Modifier.clickable { vertical = !vertical }) {
        if (vertical) {
            Column {
                Text("Text 1")
                Text("Text 2")
            }
        } else {
            Row {
                Text("Text 1")
                Text("Text 2")
            }
        }
    }
}

두 상태 간의 텍스트 요소는 동일하므로 자연스럽게 애니메이션되길 원합니다. 더 복잡한 예로 앱 내 화면 전환을 생각해볼 수 있습니다.

@Composable
fun TvShowApp() {
    var listScreen by remember { mutableStateOf(true) }

    Box(Modifier.clickable { listScreen = !listScreen }) {
        if (listScreen) {
            CharacterList()
        } else {
            Detail()
        }
    }
}

이런 전환을 구현할 때, 목적지 화면에서의 최종 위치와 크기를 미리 알지 못하면 임의의 매직 넘버를 사용해 애니메이션을 처리해야 하는 문제가 발생합니다. LookaheadLayout은 변경될 레이아웃의 크기와 위치를 미리 계산하여 이 정보를 제공함으로써, 매직 넘버 없이 정확한 목표 지점으로 애니메이션할 수 있게 해줍니다.

레이아웃 사전 계산 방식 비교

레이아웃을 미리 계산하는 방법에는 여러 가지가 있지만 명확한 차이가 있습니다.

  1. SubcomposeLayout: 측정 시간까지 컴포지션을 지연시킵니다. 비용이 많이 들며, 사전 배치보다는 조건부 컴포지션에 적합합니다.
  2. 고유 크기 측정 (Intrinsics): 효율적이며 LookaheadLayout과 유사하게 작동합니다. 하지만 이는 실제 측정을 돕기 위한 잠정적인 추정치 계산에 가깝습니다. (예: 자식들 중 가장 큰 높이에 맞추기)
  3. LookaheadLayout: 애니메이션을 위해 자식의 크기와 위치를 정확하게 사전 계산합니다. 측정뿐만 아니라 배치 계산도 수행하며, 공격적인 캐싱을 통해 트리가 변경되지 않는 한 재계산을 건너뜁니다.

동작 원리

LookaheadLayout은 측정과 배치에 대해 사전 단계(lookahead pass)를 수행합니다. 이 사전 단계에서 계산된 미래의 값(목표값)을 사용하여, 실제 측정 및 배치 단계에서 점진적으로 애니메이션을 실행합니다.

이 기능을 활용하기 위해 과거에는 Modifier.intermediateLayout과 Modifier.onPlaced가 사용되었습니다. (현재는 approachLayout 등으로 대체됨)

  • intermediateLayout: 재측정 시 호출되며, 미리 계산된 크기(lookaheadSize)에 접근하여 중간 레이아웃을 생성합니다.
  • onPlaced: 재배치 시 호출되며, 선제적 위치 좌표를 통해 현재 위치를 계산하고 애니메이션합니다.

다음은 크기 제약을 애니메이션하는 커스텀 Modifier 예제입니다.

fun Modifier.animateConstraints(lookaheadScope: LookaheadLayoutScope) =
    composed {
        var sizeAnimation: Animatable<IntSize, AnimationVector2D>? by remember {
            mutableStateOf(null)
        }
        var targetSize: IntSize? by remember { mutableStateOf(null) }
        LaunchedEffect(Unit) {
            snapshotFlow { targetSize }.collect { target ->
                if (target != null && target != sizeAnimation?.targetValue) {
                    sizeAnimation?.run {
                        launch { animateTo(target) }
                    } ?: Animatable(target, IntSize.VectorConverter).let {
                        sizeAnimation = it
                    }
                }
            }
        }

        with(lookaheadScope) {
            this@composed.intermediateLayout { measurable, _, lookaheadSize ->
                targetSize = lookaheadSize
                val (width, height) = sizeAnimation?.value ?: lookaheadSize
                val animatedConstraints = Constraints.fixed(width, height)

                val placeable = measurable.measure(animatedConstraints)
                layout(placeable.width, placeable.height) {
                    placeable.place(0, 0)
                }
            }
        }
    }

이 코드는 lookaheadSize(목표 크기)가 변경될 때마다 애니메이션을 트리거하고, 매 프레임마다 변경되는 애니메이션 값을 실제 제약 조건으로 사용하여 부드러운 크기 변화를 만들어냅니다.

이제 이 Modifier를 실제 레이아웃에 적용한 예시를 보겠습니다.

LookaheadLayout(
    content = {
        var fullWidth by remember { mutableStateOf(false) }
        Row(
            (if (fullWidth) Modifier.fillMaxWidth() else Modifier.width(100.dp))
                .height(200.dp)
                .animateConstraints(this@LookaheadLayout)
                .clickable { fullWidth = !fullWidth }) {
            Box(
                Modifier
                    .weight(1f)
                    .fillMaxHeight()
                    .background(Color.Red)
            )
            Box(
                Modifier
                    .weight(2f)
                    .fillMaxHeight()
                    .background(Color.Yellow)
            )
        }
    }
) { measurables, constraints ->
    val placeables = measurables.map { it.measure(constraints) }
    val maxWidth: Int = placeables.maxOf { it.width }
    val maxHeight = placeables.maxOf { it.height }

    layout(maxWidth, maxHeight) {
        placeables.forEach {
            it.place(0, 0)
        }
    }
}

상태가 변경되면 Row의 너비가 바뀌고, 이는 선제적 계산을 트리거합니다. animateConstraints는 이 선제적 결과를 목표로 삼아 애니메이션을 수행합니다.

위치(Placement) 애니메이션

크기뿐만 아니라 위치도 애니메이션할 수 있습니다. onPlaced를 사용하여 부모 기준의 선제적 위치와 현재 위치를 계산하고 그 차이(오프셋)를 애니메이션합니다.

fun Modifier.animatePlacementInScope(lookaheadScope: LookaheadLayoutScope) =
    composed {
        var offsetAnimation: Animatable<IntOffset, AnimationVector2D>? by remember {
            mutableStateOf(null)
        }

        var placementOffset: IntOffset by remember { mutableStateOf(IntOffset.Zero) }
        var targetOffset: IntOffset? by remember { mutableStateOf(null) }
        LaunchedEffect(Unit) {
            snapshotFlow {
                targetOffset
            }.collect { target ->
                if (target != null && target != offsetAnimation?.targetValue) {
                    offsetAnimation?.run {
                        launch { animateTo(target) }
                    } ?: Animatable(target, IntOffset.VectorConverter).let {
                        offsetAnimation = it
                    }
                }
            }
        }

        with(lookaheadScope) {
            this@composed.onPlaced { lookaheadScopeCoordinates, layoutCoordinates ->
                // the *target* position of this modifier in local coordinates.
                targetOffset = lookaheadScopeCoordinates.localLookaheadPositionOf(
                    layoutCoordinates
                ).round()

                // the *current* position of this modifier in local coordinates.
                placementOffset = lookaheadScopeCoordinates.localPositionOf(
                    layoutCoordinates, Offset.Zero
                ).round()
            }
                .intermediateLayout { measurable, constraints, _ ->
                    val placeable = measurable.measure(constraints)
                    layout(placeable.width, placeable.height) {
                        val (x, y) = offsetAnimation?.run { value - placementOffset }
                            ?: (targetOffset!! - placementOffset)
                        placeable.place(x, y)
                    }
                }
        }
    }

내부 동작 (Internal Mechanics)

LookaheadLayout의 핵심은 두 번의 패스(pass)가 실행된다는 점입니다.

  1. 선제적 측정/배치 단계 (Lookahead Pass): 루트부터 시작하여 LookaheadPassDelegate를 통해 전체 트리의 미래 상태(목표값)를 계산합니다. 이는 외부 LayoutNodeWrapper 체인을 타고 실행됩니다.
  2. 일반 측정/배치 단계 (Measure Pass): 이후 MeasurePassDelegate를 통해 실제 화면에 그릴 상태를 계산합니다. 이때 앞서 계산된 목표값을 참조하여 애니메이션을 적용합니다.

최적화를 위해 트리에 변경이 생겼을 때, 영향을 받지 않는 노드들은 무효화되지 않으며 LookaheadLayout 서브트리의 필요한 부분만 재계산됩니다.

LookaheadLayout은 중첩을 지원하며, movableContentOf를 함께 사용하면 애니메이션 도중 컴포저블의 상태를 유지하면서 이동시킬 수 있습니다. 비록 코드는 Deprecated 되었지만, 이 원리는 최신 LookaheadScope를 이해하는 데 필수적인 기반이 됩니다.


모디파이어의 내부 동작 (Modifier Internals)

노드에 스타일이나 동작을 부여하는 Modifier가 어떻게 체이닝되고 적용되는지 다룹니다.

Modifier 체인 모델링 (Modeling modifier chains)

Modifier는 컴포즈 UI에서 핵심적인 역할을 합니다. Modifier 인터페이스는 UI 컴포저블을 장식하거나 동작을 추가하는 불변 요소들의 집합을 모델링하며, 다음과 같은 3가지 주요 기능을 제공합니다.

  1. then: 여러 Modifier를 하나의 체인으로 결합합니다.
  2. foldIn 및 foldOut: 체인을 순회하며 값을 누적합니다.
  3. any 또는 all: 체인 내의 Modifier들이 특정 조건을 만족하는지 검사합니다.

코드상에서 Modifier 체인은 헤드(head)가 전체를 참조하는 연결 리스트(Linked List) 구조를 띱니다.

Box(
    modifier.then(indentMod) // Returns Modifier
        .fillMaxWidth() // Returns Modifier
        .height(targetThickness) // Returns Modifier
        .background(color = color) // Returns Modifier
)

체인 연결 방식

Modifier를 연결하는 방법은 then을 직접 호출하는 명시적 방식과 확장 함수를 사용하는 암시적 방식이 있습니다. 실제 프로젝트에서는 주로 확장 함수를 사용하지만, 내부적으로는 결국 then을 호출하여 연결하는 원리입니다.

참고로 최근 Modifier 구현체들은 성능 최적화를 위해 Modifier.Node 방식으로 재작성되고 있습니다.

@Stable
fun Modifier.padding(
    horizontal: Dp = 0.dp,
    vertical: Dp = 0.dp
) = this.then(
    PaddingModifier(
        start = horizontal,
        top = vertical,
        end = horizontal,
        bottom = vertical,
        // ...
    )
)

CombinedModifier의 구조

두 Modifier를 then으로 연결하면 CombinedModifier라는 객체가 생성됩니다. CombinedModifier는 현재 Modifier(outer)와 체인의 다음 Modifier(inner)에 대한 참조를 가지며, 이 inner 역시 또 다른 CombinedModifier일 수 있습니다.

class CombinedModifier(
    private val outer: Modifier,
    private val inner: Modifier
): Modifier

이 구조에서 outer와 inner라는 이름이 붙은 이유는 체인 상에서 현재 노드가 다음 노드를 감싸는 형태이기 때문입니다. 이를 시각화하면 다음과 같이 중첩된 구조가 됩니다.

CombinedModifier(a, CombinedModifier(b, CombinedModifier(c, d)))

이것이 Modifier 체인이 메모리 상에서 모델링되는 방식입니다. 이제 이 체인이 실제 LayoutNode에 어떻게 적용되는지 알아보겠습니다.

LayoutNode에 modifier 설정 (Setting modifiers to the LayoutNode)

모든 LayoutNode에는 Modifier(또는 체인)가 할당됩니다. Layout 컴포저블을 선언할 때, Compose UI는 update 람다를 통해 이를 처리합니다.

@Composable inline fun Layout(
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    val viewConfiguration = LocalViewConfiguration.current

    val materialized = currentComposer.materialize(modifier)

    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = { LayoutNode() },
        update = {
            set(measurePolicy, { this.measurePolicy = it })
            set(density, { this.density = it })
            set(layoutDirection, { this.layoutDirection = it })
            set(viewConfiguration, { this.viewConfiguration = it })
            set(materialized, { this.modifier = it })
        },
    )
}

update 람다는 노드가 생성된 직후 호출되어 LayoutNode의 상태를 초기화합니다. 측정 정책, 밀도 등과 함께 구체화된(materialized) modifier가 노드에 설정되는 것을 볼 수 있습니다.

Modifier의 구체화 (Materialization)

노드에 설정되기 전 modifier는 구체화 과정을 거칩니다.

val materialized = currentComposer.materialize(modifier)

이 함수는 전달된 modifier가 표준(standard) 타입이라면 별도의 처리 없이 그대로 반환합니다. 하지만 Compose UI에는 표준 modifier 외에도 Composed Modifier라는 특별한 유형이 존재합니다.

표준 modifier는 상태가 없습니다(stateless). 반면 Composed Modifier는 상태를 가질 수 있는(stateful) 유형으로, 구현을 위해 컴포지션(Composition) 컨텍스트가 필요할 때 사용됩니다. 예를 들어 remember를 사용하여 값을 저장하거나 CompositionLocal에 접근해야 하는 경우입니다.

private open class ComposedModifier(
    inspectorInfo: InspectorInfo.() -> Unit,
    val factory: @Composable Modifier.() -> Modifier
) : Modifier.Element, InspectorValueInfo(inspectorInfo)

LayoutNode는 Composed Modifier를 직접 처리하는 방법을 알지 못합니다. 따라서 factory 람다를 먼저 실행하여 이를 일반 modifier로 변환한 뒤 할당해야 합니다. 이 factory 람다는 컴포지션 컨텍스트 내에서 실행되므로 내부에서 컴포저블 함수들을 사용할 수 있습니다.

Composed Modifier의 예시: Clickable

clickable은 상태를 기억해야 하므로 Modifier.composed를 사용하는 대표적인 예시입니다.

fun Modifier.clickable(
    interactionSource: MutableInteractionSource,
    indication: Indication?,
    enabled: Boolean= true,
    onClick: () -> Unit
    // ...
) = composed(
    factory = {
        val onClickState = rememberUpdatedState(onClick)
        val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) }
        // ...
        val isClickableInScrollableContainer = remember { mutableStateOf(true) }
        val delayPressInteraction = rememberUpdatedState {
            isClickableInScrollableContainer.value || isRootInScrollableContainer()
        }
        val gesture = Modifier.pointerInput(interactionSource, enabled) {
            // ...
        }
        Modifier
            .then(...adds more extra modifiers)
            .genericClickableWithoutGesture(
                gestureModifiers = gesture,
                // ...
            )
    },
    inspectorInfo =
    // ...append info about the modifier for dev tooling
)

최신 Compose에서는 성능 최적화를 위해 많은 로직을 Modifier.Node 방식으로 이전하고 있지만, LocalIndication 값 읽기나 상태 기억(remember)과 같이 컴포지션의 기능이 필수적인 경우에는 여전히 Modifier.composed가 사용됩니다.

특수한 목적의 LayoutNode 설정

때로는 Layout 컴포저블을 통하지 않고 직접 LayoutNode를 생성해야 하는 경우도 있습니다. AndroidComposeView의 루트 노드가 그 예입니다.

override val root = LayoutNode().also {
    it.measurePolicy = RootMeasurePolicy
    it.modifier = Modifier
        .then(semanticsModifier)
        .then(_focusManager.modifier)
        .then(keyInputModifier)
    it.density = density
}

이 경우 AndroidComposeView는 루트 노드를 생성하면서 측정 정책과 밀도뿐만 아니라, 접근성(Semantics), 포커스 관리, 키 입력 처리를 위한 modifier들을 직접 체이닝하여 설정합니다.

LayoutNode가 새로운 modifier를 받아 들이는 방법 (How LayoutNode ingests new modifiers)

LayoutNode에 새로운 Modifier 체인이 설정되면, 시스템은 기존 Modifier들을 캐시에 저장하고 재사용 가능한지 확인하는 과정을 거칩니다. 이 과정은 크게 두 단계의 접기(fold) 연산으로 이루어집니다.

  1. 재사용 가능성 확인 (Modifier#foldIn)
    새로운 Modifier 체인의 머리(Head)부터 시작하여 순회합니다. 각 Modifier가 캐시에 존재하는지 확인하고, 있다면 재사용 가능으로 표시합니다. 이때 해당 Modifier뿐만 아니라 그 조상들도 함께 재사용 가능 상태가 됩니다.
  2. 래퍼 체인 재구축 (Modifier#foldOut)
    외부 LayoutNodeWrapper를 재구축하기 위해 이번에는 꼬리(Tail)부터 머리 방향으로 순회합니다. 가장 안쪽의 innerLayoutNodeWrapper를 초기값으로 시작하여 바깥쪽으로 래퍼를 하나씩 쌓아 올립니다.
  • 캐시 확인: 재사용 가능한 Modifier라면 캐시에서 가져와 체인에 추가합니다.
  • 래퍼 생성: 재사용할 수 없다면 Modifier 성격에 맞는 새로운 래퍼(LayoutNodeWrapper)를 생성하여 감쌉니다.

이 과정이 끝나고 체인의 머리인 외부 래퍼(outerLayoutNodeWrapper)가 완성되면, 이를 부모 노드의 내부 래퍼에 연결합니다. 마지막으로 재사용되지 않은 나머지 캐시들을 분리(detach)하고, 새 래퍼들에 대해 attach()를 호출한 뒤, 변경 사항 반영을 위해 부모에게 재측정을 요청합니다.


그리기 (Drawing)

측정과 배치가 끝난 노드를 실제 화면 픽셀로 그리는 단계입니다.

노드 트리 그리기 (Drawing the node tree)

그리기는 측정과 배치 단계 이후에 실행됩니다. LayoutNode가 재측정을 요청하면 더티(dirty) 상태가 되고, Owner(예: AndroidComposeView)는 다음 그리기 단계에서 dispatchDraw를 통해 이 노드들을 처리합니다.

그리기 과정은 루트 LayoutNode의 root.draw(canvas)에서 시작하여 LayoutNodeWrapper 체인을 따라 진행됩니다. 현재 노드 -> Modifier -> 자식 노드 순서로 그려집니다.

Compose Canvas와 최적화

Compose UI는 멀티플랫폼 지원을 위해 자체적인 Canvas 추상화를 사용합니다. 안드로이드에서는 네이티브 Canvas에 작업을 위임하지만, 더 사용자 친화적인 API를 제공합니다. 중요한 차이점은 Compose Canvas가 Paint 객체를 직접 받지 않는다는 점입니다. 안드로이드에서 그리기 도중 Paint 객체를 할당하는 것은 비용이 크기 때문에, Compose는 내부적으로 Paint를 재사용하도록 최적화되어 있습니다.

LayoutNodeWrapper의 그리기 로직

각 LayoutNodeWrapper는 그리기 요청을 받으면 다음 3가지 시나리오 중 하나로 동작합니다.

  1. 그리기 레이어가 있는 경우: 해당 레이어에 그리기를 지시합니다. (LayoutNode나 Modifier 등을 그림)
  2. 그리기 레이어는 없지만 Draw Modifier가 있는 경우: 연결된 Draw Modifier들을 그립니다.
  3. 둘 다 없는 경우: 체인의 다음 래퍼에게 draw를 호출하여 그리기 과정을 이어갑니다.

RenderNodeLayer와 ViewLayer

Compose UI는 노드를 그리기 위해 두 가지 유형의 레이어를 사용합니다.

  • RenderNodeLayer: 하드웨어 가속을 지원하는 RenderNode를 사용하는 기본 방식입니다. 효율적이며 안드로이드 최신 버전에서 우선적으로 사용됩니다.
  • ViewLayer: RenderNode를 직접 사용할 수 없을 때 사용하는 대체 수단(Fallback)입니다. View를 마치 RenderNode처럼 취급하여 구현하며, 컨테이너 뷰(ViewGroup)의 자식으로 자신을 등록해 View의 그리기 시스템을 활용합니다.

RootMeasurePolicy와 상태 관찰

루트 LayoutNode는 자식들의 상태 변경을 관찰하고 필요시 다시 그리기를 수행해야 합니다. 이를 위해 AndroidComposeView는 루트 노드를 생성할 때 특별한 측정 정책과 Modifier들을 설정합니다.

override val root = LayoutNode().also {
    it.measurePolicy = RootMeasurePolicy
    it.modifier = Modifier
        .then(semanticsModifier)
        .then(_focusManager.modifier)
        .then(keyInputModifier)
    it.density = density
}

여기서 RootMeasurePolicy는 자식들을 배치할 때 placeRelativeWithLayer(0, 0)를 호출합니다. 이 메서드는 자식을 (0, 0) 좌표에 배치함과 동시에 그래픽 레이어를 도입하여, 스냅샷 상태 변경 시 자동으로 이를 감지하고 다시 그리도록 합니다.


시맨틱과 접근성 (Semantics & Accessibility)

Jetpack Compose에서의 Semantics (Semantics in Jetpack Compose)

Semantics는 사전적으로 의미론이라는 뜻이지만, 컴퓨터 공학에서는 노드를 설명하기 위한 정보나 의미라는 포괄적인 관점에서 사용됩니다.

Compose에서 컴포지션이 UI를 설명하는 트리라면, 이와 나란히 존재하는 시맨틱 트리(Semantic Tree)는 접근성 서비스나 테스트 프레임워크가 이해할 수 있는 방식으로 UI를 설명하는 또 다른 트리입니다. 이 트리의 노드들은 UI의 의미와 관련된 메타데이터를 제공합니다.

시맨틱 트리와 Owner의 역할

앞서 배웠듯 LayoutNode 계층 구조의 Owner는 시맨틱 정보와 안드로이드 SDK의 접근성 API를 연결하는 역할을 담당합니다. 노드가 추가되거나, 분리되거나, 시맨틱 정보가 변경될 때마다 Owner를 통해 시맨틱 트리가 갱신됩니다.

AndroidComposeView가 생성될 때(예: setContent 호출 시), 몇 가지 필수적인 Modifier가 설정된 루트 LayoutNode가 만들어집니다.

override val root = LayoutNode().also {
    it.measurePolicy = RootMeasurePolicy
    it.modifier = Modifier
        .then(semanticsModifier)
        .then(_focusManager.modifier)
        .then(keyInputModifier)
    it.density = density
}

여기서 설정되는 Modifier들은 접근성 및 시맨틱과 밀접한 관련이 있습니다.

  1. semanticsModifier: 시맨틱 트리를 구축하기 위한 기본 구성을 루트 노드에 추가합니다.
  2. _focusManager.modifier: 전체 컴포저블 계층 구조에서 포커스 이동과 설정을 관리합니다.
  3. keyInputModifier: 키보드 입력을 처리하고 KeyDown 이벤트를 포커스 관리자로 전달합니다.

특히 포커스와 키 입력 관리는 접근성 관점에서 매우 중요한 요소입니다.

Modifier.semantics와 구조 변경

루트가 아닌 일반 노드에 시맨틱 정보를 추가하려면 Modifier.semantics를 사용합니다.

참고로 책의 원문은 Modifier.semantics가 Composed Modifier로 구현된 버전을 설명하고 있지만, 최신 Compose에서는 성능 향상을 위해 상태가 없는(Stateless) Modifier.Node 방식으로 마이그레이션되었습니다. 아래 코드는 책에서 설명하는 레거시 구현 방식입니다.

fun Modifier.semantics(
    mergeDescendants: Boolean= false,
    properties: (SemanticsPropertyReceiver.() -> Unit)
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "semantics"
        this.properties["mergeDescendants"] = mergeDescendants
        this.properties["properties"] = properties
    }
) {
    val id = remember { SemanticsModifierCore.generateSemanticsId() }
    SemanticsModifierCore(id, mergeDescendants, clearAndSetSemantics = false, properties)
}

이 코드에서 볼 수 있듯이 기존 구현에서는 고유한 ID를 생성하고 유지하기 위해 remember가 필요했고, 이 때문에 Composed Modifier를 사용했습니다. ID는 전체 LayoutNode 계층 구조에서 고유하며 순차적으로 생성됩니다. 하지만 최신 구조에서는 LayoutNode 자체가 ID 생성을 담당하도록 리팩토링되어 컴포지션 컨텍스트나 remember에 대한 의존성이 제거되었습니다.

루트 노드의 시맨틱 설정

AndroidComposeView가 루트 노드에 할당하는 기본 시맨틱 Modifier도 유사한 방식으로 생성됩니다.

private val semanticsModifier = SemanticsModifierCore(
    id = SemanticsModifierCore.generateSemanticsId(),
    mergeDescendants = false,
    clearAndSetSemantics = false,
    properties = {}
)

커스텀 레이아웃과 접근성

Material이나 Foundation 라이브러리에서 제공하는 표준 컴포저블을 사용할 때는 시맨틱 정보가 자동으로 연결되므로 크게 신경 쓸 필요가 없습니다. 하지만 직접 커스텀 레이아웃을 작성할 때는 이러한 자동 연결이 제공되지 않습니다.

따라서 새로운 Layout을 만들 때는 반드시 적절한 시맨틱 정보를 직접 제공해야 합니다. 접근성 지원과 UI 테스트 용이성을 확보하는 것은 앱 개발에서 매우 높은 우선순위를 가져야 하기 때문입니다.

semantics 변화에 대한 알림 (Notifying about semantic changes)

Semantics의 변화가 안드로이드 시스템에 어떻게 전달되는지 알아보겠습니다.

LayoutNode 계층의 Owner는 AndroidX Core 라이브러리의 AccessibilityDelegateCompat 구현체를 사용하여 접근성 변경을 처리합니다. 이때 Owner로부터 얻은 안드로이드 Context를 통해 시스템 접근성 서비스를 받아와 사용합니다.

Semantics 변경이 감지되면 네이티브 Handler를 통해 메인 루퍼에 확인 작업이 할당되며, 다음 순서로 처리가 진행됩니다.

  1. 구조적 변화 확인: 자식이 추가되거나 제거된 경우를 말합니다. 변화가 감지되면 delegate는 conflated Channel을 사용하여 이를 알립니다. 시스템 접근성 서비스에 알리는 코드가 코루틴의 중단(suspend) 함수이기 때문입니다. 이 작업은 컴포즈 생명주기 동안 반복되며, 매 100ms마다 이벤트를 일괄 처리하여 접근성 프레임워크로 보냅니다.
  2. 속성 변화 확인: Semantics 속성이 변경된 경우입니다. 이때는 네이티브의 ViewParent#requestSendAccessibilityEvent를 사용하여 즉시 접근성 서비스에 알립니다.
  3. 목록 업데이트: 이전 Semantic 노드 목록을 현재 상태로 동기화합니다.

병합된/병합되지 않은 semantic 트리 (Merged and unmerged semantic trees)

Jetpack Compose는 병합된(merged) 트리와 병합되지 않은(unmerged) 트리, 두 가지 Semantic 트리를 제공합니다.

병합된 트리가 필요한 이유는 사용자 경험 때문입니다. 예를 들어 TalkBack 같은 접근성 도구가 목록의 각 행을 읽을 때, 행 내부의 작은 텍스트들을 개별적으로 읽는 것보다 행 전체를 하나의 그룹으로 묶어 읽어주는 것이 훨씬 자연스럽습니다.

이러한 병합은 mergeDescendants 속성을 통해 제어됩니다. 이 속성이 설정된 노드는 자신의 자손 노드들의 정보를 자신에게 병합합니다. Foundation이나 Material 라이브러리의 많은 컴포저블에는 이미 이 속성이 적용되어 있습니다. 반면 병합되지 않은 트리는 이 속성을 무시하고 모든 노드를 개별적으로 유지합니다. 접근성 도구는 필요에 따라 어떤 트리를 사용할지 결정합니다.

병합 정책의 내부 동작

Semantic 속성에는 병합 정책이 정의되어 있습니다. contentDescription을 예로 들어보겠습니다.

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

Semantic 속성은 SemanticsPropertyReceiver의 확장 프로퍼티로 정의되며, 이 블록 내부에서 값을 설정할 수 있습니다.

interface SemanticsPropertyReceiver {
    operator fun <T> set(key: SemanticsPropertyKey<T>, value: T)
}

속성을 정의하기 위해서는 키(Key)와 값(Value), 그리고 병합 정책(Merge Policy)이 필요합니다.

val ContentDescription = SemanticsPropertyKey<List<String>>(
    name = "ContentDescription",
    mergePolicy = { parentValue, childValue ->
        parentValue?.toMutableList()?.also { it.addAll(childValue) } ?: childValue
    }
)

병합 정책 람다는 속성이 자손의 값을 어떻게 처리할지 결정합니다. 위 코드의 ContentDescription은 자손들의 값을 리스트에 계속 추가하는 방식으로 병합합니다.

반면 대부분의 기본 병합 정책은 아무런 병합도 수행하지 않도록 설정되어 있습니다. 즉, 부모 값이 있다면 그 값을 유지하고 자식의 값은 무시하는 형태입니다.

더 자세한 API와 구성 옵션은 공식 문서를 참고하시기 바랍니다.

profile
할머니에게 설명할 수 없다면 제대로 이해한 게 아니다

0개의 댓글