[Compose Internals] 4. Compose UI

벼리·2026년 1월 25일

Compose

목록 보기
4/10

지금까지 우리는 컴파일러가 어떻게 코드를 최적화하는지, 그리고 런타임이 어떻게 기본 구조를 잡고 작동하는지 배웠습니다. 이제 드디어 이 런타임 위에서 실제로 화면을 그려주는 클라이언트 라이브러리, Compose UI에 대해 알아볼 차례입니다.

시작하기에 앞서

이 책에서는 주로 Compose UI(안드로이드 및 데스크톱)를 예시로 들지만, 사실 Compose 런타임 위에서 돌아가는 라이브러리는 이것뿐만이 아닙니다. 웹을 위한 Compose for Web이나 터미널 UI를 위한 Mosaic 같은 라이브러리들도 있습니다. 원리는 비슷하므로 좋은 참고가 됩니다.

또한 책에 실린 코드는 지면 관계상 축약된 부분이 많습니다. 안드로이드 스튜디오나 오픈 소스 사이트에서 실제 코드를 찾아보며 읽으면 훨씬 깊이 있게 이해할 수 있습니다. 코드는 계속 변하므로 변경 이력을 살펴보는 것도 좋은 공부가 됩니다.

Compose UI와 런타임의 통합

Compose UI는 코틀린 멀티플랫폼 프레임워크입니다. 구글이 안드로이드와 공통 코드를 관리하고, 젯브레인스가 데스크톱 영역을 관리합니다. (참고로 웹 버전은 DOM을 사용하기 때문에 지금까지 Compose UI에 포함되지 않았습니다.)

런타임과 UI 라이브러리가 힘을 합치는 주된 목표는 결국 화면에 보여질 레이아웃 트리를 만드는 것입니다.

여기서 중요한 점은 런타임은 트리를 관리하는 로직만 알 뿐, 실제로 트리에 들어가는 노드가 무엇인지는 모른다는 것입니다. 노드의 정체(Android UI인지, HTML 태그인지 등)는 오직 클라이언트 라이브러리인 Compose UI만 알고 있습니다. 따라서 런타임은 노드를 추가하거나 삭제, 이동하는 구체적인 작업을 Compose UI에게 위임합니다.

트리 구축과 업데이트 과정

  • 초기 컴포지션(Initial Composition)
    • 처음 앱이 실행될 때 코드를 실행하여 레이아웃 트리를 구축합니다.
  • 리컴포지션(Recomposition)
    • 데이터(State)가 바뀌면 다시 코드를 실행하여 트리를 업데이트합니다.

이 과정에서 바로 화면을 바꾸는 것이 아니라, 변경해야 할 목록을 먼저 만듭니다. 그 후 Applier라는 객체가 이 변경 목록을 보고 실제 트리를 수정하여 사용자 눈에 보이는 화면을 갱신합니다.

예약된 변경 목록을 실제 트리의 변경 목록으로 매핑

컴포저블 함수가 실행되면 변경 사항들이 방출됩니다. 이때 Composition(대문자 C)이라 불리는 특별한 사이드 테이블이 사용됩니다. 이 테이블은 우리가 작성한 코드의 실행 흐름과 실제 화면의 노드 트리를 서로 연결(매핑)해 주는 지도 역할을 합니다.

보통 앱에 컴포지션이 하나만 있을 거라 생각하기 쉽지만, 실제로는 우리가 표현해야 할 노드 트리의 구조에 따라 여러 개의 컴포지션 객체가 존재할 수 있습니다. 이에 대해서는 뒤에서 더 자세히 알아보겠습니다.

Compose UI 관점에서의 Composition

Compose UI를 앱에 통합하는 가장 일반적인 방법은 안드로이드 Activity에서 setContent를 호출하는 것입니다.

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

하지만 꼭 Activity 전체를 Compose로 만들 필요는 없습니다. 기존 View 시스템(XML)을 사용하는 중간에 ComposeView를 배치하고, 그 안에서 setContent를 호출할 수도 있습니다.

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

여러 개의 독립적인 루트 Composition

setContent 함수는 새로운 루트 Composition을 생성하고, 가능한 한 재사용합니다. 루트
Composition이라 부르는 이유는 각각 독립적인 Composable 트리를 호스팅하기 때문입니다.

즉 앱 전체가 하나의 거대한 Composition 트리로 연결된 것이 아니라, setContent를 호출한 횟수만큼 서로 독립적인 트리가 만들어진다는 것입니다.

예를 들어 3개의 Fragment를 가진 앱을 상상해 봅시다. Fragment 1과 3은 Activity 전체를 Compose로 구현하여 각각 1개의 루트 Composition을 가집니다. 반면, Fragment 2는 XML 레이아웃 안에 3개의 ComposeView를 배치했다고 가정해 봅시다. 이 경우, 이 앱에는 서로 완전히 분리된 총 5개의 루트 Composition이 존재하게 됩니다. (Fragment 1, 3에서 각 1개 + Fragment 2에서 3개)

LayoutNode와 변경 사항의 방출

Composition 과정이 시작되면 Box, Column, LazyColumn 같은 Composable 함수들이 실행됩니다. 이 함수들은 UI를 그리기 위해 노드를 추가하거나, 이동하거나, 교체하는 변경 사항들을 만들어냅니다.

우리가 사용하는 대부분의 UI 요소(Material 라이브러리 등)는 결국 내부적으로 Layout이라는 공통 Composable을 사용합니다. 그리고 이 Layout은 공통적으로 LayoutNode라는 타입의 노드를 생성(방출)합니다. 즉, Compose UI에서 화면을 구성하는 기본 단위는 LayoutNode라고 볼 수 있습니다.

재사용 가능한 노드 (ReusableComposeNode)

Layout Composable 내부를 들여다보면 LayoutNode를 생성할 때 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

	// Emits a 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는 Compose 런타임이 제공하는 강력한 최적화 기능입니다.

일반적인 노드는 키(Key)나 데이터가 바뀌면 기존 노드를 버리고 새로 만듭니다. 하지만 재사용 가능한 노드는 기존 노드를 버리지 않고 내부 데이터만 업데이트하여 재활용합니다.

이 최적화는 노드가 숨겨진 내부 상태 없이, 외부에서 주입해 주는 속성(set/update)만으로 완벽하게 제어 가능할 때만 사용할 수 있습니다. LayoutNode는 이 조건에 딱 맞기 때문에 재사용 최적화를 사용합니다. 반면, AndroidView는 뷰 자체적으로 복잡한 내부 상태를 가지므로 이 최적화를 사용할 수 없고 일반적인 ComposeNode를 사용합니다.

ReusableComposeNode의 동작 방식

ReusableComposeNode는 크게 세 가지 단계로 동작합니다.

  1. factory: 노드를 처음 생성합니다. (예: LayoutNode())
  2. update: 값이 변경되었을 때만 실행되어 노드의 속성을 갱신합니다. 불필요한 연산을 막아주는 역할을 합니다.
  3. content: 이 노드의 자식 요소들을 구성합니다.

결론적으로 우리가 작성하는 수많은 UI 요소들은 LayoutNode 형태로 변환되어 여러 Composition에 추가됩니다. 하지만 Compose UI에는 LayoutNode만 존재하는 것은 아닙니다. 화면 구성을 위해 필요한 다른 종류의 노드와 Composition들도 존재하는데, 이에 대해서는 차차 알아보겠습니다.

Compose UI 관점에서의 Subcomposition

Composition은 꼭 루트(최상위) 레벨에서만 하나로 존재하는 것이 아닙니다. 필요에 따라 트리의 더 깊은 곳에서 별도의 Composition을 생성할 수 있으며, 이를 부모 Composition과 연결할 수 있습니다. 이것이 바로 Subcomposition(서브 컴포지션)입니다.

이전 섹션에서 Composition들이 트리처럼 연결될 수 있다고 배웠습니다. 각 Composition은 자신의 부모가 누구인지(CompositionContext) 알고 있습니다. 참고로 루트 Composition의 부모는 Recomposer 그 자체입니다.

Compose UI에서 굳이 비용을 들여 Subcomposition을 만드는 이유는 크게 두 가지입니다.

  1. 정보가 확실해질 때까지 컴포지션 실행을 미루기 위해 (예: 부모의 크기를 알 때까지)
  2. 하위 트리에서 완전히 다른 종류의 노드를 사용하기 위해 (예: UI 노드 대신 벡터 노드 사용)

각각의 이유를 자세히 살펴보겠습니다.

1. 초기 Composition 과정의 지연

가장 대표적인 예시는 SubcomposeLayout입니다. 일반적인 Layout과 비슷해 보이지만, SubcomposeLayout은 레이아웃 단계(Layout phase)에서 별도의 독립적인 Composition을 생성하고 실행한다는 차이점이 있습니다.

보통의 컴포지션은 즉시 실행되지만, SubcomposeLayout은 자식 요소들을 구성하기 전에 특정 계산값(주로 부모의 크기)이 나올 때까지 기다릴 수 있습니다.

이 기능을 활용한 대표적인 컴포저블이 바로 BoxWithConstraints입니다. BoxWithConstraints는 부모로부터 허용된 크기(제약 조건)를 먼저 확인한 뒤, 그 값에 따라 자식 컴포저블을 다르게 구성할 수 있습니다. 아래 예시를 보면 maxHeight 값에 따라 직사각형 하나를 보여줄지, 두 개를 보여줄지 결정합니다.

