아티클 목록으로 가기

Jetpack Compose Mechanisms: 시리즈, 발표, 책을 아우르는 가이드

skydovesJaewoong Eum (skydoves)||13분 소요

Jetpack Compose Mechanisms: 시리즈, 발표, 책을 아우르는 가이드

지난 몇 달 동안 Jetpack Compose Mechanisms를 여러 형태로 선보였습니다. 강의가 딸린 책, DroidKaigi 2026 & Droidcon Berlin 2026에서 한 발표와 그 발표 자료, 이곳 Dove Letter에 연재한 3부작 시리즈입니다. 모두 @Composable 함수 이면에서 실제로 무엇이 실행되는가라는 같은 질문을 좇지만, 그 질문을 바라보는 거리는 저마다 다릅니다. 발표는 약 40분 만에 답의 윤곽을 보여 주고, 시리즈는 각 주장의 근거가 되는 소스 코드를 읽으며, 책은 시리즈가 미처 가지 못한 곳까지 다룹니다.

이번 글에서는 이 모든 것을 한곳에 모았습니다. Compose의 세 계층을 잇는 원리, 시리즈 각 편의 요약과 거기서 꼭 가져가야 할 한 가지, 발표와 발표 자료가 다루는 내용과 시리즈 글이 발표보다 더 나아간 부분, 책과 강의가 더해 주는 것, 얼마나 깊이 들어가고 싶으신지에 따라 고를 수 있는 몇 가지 읽기 경로까지 차례로 살펴봅니다.

큰 그림: 세 계층과 하나의 원리

이 프로젝트의 결과물은 모두 같은 지도를 따릅니다. Compose는 흔히 UI 툴킷으로 소개되지만, 실제로는 세 부분으로 나뉘고 그중 라이브러리는 둘뿐입니다.

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

  1. Compose 컴파일러는 작성하신 컴포저블(composable) 함수를 빌드 타임에 다시 쓰는 코틀린 컴파일러 플러그인입니다.
  2. Compose 런타임은 컴파일러가 다시 쓴 함수를 실행하면서 컴포지션(composition)의 기록을 메모리에 유지합니다. 무엇이 어떤 순서로 호출되었고, 그 과정에서 어떤 값이 기억되었는지가 여기에 담깁니다.
  3. Compose UI는 그 기록을 LayoutNode 트리로 바꾸고, 노드를 측정하고 배치한 뒤, 결과를 픽셀로 그립니다.

시리즈 세 편을 관통하는 원리는 하나입니다. Compose는 안전하다고 증명할 수 있는 한도에서 최소한의 일만 하고, 그 증명은 언제나 비교입니다. 컴포저블 안에서는 컴파일러가 비교 코드를 생성하고, 런타임은 비교 대상이 되는 값을 저장합니다. 이 비교가 얼마나 자주 성공할지는 작성하신 코드에 달려 있습니다. 때로는 결정적인 비교가 리컴포즈되는 컴포저블보다 더 위쪽, 즉 StateFlow나 remember 키에서 일어나기도 합니다. 이어지는 내용은 대부분 그런 비교 하나가 어디에서 일어나는지를 짚어 냅니다.

시리즈 한눈에 보기

편계층이 편이 답하는 질문
Part 1: 컴파일러는 함수를 어떻게 컴포저블로 바꾸는가컴파일러@Composable은 무엇을 바꾸고, 컴포저블은 건너뛸지 말지를 어떻게 결정하나요?
Part 2: 런타임은 어떻게 값을 기억하고, UI는 어떻게 픽셀이 되는가런타임과 UI기억된 값은 어디에 저장되고, 상태 변경은 어떻게 화면까지 전달되며, 컴포지션은 어떻게 픽셀이 되나요?
Part 3: 리컴포지션 성능, 그리고 안정성이 여전히 중요한 이유성능Compose는 언제 필요 이상으로 일하고, 해결책은 어디에 있어야 하나요?

각 편은 따로 읽으셔도 되지만, 순서대로 읽도록 썼습니다. Part 2는 Part 1에서 다룬 그룹과 changed() 검사를 바탕으로 하고, Part 3은 앞의 두 편을 모두 활용해 불필요한 리컴포지션(Recomposition)이 어디에서 생기는지 설명합니다.

