Compose Compiler와 Runtime

cotton·2025년 6월 25일

Compose Compiler

Compose를 한 번이라도 사용해본 사람이라면 @Composable 어노테이션으로 파일들이 구성되어 있는 것을 본 적이 있을 겁니다. Kotlin 개발자라면 어노테이션은 일반적으로 어노테이션 프로세스을 통해 처리된다고 생각할 수 있습니다.

하지만 @Composable 의 경우 일반적인 어노테이션 프로세스 단계를 거치지 않고, Compose Compiler를 이용하여, 컴파일 시점 중 IR 시점에 코드를 수정합니다.

IR 과정에서는 아래와 같은 방식으로 코드를 변환합니다.

// 실제로 입력한 코드
@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

// 컴파일러를 통해 IR 과정에서 변형되는 로직
fun Greeting(name: String, composer: Composer<*>, changed: Int) {
    composer.startRestartGroup(...)
    if (changed and ... == 0) {
        composer.skipToGroupEnd()
    } else {
        Text(...)
    }
    composer.endRestartGroup()?.updateScope { ... }
}

그렇다면 Compose Compiler는 왜 코드를 변환할까요? Compose Runtime가 효율적으로 상태를 추적하고, UI 갱신을 빠르게 처리할 수 있도록 하기 위해서입니다. Compiler는 아래와 같은 작업을 진행합니다.

클래스 안정성 추론

Compose Compiler는 @Stable, @Immutable 같은 어노테이션을 활용해 각 클래스의 안정성(Stable 상태 여부)을 추론합니다. 이를 통해 불필요한 리컴포지션 발생을 줄이는 최적화를 수행합니다.

Live Literals

컴파일 도중 Hot Reload 및 Preview 지원을 위해서 Literal 변경 감지 기능을 삽입해줍니다.

Lambda 메모리제이션, remember 처리

Compose는 함수형 UI 선언 구조를 가지고 있기 때문에 우리는 람다 구조를 많이 사용하며, Button 과 같은 부분에서는 새로운 람다 객체가 선언되기 쉽습니다. 우리는 보통 Composable 타입의 람다와, 버튼 클릭 리스너를 구현하는 등, 일반적인 람다(Non-Composable Lambda) 를 구현해서 사용합니다. Compose Compiler는 각각의 람다를 구분하여 최적화해 줍니다.

Non-Composable 람다의 경우에는 컴파일러 차원에서 싱글톤으로 구현하거나 remember로 감싸 캐싱할 수 있게 도웁니다.

// Non-Composable Lambda (캡쳐 값 없음)
{ onClick("test") }

// Non-Composable Lambda (캡쳐 값 있음)
{ it -> onClick(it) }

캡쳐 값이 없는 람다의 경우, 한 번 생성하여 반복적으로 사용할 수 있기 때문에 싱글톤 객체로 만들어 관리하며, 캡쳐 값이 있는 경우 외부 변수(캡쳐 값) 이 변경되는 경우에만 람다를 호출할 수 있도록 내부적으로 remember 로 감싸 관리하며, 람다의 경우 매번 새로 생성하지 않고 Slot Table이라는 곳에 저장하여, 람다를 재사용할 수 있게 구현해 줍니다.

Composable 람다의 경우에도 캡쳐 값이 있는 람다처럼 Slot Table에 저장해 필요한 경우에만 다시 만들도록 합니다. 이렇게 하면 Composable Tree에서 값이 바뀌는 노드만 다시 컴포지션 해 성능을 좋게 만들어 줍니다.

Composer 주입

Compose Compiler는 IR 단계에서 @Composable 를 변환하는 과정 중, 함수 파라미터에 Composer를 주입합니다. 또한 해당 컴포저블 내에 또 다른 컴포저블 함수가 있는 경우, 최상단에 있는 Composer 객체를 같이 넘겨 한 개의 Composer 객체에서 최상단 컴포지션의 트리 전체를 효율적이고 일관되게 추적할 수 있게 도웁니다

fun Parent($composer: Composer<*>, changed: Int) {
    Child($composer, ...)
}

