
Compose 레이아웃 문서를 다시 읽다가 첫 문단에서 걸렸다.
"Compose는 상태를 UI로 바꾸는 과정을 3단계로 나눠서 처리한다"는 문장이었는데, 그동안 나는 리컴포지션만 신경 쓰고 살았지 그 뒤에 무슨 일이 벌어지는지는 제대로 생각해본 적이 없었다.
Modifier.offset(x)랑 Modifier.offset { }이 왜 성능 차이가 나는지도 이 3단계를 알아야 설명이 되는 거였다. 그래서 정리해봤다.
데이터 → Composition → Layout → Drawing → 화면
(무엇을) (어디에) (어떻게)
Compose는 한 프레임을 그릴 때 항상 이 순서로 흐른다. 그리고 중요한 건 역방향 의존이 없다는 점이다.
즉 한 프레임 안에서 Layout 단계의 결과가 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는 리컴포지션 → 레이아웃 → 드로잉을 전부 다시 탄다.
가장 앞 단계에서 읽었으니 뒤에 딸린 게 다 따라오는 것이다.
Composition이 만든 노드 트리를 가지고 각 노드의 크기와 위치를 정하는 단계다. 안에서 다시 두 개의 서브 스텝으로 나뉜다.
크기가 다 정해지고 나면 부모가 각 자식을 자기 기준 상대 좌표에 놓는다. 최종적으로 모든 노드가 width, height, (x, y)를 갖게 된다.
Row { Image(); Column { Text(); Text() } } 를 예로 들면 이런 순서다.
Row 측정 시작
├─ Image 측정 → 자식 없음, 크기 확정해서 Row에 보고
└─ Column 측정
├─ Text 1 측정 → 크기 확정, Column에 보고
├─ Text 2 측정 → 크기 확정, Column에 보고
├─ Column 크기 확정 (너비 = max, 높이 = 합)
└─ Column이 자식들을 세로로 배치
Row 크기 확정 (너비 = 합, 높이 = max) → 자식들 배치
여기가 Compose 레이아웃의 핵심이다. 각 노드는 측정 단계에서 딱 한 번만 방문된다.
기존 Android View는 onMeasure가 여러 번 호출될 수 있었다. RelativeLayout처럼 자식을 두 번 재는 레이아웃을 중첩하면 깊이에 따라 측정 횟수가 지수적으로 늘어났다. 뷰 계층을 깊게 만들지 말라는 얘기가 나온 이유가 이거다.
Compose는 아예 자식을 두 번 측정하는 걸 막아버린다. 커스텀 레이아웃에서 같은 자식에게 measure()를 두 번 호출하면 예외가 난다.
덕분에 얻는 게 3가지다.
물론 "자식을 재봐야 내 크기를 정할 수 있는" 케이스는 실제로 존재한다. 그건 IntrinsicSize 같은 별도 장치로 풀도록 분리해뒀다.
측정이나 배치 단계에서 상태를 읽으면 레이아웃과 드로잉만 다시 돈다. 리컴포지션은 건너뛴다.
var offsetX by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.offset {
// 이 람다는 배치 스텝에서 실행된다.
// offsetX가 바뀌면 레이아웃부터 다시 시작.
IntOffset(offsetX.roundToPx(), 0)
}
)
확정된 크기와 좌표를 가지고 실제로 픽셀을 찍는 단계다. 트리를 위에서 아래로 순회하면서 각 노드가 자기 자신을 그린다.
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() }
)
)
}
한 프레임 안에서는 역방향이 불가능하니, 이 코드는 프레임을 넘겨가며 순환한다.
imageHeightPx가 0이라 Text가 이미지 위에 겹쳐서 그려짐 → onSizeChanged가 상태 갱신결국 사용자 눈에는 UI가 한 번 튀는 게 보인다. 이런 건 Column이나 커스텀 Layout처럼 한 패스 안에서 크기를 조율해주는 도구로 풀어야 한다.
그동안 "리컴포지션 = 비싼 것"이라고만 뭉뚱그려 생각했는데, 사실 리컴포지션은 3단계 중 첫 단계일 뿐이었다.
그리고 진짜 최적화 포인트는 리컴포지션을 막는 게 아니라 애초에 그 단계에서 상태를 읽지 않는 것이라는 게 이번에 제일 크게 남았다.
offset { }은 그냥 람다를 받는 편한 오버로드인 줄 알았는데, 사실은 "이 값은 배치 스텝에서 읽겠다"는 선언이었다는 것도 이제야 이해됐다. Compose API를 보는 눈이 좀 달라진 느낌이다.
다만 실제 프로젝트에서 이 차이가 체감될 만큼 무거운 화면을 아직 만들어본 적은 없다. 레이아웃 인스펙터로 리컴포지션 카운트를 찍어가며 직접 비교해봐야 확실히 와닿을 것 같다. 그건 다음에 한 번 제대로 측정해보려 한다.
참고