Part 1: 컴파일러와 건너뛰기 판단

Part 1은 이미 한 번쯤 마주치셨을 규칙에서 출발합니다. 컴포저블은 다른 컴포저블에서만 호출할 수 있다는 규칙입니다. 이 규칙은 suspend 함수의 규칙과 뿌리가 같습니다. 코틀린 컴파일러가 모든 suspend 함수에 숨겨진 Continuation 매개변수를 추가하듯, Compose 컴파일러도 모든 컴포저블에 숨겨진 매개변수를 추가합니다.

// 직접 작성하는 코드
@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

// 컴파일러가 만들어 내는 시그니처
fun Greeting(name: String, $composer: Composer?, $changed: Int)

$composer는 런타임 컨텍스트이며, 모든 컴포저블은 자신이 호출하는 컴포저블에 이를 그대로 넘겨줍니다. 일반 함수에는 넘겨줄 컴포저(composer)가 없으므로 컴포저블을 호출할 수 없습니다. 이어서 나머지 변환 과정을 차례로 짚습니다.

  • restart group: 컴파일러는 본문을 startRestartGroup과 endRestartGroup으로 감쌉니다. 본문이 상태를 읽으면 endRestartGroup이 람다를 저장한 스코프를 돌려주고, 런타임은 이 람다로 호출한 쪽(caller)을 실행하지 않고도 이 함수 하나만 다시 호출할 수 있습니다. 리컴포지션이 선택적으로 일어나는 것도 이 덕분입니다. 다만 이 방식은 컴포저블이 멱등성(idempotence)을 지킨다는 가정이 있어야만 안전합니다.
  • 어노테이션 프로세서가 아닌 컴파일러 플러그인: KAPT와 KSP는 기존 파일 옆에 새 파일을 만들 수 있을 뿐입니다. Compose는 작성하신 함수 자체를 고쳐야 하므로 K2 컴파일러 안에서 동작합니다. 코드를 검사하는 곳은 FIR인데, 호출 컨텍스트(calling context) 오류도 여기서 나옵니다. 코드를 다시 쓰는 곳은 IR입니다.
  • 건너뛰기 판단: $changed는 호출한 쪽이 인자마다 이미 파악해 둔 정보를 매개변수당 몇 비트에 담습니다. 그 값은 Uncertain, Same, Different, Static 중 하나입니다. 호출한 쪽이 모르는 경우에는 생성된 코드가 인자를 지난번 실행에서 저장해 둔 값과 비교합니다. 그런 다음 shouldExecute가 본문을 실행할지, 아니면 런타임이 그룹 끝으로 건너뛸지를 결정합니다.

Part 1에서 꼭 기억해 둘 규칙은 다음과 같습니다. 컴포저블은 본문이 사용하는 모든 인자가 지난번 실행 때 저장된 값과 일치할 때에만 건너뜁니다. 안정적인(stable) 타입은 equals()로, 불안정한(unstable) 타입과 람다는 참조로 비교합니다. 타입의 안정성(stability)을 런타임에야 알 수 있다면, 호출한 쪽이 넘기는 비트 하나가 둘 중 어느 방식을 쓸지 정합니다. Part 3의 내용은 모두 이 규칙 위에 쌓입니다.

Part 2: 값이 머무는 곳, 그리고 픽셀이 되는 과정

Part 2는 같은 컴포저블을 네 가지 버전으로 작성한 예제에서 출발합니다. 버전마다 값을 보관하는 방식만 다릅니다. 이 네 버전으로 자칫 하나로 뭉뚱그리기 쉬운 두 가지 역할을 나눠 봅니다.

버전실행 간 유지변경 알림
Dove()아니요아니요
remember { Dove() }예아니요
mutableStateOf(Dove())아니요예, 단 실행할 때마다 새 상태로 교체됨
remember { mutableStateOf(Dove()) }예예