BoxWithConstraints {
    val rectangleHeight = 100.dp
    
    // 부모의 높이 제약조건(maxHeight)을 확인한 후 UI를 결정합니다
    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은 컴포지션이 일어나는 시점을 루트가 실행될 때가 아니라, 실제 레이아웃 크기를 측정하는 단계로 미룹니다.

  • 레이아웃 단계

또한 Subcomposition은 부모와 독립적으로 리컴포지션 될 수 있습니다. 레이아웃 단계에서 측정된 크기가 바뀌면 Subcomposition 내부만 다시 그려지게 됩니다. 반대로 Subcomposition 내부의 상태(State)가 바뀌면, 초기 구성 이후 부모 쪽에 리컴포지션을 요청하기도 합니다.

SubcomposeLayout은 여전히 LayoutNode(UI 노드)를 사용하므로 부모와 동일한 노드 타입을 사용합니다. 그렇다면 아예 다른 타입의 노드를 쓸 수는 없을까요? 기술적으로 Applier가 지원한다면 가능합니다. 이것이 바로 두 번째 사용 사례입니다.

2. 서브 트리의 노드 타입 변경

Compose UI에서 완전히 다른 노드 타입을 사용하는 가장 좋은 예시는 벡터 그래픽(Vector Graphics)입니다.

벡터 이미지를 그릴 때 우리는 벡터 데이터(경로, 선 등)를 트리 구조로 관리해야 합니다. 하지만 이는 일반적인 UI 레이아웃(Box, Column)과는 성격이 다릅니다. 그래서 벡터 컴포저블은 VNode(Vector Node)라는 전혀 다른 노드 타입을 사용하는 자체적인 Subcomposition을 생성합니다.

@Composable
fun MenuButton(onMenuClick: () -> Unit) {
    Icon(
        // 내부적으로 VNode 트리를 생성하는 VectorPainter를 사용합니다
        painter = rememberVectorPainter(image = Icons.Rounded.Menu),
        contentDescription = "Menu button",
        modifier = Modifier.clickable { onMenuClick() }
    )
}

위 코드에서 Icon이나 Image 같은 컴포저블은 겉보기엔 일반적인 LayoutNode를 방출합니다. 하지만 그 내부에서 작동하는 rememberVectorPainter는 벡터를 그리기 위해 별도의 Subcomposition을 만들고, 그 안에서 VNode들을 관리합니다.

이렇게 Subcomposition을 사용하면, 벡터라는 완전히 다른 세상을 구현하면서도 기존 Composition과 연결할 수 있습니다. 연결되어 있기 때문에 벡터 내부에서도 부모 Composition이 가진 테마 색상이나 화면 밀도(Density) 같은 공유 정보(CompositionLocal)에 편하게 접근할 수 있는 것입니다.

벡터를 위해 만들어진 이 Subcomposition은 벡터를 포함한 컴포저블이 화면에서 사라질 때 함께 폐기됩니다.

지금까지 루트 Composition 외에도, 필요에 따라 독립적인 Subcomposition이 트리의 하위 레벨에서 만들어질 수 있음을 배웠습니다. 이는 주로 부모의 크기를 알 때까지 UI 구성을 미루거나(BoxWithConstraints), 벡터 그래픽처럼 전혀 다른 노드 시스템을 다룰 때(VectorPainter) 사용됩니다.

이제 Compose UI의 트리 구조에 대한 이해가 끝났으니, 다음 장에서는 이렇게 만들어진 트리가 어떻게 실제 화면(픽셀)으로 구체화되는지 알아보겠습니다.

UI에 변경사항 반영하기

앞서 우리는 초기 컴포지션과 리컴포지션 과정을 통해 UI 노드가 어떻게 생성되는지 배웠습니다. 런타임은 이 계산 과정을 담당하지만, 이것만으로는 실제 화면에 아무것도 나타나지 않습니다. 런타임이 계산한 결과를 실제 사용자가 볼 수 있는 화면으로 만들어내는 과정이 필요한데, 이를 구체화(materialization)라고 부릅니다. 이 작업은 런타임이 아니라 Compose UI 같은 클라이언트 라이브러리가 담당합니다.

다양한 타입의 Applier들

이전에 Applier를 런타임과 플랫폼 사이의 연결 고리(추상화 계층)라고 설명했습니다.

런타임은 안드로이드 뷰가 무엇인지, HTML이 무엇인지 전혀 모릅니다. 대신 Applier라는 인터페이스를 통해 트리에 노드를 추가해, 삭제해 같은 명령만 내립니다. 이렇게 하면 Compose UI가 자신에게 맞는 Applier를 구현해서 플랫폼에 맞는 UI를 그릴 수 있게 됩니다.

그리고 이를 통해 플랫폼과의 통합에 사용될 자신의 노드 타입을 선택합니다. 아래는 이를 보여주는 간단한 다이어그램입니다.

AbstractApplier와 탐색 로직

Compose 런타임은 AbstractApplier라는 기본 클래스를 제공합니다. 이 클래스는 스택(Stack) 구조를 사용하여 트리를 탐색하고 관리합니다.

  • down(node): 자식 노드로 내려가면서 해당 노드를 스택에 쌓습니다.
  • up(): 부모 노드로 다시 올라가면서 스택에서 노드를 꺼냅니다.

이해를 돕기 위해 아래 코드를 예로 들어보겠습니다.

Column {
    Row {
        Text("Some text")
        if (condition) {
            Text("Some conditional text")
        }
    }
    if (condition) {
        Text("Some more conditional text")
    }
}

만약 condition 값이 바뀌면 Applier는 다음과 같이 움직이며 화면을 갱신합니다.

  1. Column으로 진입합니다 (down).
  2. Row로 진입합니다 (down).
  3. 조건에 따라 텍스트를 추가하거나 삭제합니다.
  4. 다시 Column으로 돌아옵니다 (up).
  5. 두 번째 조건부 텍스트를 추가하거나 삭제합니다.

이렇게 AbstractApplier는 노드의 종류가 무엇이든 상관없이 트리를 오르내리는 공통된 로직을 제공합니다.

Compose UI의 두 가지 Applier

Compose UI는 안드로이드와 통합하기 위해 두 가지 주요 Applier를 제공합니다.

  1. UiApplier
    • 일반적인 안드로이드 UI(Box, Column, Text 등)를 그릴 때 사용합니다. LayoutNode라는 노드 타입을 다룹니다.
  2. VectorApplier
    • 벡터 아이콘 같은 그래픽을 그릴 때 사용합니다. VNode라는 노드 타입을 다룹니다.

화면 하나를 구성할 때, 전체 레이아웃은 UiApplier가 담당하고, 그 안의 아이콘 그림은 VectorApplier가 담당하는 식으로 두 Applier가 함께 사용되기도 합니다.

트리 구축 전략: 상향식 vs 하향식

흥미로운 점은 UiApplier와 VectorApplier가 트리를 조립하는 순서가 다르다는 것입니다. 이는 3장에서 다뤘던 성능 문제와 관련이 있습니다.

  1. UiApplier의 상향식(Bottom-up) 전략
    • 자식 노드들을 먼저 조립한 뒤, 완성된 덩어리를 부모에게 한 번에 갖다 붙이는 방식입니다.
    • 안드로이드 UI는 레이아웃이 깊게 중첩되는 경우가 많습니다. 만약 부모를 먼저 만들고 자식을 하나씩 붙이면, 자식이 붙을 때마다 부모와 그 조상들에게 변경 알림이 계속 가게 됩니다. 상향식을 사용하면 이런 중복 알림을 피할 수 있어 성능에 훨씬 유리합니다.
  2. VectorApplier의 하향식(Top-down) 전략
    • 부모를 먼저 만들고 자식을 하나씩 추가하는 방식입니다.
    • 벡터 그래픽은 UI 레이아웃처럼 복잡하게 알림을 전파할 필요가 없습니다. 그래서 굳이 복잡한 상향식을 쓰지 않고, 직관적인 하향식 방식을 사용합니다.

요약하자면, Compose UI는 상황에 맞는 Applier를 사용하여 런타임의 명령을 실제 화면(픽셀)으로 구체화하며, 각 노드의 특성에 맞는 최적의 방법으로 트리를 구성합니다.

새로운 LayoutNode를 구체화 하기

이전 섹션에서 우리는 Applier가 노드 트리를 조작한다는 것을 배웠습니다. 이제 Compose UI가 사용하는 UiApplier가 내부적으로 어떻게 구현되어 있는지, 그리고 실제로 노드가 어떻게 화면에 나타나는지 알아보겠습니다.

아래는 UiApplier의 핵심 동작을 보여주는 간소화된 코드입니다.

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

    // 하향식(Top-Down) 삽입은 무시합니다. 
    // UiApplier는 성능을 위해 상향식(Bottom-Up)으로 트리를 구축하기 때문입니다.
    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()
    }
    // ...
}

