[Compose] Compose Compiler

easyhooon·2025년 10월 18일
post-thumbnail

Jetpack Compose Internals 책을 읽고, 몰랐던 내용을 정리하고, 책에 언급된 내용에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.

책의 모든 내용을 정리할 목적으로 작성하는 글은 아니라, 내용에 대한 정리는 아래 블로그글을 참고하면 도움이 될듯 합니다.

Compose annotations

Compose UI는 Compose 아키텍처의 일부가 아니다.

그 이유는 Runtime과 Compiler는 그 요구 사항을 충족하는 어떤 클라이언트 라이브러리에서든 사용될 수 있도록 포괄적으로 디자인되었기 때문이다.

Compose UI는 Runtime과 Compiler를 활용하는 클라이언트 중 하나에 불과하다. Compose UI와 관련해서 다른 클라이언트 라이브러리들도 있는데, 대표적으로 JetBrains에서 개발 중인 데스크톱 및 웹용 클라이언트 라이브러리가 있다.

그 말인 즉, Compose UI를 살펴보면 Compose가 Composable 트리의 런타임 인메모리 표현을 제공하는 방법과, 궁극적으로 해당 인메모리 표현을 어떻게 실제 요소로 구체화하는지를 이해하는 데 도움이 된다.

Compose Compiler VS Annotation Processor

Compose Compiler와 Annotation Processor 의 가장 큰 차이점은, Compose의 경우 실제로 어노테이션이 붙어있는 선언이나 표현식을 변형한다는 것이다.

대부분의 Annotation Processor 는 표현식을 변형하는 행위 등은 할 수 없으며, 새로운 코드를 생성하거나 기존 코드와 동등한 수준의 코드만 추가로 생성할 수 있다. 그렇기 때문에 Compose Compiler는 IR 변환을 사용합니다.

@Composable 어노테이션은 실제로 어노테이션이 붙은 대상의 타입을 변경하며, 컴파일러 플러그인은 프론트엔드에서 Composable 타입이 일반적인(Composable이 아닌) 함수들과 동일한 취급을 받지 않도록 모든 종류의 규칙을 강제하는 데 활용된다.

@Composable 을 통해 선언이나 표현식의 타입을 변경하는 것은 대상에게 “메모리“를 부여하는 것을 의미한다. 즉, remember를 호출하고 Composer 및 슬롯 테이블을 활용할 수 있는 능력을 의미한다. 또한, Composable의 본문 내에서 구동된 이펙트들(effects)이 준수할 수 있는 라이프사이클을 제공한다. (ex. recomposition이 수행되어도 기존 작업 유지하기 등)

Compose Compiler와 Annotation Processor는 둘다 코드를 생성할 수 있다. 둘다 런타임 라이브러리들이 사용할 수 있는 편리한 코드를 생성하거나 합성하는데 자주 활용되기 때문이다.

방문자 패턴(visitor pattern)

방문자 패턴은 객체 구조를 변경하지 않고 새로운 동작을 추가할 수 있게 해주는 디자인 패턴이다.

예시

트리 구조를 순회하면서 각 노드에 대해 특정 작업을 수행

// 방문받을 요소들
interface PsiElement {
    fun accept(visitor: PsiVisitor)
}

class FunctionCall(val name: String, val isReadOnly: Boolean) : PsiElement {
    override fun accept(visitor: PsiVisitor) = visitor.visit(this)
}

class PropertyAccess(val name: String, val isWrite: Boolean) : PsiElement {
    override fun accept(visitor: PsiVisitor) = visitor.visit(this)
}

// 방문자
interface PsiVisitor {
    fun visit(call: FunctionCall)
    fun visit(access: PropertyAccess)
}

// 구체적인 검사 로직
class ReadOnlyChecker : PsiVisitor {
    val errors = mutableListOf<String>()
    
    override fun visit(call: FunctionCall) {
        if (!call.isReadOnly) {
            errors.add("${call.name}은 ReadOnly가 아닙니다")
        }
    }
    
    override fun visit(access: PropertyAccess) {
        if (access.isWrite) {
            errors.add("쓰기 작업은 허용되지 않습니다")
        }
    }
}