런타임을 다루는 전반부에서는 표의 각 열을 하나씩 풀어 봅니다.

  • remember는 값을 슬롯 테이블(SlotTable)에 보관합니다. 실제로는 Composer.cache를 호출하는데, 이 함수는 지난번 실행에서 같은 위치에 저장해 둔 값을 읽고, 아직 저장된 값이 없거나 키가 바뀌었을 때에만 새 값을 계산합니다. 슬롯 테이블은 많은 텍스트 에디터가 쓰는 것과 같은 자료 구조인 갭 버퍼(gap buffer)입니다. 그래서 카운트가 양수가 되는 순간 나타나는 Icon처럼 콘텐츠를 중간에 삽입할 때도 테이블을 다시 만들지 않고 갭만 옮깁니다.
  • 위치가 곧 정체성입니다. 런타임은 컴파일러가 생성한 그룹 키(group key)와 그룹이 나타나는 순서로 값을 알아봅니다. 호출 지점마다 상태가 따로 생기는 것도, 위치가 움직이는 콘텐츠에 key()가 필요한 것도 그래서입니다.
  • State 읽기가 곧 구독입니다. observe도 collect도 없습니다. 컴포지션 중에 일어난 읽기는 스냅샷 시스템(snapshot system)의 read observer를 거치는데, read observer는 그 상태를 읽은 스코프를 하나하나 기록합니다. 나중에 값이 바뀌면 정확히 그 스코프들만 무효화(invalidate)됩니다.
  • 쓰기는 프레임 단위로 합쳐집니다. 쓰기 자체는 아무것도 리컴포즈하지 않습니다. Recomposer가 다음 프레임까지 기다리므로, 그 전에 일어난 여러 번의 쓰기도 리컴포지션 한 번으로 끝납니다.

UI를 다루는 후반부는 그 결과를 화면까지 따라갑니다. 런타임은 노드 안을 결코 들여다보지 않습니다. 대신 노드 타입을 타입 매개변수로 받는 제네릭 인터페이스인 Applier로 트리를 편집하는데, 같은 런타임이 벡터 그래픽, Glance 위젯, DOM 노드, 터미널 UI까지 구동하는 것도 이 때문입니다. Compose UI는 LayoutNode용 UiApplier를 제공합니다. 거의 모든 컴포넌트는 측정 정책(measure policy)과 Modifier를 받는 Layout 컴포저블로 끝나고, 평범한 ViewGroup인 AndroidComposeView가 안드로이드에서 전달받은 Canvas에 루트 LayoutNode를 그립니다. Text조차 자신의 측정 정책에서는 아무것도 측정하지 않습니다. 측정과 그리기는 Text의 Modifier 노드에서 일어납니다.

Part 2에서 기억해 둘 질문은 두 가지입니다. 리컴포지션이 일어나도 살아남아야 하는 값인가요? 그렇다면 remember가 맡을 일입니다. 값이 바뀌면 화면까지 반영되어야 하나요? 그렇다면 State가 맡을 일입니다. 대부분의 UI 상태는 둘 다 필요합니다.

Part 3: 성능, 그리고 새 인스턴스가 만들어지는 곳

Part 3은 숫자 하나로 시작합니다. Google의 히어로 벤치마크(hero benchmarks)는 제가 만든 오픈소스 Pokedex 앱의 뷰 기반 버전과 Compose 기반 버전을 비교합니다. 이 벤치마크에서 Compose와 뷰는 Compose 1.9.0부터 같은 스크롤 버벅임(jank) 비율을 기록하고 있습니다. 스크롤에서는 더 이상 프레임워크가 병목이 아니며, 남은 것은 앱 코드가 요구하는 작업입니다.

