Compose Layout의 기초를 알아보자

우발자·2026년 8월 15일

Compose 레이아웃 문서를 다시 읽다가 첫 문단에서 걸렸다.
"Compose는 상태를 UI로 바꾸는 과정을 3단계로 나눠서 처리한다"는 문장이었는데, 그동안 나는 리컴포지션만 신경 쓰고 살았지 그 뒤에 무슨 일이 벌어지는지는 제대로 생각해본 적이 없었다.

Modifier.offset(x)Modifier.offset { }이 왜 성능 차이가 나는지도 이 3단계를 알아야 설명이 되는 거였다. 그래서 정리해봤다.


🌀 Composition → Layout → Drawing

데이터 → Composition → Layout → Drawing → 화면
        (무엇을)      (어디에)   (어떻게)

Compose는 한 프레임을 그릴 때 항상 이 순서로 흐른다. 그리고 중요한 건 역방향 의존이 없다는 점이다.

즉 한 프레임 안에서 Layout 단계의 결과가 Composition으로 다시 돌아가는 일은 없다. 단방향이다.
이게 왜 중요하냐면, 역방향이 허용되는 순간 "자식 크기를 보고 부모가 다시 구성 → 그럼 자식도 다시" 같은 순환이 생기고 프레임이 언제 끝날지 보장할 수 없기 때문이다.


1️⃣ Composition — 무엇을 보여줄지

Compose 런타임이 @Composable 함수들을 실행하는 단계다.
결과물은 화면 픽셀이 아니라 레이아웃 노드 트리(layout node tree) 다. 뒤의 두 단계가 쓸 재료를 만들어두는 셈이다.

var padding by remember { mutableStateOf(8.dp) }

Text(
    text = "Hello",
    // padding을 Composition 단계에서 읽는다.
    // Modifier 객체를 만들 때 값이 필요하기 때문.
    modifier = Modifier.padding(padding)
)

여기서 padding이 바뀌면 이 Text리컴포지션 → 레이아웃 → 드로잉을 전부 다시 탄다.
가장 앞 단계에서 읽었으니 뒤에 딸린 게 다 따라오는 것이다.


2️⃣ Layout — 어디에 놓을지

Composition이 만든 노드 트리를 가지고 각 노드의 크기와 위치를 정하는 단계다. 안에서 다시 두 개의 서브 스텝으로 나뉜다.

측정(Measurement)

  1. 부모가 자식들에게 "너 얼마나 필요해?"라고 물어본다
  2. 자식이 없는 노드(leaf)는 자기 크기를 바로 정해서 보고한다
  3. 부모는 자식들 크기를 취합해서 자기 크기를 정한다

배치(Placement)

크기가 다 정해지고 나면 부모가 각 자식을 자기 기준 상대 좌표에 놓는다. 최종적으로 모든 노드가 width, height, (x, y)를 갖게 된다.

Row { Image(); Column { Text(); Text() } } 를 예로 들면 이런 순서다.

Row 측정 시작
 ├─ Image 측정 → 자식 없음, 크기 확정해서 Row에 보고
 └─ Column 측정
     ├─ Text 1 측정 → 크기 확정, Column에 보고
     ├─ Text 2 측정 → 크기 확정, Column에 보고
     ├─ Column 크기 확정 (너비 = max, 높이 = 합)
     └─ Column이 자식들을 세로로 배치
Row 크기 확정 (너비 = 합, 높이 = max) → 자식들 배치

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

여기가 Compose 레이아웃의 핵심이다. 각 노드는 측정 단계에서 딱 한 번만 방문된다.

기존 Android View는 onMeasure가 여러 번 호출될 수 있었다. RelativeLayout처럼 자식을 두 번 재는 레이아웃을 중첩하면 깊이에 따라 측정 횟수가 지수적으로 늘어났다. 뷰 계층을 깊게 만들지 말라는 얘기가 나온 이유가 이거다.

Compose는 아예 자식을 두 번 측정하는 걸 막아버린다. 커스텀 레이아웃에서 같은 자식에게 measure()를 두 번 호출하면 예외가 난다.

덕분에 얻는 게 3가지다.

  1. 노드 수에 대해 O(n) 이 보장된다
  2. 깊게 중첩해도 성능 페널티가 없다 (그래서 Compose는 뷰 계층 깊이를 걱정하지 않아도 된다)
  3. 측정 로직이 항상 한 번만 도니까 부수효과를 예측하기 쉽다

물론 "자식을 재봐야 내 크기를 정할 수 있는" 케이스는 실제로 존재한다. 그건 IntrinsicSize 같은 별도 장치로 풀도록 분리해뒀다.

상태 읽기

측정이나 배치 단계에서 상태를 읽으면 레이아웃과 드로잉만 다시 돈다. 리컴포지션은 건너뛴다.

var offsetX by remember { mutableStateOf(8.dp) }