이 코드를 보면 두 가지 중요한 특징을 알 수 있습니다.

  1. 노드 타입이 LayoutNode로 고정되어 있습니다.
  2. 삽입, 제거, 이동 같은 실제 작업은 Applier가 직접 하지 않고, 현재 방문 중인 노드(current)에게 위임합니다.

이렇게 구현된 이유는 LayoutNode가 스스로를 어떻게 관리하고 구체화해야 하는지 가장 잘 알고 있기 때문입니다. 덕분에 런타임은 복잡한 플랫폼 로직을 몰라도 됩니다.

LayoutNode와 Owner의 관계

LayoutNode는 순수한 코틀린 클래스입니다. 안드로이드에 대한 의존성이 전혀 없기 때문에 안드로이드뿐만 아니라 데스크톱 등 다른 플랫폼에서도 재사용될 수 있습니다.

LayoutNode들은 부모-자식 관계로 연결되어 거대한 트리를 형성합니다. 그리고 이 모든 노드는 공통적으로 하나의 Owner에게 연결되어 있습니다.

Owner는 플랫폼과의 연결점(접착제) 역할을 하는 추상화된 개념입니다. 안드로이드에서 Owner의 구현체는 AndroidComposeView입니다. 이 클래스는 안드로이드의 View 시스템과 우리의 LayoutNode 트리 사이를 이어줍니다.

노드가 추가되거나 변경되면 Owner에게 신호를 보냅니다. 그러면 Owner는 안드로이드 시스템에 화면 갱신(invalidation)을 요청하고, 다음 프레임에 변경된 UI가 그려지게 됩니다. 이것이 Compose UI가 화면을 그리는 마법의 원리입니다.

노드 삽입의 내부 과정 (insertAt)

새로운 노드가 트리에 추가될 때 어떤 일이 일어나는지 insertAt 함수의 내부를 살펴보겠습니다.

internal fun insertAt(index: Int, instance: LayoutNode) {
    // 1. 유효성 검사: 이미 부모나 Owner가 있는 노드는 삽입할 수 없습니다.
    check(instance._foldedParent == null) { "Cannot insert..." }
    check(instance.owner == null) { "Cannot insert..." }

    // 2. 부모-자식 관계 설정
    instance._foldedParent = this
    _foldedChildren.add(index, instance)
    
    // 3. Z-Index(그리는 순서) 재정렬을 위해 invalidate
    onZSortedChildrenInvalidated()

    // 4. 레이아웃 래퍼 연결 (Coordinator 관련 설정)
    instance.outerLayoutNodeWrapper.wrappedBy = innerLayoutNodeWrapper

    // 5. Owner 연결 (가장 중요한 단계!)
    val owner = this.owner
    if (owner != null) {
        instance.attach(owner)
    }
}
  1. 관계 설정: 현재 노드를 새 노드의 부모로 설정하고, 자식 목록에 추가합니다.
  2. Z-Index 정렬
    • 화면에 그릴 순서를 다시 계산하기 위해 정렬 목록을 갱신합니다. (Modifier.zIndex 등으로 순서가 바뀔 수 있기 때문입니다.)
  3. Owner 연결
    • 부모가 이미 Owner(안드로이드 뷰)에 연결되어 있다면, 새로 들어온 자식 노드도 즉시 그 Owner에 연결(attach)합니다.

(참고: 코드에 나오는 LayoutNodeWrapper는 최신 버전에서 NodeCoordinator로 이름이 변경되었습니다.)

Owner에 연결하기 (attach)

노드가 Owner에 연결될 때(attach) 수행되는 작업은 매우 중요합니다.

internal fun attach(owner: Owner) {
    // 1. 부모와 다른 Owner에 붙으려고 하면 에러를 발생시킵니다.
    check(_foldedParent == null || _foldedParent?.owner == owner) { ... }
    
    // 2. Owner 할당
    this.owner = owner

    // 3. 의미론(Semantics) 정보 전달 (접근성 등을 위해)
    if (outerSemantics != null) {
        owner.onSemanticsChange()
    }
    owner.onAttach(this)

    // 4. 자식들에게도 전파 (재귀 호출)
    _foldedChildren.forEach { child ->
        child.attach(owner)
    }

    // 5. 재측정(Remeasure) 요청 -> 화면 갱신 트리거
    requestRemeasure()
    parent?.requestRemeasure()
}

attach 함수는 재귀적으로 호출되어, 하위 트리에 있는 모든 노드가 동일한 Owner를 바라보게 만듭니다. 또한 접근성 서비스 등을 위한 의미론적(Semantics) 정보가 있다면 Owner에게 알려줍니다.

마지막으로 가장 중요한 작업인 재측정(requestRemeasure)을 요청합니다. 이는 "나 새로 들어왔으니까 크기랑 위치 다시 계산해서 화면에 그려줘!"라고 Owner(안드로이드 뷰)에게 알리는 것입니다.

전체 과정의 마무리

새로운 노드에 따른 변경사항이 사용자에게 전달될 수 있도록 노드가 삽입되고 구체화되는 과정을 다시 정리해보겠습ㄴ디ㅏ.

  1. 시작
    • setContent를 호출하면 AndroidComposeView(Owner)가 생성되고 안드로이드 화면에 붙습니다.
  2. 노드 추가
    • Applier가 새로운 LayoutNode를 트리에 추가(insertAt)합니다.
  3. 연결 및 요청
    • 새 노드는 Owner에 연결(attach)되면서 재측정을 요청합니다.
  4. 안드로이드 시스템 알림
    • Owner는 안드로이드 시스템에 invalidate()를 호출하여 "화면이 더러워졌으니(dirty) 다시 그려야 해"라고 알립니다.
  5. 그리기 수행
    • 안드로이드 시스템이 다음 그리기 타이밍에 dispatchDraw()를 호출합니다.
  6. Compose 그리기
    • 이때 Compose UI는 예약된 모든 노드의 크기를 다시 측정(Measure)하고, 위치를 잡고(Place), 마지막으로 그립니다(Draw).
    • 이러한 재측정 중에 루트 노드의 크기가 변경되면 AndroidComposeView#requestLayout()이 호출됩니다. 이를 통해 onMeasure를 다시 트리거하고 모든 형제 View들의 크기에 영향을 끼치도록 할 수 있습니다.

이 일련의 과정을 통해 코드로 작성한 UI 변경 사항이 실제 사용자의 눈앞에 픽셀로 나타나게 됩니다.

노드를 제거, 이동, 전체 삭제할 때의 변경 사항 구체화

새로운 노드를 추가하는 것뿐만 아니라, 기존 노드를 제거하거나 이동하는 작업도 결국 화면에 반영되어야 합니다.

  1. 노드 제거 (Removing nodes)
    • 여러 개의 자식 노드를 지울 때, UiApplier는 removeAt 함수를 호출하여 현재 노드(부모)에게 작업을 맡깁니다.
    • 부모 노드는 뒤에서부터(마지막 인덱스부터) 자식들을 하나씩 지웁니다.
      1. 자식 목록에서 제거
      2. Z-Index 정렬 목록 갱신
      3. Owner와의 연결 끊기 (detach)
      4. 부모 노드에 대한 재측정 요청
  2. 노드 이동 (Moving nodes)
    • 노드를 이동시킨다는 것은 자식들의 순서를 바꾼다는 뜻입니다.
    • move 함수가 호출되면, 내부적으로는 해당 노드를 제거(removeAt)한 뒤 새로운 위치에 다시 삽입하는 방식으로 동작합니다. 마지막에는 역시 부모 노드에 재측정을 요청합니다.
  3. 전체 삭제 (Clearing nodes)
    • 모든 자식을 한 번에 지우는 것도 여러 노드를 제거하는 과정과 동일합니다.
    • 마지막 자식부터 차례대로 연결을 끊고 제거한 뒤, 부모에게 재측정을 요청합니다.

Compose UI에서의 측정

이제 가장 핵심적인 측정(Measure) 과정에 대해 알아보겠습니다.

재측정 요청과 처리 흐름

어떤 LayoutNode든 변경이 생기면 Owner(안드로이드 뷰)에게 "나 다시 재야 해!"(requestRemeasure)합니다. 그러면 Owner는 화면이 갱신되어야 함(invalidate)을 안드로이드 시스템에 알립니다.

다음 그리기 타이밍(dispatchDraw)이 오면, Owner는 재측정이 필요한 노드들의 목록을 들고 순회하며 작업을 시작합니다. 각 노드에 대해 다음 3단계가 수행됩니다.

  1. 재측정(Remeasure): 크기가 바뀌었는지 다시 잽니다.
  2. 재배치(Relayout): 위치가 바뀌어야 한다면 다시 배치합니다.
  3. 연기된 측정 처리: 측정 도중에 또 다른 재측정 요청이 들어왔다면, 이를 목록에 추가하고 1번으로 돌아가 반복합니다.

측정의 핵심: LayoutNodeWrapper 체인

LayoutNode를 측정하는 일은 단순히 그 노드 하나만 보는 것이 아닙니다. 여기에 적용된 Modifier들(padding, background 등)도 크기에 영향을 주기 때문입니다.