이어서 Part 1의 건너뛰기 판단을 트리 전체로 넓혀 봅니다. 리컴포지션은 함수 호출만으로 끝나지 않습니다. 런타임은 무효화된 스코프를 찾아내고, 슬롯 테이블을 훑어 그 스코프까지 내려간 뒤, 스코프를 다시 실행하고 변경 사항을 LayoutNode 트리에 적용합니다. 서브트리의 꼭대기에서 건너뛰면 그 아래 전부를 아낄 수 있으므로, 모든 것은 Compose가 입력이 같다는 사실을 알아낼 수 있느냐에 달려 있습니다. 이 판단을 좌우하는 것이 바로 안정성입니다.

  • Strong Skipping Mode는 코틀린 2.0.20부터 기본으로 켜져 있습니다. 이 모드 덕분에 불안정한 매개변수가 있는 컴포저블도 건너뛸 수 있게 되었지만, 그런 매개변수는 참조, 즉 ===로만 비교합니다. 어떤 타입도 안정적으로 바뀌지는 않았습니다.
  • 코틀린 2.4부터는 List 같은 인터페이스를 더 이상 불안정하다고 보지 않습니다. 이런 인터페이스의 안정성은 런타임에 결정되는데, 인터페이스에는 안정성 정보가 담겨 있지 않으므로 List 인자는 equals()로 비교합니다. Compose 컴파일러가 컴파일했고 어노테이션이 없는 클래스가 다른 파일에 선언되어 있다면, 이 클래스의 안정성도 런타임에 결정됩니다. 이때 근거가 되는 것은 컴파일러가 그 클래스에 생성해 둔 필드입니다. Part 3의 Message 모델은 평범한 List를 그대로 들고 있으므로, 모든 프로퍼티를 val로 바꿔도 이 필드는 여전히 불안정하다고 알립니다.

그다음에는 일상적인 세 가지 사례가 이어지는데, 사례마다 새 인스턴스가 만들어지는 곳이 컴포저블에서 점점 멀어집니다.

  1. 컴포저블 안에서 매핑하는 리스트. users.map { ... } 호출은 리컴포지션마다 새 리스트를 만듭니다. 이를 remember(users)로 감싸면 인스턴스가 유지되며, 코틀린 2.3.21로 진행한 테스트에서는 자식의 실행 횟수가 6번에서 1번으로 줄었습니다. 코틀린 2.4는 리스트를 equals()로 비교하므로 이 경우 이미 건너뛰는데, Part 3에서는 그래도 remember를 남겨 둘 만한 이유를 설명합니다.
  2. 상태를 복사하는 ViewModel. 이 사례에서는 리컴포지션 비용이 전혀 들지 않습니다. StateFlow는 현재 값과 같은 새 값을 버리므로, 내용이 같은 data class 사본은 Compose까지 닿지 않습니다.
  3. 모든 행을 다시 매핑하는 리포지토리. 화면에 보이는 메시지 12개 중 하나를 읽음으로 표시하자 버블 12개가 전부 리컴포즈되었습니다. 모든 행이 불안정한 Message의 새 인스턴스로 도착했기 때문입니다. 프로퍼티를 val로 바꾸고 ImmutableList를 써서 모델을 안정적으로 만들자, 리컴포즈되는 버블이 1개로 줄었습니다.

Part 3은 이 밖에도 @Immutable, Now in Android가 모델 패키지 전체에 사용하는 안정성 설정 파일(stability configuration file), Compose 컴파일러 보고서(compiler reports)를 읽는 법, 에디터에서 바로 안정성을 확인할 수 있는 Compose Stability Analyzer까지 다룹니다.

Part 3에서 익혀 둘 습관은 하나입니다. 데이터가 바뀌는 것보다 더 자주 리컴포즈되는 컴포저블이 있다면, 인자가 만들어진 곳까지 거슬러 올라가 거기서 문제를 고치세요. 그곳은 리컴포즈되는 컴포저블이 아닌 다른 곳인 경우가 많습니다.

발표: Jetpack Compose Mechanisms

이 시리즈는 DroidKaigi 2026 & Droidcon Berlin 2026에서 발표한 Jetpack Compose Mechanisms 세션을 바탕으로 하며, 두 세션 모두 녹화되었습니다.

발표는 같은 지도를 한자리에서 끝까지 따라갑니다. Compose의 짧은 역사와 세 계층 구조로 문을 연 뒤, 컴포저블 함수와 컴파일러, remember와 슬롯 테이블, State를 다루는 런타임, Applier에서 AndroidComposeView까지 이어지는 UI 계층을 차례로 거치고, 마지막으로 성능과 안정성으로 마무리합니다. 무엇이든 읽기 전에 전체 그림부터 보고 싶으시다면 여기서 시작하세요.

발표 자료: 모든 다이어그램을 한곳에