// 사용
fun main() {
    val elements = listOf(
        FunctionCall("Text", isReadOnly = true),
        FunctionCall("remember", isReadOnly = false),
        PropertyAccess("state", isWrite = true)
    )
    
    val checker = ReadOnlyChecker()
    elements.forEach { it.accept(checker) }
    
    checker.errors.forEach { println(it) }
    // 출력:
    // remember은 ReadOnly가 아닙니다
    // 쓰기 작업은 허용되지 않습니다
}

Element: accept(visitor) 메서드로 방문자를 받아들임
Visitor: 각 Element 타입별로 다른 visit() 메서드 구현
-> 트리 구조 변경 없이 새로운 검사 로직 추가 가능

Compose Compiler 는 이 패턴으로 PSI 트리를 순회하며 @ReadOnlyComposable 같은 제약을 검사한다.

PSI 는 Program Structure Interface 의 약자로, 파일을 구문 분석하고 플랫폼의 다양한 기능을 지원하는 문법(syntatic) 및 의미론적(semantic) 모델을 생성하는 Intellij 플랫폼의 계층 구조를 의미한다.

Kotlin IR

중간 표현(Intermediate Representation)은 플랫폼에 독립적인 코드 형태이다. Compose Compiler는 IrGenerationExtension을 통해 IR을 생성하며, 개발자가 작성한 소스 코드를 수정할 수 있다.

IR의 주요 기능

코드 변형: 매개 변수 삽입/교체 -> 코드 구조를 재구성
Composer 주입: 각 Composable 함수에 암시적인 추가 매개변수인 Composer 매개 변수 삽입
멀티플랫폼: IR 생성으로 모든 플랫폼(JVM,JS,Native) 지원

Lowering

고수준 개념을 저수준 원자적(atomic)개념으로 변환하는 과정

Compose Compiler는 IR 트리를 순회하며, Compose Runtime 이 이해할 수 있는 표현으로 정규화를 수행한다.(코드를 변환한다로 해석)

주요 변환 작업

클래스 안정성 추론: 클래스 안정성 메타데이터 추가
라이브 리터럴: 리터럴(코드에 하드코딩된 값)을 가변 state로 변환(런타임 반영)
Composer 전파: 모든 Composable 호출에 Composer 전달
Composable 함수의 본문을 래핑:
Control flow group 생성(replaceable groups, movable groups)
별도의 디폴트 매개변수 지원
Recomposition 스킵 로직 추가
상태 변경 트리 하향식 전파 -> 상태 변경시 자동으로 Recomposition

라이브 리터럴(Live Literals) 은 리컴파일 없이 상수 값 변경을 실시간으로 반영하는 개발 편의성을 위한 기능으로, 상수를 state로 변환하여 런타임에 동적으로 값을 변경 가능하게 한다. 성능에 민감한 코드의 속도를 크게 저하시킬 수 있으므로, release 빌드에선 비활성화를 권장

Recomposition 실행 여부 판단은 equals로(동등성, 값 비교) 판단

Kotlin에선 equals가 ==

비교 전파(Comparison propagation)

매개변수 변경 정보를 트리 아래로 전파하여 불필요한 비교와 recomposition을 생략하는 최적화 기법이다.

Compose Compiler는 성능 최적화를 위해 비트마스킹 기법을 적극 활용한다.

$changed

각 매개변수의 변경 상태를 비트로 인코딩한 메타데이터
함수에 전달된 매개변수들의 안정성 정보를 함께 인코딩

$dirty

매개변수가 실제로 변경되었는지(dirty) 판단하는 로컬 변수
$dirty를 기반으로 recomposition 여부 결정

$default

디폴트 매개변수 사용 여부를 비트로 표현한 메타데이터(각 매개변수의 인덱스를 비트로 매핑)

Kotlin 디폴트 매개변수는 생성된 그룹 범위 밖에서 실행
-> Composable은 그룹 내부에서 디폴트 값 평가(실행 및 계산) 필요
-> $default 비트마스크로 독자적 구현

Control flow group 생성

Compose Compiler는 Composable 함수의 제어 흐름에 따라 3가지 그룹을 Composable 함수 본문에 삽입하여 Compose Runtime이 상태를 관리하고 Recomposition을 효율적으로 처리하도록 함

Replaceable groups

교체될 수 있는 블록을 감싸는 그룹(조건부 블록)