예를 들어 Modifier.padding(8.dp).background(Color.Red)의 경우 패딩을 적용한 후 남은 공간에만 색을 입힙니다. 이 모든 것은 modifier의 측정된 크기를 어딘가에 보관할 필요가 있다는 것을 의미합
니다. 하지만 Modifier는 상태가 없으므로 상태를 유지하려면 래퍼가 필요합니다. 이러한 이유로
LayoutNode는 내·외부 래퍼 뿐만 아니라 적용된 modifier 각각에 대한 래퍼를 가집니다.

LayoutNode는 이를 처리하기 위해 래퍼(Wrapper) 체인을 사용합니다.

  • 외부 래퍼 (Outer LayoutNodeWrapper): 노드 전체의 가장 바깥쪽 껍질입니다. 부모 입장에서 이 노드를 측정할 때 사용됩니다.
  • Modifier 래퍼들: 각 Modifier(padding, border 등)마다 래퍼가 생성되어 체인처럼 연결됩니다. 각 래퍼는 자신의 Modifier 로직(예: 패딩 추가)을 수행하고 다음 래퍼에게 측정을 넘깁니다.
  • 내부 래퍼 (Inner LayoutNodeWrapper): 모든 Modifier를 통과한 후, 실제 노드의 내용물(자식들)을 측정하고 그리기 위한 가장 안쪽의 래퍼입니다.

측정은 바깥에서 안쪽으로 파고들며 진행됩니다.

  1. 부모 노드가 자식의 외부 래퍼를 측정합니다.
  2. 외부 래퍼는 첫 번째 Modifier 래퍼를 호출합니다.
  3. 첫 번째 Modifier는 두 번째 Modifier를 호출합니다... (반복)
  4. 마지막 Modifier는 내부 래퍼를 호출합니다.
  5. 내부 래퍼는 비로소 MeasurePolicy를 사용하여 자식 노드들을 측정합니다.
  • 측정 체인 예시
    OuterWrapper -> PaddingModifier -> BackgroundModifier -> InnerWrapper -> (자식들 측정)

이 모든 LayoutNodeWrapper은 상태를 가질 수 있으며, 노드가 연결(attach)되거나 분리(detach)될 때 알림을 받아 필요한 초기화나 정리 작업을 수행합니다. (예: FocusModifier가 포커스 이벤트를 처리하는 등)

결과 반영

측정이 끝난 후, 노드의 크기가 이전과 달라졌다면 부모에게 다시 재측정을 요청합니다. 이렇게 해서 변경 사항이 트리 위쪽으로 전파될 수 있습니다.

또한 측정 과정(MeasurePolicy 실행) 중에 상태(State)를 읽었다면, Compose는 이를 기록해 둡니다. 나중에 그 상태가 바뀌면 자동으로 다시 측정을 수행하기 위해서입니다.

이제 다음 섹션에서는 노드의 크기를 결정하는 핵심 로직인 측정 정책(MeasurePolicy)에 대해 자세히 알아보겠습니다.

측정 정책 (Measuring policies)

레이아웃 노드가 화면에 그려질 크기를 결정할 때, 레이아웃을 생성할 당시에 전달된 측정 정책(Measure Policy)을 따릅니다. Compose의 Layout 함수 내부를 보면, 재사용 가능한 노드 (ReusableComposeNode)를 만들 때 외부에서 전달받은 measurePolicy를 노드에 주입하는 것을 볼 수 있습니다.

@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 자체는 어떻게 측정할지에 대한 로직을 가지고 있지 않습니다. 대신 외부에서 "이렇게 측정해!"라고 정책을 쥐어주면, 군말 없이 그 정책을 따릅니다. 따라서 측정 정책이 바뀔 때마다 노드는 다시 크기를 재야(Remeasure) 합니다.

이전에 Custom Layout을 만들어 본 적이 있다면 MeasurePolicy라는 단어가 익숙할 것입니다. Layout 함수 마지막에 붙는 람다 블록이 바로 측정 정책입니다.

Spacer의 예시: 자식이 없는 경우

가장 단순한 예시인 Spacer를 통해 측정 정책의 기본을 알아보겠습니다.

@Composable
fun Spacer(modifier: Modifier) {
    Layout({}, modifier) { _, constraints ->
        with(constraints) {
            // 고정된 크기(Constraints)가 있으면 그 크기를 쓰고, 아니면 0을 씁니다.
            val width = if (hasFixedWidth) maxWidth else 0
            val height = if (hasFixedHeight) maxHeight else 0
            layout(width, height) {}
        }
    }
}

Spacer의 측정 정책(람다)은 두 가지 정보를 받습니다.

  1. measurables: 자식 요소들의 목록 (Spacer는 자식이 없으므로 무시)
  2. constraints: 부모로부터 받은 제약 조건 (최소/최대 너비와 높이)

Spacer는 자식이 없기 때문에, 스스로 크기를 결정해야 합니다. 부모가 고정 크기(예: width=100dp)를 줬다면 그 크기를 따르고, 아니라면 0이 됩니다. 즉, Spacer는 Modifier.width() 등을 통해 외부에서 크기를 지정해주지 않으면 보이지 않게 됩니다.

Box의 예시: 자식을 포함하는 정책

자식이 있는 레이아웃은 훨씬 흥미롭습니다. Box 컴포저블의 측정 정책을 살펴보겠습니다. Box는 자식들을 겹쳐서 배치하는 레이아웃입니다.

@Composable
inline fun Box(
	modifier: Modifier = Modifier,
	contentAlignment: Alignment = Alignment.TopStart,
	propagateMinConstraints: Boolean= false,
	content: @Composable BoxScope.()> Unit
) {
	val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
	Layout(
		content = { BoxScopeInstance.content() },
		measurePolicy = measurePolicy,
		modifier = modifier
	)
}

// Box의 정책을 결정하는 부분 (간소화됨)
MeasurePolicy { measurables, constraints ->
    // 1. 자식이 없으면?
    if (measurables.isEmpty()) {
        // 부모가 허락하는 최소 크기로 설정
        return@MeasurePolicy layout(
            constraints.minWidth,
            constraints.minHeight
        ) {}
    }
    // 2. 그렇지 않은 경우에는 최소 너비와 높이 제한을 제거하여 자식들이 이에 대해 결정할 수 있도록 합니다.
    
    // ... 자식 측정 로직 (아래 계속)
}
  1. 자식에게 줄 제약 조건 준비

Box는 먼저 자식들에게 전달할 제약 조건을 다듬습니다. propagateMinConstraints 옵션에 따라 부모의 제약 조건을 그대로 전달할지, 아니면 최소 크기 제한을 풀어서(min=0) 자식들이 자유롭게 크기를 정하게 할지 결정합니다.

val contentConstraints = if (propagateMinConstraints) {
    constraints
} else {
    // 최소 크기 제한을 풀어줍니다. (자식들이 작아질 수 있도록)
    constraints.copy(minWidth = 0, minHeight = 0)
}
  1. 자식 측정 및 내 크기 결정

이제 자식들을 측정하고, 그 결과에 따라 Box 자신의 크기를 정해야 합니다. 크게 두 가지 시나리오가 있습니다.

  • 시나리오 A: 자식이 하나뿐일 때
    • 자식이 하나뿐일 때 자식을 먼저 측정해보고, 자식의 크기와 Box의 최소 크기(minWidth) 중 더 큰 값을 Box의 크기로 정합니다.
    • 즉, "나는 내 자식보다는 커야 해"라는 논리입니다. (단, 자식이 matchParentSize라면 부모 크기를 따라갑니다.)
  • 시나리오 B: 자식이 여러 개일 때
    • 자식이 여러 개일 때 먼저, matchParentSize(부모 크기 맞춤)가 아닌 일반 자식들을 먼저 측정합니다. 이 자식들 중에서 가장 큰 녀석의 크기만큼 Box를 늘립니다.
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight

// 1단계: 일반 자식들 측정하면서 Box 크기 늘리기
measurables.fastForEachIndexed { index, measurable ->
    if (!measurable.matchesParentSize) {
        val placeable = measurable.measure(contentConstraints)
        // 지금까지 본 놈들 중에 제일 큰 놈에 맞춘다
        boxWidth = max(boxWidth, placeable.width)
        boxHeight = max(boxHeight, placeable.height)
    } 
    // ...
}
  1. 부모 크기 맞춤 자식들의 측정 (2단계)

Box의 크기가 어느 정도 결정되었으므로, 이제 matchParentSize(부모 크기 꽉 채우기)를 선언했던 자식들을 측정합니다. 이 자식들에게는 방금 계산된 Box의 크기(boxWidth, boxHeight)를 정확한 크기 제약 조건으로 전달합니다.

// 2단계: 꽉 채우는 자식들 측정
if (hasMatchParentSizeChildren) {
    val matchParentSizeConstraints = Constraints(
        minWidth = boxWidth, 
        minHeight = boxHeight,
        maxWidth = boxWidth, 
        maxHeight = boxHeight
    )
    // ... 해당 자식들을 이 크기로 측정
}
  1. 배치 (Layout)

모든 자식의 크기 측정(Measure)이 끝났습니다. 이제 Box는 최종 결정된 자신의 크기(boxWidth, boxHeight)를 신고하고, 자식들을 정렬(Alignment)에 맞춰 배치(Place)합니다.

layout(boxWidth, boxHeight) {
    placeables.forEachIndexed { index, placeable ->
        // 자식들을 적절한 위치에 놓아줍니다 (TopStart, Center 등)
        placeInBox(placeable, ..., boxWidth, boxHeight, alignment)
    }
}