발표 자료는 Speaker Deck에 올라가 있으며, 컴포저블 함수, Compose 컴파일러, Compose 런타임, Compose UI, Compose 성능을 86장에 걸쳐 다룹니다. 시리즈의 다이어그램은 전부 이 자료에서 가져왔습니다. 그래서 발표 자료 자체가 그림으로 찾아보는 색인 역할도 합니다. 컴포저블이 다시 쓰이는 과정, 슬롯 테이블을 지나며 움직이는 갭, 런타임과 트리 사이에 놓인 Applier가 모두 여기에 있습니다.

발표 자료에는 시리즈 글에 없는 측정값도 하나 들어 있습니다. 슬라이드에 따르면 Now in Android의 For You 피드 화면에서 Strong Skipping Mode를 켠 상태로 측정했을 때, 불안정한 UserNewsResource 모델에서는 리컴포지션이 140번, 안정적인 모델에서는 30번 일어났습니다. 슬라이드는 이 측정값을 Now in Android가 모델 패키지에 사용하는 안정성 설정 파일과 함께 소개하는데, Part 3에서 살펴본 바로 그 파일입니다.

시리즈 글이 발표보다 더 나아간 부분

발표에서는 내용을 단순하게 다듬을 수밖에 없지만, 시리즈를 쓰면서는 모든 슬라이드를 현재 소스 코드와 하나하나 대조했습니다. 그 과정에서 몇 가지가 달라졌습니다.

  • 건너뛰기 검사: 발표 자료에는 $dirty and 0b0011 != 0b0010 || !$composer.skipping처럼 인라인으로 생성된 형태가 나옵니다. 코틀린 2.2부터 컴파일러의 PausableComposition 플래그가 기본으로 켜지면서, 이제 컴파일러는 런타임이 일시 중지 여부를 판단할 수 있도록 shouldExecute를 호출합니다. Part 1은 현재 형태를 읽고, 인라인 형태가 아직 생성되는 경우도 짚어 줍니다.
  • remember { 0 }: 발표 자료에는 var count = remember { 0 } 코드가 리컴포지션 이후에도 카운트를 유지하는 것으로 나와 있지만, 실제로는 그렇지 않습니다. remember는 변수가 아니라 값 0을 저장했기 때문입니다. Part 2는 이때 어떤 일이 일어나는지, 대신 무엇을 remember로 감싸야 하는지 보여 줍니다.
  • 코틀린 2.4: 발표 자료에는 List 매개변수가 unstable로 보고되는 것으로 나옵니다. 코틀린 2.4는 이 판정을 런타임에 결정되도록 바꿨고, Part 3에서는 이 변화가 도움이 되는 곳과 그렇지 않은 곳을 측정합니다.
  • 측정한 수치: Part 2와 Part 3에 나오는 리컴포지션 횟수는 코드만 읽고 추정한 값이 아니라, 테스트 환경을 밝히고 JVM 위에서 실제로 실행한 테스트에서 나온 값입니다.

책: Jetpack Compose Mechanisms

시리즈의 각 편은 책을 소개하며 끝나는데, 여기서는 책이 이 그림에서 어디에 자리하는지 정리해 보겠습니다. Jetpack Compose Mechanisms 책은 Compose 컴파일러, Compose 런타임, Compose UI, 성능의 4개 장으로 같은 계층을 따라가며, 분량은 539쪽입니다. 시리즈와 마찬가지로 AOSP 소스 코드를 읽어 가며 썼고, 코틀린 2.4.0과 Compose 컴파일러 2.4.0 기준으로 업데이트되어 있습니다.

시리즈는 계층마다 한 갈래 길만 따라갑니다. 책은 그 길 주변의 영역까지 아우릅니다.

  • 컴파일러: 안정성 추론, 람다 메모이제이션(memoization), durable 함수 키, 패스 하나하나를 추적해 보는 IR 변환 파이프라인, 컴파일러 보고서, Live Literals
  • 런타임: 스냅샷 시스템과 MVCC 격리, 파생 상태(derived state), 이펙트, CompositionLocal, Recomposer, MovableContent, Flow와 상태를 잇는 방법
  • UI: Modifier 순서, Modifier.Node 시스템과 코디네이터(coordinator) 체인, 단일 패스(single pass) 측정, 렌더링 파이프라인, 입력, 포커스, 시맨틱스(semantics), 룩어헤드(lookahead), SubcomposeLayout, 공유 요소 전환(shared element transition), 중첩 스크롤, 지연 레이아웃(lazy layout), 마지막으로 처음부터 직접 만들어 보는 작은 Compose UI
  • 성능: 단계별로 살펴보는 안정성 추론 알고리즘, 상태 읽기 위치, 람다 리컴포지션, 컴파일러 보고서부터 CI까지 이어지는 측정, Compose를 조용히 느리게 만드는 6가지 안티패턴, 초당 47번 리컴포즈되며 버벅이는 채팅 화면을 최적화하는 전체 사례 연구

