아티클 목록으로 가기

Jetpack Compose Mechanisms, Part 1: 컴파일러는 함수를 어떻게 컴포저블로 바꾸는가

skydovesJaewoong Eum (skydoves)||17분 소요

Jetpack Compose Mechanisms, Part 1: 컴파일러는 함수를 어떻게 컴포저블로 바꾸는가

2021년 7월 Jetpack Compose의 안정 버전 1.0이 출시되었고, 2026년 5월에는 Google이 안드로이드 UI 개발은 Compose First라고 공식 발표했습니다. 아마 여러분도 매일 @Composable 함수를 작성하고 계실 겁니다. 평범한 코틀린 함수에 이 어노테이션 하나만 붙이면 함수가 UI로 바뀝니다. 이렇게 UI가 된 함수는 실행과 실행 사이에 값을 기억하고, 데이터가 바뀌면 스스로 다시 실행되며, 때로는 아예 실행하지 않기로 결정하기도 합니다. 그런데 작성하신 본문만으로는 이 가운데 어느 것도 일어나지 않습니다. 그렇다면 이 일은 누가 하는 걸까요?

이번 글에서는 Compose 컴파일러가 그 함수를 어떻게 바꿔 놓는지 깊이 파고듭니다. 이미 한 번쯤 마주치셨을 규칙에서 출발해 점점 안쪽으로 들어가면서, 호출 컨텍스트(calling context), 숨겨진 $composer 매개변수, restart group과 멱등성(idempotence), 컴파일러 플러그인 모델을 거쳐, 컴포저블(composable)을 실행할지 건너뛸지 결정하는 생성된 코드까지 차례로 살펴봅니다.

이 글은 DroidKaigi 2026 & Droidcon Berlin 2026에서 발표한 Jetpack Compose Mechanisms 세션을 바탕으로 한 3부작 시리즈의 첫 번째 글입니다. 발표 자료를 함께 보셔도 좋습니다. 이 시리즈의 다이어그램도 모두 발표 자료에서 가져왔습니다. Part 2에서는 런타임과 UI 계층을 다루고, Part 3에서는 앞의 두 편에서 그린 전체 그림을 성능 개선 작업에 적용해 봅니다.

세 계층: 컴파일러, 런타임, UI

Compose는 흔히 UI 툴킷으로 소개되지만, 세 부분 가운데 라이브러리는 둘뿐입니다. 나머지 하나는 컴파일러 플러그인이며, 각 부분은 결과물을 다음 부분에 넘겨줍니다.

Composable functions pass through the Compose compiler, the Compose runtime, and Compose UI before they become pixels

  1. Compose 컴파일러는 빌드 타임에 동작하며, 컴포저블 함수를 런타임이 다룰 수 있는 형태로 변환합니다.
  2. Compose 런타임은 변환된 함수를 실행하면서 컴포지션(composition)을 나타내는 정보를 메모리에 유지합니다. 무엇이 어떤 순서로 호출되었고, 그 과정에서 어떤 값이 기억되었는지가 여기에 담깁니다.
  3. Compose UI는 그 정보를 레이아웃 노드 트리로 바꾸고, 노드를 측정하고 배치한 뒤, 결과를 픽셀로 그립니다.

이 구분은 모듈의 빌드 파일에서도 확인하실 수 있습니다. 컴파일러는 코틀린 컴파일러 플러그인입니다. 코틀린과 함께 릴리스되며 버전 번호도 같습니다. 반면 런타임과 UI 계층은 평범한 라이브러리 의존성입니다. 런타임은 보통 ui를 거쳐 전이 의존성(transitive dependency)으로 들어오지만, 아래에서는 구분이 잘 보이도록 명시했습니다.

plugins {
    id("org.jetbrains.kotlin.plugin.compose")
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:<version>"))
    implementation("androidx.compose.runtime:runtime")
    implementation("androidx.compose.ui:ui")
}

이 글에서는 세 계층 가운데 첫 번째인 컴파일러만 다루며, 작은 함수 하나가 컴파일러를 거치는 과정을 따라가 보겠습니다.

근본적인 문제: 아무것도 반환하지 않는 함수

가장 작은 컴포저블부터 시작해 보겠습니다.

@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

시그니처를 보면 빠진 것이 하나 있습니다. 바로 반환 타입입니다. Greeting은 UI를 방출(emit)하는 다른 모든 컴포저블처럼 Unit을 반환합니다. 뷰도, 다른 어떤 UI 객체도 반환하지 않습니다. 대신 실행되는 동안 컴포지션에 정보를 방출하고, UI는 그렇게 방출된 정보로 만들어집니다.