요약

측정 정책(MeasurePolicy)은 "자식들을 어떻게 측정하고, 그 결과 내 크기는 어떻게 정할 것이며, 마지막으로 자식들을 어디에 놓을 것인가"를 정의하는 규칙입니다.

Spacer는 자식이 없어 단순하게 제약 조건만 따르고, Box는 자식들의 크기를 먼저 잰 뒤 가장 큰 자식에 맞춰 몸집을 불리는 전략을 사용합니다. 모든 레이아웃(Column, Row 등)은 각자만의 이러한 측정 정책을 가지고 있습니다.

고유 크기 측정

MeasurePolicy에는 레이아웃의 고유 크기(Intrinsic Size)를 미리 계산하는 메서드들이 포함되어 있습니다. 여기서 고유 크기란, 부모로부터 아직 제약 조건(Constraints)을 받지 않았을 때 레이아웃이 생각하는 자신의 이상적인 추정 크기를 말합니다.

왜 고유 크기 측정이 필요할까요?

보통은 부모가 주는 제약대로 크기를 정하면 되지만, 가끔은 측정 단계 이전에 자식들의 대략적인 크기를 알아야 할 때가 있습니다.

예를 들어, 수평으로 나열된 여러 개의 카드 중 가장 키가 큰 카드의 높이에 맞춰 나머지 카드들의 높이를 똑같이 늘리고 싶다고 가정해 봅시다. 그러려면 측정하기도 전에 누가 제일 키가 큰지 알아야 합니다.

한 가지 방법은 Subcomposition을 쓰는 것이지만 이는 비용이 많이 듭니다. 그렇다고 뷰를 두 번 측정(Double Measurement)하는 것은 불가능합니다. Compose는 성능 최적화를 위해 무조건 단 한 번의 측정(Single Pass)만 허용하며, 두 번 측정하려고 하면 강제로 예외를 발생시킵니다.

이때 고유 크기 측정은 아주 훌륭한 타협책입니다. 실제로 렌더링하기 위한 정밀한 측정은 아니지만, 가볍게 대략적인 측정 값을 계산합니다.

4가지 고유 크기 계산 메서드

고유 크기를 계산할 때는 보통 반대쪽 치수를 힌트로 줍니다. (예: "높이가 이만할 때 너비는 얼마가 필요하니?")

  1. minIntrinsicWidth: (주어진 높이에서) 콘텐츠를 제대로 그리기 위한 최소한의 너비
  2. minIntrinsicHeight: (주어진 너비에서) 콘텐츠를 제대로 그리기 위한 최소한의 높이
  3. maxIntrinsicWidth: (주어진 높이에서) 가장 넓게 폈을 때의 너비 (이 이상 넓혀도 콘텐츠 너비가 늘어나지 않음)
  4. maxIntrinsicHeight: (주어진 너비에서) 가장 길게 늘렸을 때의 높이

실제 사용 예시: Modifier.width(IntrinsicSize)

이 개념을 가장 쉽게 접할 수 있는 것이 바로 Modifier.width(IntrinsicSize.Min 또는 Max)입니다. 일반적으로 Modifier.width(100.dp)는 크기를 숫자로 고정하지만, IntrinsicSize를 사용하면 "내부 콘텐츠의 고유 크기에 맞춰줘"라고 선언하는 것입니다.

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

만약 Modifier.width(IntrinsicSize.Max)를 사용하면, 내부적으로 MaxIntrinsicWidthModifier가 동작합니다. 이 Modifier는 들어오는 제약 조건(Constraints)을 무시하고, 자식이 계산한 최대 고유 너비(maxIntrinsicWidth)를 찾아 그 값을 자신의 고정 너비로 사용합니다.

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에서 어떻게 보이는지 이해하기 가장 좋은 예시는 드롭다운 메뉴입니다. 드롭다운 메뉴를 열면, 메뉴의 너비는 메뉴 항목(Text) 중 글자가 가장 긴 항목의 너비에 딱 맞춰집니다.

@Composable
fun DropdownMenuContent(...) {
    // ...
    Column(
        modifier = modifier
            .padding(vertical = DropdownMenuVerticalPadding)
            // Column의 너비를 자식들 중 가장 넓은 놈(Max)에 맞춥니다.
            .width(IntrinsicSize.Max) 
            .verticalScroll(rememberScrollState()),
        content = content
    )
    // ...
}

위 코드에서 Column에 IntrinsicSize.Max를 적용했습니다. 이렇게 하면 Column은 자식들(메뉴 아이템들)에게 "너희들 중 가장 넓은 너비가 얼마야?"라고 미리 물어봅니다(고유 크기 측정). 그리고 그중 가장 큰 값으로 Column 전체의 너비를 결정합니다.

결과적으로 짧은 메뉴 항목들도 가장 긴 메뉴 항목의 너비만큼 공간을 차지하게 되어 깔끔한 드롭다운 UI가 완성됩니다

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

레이아웃 제약 조건(Constraints)은 부모 레이아웃이나 Modifier가 자식에게 전달하는 크기의 규칙입니다. 자식 레이아웃은 이 규칙 안에서만 자신의 크기를 결정할 수 있습니다.

제약 조건은 너비와 높이의 최소값과 최대값으로 구성되며, 측정된 자식의 크기는 반드시 이 범위 안에 있어야 합니다.

  • minWidth <= 선택된 너비 <= maxWidth
  • minHeight <= 선택된 높이 <= maxHeight

대부분의 레이아웃은 부모에게 받은 제약 조건을 그대로 자식에게 전달하거나, 최소 크기 제약만 0으로 풀어주는(완화하는) 방식을 사용합니다. 후자의 예로는 측정 정책에 대해 읽을 때 배운 Box가 있습니다.

무한대 제약 조건 (Constraints.Infinity)

때로는 부모가 자식에게 네가 원하는 만큼 크기를 가져가라고 말하고 싶을 때가 있습니다. 이때 사용하는 것이 바로 Constraints.Infinity(무한대)입니다.

보통 자식에게 최대 크기 제한을 두지 않음으로써, 자식이 스스로 필요한 만큼의 크기를 계산해서 알려달라는 신호로 사용됩니다.

예를 들어, 아래 코드를 살펴봅시다.

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

일반적인 상황에서 이 Box는 화면의 세로 공간을 가득 채웁니다. 하지만 만약 이 코드가 스크롤이 가능한 LazyColumn 안에 있다면 어떻게 될까요?

LazyColumn은 세로로 무한히 스크롤 될 수 있으므로, 자식에게 높이 제약으로 무한대(Infinity)를 전달합니다. 무한대를 가득 채운다는 것은 불가능하므로, Box는 fillMaxHeight 명령을 무시하고 내부 콘텐츠인 Text의 크기에 맞춰 자신의 크기를 줄입니다(wrap content).

LazyColumn에서의 제약 조건 활용

LazyColumn은 무한대 제약 조건을 이해하기 가장 좋은 예시입니다. LazyColumn은 내부적으로 LazyList를 사용하며, 화면에 보일 아이템을 측정할 때 다음과 같이 제약 조건을 생성합니다.

// 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
)

세로 스크롤(Vertical)인 경우, 너비는 화면 크기로 제한하지만 높이는 Infinity로 설정합니다. 따라서 리스트의 아이템들은 높이 제한 없이 자신의 콘텐츠에 맞는 높이를 자유롭게 결정할 수 있습니다.

고정된 제약 조건 (Exact Constraints)

반대로 부모가 자식의 크기를 강제로 지정해야 할 때도 있습니다. 이때는 최소값과 최대값을 똑같이 설정합니다.

  • minWidth == maxWidth
  • minHeight == maxHeight

이렇게 하면 자식은 선택의 여지 없이 부모가 정해준 정확한 크기에 맞춰야 합니다.

예를 들어 그리드 레이아웃(LazyVerticalGrid)을 생각해 봅시다.

💡 참고: 최신 버전에서는 구현 방식이 LazyLayout으로 변경되었음

GridLayout은 각 열(Column)의 너비를 정확히 계산한 뒤, 자식 아이템에게 그 너비에 딱 맞추라고 강요합니다.

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

이 코드는 다음과 같은 제약 조건을 생성합니다.

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

즉, 너비는 계산된 값으로 고정하고, 높이는 자식이 자유롭게 결정하도록 하는 것입니다.

제약 조건의 구현

참고로 Constraints는 성능 최적화를 위해 인라인 클래스(inline class)로 구현되어 있습니다. 4가지 값(min/max 너비, min/max 높이)을 별도의 객체가 아닌 단일 Long 값에 비트마스크(Bitmask) 방식으로 저장하여 메모리 할당을 줄이고 효율성을 높였습니다.

LookaheadLayout(LookaheadScope)

Lookahead는 말 그대로 앞을 미리 내다보는 것을 의미합니다. UI 관점에서는 레이아웃이 최종적으로 어떤 크기와 위치를 가질지 미리 계산해보는 것을 뜻합니다.

LookaheadLayout을 여기서 소개하는 것은 매우 자연스러운데, 이것은 Compose에서의 측정과 배치에 전적으로 관련되어 있기 때문입니다.

