본문 바로가기
개발/Compose

(Compose) Compose phase - 어떻게 화면을 그릴까?

by DinoDev 2025. 8. 18.
728x90
반응형

"Jetpack Compose는 선언형 UI 툴킷입니다." 우리는 이 말을 정말 많이 듣습니다. 단지 상태(State)를 선언하면 UI가 마법처럼 그려진다고 하죠. 그런데 그 '마법' 뒤에서는 어떤 일이 일어나고 있을까요? 🤔

Compose가 UI를 화면에 표시하기까지의 과정을 이해하는 것은 단순히 지식을 넘어, 성능을 최적화하고 복잡한 UI 문제를 해결하는 데 매우 중요합니다. 오늘은 Compose의 핵심 동작 원리인 3단계 렌더링 파이프라인(Composition, Layout, Drawing)에 대해 알아보겠습니다.

1단계: Composition - 무엇을 보여줄까?

"어떤 UI를 보여줄지 결정하는 단계"

가장 먼저 Compose는 Composition 단계를 실행합니다. 이 단계에서 Compose 런타임은 우리가 작성한 @Composable 함수들을 호출합니다. 그리고 그 결과로 UI에 대한 '설계도'를 만듭니다. 이 설계도는 단순한 그림이 아니라, UI의 구조와 내용을 담고 있는 데이터 트리(UI Tree)입니다.

  • 최초 컴포지션 (Initial composition): 앱이 처음 실행될 때, Compose는 모든 관련 컴포저블 함수를 실행하여 전체 UI 트리를 구축합니다.
  • 리컴포지션 (Recomposition): 앱의 상태가 변경되면, Compose는 똑똑하게 해당 상태와 관련된 컴포저블 함수만 다시 호출하여 UI 트리의 필요한 부분만 업데이트합니다.

핵심: 이 단계는 오직 '무엇을' 보여줄지에만 관심이 있습니다. 각 UI 요소가 화면 어디에, 어떤 크기로 그려질지는 전혀 신경 쓰지 않습니다.

@Composable
fun Greeting(
    name: String
) {
    // 1. Composition 단계에서 이 Text 함수가 호출됩니다.
    // 2. "Hello $name!" 이라는 내용을 가진 UI 노드가
    //    UI 트리에 생성됩니다.
    Text(
        text = "Hello $name!"
    )
}

2단계: Layout - 어디에 배치할까?

"UI 요소의 크기와 위치를 결정하는 단계"

Composition 단계에서 '무엇을' 보여줄지 결정했다면, Layout 단계에서는 이것들을 '어디에, 얼마나 크게' 배치할지 계산합니다. 이 과정은 모든 UI 노드에 대해 측정과 배치를 수행하며, 2개의 단계(step)로 진행됩니다.

  1. 측정 (Measurement): 부모 노드가 자식 노드에게 "이런 제약 조건 안에서 얼마나 크기가 필요하니?"라고 묻습니다. 자식은 자신의 크기를 측정하여 부모에게 보고합니다.
  2. 배치 (Placement): 부모는 자식들의 크기 정보를 바탕으로, 각 자식을 자신의 좌표계 내의 정확한 x, y 위치에 배치합니다.

핵심: Modifier.size(), Modifier.padding() 등 레이아웃과 관련된 모든 수정자는 바로 이 단계에서 작동합니다. 부모가 주는 제약 조건(Constraints)과 자식 스스로의 요구사항(Intrinsic size)이 결합하여 최종 크기와 위치가 결정됩니다.

@Composable
fun UserProfile(
    name: String
) {
    // 1. Row가 Text에게 "얼마나 필요해?"라고 묻습니다.
    // 2. Text는 "Hello $name!"을 표시하는데 필요한 너비와 높이를 계산해 응답합니다.
    // 3. Row는 Text를 자신의 시작점(x=0, y=0)에 배치합니다.
    Row(
        modifier = Modifier.padding(
            all = 16.dp
        )
    ) {
        Text(
            text = "Hello $name!"
        )
    }
}

3단계: Drawing - 어떻게 그릴까?

 

"화면에 실제로 픽셀을 그리는 단계"

이제 모든 UI 요소의 종류, 크기, 위치를 알게 되었습니다. 마지막 Drawing 단계에서는 이 정보를 이용해 화면의 캔버스(Canvas)에 실제로 픽셀을 그립니다. 각 요소는 Modifier.drawBehind나 Modifier.border 같은 수정자를 통해 자신을 어떻게 그려야 하는지에 대한 지침을 가집니다.

핵심: Compose는 이 단계에서도 매우 효율적입니다. 만약 어떤 요소의 색상이나 내용이 아니라 위치만 변경되었다면, Compose는 해당 요소의 픽셀 정보를 다시 그릴 필요 없이 기존의 그래픽 데이터를 그대로 이동시켜 사용하기도 합니다.

⚠️ 주의: 리컴포지션 루프를 피하세요 (Recomposition Loop)

리컴포지션 루프란 이름 그대로 컴포저블이 스스로를 끊임없이 재호출하며 멈추지 않는 상태를 말합니다. 앱은 아무런 반응 없이 얼어붙고, CPU 사용량은 치솟게 되죠.