A composable takes data and, while it runs, emits into the composition

여기서 평범한 코틀린 함수로는 답할 수 없는 질문이 남습니다. 아무것도 반환하지 않는다면 방출된 정보는 어디로 가고, Text는 Greeting을 기준으로 자기가 어디에 놓이는지 어떻게 알까요? name의 바탕이 되는 데이터가 바뀌면, Compose는 어떻게 화면 전체가 아니라 그 데이터에 의존하는 코드만 다시 실행하나요? 또 name이 바뀌지 않았다면, 작업을 건너뛰어도 된다는 사실은 어떻게 알까요? 일반 함수라면 시그니처에는 이런 정보를 담을 자리가 없고, 본문에도 이를 보고할 방법이 없습니다. 컴파일러는 이 둘을 모두 채워 넣습니다. 하나는 빠진 컨텍스트를 실어 나르는 매개변수이고, 다른 하나는 본문 안에서 Compose 런타임에 보고하는 호출들입니다.

호출 컨텍스트: 컴포저블은 컴포저블에서만 호출할 수 있다

@Composable을 붙이면 가장 먼저 마주치는 규칙이 있는데, 아마 이미 겪어 보셨을 겁니다. 컴포저블 함수는 다른 컴포저블 함수에서만 호출할 수 있습니다.

@Composable
fun Place(name: String) {
    Person(name) // 컴파일됨
}

fun place(name: String) {
    Person(name) // 컴파일 오류
}

@Composable
fun Person(name: String) {
    Text(name)
}

두 번째 호출에서는 "@Composable invocations can only happen from the context of a @Composable function" 오류가 나면서 컴파일에 실패합니다. @Composable 호출은 @Composable 함수의 컨텍스트 안에서만 할 수 있다는 뜻입니다. place 자체에도 두 번째 오류가 붙는데, 컴포저블을 호출하는 함수는 @Composable로 표시해야 한다는 내용입니다. 이 규칙을 호출 컨텍스트라고 부릅니다. 낯익게 느껴지신다면 그럴 만합니다. 코틀린 코루틴에도 똑같은 규칙이 있기 때문입니다.

suspend 비유: CPS(Continuation-Passing Style)

suspend 함수는 다른 suspend 함수나 코루틴 안에서만 호출할 수 있습니다.

suspend fun fetchProfile(name: String): Profile {
    return profileDao.findByName(name)
}

coroutineScope.launch {
    fetchProfile(name = "skydoves")
}

이유는 코틀린 컴파일러가 만들어 내는 코드를 보면 드러납니다. 컴파일러는 모든 suspend 함수를 CPS로 다시 씁니다. Continuation 타입의 숨겨진 $completion 매개변수를 끝에 덧붙이고, 반환 타입을 Any?로 바꿉니다. 그래야 함수가 결과를 반환하거나, 일시 중단되었다는 표식을 반환할 수 있습니다.

fun fetchProfile(name: String, $completion: Continuation<Profile>): Any?

Continuation에는 나중에 작업을 재개하는 데 필요한 모든 것이 담겨 있으며, 함수는 호출한 쪽(caller)에서 Continuation을 받았을 때에만 이를 다음 호출로 넘겨줄 수 있습니다. 변환 과정 전체가 궁금하시다면 마법 뒤의 기계 장치: 코틀린이 suspend를 상태 머신으로 변환하는 과정에서 처음부터 끝까지 따라가 보세요. 컴포저블 함수도 같은 패턴을 따르며, 숨겨진 매개변수만 다릅니다.

숨겨진 매개변수: $composer

Greeting을 컴파일하면 시그니처가 바뀝니다. 이 글에 실린 코드는 컴파일러 출력을 코틀린으로 옮겨 적은 것이라, 실제 소스에서는 허용되지 않는 부분이 섞여 있을 수 있습니다. 새 시그니처는 다음과 같습니다.

fun Greeting(name: String, $composer: Composer?, $changed: Int)

작성하신 적 없는 매개변수가 두 개 생겼습니다. $composer는 런타임 컨텍스트로, 생성된 코드가 Compose 런타임과 대화할 때 거치는 객체입니다. $changed는 호출한 쪽이 인자마다 이미 파악해 둔 정보를 실어 나릅니다. 이 매개변수는 글 후반부에서 다시 등장합니다. 둘 중 어느 것도 직접 넘기실 일은 없습니다.

컴파일러는 모든 컴포저블에 $composer를 추가하고, 본문 안의 모든 컴포저블 호출은 같은 인스턴스를 그대로 넘겨줍니다. 다른 컴포저블 세 개를 호출하는 함수를 예로 들어 보겠습니다.

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

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

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