https://x.com/i/status/1531364543305175041

왜 미리 계산(Lookahead)해야 할까요?

화면이 바뀔 때 요소들이 뚝 하고 끊기며 바뀌는 것이 아니라, 부드럽게 크기가 변하거나 위치가 이동하는 애니메이션을 만들고 싶을 때가 있습니다.

예를 들어, 작은 사각형이 갑자기 큰 사각형으로 변한다고 상상해 보세요. 단순히 크기를 바꾸면 순간적으로 팍! 하고 커집니다. 하지만 우리가 원하는 것은 부드럽게 커지는 것입니다.

이를 위해서는 애니메이션을 시작하기 전에 두 가지를 알아야 합니다.

  1. 지금 크기 (시작점)
  2. 변하고 난 뒤의 최종 크기 (목표점)

LookaheadScope는 바로 이 목표점(최종 크기와 위치)을 미리 계산해 줍니다. 덕분에 우리는 "아, 결국엔 저 크기가 될 거니까, 지금은 중간 단계 크기로 그려야지"라고 판단해서 부드러운 애니메이션을 만들 수 있습니다.

실제 코드를 사용한 예를 살펴보겠습니다.

@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)
			}
		}
	}
}

두 상태 사이의 텍스트들은 완전히 동일하기 때문에 이상적으로는 공유 요소(shared element)
가 되어야 합니다. 또한, 그들을 쉽게 애니메이션할 수 있는 방법이 있으면 좋을 것입니다(현재는
즉시 변경됩니다). 그에 더하여, 애니메이션 중/후에 그들의 상태를 잃지 않고 재사용할 수 있도록
movableContentOf를 사용하는 것도 좋을 것입니다.

아래 예제는 위의 예시를 더 확장한 버전입니다.

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

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

복잡한 화면 간 이동 시, 도착할 화면에서의 정확한 위치와 크기를 미리 알기 어렵습니다. 이 때문에 개발자들은 종종 임의의 숫자(매직 넘버)를 억지로 끼워 넣어 애니메이션 목표를 설정하는 비효율적인 방식을 사용하곤 했습니다.

하지만 LookaheadLayout을 사용하면 시스템이 사전에 도착지의 크기와 위치를 미리 계산하여 제공해 줍니다. 덕분에 개발자는 임의의 숫자에 의존하지 않고도 정확하고 자연스러운 애니메이션을 구현할 수 있습니다.

다른 사전 계산 방식과의 차이점

Compose에는 미리 계산하는 다른 방법들도 있지만, 목적이 다릅니다.

  1. SubcomposeLayout:
    • "공간이 얼마나 남았는지 보고 뭘 그릴지 정하자."
    • 조건부 구성 - 비용이 비쌉니다.
  2. 고유 크기 측정 (Intrinsics)
    • "너네 대충 얼마나 클 것 같아?"
    • 대략적 견적 - 정확한 배치보다는 크기 협상용입니다.
  3. LookaheadScope
    • "애니메이션을 위해 최종 결과를 정확히 미리 계산하자."
    • 애니메이션 전용이며, 캐싱을 통해 효율적으로 동작합니다

LookaheadScope의 동작 원리

LookaheadScope 안에서는 매 프레임마다 두 번의 단계를 거친다고 이해하면 쉽습니다.

  1. 선제적 패스 (Lookahead Pass)
    • 애니메이션이 다 끝났다고 가정하고, 최종적으로 배치될 크기와 위치를 미리 싹 계산합니다. (미래를 봅니다.)
  2. 접근 (Actual Pass)
    • 미리 계산된 정보(미래)를 바탕으로, 현재 프레임에 보여줄 모습을 그립니다. "미래에는 너비가 200이 되겠지만, 지금은 애니메이션 중이니까 150만큼만 그리자"라고 조절하는 것입니다.

이 과정에서 Modifier.approachLayout(구 intermediateLayout)을 사용하여, 미리 계산된 미래의 크기를 보고 현재 크기를 조금씩 변화시킵니다.

cs.android.com

예시1: 크기 애니메이션 (animateConstraints)

크기가 변할 때 부드럽게 애니메이션하는 커스텀 Modifier를 만드는 예시입니다.

fun Modifier.animateConstraints(
    sizeAnimation: DeferredTargetAnimation<IntSize, AnimationVector2D>,
    coroutineScope: CoroutineScope,
) = this.approachLayout(
    isMeasurementApproachInProgress = { lookaheadSize ->
        // 1. 시스템이 계산한 미래의 크기(lookaheadSize)를 애니메이션의 목표로 설정합니다.
        sizeAnimation.updateTarget(lookaheadSize, coroutineScope)
        
        // 2. 애니메이션이 아직 안 끝났다면 true를 반환해 계속 갱신하게 합니다.
        !sizeAnimation.isIdle
    }
) { measurable, _ ->
    // 3. 측정 단계: 애니메이션 객체로부터 '현재 시점'의 크기를 가져옵니다.
    // updateTarget은 목표를 갱신함과 동시에 현재 값을 반환합니다.
    val (width, height) = sizeAnimation.updateTarget(lookaheadSize, coroutineScope)
    
    // 4. 현재 크기로 고정된 제약 조건을 만듭니다.
    val animatedConstraints = Constraints.fixed(width, height)
    
    // 5. 자식을 측정하고 배치합니다.
    val placeable = measurable.measure(animatedConstraints)
    layout(placeable.width, placeable.height) {
        placeable.place(0, 0)
    }
}

예시2: 위치 애니메이션

// 1. 커스텀 Modifier Node 정의
// 이 Node는 위치(Placement)가 변경될 때 애니메이션을 수행합니다.

/*
과거에는 여러 Modifier를 조합해야 했지만, 이제는 ApproachLayoutModifierNode 인터페이스를 구현한 커스텀 Node 하나로 로직을 깔끔하게 정리할 수 있습니다.
이 Node는 시스템과 대화하며 두 가지 질문에 답합니다.

1. 지금 측정(크기 변화) 애니메이션이 필요한가? (isMeasurementApproachInProgress)
2. 지금 배치(위치 이동) 애니메이션이 필요한가? (isPlacementApproachInProgress)

위치만 움직이는 경우, 1번은 false로 답하고 2번에서 애니메이션 로직을 처리하면 됩니다.
*/
class AnimatedPlacementModifierNode(var lookaheadScope: LookaheadScope) :
    ApproachLayoutModifierNode, Modifier.Node() {

    // 위치 이동을 위한 애니메이션 객체
    val offsetAnimation: DeferredTargetAnimation<IntOffset, AnimationVector2D> =
        DeferredTargetAnimation(IntOffset.VectorConverter)

    // 크기(Measurement) 애니메이션은 하지 않으므로 false 반환
    override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
        return false
    }

    // 위치(Placement) 애니메이션이 진행 중인지 시스템에 알림
    override fun Placeable.PlacementScope.isPlacementApproachInProgress(
        lookaheadCoordinates: LayoutCoordinates
    ): Boolean {
        // 1. LookaheadScope 기준의 최종 목표 위치(미래)를 계산
        val target = with(lookaheadScope) {
            lookaheadScopeCoordinates.localLookaheadPositionOf(lookaheadCoordinates).round()
        }
        
        // 2. 애니메이션 목표를 업데이트
        offsetAnimation.updateTarget(target, coroutineScope)
        
        // 3. 애니메이션이 안 끝났으면 true를 반환해 계속 갱신 유도
        return !offsetAnimation.isIdle
    }

    // 실제 측정 및 배치 로직
    override fun ApproachMeasureScope.approachMeasure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        // 자식은 제약 조건 그대로 측정 (크기는 변하지 않음)
        val placeable = measurable.measure(constraints)
        
        return layout(placeable.width, placeable.height) {
            val coordinates = coordinates
            if (coordinates != null) {
                // 1. 미래의 목표 위치 계산
                val target = with(lookaheadScope) {
                    lookaheadScopeCoordinates.localLookaheadPositionOf(coordinates).round()
                }

                // 2. 현재 애니메이션 값 가져오기
                val animatedOffset = offsetAnimation.updateTarget(target, coroutineScope)
                
                // 3. 현재 레이아웃 시스템상에서의 위치 계산
                val placementOffset = with(lookaheadScope) {
                    lookaheadScopeCoordinates
                        .localPositionOf(coordinates, Offset.Zero)
                        .round()
                }
                
                // 4. (애니메이션 위치 - 현재 시스템 위치) 차이만큼 이동시켜서 그리기
                val (x, y) = animatedOffset - placementOffset
                placeable.place(x, y)
            } else {
                placeable.place(0, 0)
            }
        }
    }
}

// 2. Modifier Element 정의 (Compose에서 쓸 수 있도록 포장)
data class AnimatePlacementNodeElement(val lookaheadScope: LookaheadScope) :
    ModifierNodeElement<AnimatedPlacementModifierNode>() {

    override fun update(node: AnimatedPlacementModifierNode) {
        node.lookaheadScope = lookaheadScope
    }

    override fun create(): AnimatedPlacementModifierNode {
        return AnimatedPlacementModifierNode(lookaheadScope)
    }
}