이 현상은 상태를 읽는(read) 단계에서 그 상태를 다시 쓰는(write) 경우에 발생합니다. 특히 Composition 단계뿐만 아니라 Layout 단계에서 상태를 변경할 때 미묘한 루프가 발생하기 쉽습니다.

❌ 잘못된 예시: 형제 컴포저블의 크기로 레이아웃 변경 시도

아래 코드는 이미지의 실제 높이를 측정하여 그 아래에 있는 텍스트의 상단 패딩을 동적으로 지정하려고 합니다. 하지만 이 구조는 즉시 리컴포지션 루프를 유발합니다.

@Composable
fun BadExample() {
    Box {
        var imageHeightPx by remember { mutableStateOf(0) }

        Image(
            painter = painterResource(R.drawable.rectangle),
            contentDescription = "I'm above the text",
            modifier = Modifier
                .fillMaxWidth()
                .onSizeChanged { size ->
                    // ❌ 나쁨: Layout 단계에서 측정한 값을 State에 기록합니다.
                    imageHeightPx = size.height
                }
        )
        Text(
            text = "I'm below the image",
            modifier = Modifier.padding(
                // 이 Text는 imageHeightPx 상태를 읽고 있습니다.
                // 위 Image에서 이 값이 변경되면 Text가 리컴포지션되고,
                // 전체 프로세스가 다시 시작되어 루프가 발생합니다.
                top = with(LocalDensity.current) { imageHeightPx.toDp() }
            )
        )
    }
}

✅ 올바른 해결책: 목적에 맞는 레이아웃 사용하기 (Column)

이미지 아래에 텍스트를 배치하는 것이 목적이라면, 복잡하게 크기를 측정하고 상태를 전달할 필요가 전혀 없습니다. 단순히 자식들을 수직으로 배치해 주는 Column 컴포저블을 사용하면 모든 문제가 해결됩니다.

Column은 내부적으로 자식들을 하나씩 측정하고 그 순서대로 아래에 배치하는 로직을 모두 처리해 줍니다. 이것이 바로 Compose의 선언적인 힘입니다.

@Composable
fun GoodExample() {
    // ✅ 좋음: Column은 자식들을 수직으로 차례대로 배치합니다.
    // 복잡한 측정이나 상태 전달 없이 원하는 UI를 명확하게 선언할 수 있습니다.
    Column {
        Image(
            painter = painterResource(R.drawable.rectangle),
            contentDescription = "I'm above the text",
            modifier = Modifier.fillMaxWidth()
        )

        Text(
            text = "I'm below the image"
        )
    }
}

만약 더 복잡한 layout을 그려야한다면 ConstraintLayout을 사용할 수 있습니다.

ConstraintLayout을 사용하면 한 요소의 아래(bottom)에 다른 요소의 위(top)를 연결하는 식으로 관계를 직접 선언할 수 있어, 리컴포지션 루프 없이 원하는 UI를 구성할 수 있습니다.

마치며

우리가 보는 Compose 화면은 아래와 같은 과정을 거쳐 완성됩니다.

상태 변경(State Change) → Composition (무엇을?) → Layout (어디에?) → Drawing (어떻게?) → 화면 표시

이 세 단계는 항상 같은 순서로 진행되며, 하나의 프레임을 완성하는 파이프라인처럼 작동합니다.

왜 이것을 알아야 할까요?

  • 성능 최적화: 무거운 계산을 컴포저블 함수 안에 직접 넣으면, 리컴포지션이 일어날 때마다 해당 계산이 반복되어 버벅거림을 유발할 수 있습니다. 이는 Composition 단계에 부담을 주는 행위입니다.
  • 커스텀 레이아웃: Layout 컴포저블을 사용하여 자신만의 커스텀 레이아웃을 만들려면, 측정(Measure)과 배치(Place)가 어떻게 동작하는지 반드시 이해해야 합니다.
  • 디버깅: UI가 예상치 못한 위치에 그려지거나 크기를 차지한다면, 그것은 Layout 단계의 제약 조건(Constraints)과 관련이 있을 가능성이 높습니다.

이제 Compose의 '마법'이 조금은 더 명확하게 보이시나요? 이 세 가지 단계를 기억한다면, 여러분은 더 효율적이고 정교한 UI를 자신 있게 만들어 나갈 수 있을 겁니다.

 


블로그 글에 대해 궁금한 점이 있다면 아래 카카오톡 오픈채팅에 들어와서 질문해주세요

 

Android Kotlin Compose QnA

 

open.kakao.com

 

728x90
반응형

'개발 > Compose' 카테고리의 다른 글

(Compose) Compose Side-Effect  (2) 2025.08.17
(Compose) Lifecycle - Composition의 탄생부터 소멸까지  (2) 2025.08.08
(Compose) Stateful vs Stateless  (5) 2025.08.07
(Compose) Compose UI Test  (4) 2025.08.05
(Compose) LazyColumn  (3) 2025.08.05