Compose Compiler는 이런 여러 작업들을 진행해 Compose Runtime이 상태 추적과 변경을 감지해 UI 갱신을 더 빠르게 할 수 있게 도와줍니다. 그렇다면 Compose Runtime은 어떤 역할을 할까요?

Compose Runtime

Compose Runtime은 Compose UI를 그리는 데 있어 핵심적인 역할을 합니다. 상태를 관리하고, 리컴포지션을 관리해 UI의 효율적인 업데이트를 돕습니다.

Compose Compiler가 Composable 함수에 주입한 Composer는 Runtime과 함수를 연결하여 Runtime의 Recomposition 로직을 사용할 수 있게 합니다.

Slot Table

// androidx.compose.runtime.Composer.kt
internal class ComposerImpl(

    /**
     * The slot table to use to store composition data
     */
    private val slotTable: SlotTable,
)

Composer는 내부적으로 Slot Table을 이용하여 효율적인 업데이트를 진행합니다.

Slot Table은 여러 컴포지션 중 어떤 컴포지션이 어느 위치에서 호출되었고, 어떤 값들을 가졌는지 저장하는 Gap Buffer 구조를 가지고 있는 하나의 테이블입니다. 컴포저블 함수가 실행되는 경우 Runtime은 현재 Composable 함수 내에 있는 또 다른 composable, 상태 값, remember 값, lambda, CompositionLocals 와 같은 값을 저장하고, 데이터가 변경되는 경우 해당 Slot Table을 참고하여 리컴포지션을 진행합니다.

Slot Table이 어떻게 빠르게 동작하고 데이터를 저장하는 지는 해당 문서 참고
https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd

UI 변경 사항이 발생해 화면에서 리컴포지션이 발생하는 경우에는 보편적으로 remember 내부 값이나, state 이 변경됩니다. Runtime은 변경된 정보들을 감지하여, Slot Table을 기준으로 변경된 값들과 비교를 진행, 변경이 필요한 리스트(Change Set)를 정리해, Applier에 전달합니다.

Applier

// androidx.compose.runtime.Composer.kt
internal class ComposerImpl(

    /**
     * An adapter that applies changes to the tree using the Applier abstraction.
     */
    override val applier: Applier<*>,
)

Change Set은 Applier 인터페이스로 전달된다, Applier는 Change Set에 있는 각각의 변경 작업들을 UI 노드 트리에 적용합니다.

Column {
    Text("Hello")
    if (showDetails) {
        Text("Details here")
    }
}

예를 들어 설명해 보겠습니다. 위 컴포저블에서 만약 showDetails 값이 true로 변경되어 Text 컴포저블을 하나 더 생성해야 하는 상황이 발생한다면, Runtime은 Text Node 값을 하나 추가해야 한다고 판단하고, 이를 Applier에 전달합니다.

interface Applier<N> {
    val current: N

    fun down(node: N)
    fun up()
    fun insertBottomUp(index: Int, instance: N)
    fun insertTopDown(index: Int, instance: N)
    fun remove(index: Int, count: Int)
    fun move(from: Int, to: Int, count: Int)
    fun clear()
}

Hello Text 아래에 새로운 텍스트 노드가 추가로 생성되어야 하기 때문에 insertTopDown 함수가 호출되어 실제 UI Tree 내에 새로운 Text Node가 삽입되어 UI에 업데이트 됩니다.

위와 같은 구조를 가지고 있는 이유는 Applier는 UI를 직접 그리지 않고, 트리를 기준으로 UI의 구조를 선언하고 있는 형태를 가지고만 있다는 점에서 기존 View 시스템과는 완전히 다른 목적을 가지고 있습니다.

이렇게 실제 노드의 형태만을 관리하고 책임지는 Applier라는 인터페이스로 관심사 분리가 아주 잘 되어있기 때문에, Runtime 로직은 UI를 아예 모르는 채로 독립적으로 구현될 수 있으며, 해당 노드를 각각의 플랫폼에서 어떻게 그려줄 지 구현, 즉 Compose Multiplatform이 가능해지는 형태를 제공합니다.

profile
안드로이드 개발자

0개의 댓글