// 3. 사용 예시
@Composable
fun LookaheadAnimationExample() {
    val colors = listOf(Color(0xffff6f69), Color(0xffffcc5c), Color(0xff264653))
    var isInColumn by remember { mutableStateOf(true) }

    LookaheadScope {
        // Row <-> Column 전환 시 아이템들이 부드럽게 이동함
        val items = remember {
            movableContentOf {
                colors.forEach { color ->
                    Box(
                        Modifier
                            .padding(15.dp)
                            .size(100.dp, 80.dp)
                            // 여기서 커스텀 Modifier 사용
                            .then(AnimatePlacementNodeElement(this@LookaheadScope))
                            .background(color, RoundedCornerShape(20))
                    )
                }
            }
        }

        Box(Modifier.fillMaxSize().clickable { isInColumn = !isInColumn }) {
            if (isInColumn) {
                Column(Modifier.fillMaxSize()) { items() }
            } else {
                Row { items() }
            }
        }
    }
}

Lookaheadlayout의 내부 동작 원리

LookaheadScope가 겉으로 어떻게 동작하는지 이해했다면, 이제 내부에서 실제로 어떤 일이 벌어지는지 알아볼 차례입니다. 구글 제트팩 컴포즈 팀의 엔지니어 Doris Liu가 제공한 다이어그램의 개념을 빌려 설명해 드리겠습니다.

핵심은 컴포즈가 화면을 그릴 때 두 번의 단계를 거친다는 점입니다.

1. 측정 단계 (Measurement Phase)

새로운 UI 요소(LayoutNode)가 화면에 추가되거나 변경되면 측정 단계가 시작됩니다. 이때 일반적인 측정과는 다르게 두 가지 패스(Pass)가 순차적으로 실행됩니다.

  • 첫 번째: 선제적 측정 (Lookahead Pass)
    • 가장 먼저 실행되는 단계입니다.
    • LookaheadScope 안에 있는 노드들을 대상으로 미래의 최종 상태를 계산합니다.
    • 이 과정은 LookaheadDelegate라는 담당자가 처리합니다. 컴포즈의 기본적인 측정 방식처럼 Modifier 사슬을 따라 쭉 내려가며 측정하는데, 이때는 애니메이션이 완료된 최종 크기를 계산한다는 점이 다릅니다.
  • 두 번째: 실제 측정 (Actual Pass)
    • 선제적 측정이 끝나면, 이제 실제로 화면에 그릴 크기를 측정합니다.
    • 이 과정은 MeasureDelegate라는 담당자가 처리합니다. 이 단계에서 우리가 앞서 배운 Modifier.approachLayout이 동작합니다. 앞 단계에서 계산해 둔 미래의 크기(Lookahead Result)를 참고하여, 현재 시점의 크기(애니메이션 중간값)를 결정합니다.

쉽게 말해, 미래를 먼저 보고(Lookahead Pass), 그 정보를 바탕으로 현재를 결정(Actual Pass)하는 순서입니다.

2. 배치 단계 (Placement Phase)

배치 단계도 측정 단계와 똑같은 원리로 동작합니다.

  • 첫 번째: 선제적 배치 (Lookahead Placement)
    • 요소가 최종적으로 놓일 위치(좌표)를 계산합니다.
    • placeAt 함수가 호출되지만, 실제 화면에 그리기 위함이 아니라 좌표 계산이 목적입니다.
  • 두 번째: 실제 배치 (Actual Placement)
    • 계산된 미래의 좌표를 참고하여, 현재 프레임에서 요소가 실제로 위치할 곳을 정합니다.
    • 이때 위치 애니메이션이 적용되어 부드럽게 이동하는 효과가 만들어집니다.

3. 갱신 최적화 (Invalidation Optimization)

화면이 다시 그려져야 할 때(Re-measure/Re-layout), 컴포즈는 매우 효율적으로 움직입니다.

트리 구조의 일부만 변경되었다면, 전체를 다시 계산하지 않고 변경된 부분과 그 영향을 받는 노드들만 골라서 다시 계산합니다.

LookaheadScope는 이 최적화를 더 정교하게 수행합니다. 선제적 계산이 필요한 부분과 실제 계산만 필요한 부분을 구분하여, 불필요한 연산을 최소화합니다. 덕분에 애니메이션이 돌아가는 중에도 성능 저하 없이 부드러운 화면을 유지할 수 있습니다.

4. 알아두면 좋은 추가 정보

중첩 지원

새로운 LayoutNode가 추가되면 가장 가까운 바깥쪽의 LookaheadScope에 소속됩니다.(즉, 상속받고, 공유할 수 있습니다.) LookaheadScope 안에 또 다른 LookaheadScope가 있어도 문제없습니다. 컴포즈는 이를 단일 패스로 효율적으로 처리합니다.

movableContentOf 활용

애니메이션 도중 UI 구조가 바뀌어도(예: Row에서 Column으로 이동) 텍스트 입력값 같은 상태를 유지하고 싶다면 movableContentOf를 함께 사용하면 좋습니다.

Modifier 체인 모델링

Modifier는 Compose UI에서 가장 중요한 핵심 요소 중 하나입니다. 이번 장에서는 Modifier가 내부적으로 어떻게 연결되고 동작하는지 조금 더 깊이 살펴보겠습니다.

Modifier의 정의와 3가지 핵심 기능

Modifier 인터페이스는 UI 구성 요소를 꾸미거나 행동을 추가하는 설정들의 집합입니다. 이들은 불변(Immutable) 특성을 가지며, 내부적으로 다음 세 가지 기능을 제공하도록 설계되어 있습니다.

  1. 연결하기 (then): 여러 Modifier를 사슬처럼 하나로 연결합니다.
  2. 순회하기 (foldIn, foldOut): 연결된 체인을 따라가며 값을 누적하거나 처리합니다.
  3. 검사하기 (any, all): 체인 내에 특정 조건을 만족하는 Modifier가 있는지 확인합니다.

Modifier 체인의 구조: 연결 리스트

코드에서 Modifier를 연달아 작성하면, 내부적으로는 헤드(Head)가 다음 요소를 가리키는 연결 리스트(Linked List) 구조가 만들어집니다.

아래 코드를 보면 여러 Modifier가 점(.)으로 연결되어 있습니다.

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

명시적 연결과 암시적 연결

Modifier를 연결하는 방법은 두 가지입니다.

  1. 위 코드의 modifier.then(indentMod)처럼 then을 직접 호출하는 명시적 방법
  2. .fillMaxWidth()처럼 확장 함수를 사용하는 암시적 방법

사실 대부분의 Modifier 확장 함수는 내부적으로 then을 호출하도록 구현되어 있습니다. 따라서 두 방식은 완전히 동일하게 동작하며, 실제 프로젝트에서는 읽기 편한 확장 함수 형태를 주로 사용합니다.

참고: 최근 라이브러리의 Modifier들은 성능 최적화를 위해 Modifier.Node 방식으로 재작성되었습니다. 하지만 체인을 형성하는 기본 원리는 동일합니다.

Modifier.padding이 내부적으로 어떻게 구현되어 있는지 살펴보면, 결국 this.then()을 호출하여 체인을 연결하고 있음을 알 수 있습니다.

@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라는 객체가 생성됩니다. 이것이 바로 Modifier 체인의 실체입니다. CombinedModifier는 다음 두 가지 요소를 가집니다.

  • outer: 현재의 Modifier
  • inner: 체인의 그 다음 Modifier (또 다른 CombinedModifier일 수 있음)
class CombinedModifier(
    private val outer: Modifier,
    private val inner: Modifier
): Modifier

여러 Modifier를 계속 연결하면, CombinedModifier가 꼬리에 꼬리를 물고 중첩되는 구조가 됩니다. 예를 들어 A, B, C, D 순서로 연결했다면 내부 구조는 다음과 같습니다.

CombinedModifier(
    outer = a,
    inner = CombinedModifier(
        outer = b,
        inner = CombinedModifier(
            outer = c,
            inner = d
        )
    )
)

outer가 현재 노드이고 inner가 다음 노드를 감싸고 있는 형태이기 때문에, Modifier 체인은 바깥에서 안으로 파고드는 구조를 가집니다.

이제 Modifier가 어떻게 연결되는지 알았으니, 다음 섹션에서는 이 체인이 실제 화면 요소인 LayoutNode에 어떻게 적용되는지 알아보겠습니다.

LayoutNode에 modifier 설정

모든 UI 요소인 LayoutNode에는 Modifier(또는 Modifier 체인)가 할당됩니다. 우리가 Layout 컴포저블을 선언할 때, 내부적으로는 update 람다를 통해 이 설정이 이루어집니다.

아래 코드를 보면 노드가 생성되자마자 update 블록이 실행되며 측정 정책, 밀도, 방향 설정과 함께 Modifier가 설정되는 것을 볼 수 있습니다.

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

    // modifier를 구체화(materialize)하는 과정
    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 })
            // 구체화된 modifier를 노드에 설정
            set(materialized, { this.modifier = it })
        }
    )
}

구체화된(Materialized) Modifier란?