책은 Leanpub에서 PDF, EPUB, 웹 버전으로 제공되며, 한국어판과 일본어판도 나와 있습니다. 인쇄판을 포함해 형식별로 어디에서 구할 수 있는지는 howcomposeworks.com에 정리되어 있습니다.

강의: 책을 바탕으로 한 실전 연습

Jetpack Compose Mechanisms 강의는 같은 내용을 8개 장, 59개 레슨으로 이루어진 체계적인 학습 과정으로 엮었습니다. 240개 이상의 인터랙티브 평가 문항과 전체 답안이 딸린 실전 질문을 갖추고 있고, 과정을 마치면 수료증도 받으실 수 있습니다. 책을 바탕으로 만들었지만 책을 그대로 옮긴 것은 아닙니다. 분량이 약 1.5배 더 많고, 더 자세한 해설과 후속 과제가 더해졌으며, 실전 질문의 전체 답안은 책에 실려 있지 않습니다.

먼저 지금 실력이 어느 정도인지 확인해 보고 싶으시다면 Jetpack Compose Mechanisms 퀴즈를 풀어 보세요. Compose 내부 동작을 은근히 깊게 묻는 실전 문제 10개로 이루어져 있으며, 정답 해설에서 각 정답에 깔린 메커니즘을 설명합니다.

어디서 시작할까: 세 가지 경로

들일 수 있는 시간에 따라 아래 세 경로 중 하나를 고르시면 됩니다.

  • 약 40분: 발표 자료를 띄워 두고 두 발표 영상 중 하나를 보세요. 세 계층, 건너뛰기 판단, 안정성 사례를 한 번에 파악하실 수 있습니다.
  • 하룻저녁: Part 1, Part 2, Part 3을 순서대로 읽으세요. 각 편은 발표에서 한 주장의 근거가 되는 소스 코드를 읽고, Part 2와 Part 3에서는 예제를 직접 실행해 측정합니다.
  • 전부: 책을 읽은 뒤, 강의를 완주하며 내용을 단단히 다지세요.

특정 메커니즘이 더 궁금해지셨다면 다음 Dove Letter 글이 도움이 됩니다. 각 글은 주제 하나를 깊이 파고듭니다.

결론

실무에서 좋은 Compose 코드를 작성하는 데 여기 나온 세부 사항이 전부 필요하지는 않습니다. 다만 언젠가는 여러 질문을 던지시게 될 텐데, 계층마다 답해 주는 질문의 종류가 다릅니다. 컴포저블을 호출할 수 없거나 건너뛰어야 할 때 건너뛰지 않는다면, 답은 컴파일러가 컴포저블을 어떻게 바꿔 놓았는지에 있습니다. 값이 초기화되거나 화면이 갱신되지 않는다면 슬롯 테이블과 스냅샷 시스템을 살펴보셔야 합니다. 리스트의 모든 행이 리컴포즈된다면, 작성하신 코드가 새 인스턴스를 어디에서 만드는지가 답입니다. 어느 계층을 들여다봐야 하는지 아는 것만으로도 일의 대부분은 끝난 셈입니다.

발표, 발표 자료, 시리즈, 책은 같은 주제를 서로 다른 깊이로 다루지만, 모두 시리즈가 마지막에 이른 원리를 가리킵니다. Compose는 안전하다고 증명할 수 있는 한도에서 최소한의 일만 하고, 그 증명은 언제나 비교입니다. 각 비교가 어디에서 일어나는지 보이기 시작하면, 리컴포지션은 더 이상 영문도 모른 채 겪는 일이 아니라 직접 추적할 수 있는 대상이 됩니다.

아티클 목록으로 가기