아티클 목록으로 가기

Layout()을 활용한 복잡한 레이아웃 구현과 측정/배치 원리 이해

skydovesJaewoong Eum (skydoves)||19분 소요

Layout()을 활용한 복잡한 레이아웃 구현과 측정/배치 원리 이해

Jetpack Compose에서 복잡한 UI를 구현하다 보면, Box, Row, Column 같은 기본 레이아웃만으로는 한계를 느끼는 순간이 찾아옵니다. 물론 이러한 기본 컴포저블(composable)은 대부분의 일반적인 시나리오를 훌륭하게 처리하지만, 자식 요소의 측정 및 배치 방식을 완전히 제어해야 하는 경우가 분명히 있습니다. 바로 이때 Layout 컴포저블이 필수적인 역할을 하게 됩니다. Layout은 매일 사용하시는 Box, Row, Column을 포함한 Compose의 모든 레이아웃을 움직이는 핵심 빌딩 블록이기도 합니다.

이 글에서는 Layout 컴포저블의 내부 구조를 깊이 파헤쳐, 측정(measurement)과 배치(placement)가 내부적으로 어떻게 동작하는지 살펴보겠습니다. Compose UI 라이브러리의 실제 구현 코드를 분석하고, 제약 조건(constraint) 시스템을 이해하며, 정교한 커스텀 레이아웃을 구현하기 위한 패턴까지 학습하실 수 있습니다. 단순한 입문용 튜토리얼이 아니라, 레이아웃 시스템의 내부 원리와 이를 강력하게 만드는 설계 결정에 대한 심층 탐구라고 할 수 있습니다.

핵심 추상화 이해하기: Layout이 특별한 이유

Layout 컴포저블은 본질적으로 콘텐츠와 측정 정책(MeasurePolicy)을 받아, 특정 크기와 자식 배치를 가진 UI 요소를 만들어 내는 함수입니다. 상위 레벨 레이아웃과 구별되는 핵심 원리는 **단일 패스 측정(single-pass measurement)**과 제약 조건 기반 크기 결정(constraint-based sizing), 이 두 가지에 있습니다.

단일 패스 측정(Single-pass measurement)

단일 패스 측정이란, 레이아웃 패스(layout pass) 한 번에 각 자식 요소를 정확히 한 번만 측정한다는 의미입니다. 이러한 제약은 성능을 위해 존재합니다. 같은 자식을 여러 번 측정하면, 레이아웃 계층이 깊어질수록 복잡도가 지수적으로 증가하기 때문입니다. 결과적으로, 한 번의 패스에서 제공되는 정보만으로 모든 측정 결정을 내려야 한다는 점이 중요합니다.

Layout(content) { measurables, constraints ->
    // 각 measurable은 단 한 번만 측정 가능
    val placeables = measurables.map { it.measure(constraints) }

    // 측정 완료 후에는 Measurable이 아닌 Placeable로 작업
    layout(width, height) {
        placeables.forEach { it.place(x, y) }
    }
}

이는 기존 안드로이드 뷰 시스템과 근본적으로 다른 접근 방식입니다. 전통적인 뷰 시스템에서는 onMeasure가 다양한 MeasureSpec 설정으로 여러 번 호출될 수 있었습니다. Compose의 단일 패스 모델은 더 빠르지만, 사전에 충분한 설계가 필요합니다.

제약 조건 기반 크기 결정(Constraint-based sizing)

제약 조건 기반 크기 결정이란, 부모가 Constraints 객체를 통해 크기 기대치를 자식에게 전달하고, 자식이 Placeable 객체를 통해 선택한 크기를 응답하는 구조를 뜻합니다. 이러한 양방향 통신 방식 덕분에 사용 가능한 공간에 유연하게 적응하는 레이아웃을 구성할 수 있습니다.

부모(Parent)
    │
    ├─── Constraints(minWidth, maxWidth, minHeight, maxHeight) ───→ 자식(Child)
    │
    └─── Placeable(width, height) ←───────────────────────────────── 자식(Child)

Constraints 클래스는 minWidth, maxWidth, minHeight, maxHeight의 네 가지 값을 캡슐화합니다. 자식은 반드시 이 범위 안에서 크기를 결정해야 합니다. 이 방식은 한 번에 하나의 차원에 대한 제약 조건만 전달할 수 있었던 안드로이드의 MeasureSpec보다 훨씬 표현력이 뛰어납니다.

이러한 속성은 단순한 구현 세부 사항이 아닌, 예측 가능한 성능과 조합 가능한 레이아웃 로직을 실현하기 위한 아키텍처적 제약 조건입니다.

Layout 함수 시그니처: 커스텀 레이아웃의 구조 분석

Layout 함수의 시그니처를 분석하여 각 구성 요소를 이해해 보겠습니다.

@Composable
inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
)