위 코드에서 modifier를 바로 할당하지 않고 currentComposer.materialize(modifier)를 거치는 것을 볼 수 있습니다. 이것은 Modifier를 두 가지 유형으로 구분하여 처리하기 위함입니다.

  1. 표준 Modifier (Standard Modifier)
    상태가 없는(Stateless) 일반적인 Modifier입니다. background나 padding처럼 단순히 속성만 변경하는 경우입니다. 이 경우 materialize 함수는 아무런 변환 없이 Modifier를 그대로 반환합니다.
  2. 구성된 Modifier (Composed Modifier)
    상태를 가진(Stateful) 특별한 Modifier입니다. Modifier 내부에서 remember를 사용하거나 CompositionLocal 값을 읽어야 할 때 사용됩니다.

Composed Modifier가 필요한 이유

LayoutNode는 기본적으로 상태를 저장하거나 Composition의 문맥을 이해하지 못합니다. 하지만 개발을 하다 보면 Modifier 내부에서 상태 관리가 필요한 경우가 생깁니다. 이때 사용하는 것이 Modifier.composed 함수입니다.

이 함수는 팩토리 람다를 통해 실행되는데, 이 람다는 Composition 컨텍스트 안에서 실행됩니다. 덕분에 remember나 LocalContext.current 같은 기능들을 사용할 수 있게 됩니다.

즉, composed modifier는 LayoutNode가 이해할 수 없는 복잡한 상태 로직을 처리한 뒤, LayoutNode가 이해할 수 있는 일반 Modifier 형태로 변환(구체화)되어 전달됩니다.

가장 대표적인 예가 clickable입니다. 클릭 상태를 추적하거나 리플 효과를 주기 위해 내부적으로 remember를 사용해야 하기 때문입니다

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) }
        
        // Composition 내부에서 상태를 처리한 후 결과 Modifier 반환
        val gesture = Modifier.pointerInput(interactionSource, enabled) {
            // ...
        }
        
        Modifier
            .then(/* 다른 modifier 추가 */)
            .genericClickableWithoutGesture(
                gestureModifiers = gesture,
                // ...
            )
    },
    inspectorInfo = {
        // 도구 지원을 위한 정보
    }
)

성능과 Modifier.composed

최근 구글 공식 문서나 가이드를 보면 Modifier.composed를 성능상의 이유로 추천하지 않는다는 내용을 볼 수 있습니다. 이는 불필요한 객체 생성을 줄이고 성능을 높이기 위해 Modifier.Node라는 새로운 API가 도입되었기 때문입니다.

하지만 Modifier.composed가 완전히 사라지는 것은 아닙니다. CompositionLocal 값을 읽거나 remember를 사용해야 하는 경우에는 여전히 Modifier.composed가 필요합니다.

실제로 최신 clickable 구현을 보면, 핵심 로직은 성능이 좋은 Modifier.Node로 이전되었지만, Indication이나 InteractionSource 같은 상태 값을 처리하기 위해 껍데기는 여전히 Modifier.composed를 사용하고 있습니다. 우리는 이를 통해 성능과 기능 사이에서 적절한 균형을 잡는 방법을 배울 수 있습니다.

참고: 루트 LayoutNode 직접 설정

때로는 Compose UI 내부적으로 루트(Root) LayoutNode를 직접 생성해야 할 때가 있습니다. 예를 들어 안드로이드 뷰 시스템과 연결되는 AndroidComposeView가 그렇습니다.

이때는 아래 코드처럼 필요한 측정 정책, 밀도, 접근성 관련 Modifier들을 수동으로 체이닝하여 설정합니다.

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

LayoutNode가 새로운 Modifier를 받아들이는 방법

LayoutNode에 설정된 Modifier가 변경되면, 컴포즈는 효율성을 위해 무작정 새로 만들지 않고 기존 객체를 재사용하려고 시도합니다. 이 과정은 크게 캐싱, 비교, 재구축 단계로 이루어집니다.

  1. 캐싱과 재사용 확인 (Diffing)
    • 새로운 Modifier 체인이 들어오면, 먼저 기존에 사용하던 Modifier들을 캐시에 저장합니다.
    • 그다음 새로운 체인의 첫 번째(Head)부터 끝까지 훑으면서(foldIn), 캐시에 있는 Modifier와 일치하는지 확인합니다.
    • 만약 일치하는 Modifier가 있다면 재사용 표시를 하고, 그 부모나 조상 Modifier들도 함께 재사용하도록 설정합니다.
  2. 래퍼 체인 재구축 (Rebuilding Wrappers)
    • 재사용 여부 확인이 끝나면, LayoutNode를 감싸는 껍질인 LayoutNodeWrapper들을 다시 만듭니다.
    • 이때는 체인의 끝(Tail)에서부터 거꾸로 올라가며(foldOut) 작업합니다. 가장 안쪽에 있는 innerLayoutNodeWrapper부터 시작해서 바깥쪽으로 감싸 나가는 방식입니다.
  3. 래퍼 생성 및 정리
    • 각 단계에서 Modifier가 재사용 가능하다면 캐시에서 꺼내 쓰고, 아니라면 Modifier 성격에 맞는 새로운 래퍼를 만듭니다.
    • 최종적으로 가장 바깥쪽 래퍼(outerLayoutNodeWrapper)까지 도달하면 체인 구성이 완료됩니다.
    • 마지막으로, 선택되지 못한 나머지 캐시된 Modifier들은 폐기하고, 새로운 래퍼들을 연결(attach)한 뒤 화면을 갱신합니다.

노드 트리 그리기 (Drawing)

측정과 배치가 끝나면 드디어 화면에 그리는 단계가 시작됩니다. 안드로이드 시스템의 View가 draw를 호출하듯, 컴포즈도 비슷한 흐름을 따릅니다.

그리기 순서

그리기는 LayoutNodeWrapper 체인을 따라 진행됩니다.

  1. 현재 노드
  2. Modifier들 (순서대로)
  3. 자식 노드들

이 순서로 전체 계층 구조를 순회하며 그립니다.

그리기 과정

어떤 노드의 상태가 변해 다시 그려야 한다면(dirty), 최상위 주인(Owner, 예: AndroidComposeView)이 이를 감지합니다.

Owner는 그리기 단계(dispatchDraw)에서 다시 그려야 할 노드들을 찾아 업데이트합니다.
그리기 명령은 루트 노드에서 시작하여(root.draw), 각 노드의 가장 바깥쪽 래퍼(outerLayoutNodeWrapper)로 위임됩니다.

캔버스와 페인트 최적화

컴포즈는 안드로이드뿐만 아니라 여러 플랫폼에서 동작해야 하므로, 추상화된 Canvas를 사용합니다. 안드로이드에서는 이를 네이티브 Canvas로 연결합니다.

중요한 최적화 포인트는 Paint 객체 관리입니다. 안드로이드에서는 그리기 함수 내에서 Paint 객체를 매번 생성하는 것이 성능에 좋지 않습니다. 그래서 컴포즈 캔버스는 API 내부적으로 Paint 객체를 알아서 재사용하도록 설계되어 있어, 개발자가 직접 Paint를 관리하는 수고를 덜어줍니다.

그리기 레이어의 종류 (RenderNodeLayer vs ViewLayer)

각 래퍼는 자신만의 그리기 레이어를 가질 수 있습니다. 컴포즈는 상황에 따라 두 가지 방식 중 하나를 선택해 그립니다.

  1. RenderNodeLayer (권장)
    • 안드로이드의 RenderNode를 이용한 방식입니다.
    • 하드웨어 가속을 적극 활용하여 한 번 그려진 내용을 매우 효율적으로 다시 사용할 수 있습니다. 컴포즈가 가장 선호하는 방식입니다.
  2. ViewLayer (대체재)
    • RenderNode를 직접 사용할 수 없는 경우에 사용하는 호환성 모드입니다.
    • 일반 안드로이드 View를 생성하여 컨테이너에 붙이는 방식으로 동작합니다. View의 기능을 빌려 쓰기 때문에 RenderNode 방식보다는 구조가 복잡하고 무겁습니다.

컴포즈는 가능하다면 항상 RenderNodeLayer를 우선 사용하고, 지원되지 않는 환경에서만 ViewLayer를 사용합니다.

상태 변화 감지와 자동 갱신

컴포즈 UI는 상태(State)가 변하면 자동으로 화면을 갱신해야 합니다. 이를 위해 루트 노드는 특별한 측정 정책(RootMeasurePolicy)을 가지고 있습니다.

이 정책은 자식 노드를 배치할 때 placeRelativeWithLayer라는 함수를 사용합니다. 이 함수는 자식을 배치함과 동시에 그래픽 레이어를 생성하는데, 이 레이어는 내부적으로 스냅샷 상태 변화를 감시합니다. 덕분에 우리가 별도로 요청하지 않아도, 상태가 바뀌면 알아서 해당 레이어가 무효화되고 다시 그려지게 됩니다.

아래는 루트 노드가 초기화될 때 기본적인 Modifier와 정책을 설정하는 코드입니다.

override val root = LayoutNode().also {
    // 루트 노드의 측정 정책 설정
    it.measurePolicy = RootMeasurePolicy
    
    // 기본적인 Modifier 체인 구성
    it.modifier = Modifier
        .then(semanticsModifier)      // 접근성 및 테스트용 의미론적 정보
        .then(_focusManager.modifier) // 포커스 관리
        .then(keyInputModifier)       // 키보드 입력 처리
        
    it.density = density
}
profile
코딩일기

0개의 댓글