생성 시점

  • Composable 람다식
  • @NonRestartableComposable 함수
  • 조건문(if/else, when)의 각 브랜치

예시

// 변환 전
@NonRestartableComposable
@Composable
fun Foo(x: Int) {
  Wat()
}

// 변환 후
@Composable
fun Foo(x: Int, $composer: Composer, $changed: Int) {
  $composer.startReplaceableGroup(<key>)
  Wat($composer, 0)
  $composer.endReplaceableGroup()
}

동작

if (condition) {
  Text("Hello")  // 교체 가능한 그룹 1
} else {
  Text("World")  // 교체 가능한 그룹 2
}
// condition 변경 시 그룹 전체 교체

Movable groups

정체성을 유지하며 재정렬 가능한 그룹

생성 시점

key() 함수 내부에서만 생성

예시

// 변환 전
@Composable
fun Test(value: Int) {
  key(value) {
    Wrapper {
      Leaf("Value $value")
    }
  }
}

// 변환 후
@Composable
fun Test(value: Int, $composer: Composer, $changed: Int) {
  $composer.startMovableGroup(<key>, value)
  Wrapper(composableLambda(...) { 
    Leaf("Value $value", $composer, 0)
  }, $composer, 0b0110)
  $composer.endMovableGroup()
}

사용 예시

Column {
  for (talk in talks) {
    key(talk.id) {  // 이동 가능한 그룹 생성
      Talk(talk)    // 리스트 순서 변경 시에도 정체성 유지
    }
  }
}

Restartable groups

상태를 읽고 recomposition이 필요한 Composable을 감싸는 그룹

생성 시점

상태(state)를 읽는 모든 Composable 함수

예시

// 변환 전
@Composable 
fun A(x: Int) {
  f(x)
}

// 변환 후
@Composable
fun A(x: Int, $composer: Composer, $changed: Int) {
  $composer.startRestartGroup(<key>)
  f(x)
  $composer.endRestartGroup()?.updateScope { next -> 
    A(x, next, $changed or 0b1)  // 재시작 방법 등록
  }
}

동작

endRestartGroup()이 null이 아닌 값 반환 시, Composable을 재시작(재실행)하는 람다 생성

updateScope()로 recomposition 방법 등록

null 반환 시: 상태를 읽지 않음 → recomposition 불필요

Composer의 역할

Composable 함수의 실행을 추적하고, 슬롯 테이블을 관리하며, recomposition을 제어하는 런타임 객체

이전에 언급했듯 Compose Compiler가 모든 Composable에 자동으로 주입

슬롯 테이블 관리

@Composable
fun Greeting(name: String, $composer: Composer) {
    $composer.startRestartGroup(...)  // 그룹 시작
    
    // 이전 값과 비교
    if ($composer.changed(name)) {
        // 변경됨 → recomposition
    }
    
    $composer.endRestartGroup()  // 그룹 종료
}

이전 composition의 데이터를 슬롯 테이블에 저장, 현재 값과 비교하여 변경 여부 판단

Recomposition 제어

changed(): 값 변경 감지

@Composable
fun Greeting(name: String, $composer: Composer) {
    if ($composer.changed(name)) {
        // name이 변경됨 → recomposition 수행
    }
}

skipping: recomposition 생략 여부 판단

@Composable
fun Header(text: String, $composer: Composer, $changed: Int) {
    var $dirty = $changed
    
    // $dirty 계산...
    
    if ($dirty and 0b1011 xor 0b1010 !== 0 || !$composer.skipping) {
        // recomposition 필요 → 본문 실행
        Text(text)
    } else {
        // 변경 없음 → 생략
        $composer.skipToGroupEnd()
    }
}

skipToGroupEnd(): 변경 없으면 건너뛰기

@Composable
fun Profile(user: User, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(...)
    
    if (/* 변경 없음 */) {
        $composer.skipToGroupEnd()  // 그룹 끝까지 건너뜀
        return  // 본문 실행 안 함
    }
    
    // 변경 있을 때만 실행
    Text(user.name)
    Text(user.email)
    
    $composer.endRestartGroup()
}

그룹 생성

startRestartGroup: 재시작 가능한 그룹 생성
startReplaceableGroup: 교체 가능한 그룹 생성
startMovableGroup: 이동 가능한 그룹 생성

reference)
Composer의 역할

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글