Text(
    text = "Hello",
    modifier = Modifier.offset {
        // 이 람다는 배치 스텝에서 실행된다.
        // offsetX가 바뀌면 레이아웃부터 다시 시작.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

3️⃣ Drawing — 어떻게 그릴지

확정된 크기와 좌표를 가지고 실제로 픽셀을 찍는 단계다. 트리를 위에서 아래로 순회하면서 각 노드가 자기 자신을 그린다.

Row 가 배경을 그림
 ├─ Image 가 자기를 그림
 └─ Column 이 자기를 그림
     ├─ Text 1
     └─ Text 2
var color by remember { mutableStateOf(Color.Red) }

Canvas(modifier = modifier) {
    // 드로잉 단계에서 읽는다.
    // color가 바뀌어도 다시 그리기만 한다.
    drawRect(color)
}

color가 바뀌어도 크기나 위치는 그대로다. 그러니 Composition도 Layout도 건드릴 이유가 없다. 드로잉만 다시 돈다.


🔍 결국 핵심은 "상태를 어느 단계에서 읽느냐"

세 단계를 굳이 외워야 하는 이유가 여기 있다. Compose는 상태를 읽은 그 단계부터 뒤쪽만 다시 실행한다.

상태를 읽는 위치다시 도는 단계
Composable 함수 본문 / Modifier 인자리컴포지션 → 레이아웃 → 드로잉
offset { }, 커스텀 Layout의 측정·배치 블록레이아웃 → 드로잉
Canvas, drawBehind, graphicsLayer { }드로잉

그래서 최적화 방향은 하나다. 상태 읽기를 최대한 뒤 단계로 미룬다.

스크롤에 맞춰 이미지를 패럴랙스로 움직이는 흔한 코드를 보자.

// ❌ 값을 직접 넘기는 방식
Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        modifier = Modifier.offset(
            with(LocalDensity.current) {
                // Composition 단계에서 스크롤 값을 읽어버린다
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) { /* ... */ }
}

offset(x: Dp)는 값을 인자로 받는다. 그래서 이 값을 계산하려면 Composition 단계에서 스크롤 오프셋을 읽어야 한다.
결과적으로 스크롤 픽셀이 1 움직일 때마다 Box 전체가 리컴포지션된다. 실제로 바뀐 건 이미지 위치 하나뿐인데.

// ✅ 람다로 넘기는 방식
Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        modifier = Modifier.offset {
            // 배치 스텝에서 읽는다
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) { /* ... */ }
}

람다로 넘기면 상태를 읽는 시점이 배치 스텝으로 밀린다. 리컴포지션은 통째로 건너뛰고 레이아웃과 드로잉만 다시 돈다.
graphicsLayer { }도 같은 이유로 존재한다. alpha나 rotation을 자주 바꿀 거면 인자 버전 대신 람다 버전을 쓰면 드로잉만 다시 돈다.

Modifier에 값 대신 람다를 받는 오버로드가 있으면, 그건 대부분 "상태 읽기를 뒤로 미루라"는 신호다.


⚠️ 반대로, 단방향을 거스르면 생기는 일

레이아웃 단계에서 얻은 값을 Composition 상태로 되돌리면 순환이 생긴다.

// ❌ 하지 말 것
Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = null,
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // 레이아웃 단계의 결과가 Composition 상태를 건드린다
                imageHeightPx = size.height
            }
    )

    Text(
        text = "이미지 아래에 놓고 싶다",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

한 프레임 안에서는 역방향이 불가능하니, 이 코드는 프레임을 넘겨가며 순환한다.

  • 1프레임: imageHeightPx가 0이라 Text가 이미지 위에 겹쳐서 그려짐 → onSizeChanged가 상태 갱신
  • 2프레임: 리컴포지션되면서 그제서야 제자리로 감

결국 사용자 눈에는 UI가 한 번 튀는 게 보인다. 이런 건 Column이나 커스텀 Layout처럼 한 패스 안에서 크기를 조율해주는 도구로 풀어야 한다.


후기

그동안 "리컴포지션 = 비싼 것"이라고만 뭉뚱그려 생각했는데, 사실 리컴포지션은 3단계 중 첫 단계일 뿐이었다.
그리고 진짜 최적화 포인트는 리컴포지션을 막는 게 아니라 애초에 그 단계에서 상태를 읽지 않는 것이라는 게 이번에 제일 크게 남았다.

offset { }은 그냥 람다를 받는 편한 오버로드인 줄 알았는데, 사실은 "이 값은 배치 스텝에서 읽겠다"는 선언이었다는 것도 이제야 이해됐다. Compose API를 보는 눈이 좀 달라진 느낌이다.

다만 실제 프로젝트에서 이 차이가 체감될 만큼 무거운 화면을 아직 만들어본 적은 없다. 레이아웃 인스펙터로 리컴포지션 카운트를 찍어가며 직접 비교해봐야 확실히 와닿을 것 같다. 그건 다음에 한 번 제대로 측정해보려 한다.

참고

profile
어제보다 나은 개발자가 되자

0개의 댓글