세 개의 매개변수는 각각 고유한 역할을 담당합니다.

  1. content - 자식을 정의하는 컴포저블 람다입니다. 측정 단계에서 Measurable 객체로 변환됩니다.

  2. modifier - 레이아웃 자체에 적용되어 측정 및 드로잉에 영향을 미칩니다. Modifier는 제약 조건이 측정 정책(MeasurePolicy)에 도달하기 전에 이를 가로채거나 변환할 수 있습니다.

  3. measurePolicy - 레이아웃의 핵심 로직을 담당합니다. Measurable 자식과 부모의 Constraints를 전달받아, 레이아웃의 크기와 배치 로직이 담긴 MeasureResult를 반환합니다.

MeasurePolicy 인터페이스에서 실제 핵심 작업이 이루어집니다.

interface MeasurePolicy {
    fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult
}

MeasureScope 수신자(receiver)는 밀도(density) 정보와 결과를 생성하기 위한 layout() 함수를 제공합니다. measurables 리스트는 자식 컴포저블 하나당 하나의 항목을 포함하며, constraints는 부모가 허용하는 크기 범위를 나타냅니다.

실전 사례 분석: Box 구현

Compose UI 라이브러리에서 Box가 실제로 어떻게 구현되어 있는지 살펴보겠습니다. 소스 위치는 foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt입니다.

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

여기에는 여러 가지 정교한 패턴이 숨어 있습니다.

MeasurePolicy 캐싱

Box는 리컴포지션(Recomposition)이 발생할 때마다 새로운 MeasurePolicy를 생성하지 않습니다. 대신 maybeCachedBoxMeasurePolicy를 활용합니다.

private val Cache1 = cacheFor(true)
private val Cache2 = cacheFor(false)

internal fun maybeCachedBoxMeasurePolicy(
    alignment: Alignment,
    propagateMinConstraints: Boolean,
): MeasurePolicy {
    val cache = if (propagateMinConstraints) Cache1 else Cache2
    return cache[alignment] ?: BoxMeasurePolicy(alignment, propagateMinConstraints)
}

이 캐시에는 9가지 표준 정렬(TopStart, TopCenter, TopEnd 등)에 대해 propagateMinConstraints 값 두 가지를 조합한 MeasurePolicy 객체가 미리 생성되어 있습니다. 자주 사용되는 설정에 대해 새로운 객체 할당을 피할 수 있으며, 레이아웃이 빈번하게 리컴포지션되는 상황에서 의미 있는 성능 최적화가 됩니다.

BoxMeasurePolicy 구현

실제 측정 로직에서는 중요한 패턴들이 드러납니다.

private data class BoxMeasurePolicy(
    private val alignment: Alignment,
    private val propagateMinConstraints: Boolean,
) : MeasurePolicy {
    override fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints,
    ): MeasureResult {
        if (measurables.isEmpty()) {
            return layout(constraints.minWidth, constraints.minHeight) {}
        }

        val contentConstraints =
            if (propagateMinConstraints) {
                constraints
            } else {
                constraints.copyMaxDimensions()
            }
        // ... 측정 로직 계속
    }
}

패턴 1: 빈 콘텐츠 처리 - 자식이 없을 때 Box는 최소 제약 조건 크기로 자체 크기를 결정합니다. 레이아웃은 콘텐츠가 비어 있을 때도 합리적으로 동작해야 한다는 일반적인 패턴입니다.

패턴 2: 제약 조건 전파 제어 - propagateMinConstraints 매개변수는 자식에게 부모의 최소 제약 조건을 전달할지 여부를 결정합니다. false인 경우, constraints.copyMaxDimensions()를 통해 minWidth = 0, minHeight = 0인 새로운 제약 조건을 생성하여 자식에게 더 많은 유연성을 부여합니다.

matchParentSize를 위한 2단계 측정

Box에는 정교한 기능이 하나 더 있습니다. Modifier.matchParentSize()가 적용된 자식은 Box의 크기에 맞춰 자기 자신의 크기를 결정하지만, Box의 크기 결정에는 영향을 주지 않습니다. 이를 구현하려면 2단계 측정이 필요합니다.

// 먼저 matchParentSize가 아닌 자식을 측정하여 Box 크기를 결정
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
    }
}

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

여기서 핵심 패턴을 확인할 수 있습니다. 레이아웃 크기를 확정한 후에 일부 자식의 측정을 지연시킬 수 있다는 것이며, 각 자식이 여전히 정확히 한 번만 측정된다는 원칙은 그대로 지켜집니다. matchesParentSize 프로퍼티는 Modifier를 통해 부모 데이터(parent data)로 전달됩니다.

정렬을 이용한 배치

배치 단계에서는 정렬에 따라 자식의 위치가 결정됩니다.

private fun Placeable.PlacementScope.placeInBox(
    placeable: Placeable,
    measurable: Measurable,
    layoutDirection: LayoutDirection,
    boxWidth: Int,
    boxHeight: Int,
    alignment: Alignment,
) {
    val childAlignment = measurable.boxChildDataNode?.alignment ?: alignment
    val position = childAlignment.align(
        IntSize(placeable.width, placeable.height),
        IntSize(boxWidth, boxHeight),
        layoutDirection,
    )
    placeable.place(position)
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기