Compose 컴파일러 (The Compose compiler)

Philipy (윤상필)·2025년 12월 14일

핵심 아키텍처 흐름

컴포즈의 아키텍처는 개발자가 작성한 소스 코드(Sources)가 컴파일러(Compiler)를 통해 런타임(Runtime)이 이해할 수 있는 코드로 변환되고, 이 런타임이 최종적으로 UI를 그리거나 업데이트하는 흐름으로 구성된다.

  • Sources: 개발자가 @Composable 어노테이션을 사용하여 작성한 Kotlin 소스 코드.
  • Compiler: 소스 코드를 분석하고 IR(중간 표현)을 변형하여 런타임에 필요한 추가 정보(매개변수, 그룹 등)를 주입하는 Kotlin 컴파일러 플러그인.
  • Runtime: 컴파일러가 변환한 코드를 실행하여 Composable 트리의 인메모리 표현(슬롯 테이블)을 관리하고, 상태 변경 시 Recomposition을 트리거하는 역할.
  • UI: 런타임이 관리하는 트리 정보를 바탕으로 실제 화면에 그려지는 UI 툴킷. (예: compose-ui)

주요 구성 요소의 역할

구성 요소역할비고
Compose CompilerComposable 함수를 분석하고 런타임이 요구하는 코드로 변환하는 역할.Kotlin 컴파일러 플러그인 형태로 동작함.
Compose RuntimeComposable 트리의 인메모리 표현을 관리하고 Recomposition을 트리거함.UI에 종속되지 않은 포괄적 디자인을 가짐.
Compose UICompiler와 Runtime을 활용하는 안드로이드 UI 툴킷 클라이언트.Compose Multiplatform 등 다른 클라이언트 존재.

1. 컴포즈 컴파일러의 정체성: Kotlin 컴파일러 플러그인

기존 안드로이드 생태계에서 코드 생성에 널리 사용되던 kapt와 같은 어노테이션 프로세서와 달리, 컴포즈는 'Kotlin 컴파일러 플러그인'이라는 방식을 채택함. 이는 단순히 새로운 코드를 추가하는 수준을 넘어, 컴파일 과정에 직접 개입하여 기존 코드의 구조를 변형하고 언어의 기능을 확장하는 강력한 접근 방식임.

어노테이션 프로세서와의 비교 분석

컴파일러 플러그인 방식은 컴파일 과정에 직접 통합되어 더 깊은 수준의 코드 접근과 빠른 피드백을 제공함.

구분어노테이션 프로세서 (kapt)Kotlin 컴파일러 플러그인 (Compose)
실행 시점컴파일 이전 단계에서 실행됨.Kotlin 컴파일 과정에 직접 내장되어 함께 실행됨.
코드 접근/수정새로운 코드 추가만 가능. 기존 코드 수정은 불가능함.기존 코드의 IR을 정적 분석하고 직접 수정 및 변형 가능.
피드백 속도상대적으로 느림.컴파일러 프론트엔드 단계에서 진단하여 컴파일 시 피드백 루프가 매우 빠름.
핵심 장점표준화된 코드 생성 방식.언어 자체를 확장하는 수준의 강력한 코드 변형과 최적화 가능.

컴포즈 컴파일러 플러그인이 제공하는 빠른 진단은 컴파일 시점에 이루어짐. 우리가 코드를 작성하는 동안 IDE에서 즉각적으로 받는 경고나 오류는 컴파일러 플러그인이 직접 보고하는 것이 아니라, 별도의 전용 IDEA 플러그인을 통해 제공됨.

2. 컴파일러와의 약속: Compose 어노테이션 분석

컴포즈에서 어노테이션은 단순한 메타데이터를 넘어, 컴파일러의 동작을 유도하고 제어하는 핵심적인 '계약(Contract)'으로 작용. 개발자가 이 계약을 준수하면, 컴파일러의 가장 강력한 최적화 기능들을 활성화할 수 있음. 각 어노테이션이 컴파일러에게 어떤 정보를 제공하고, 이를 통해 어떤 최적화나 제약이 발생하는지 분석하는 것이 중요함.

핵심 어노테이션: @Composable

@Composable 어노테이션은 다음 주요 기능을 갖음.

  • 메모리 부여: 함수가 remember를 호출하고 내부적으로 Composer를 활용할 수 있는 능력을 부여함. 이를 통해 상태를 기억하고 UI 트리를 구축할 수 있게 됨.
  • 라이프사이클 제공: LaunchedEffect와 같은 이펙트(effects)들이 Recomposition 과정에서도 자신의 상태를 유지하고 올바르게 동작할 수 있는 생명주기를 제공함.
  • 정체성(ID) 할당: UI 트리 내에서 특정 위치에 고유한 ID를 할당받아, 런타임이 위치 기반 기억법(positional memoization)을 통해 상태와 노드를 효율적으로 관리하는 기반이 됨.

동작 제어 및 최적화 어노테이션

@DisallowComposableCalls

  • 특정 람다 매개변수 내에서 Composable 함수를 호출하는 것을 금지하는 제약 어노테이션임.
  • 이는 Recomposition마다 호출될 필요가 없는 코드 블록의 의도치 않은 Composable 호출을 방지하기 위함.
    • 대표적인 사용 사례는 remember 함수의 calculation 람다로, 이 람다는 최초 Composition 시에만 실행되어야 하므로 내부에서 UI 노드를 방출하는 Composable 함수 호출이 허용되지 않음. 컴파일러는 이 어노테이션을 보고 해당 람다 내에서의 Composable 호출을 컴파일 오류로 진단함.

@ReadOnlyComposable

  • 해당 Composable 함수가 Composition에 어떠한 '쓰기' 작업(노드 생성, 상태 변경 등)도 하지 않고, 오직 '읽기' 작업(예: CompositionLocal 값 조회)만 수행함을 컴파일러에 알리는 최적화 어노테이션임. 컴파일러는 이 약속을 믿고, 해당 함수를 추적하거나 재정렬할 필요가 없다고 판단하여 재시작(restartable)이나 이동(movable) 가능한 컨트롤 플로우 그룹의 생성을 생략함.
    • 예) MaterialTheme.colorsLocalContext.current와 같이 한 번 설정된 후 변경되지 않는 값을 조회하는 유틸리티 함수들이 대표적인 예시임.

@NonRestartableComposable

  • 해당 Composable 함수가 상태를 읽지 않아 스스로 Recomposition을 트리거할 일이 없음을 명시하여, Recomposition을 위한 상용구 코드 생성을 건너뛰게 하는 어노테이션임.
  • 이 함수는 오직 상위 Composable의 Recomposition에 의해서만 다시 실행될 수 있음. 논리가 거의 포함되지 않은 단순한 래퍼(wrapper) Composable 등 매우 제한적인 경우에만 사용되며, 실제 사용 사례는 드묾.

타입 안정성(Stability) 관련 어노테이션

타입 안정성은 입력값이 변경되지 않았을 때 Composable 함수의 재실행을 건너뛰는 '스마트 Recomposition'의 핵심 전제 조건임. 아래 어노테이션들은 특정 타입이 안정적이라는 것을 컴파일러에 약속하여 최적화를 유도함.

  • @StableMarker @Immutable@Stable의 기반이 되는 메타 어노테이션으로, 안정성을 위해 타입이 충족해야 할 세 가지 요구사항을 정의함.
    • 두 인스턴스에 대한 equals 함수의 결과는 항상 동일해야 함.
    • 타입의 public 프로퍼티가 변경되면 Composition에 그 사실을 알려야 함.
    • 모든 public 프로퍼티 역시 안정적인(stable) 타입이어야 함.
  • @Immutable 클래스의 인스턴스가 생성된 후에는 어떠한 public 프로퍼티도 절대 변경되지 않음을 보장하는 강력한 약속임. 이는 프로퍼티 재할당을 막는 Kotlin의 val보다 더 엄격한 개념으로, val로 선언된 가변 컬렉션(e.g., MutableList)의 내부 요소 변경까지 금지하는 수준의 불변성을 의미함. 컴파일러는 이 약속을 바탕으로 해당 타입의 객체 비교를 생략할 수 있음.
  • @Stable 이 어노테이션은 적용 대상에 따라 두 가지 의미를 가짐.
    1. 타입에 적용 시: 타입 자체는 가변적(mutable)일 수 있으나, 만약 public 프로퍼티의 값이 변경될 경우 반드시 Composition에 알릴 것임을 보장하는 가벼운 약속임. MutableState가 대표적인 예로, 내부 값은 변경될 수 있지만 그 변경이 Composition 시스템에 통지되므로 안정적으로 간주됨.
    2. 함수/프로퍼티에 적용 시: 해당 함수나 프로퍼티가 동일한 입력에 대해 항상 동일한 결과를 반환할 것을 약속함.

3. 컴파일러 내부 프로세스 1: 정적 분석 (Static Analysis)

코드 생성에 앞서, 컴포즈 컴파일러는 코드의 유효성을 검사하고 개발자에게 빠른 피드백을 제공하는 '정적 분석' 단계를 수행함. 이 단계는 Kotlin 컴파일러의 프론트엔드에서 실행되므로, 무거운 컴파일 작업을 거치지 않고도 빠른 피드백 루프를 가능하게 하여 개발 생산성을 크게 향상시키는 중요한 과정임.

주요 정적 검사기(Static Checkers) 분석

컴파일러는 라이브러리의 올바른 사용을 보장하기 위해 다양한 정적 검사기를 등록하여 코드의 문법과 규칙을 검증함.

  • 호출 검사 (Call checks) @Composable 함수가 허용되지 않는 컨텍스트에서 호출되는지를 검사함. 예를 들어, @DisallowComposableCalls 어노테이션이 붙은 람다 내부나, try/catch 블록과 같이 컴포즈 런타임이 상태를 올바르게 추적할 수 없는 곳에서 Composable 함수를 호출하면 오류를 보고함. 컴파일러는 PSI(Program Structure Interface) 트리를 순회하며 현재 코드의 컨텍스트를 추적하고 규칙 위반 여부를 판단함.
  • 타입 검사 (Type checks) @Composable 어노테이션이 함수 타입의 일부로 올바르게 사용되었는지 검사함. 예를 들어, @Composable () -> Unit 타입의 매개변수가 필요한 곳에 일반 () -> Unit 타입의 람다가 전달되면, 컴파일러는 타입 불일치 오류를 보고하고 @Composable 어노테이션을 추가하도록 제안함.
  • 선언 검사 (Declaration checks) @Composable 어노테이션이 지원되지 않는 선언과 함께 사용되는 것을 금지함. 예를 들어, Composable 함수는 suspend 함수가 될 수 없으며, 프로그램의 진입점인 main 함수 역시 Composable로 선언할 수 없음. 이러한 규칙들은 컴파일러가 각 선언의 유효성을 검사하여 강제함.

진단 제지기 (Diagnostic suppression)

컴포즈 컴파일러는 때로는 일반적인 Kotlin 문법에서는 오류로 간주될 상황을 의도적으로 '무시'하여 컴포즈만의 유연한 문법을 허용함. 이를 '진단 제지기'라고 하며, 컴파일러 플러그인의 강력함을 보여주는 대표적인 사례임.

  • 사례 1: 호출 위치(Call-site) 어노테이션: 원래 Kotlin에서는 런타임에 정보가 남아야 하는 어노테이션(non-source retention)을 인라인 람다에 붙일 수 없음. 하지만 컴포즈 컴파일러는 @Composable 어노테이션에 한해 이 제약을 우회하여, 개발자가 인라인 함수의 람다 매개변수를 호출하는 위치에서 @Composable을 붙일 수 있도록 지원함.
  • 사례 2: 함수 타입의 명명된 매개변수: 표준 Kotlin에서는 함수 타입 선언 시 매개변수 이름을 지정할 수 없음(예: (String) -> Unit). 그러나 컴포즈 컴파일러는 @Composable 함수 타입에 한해 이 규칙을 제지하여 content: @Composable (path: String) -> Unit 와 같은 선언을 가능하게 함. 이는 코드 가독성을 크게 향상시킴.

정적 분석을 통해 코드의 유효성을 확보한 컴파일러는, 이제 코드를 실질적으로 변형하고 런타임에 필요한 정보를 심는 '코드 생성' 단계로 넘어감.

4. 컴파일러 내부 프로세스 2: 코드 생성 및 낮추기 (Lowering)

컴포즈 컴파일러의 가장 핵심적인 역할은 개발자가 작성한 선언적 코드를 런타임이 실제로 실행할 수 있는 구체적인 코드로 변환하는 '코드 생성'임. 이 과정은 컴파일러 백엔드에서 소스 코드의 IR(중간 표현)을 직접 수정하고 변형하는 방식으로 이루어짐. 이처럼 더 높은 수준의 추상적인 개념을 더 낮은 수준의 구체적인 코드로 변환하는 과정을 '낮추기(Lowering)'라고 부름. 컴파일러는 이 단계를 통해 런타임에 필요한 암시적 매개변수를 주입하고, 최적화를 위한 그룹을 생성하며, Recomposition을 효율적으로 처리하기 위한 장치들을 코드에 심어 넣음.

주요 낮추기(Lowering) 기법 분석

  • 클래스 안정성 추론 (Inferring class stability) 컴파일러는 클래스에 @Stable 또는 @Immutable 어노테이션이 없더라도, 클래스의 모든 필드와 제네릭 타입을 분석하여 안정성을 자동으로 추론함. 이 추론 결과를 바탕으로 런타임에 안정성 정보를 제공하기 위해 $stable이라는 합성 필드를 클래스에 추가함. 하지만 모든 타입을 완벽하게 추론할 수는 없음.
    • List<T>와 같은 인터페이스 타입은 불안정(unstable)하다고 간주됨. 컴파일러는 해당 인터페이스가 ArrayList와 같은 가변적인 구현체에 의해 인스턴스화될 가능성을 배제할 수 없기 때문임.
    • class Foo<T>(val value: T)와 같은 제네릭 클래스의 안정성은 런타임에 T에 전달되는 실제 타입의 안정성에 따라 결정됨. 컴파일러는 이 의존성을 코드에 기록하여 런타임이 최종 안정성을 판단할 수 있도록 함.
  • 람다 기억법 (Lambda memoization) Recomposition 시 불필요한 람다 인스턴스 생성을 방지하여 성능을 최적화하는 기법임.
    • Composable이 아닌 람다: 외부 변수를 캡처하는 일반 람다의 경우, 캡처된 변수들이 모두 안정적이라면 컴파일러가 자동으로 remember 호출 코드를 생성하여 람다 인스턴스를 감싸줌. 이를 통해 입력값이 동일할 경우 기존 람다 인스턴스를 재사용함.
    • Composable 람다: 컴파일러는 composableLambda라는 팩토리 함수 호출로 람다를 래핑함. 이 팩토리 함수는 고유 키를 사용하여 람다를 슬롯 테이블에 저장하고 재사용함. 이 방식은 "도넛 홀 생략하기(donut-hole skipping)"라는 강력한 최적화를 가능하게 함. 즉, 상위 계층에서 람다 인스턴스가 변경되더라도, 그 람다를 실제로 호출(읽기)하는 하위 Composable만 Recomposition되고, 중간에 람다를 그저 전달만 하는 Composable들은 건너뛸 수 있게 됨.
  • Composer 주입 (Injecting the Composer) 컴포즈 코드 변환의 가장 근본적인 과정으로, 모든 @Composable 함수에 $composer라는 추가 매개변수를 암시적으로 주입함. 이 Composer 객체는 런타임의 슬롯 테이블과 상호작용하며 UI 트리를 구축하고 업데이트하는 모든 작업을 관장함. 개발자는 이 매개변수를 직접 다루지 않지만, 컴파일러에 의해 모든 Composable 호출에 자동으로 전달됨.
  • 비교 전파 (Comparison propagation) 입력 매개변수의 변경 여부 정보를 담은 $changed라는 비트마스크(bitmask) 매개변수를 모든 Composable 함수에 추가함. 이 비트마스크는 단순히 '변경됨/안됨'을 넘어 더 세밀한 정보를 인코딩하여 런타임이 불필요한 equals() 비교를 생략하게 함.
    • 정적(Static) 상태: 매개변수가 상수 리터럴일 경우, 컴파일 타임에 이미 변경되지 않음을 알 수 있으므로 런타임 비교를 완전히 생략함.
    • 확실한(Certain) 상태: 상위 Composable에서 이미 비교를 마쳐 변경되지 않았음이 보장된 경우, 하위에서는 비교를 건너뜀.
    • 불확실한(Uncertain) 상태: 변경 여부를 알 수 없는 경우, 런타임은 equals()를 호출하여 이전 값과 비교함.
  • 디폴트 매개변수 처리 (Default parameters) 컴포즈는 Kotlin의 기본 디폴트 매개변수 생성 메커니즘을 사용하지 않음. 대신, $default라는 비트마스크 매개변수를 추가하여 어떤 매개변수가 호출자로부터 전달되지 않아 디폴트 값을 사용해야 하는지를 추적함. 이는 디폴트 값을 계산하는 표현식이 Composable 함수의 실행 컨텍스트(그룹) 내에서 실행되어야 하기 때문이며, 컴포즈만의 방식으로 이를 구현한 것임.
  • 컨트롤 플로우 그룹 생성 (Control flow group generation) if, for, when과 같은 제어 흐름 구문을 런타임이 인식하고 관리할 수 있도록, 컴파일러는 해당 코드 블록을 특정 목적을 가진 '그룹'으로 감쌈.
    • 교체 가능한 그룹 (Replaceable groups): if-else 문과 같이 조건에 따라 UI의 일부가 다른 것으로 교체될 때 사용됨. 런타임은 이 그룹을 통해 이전 분기(branch)의 노드를 정리하고 새로운 분기의 노드를 생성함.
    • 이동 가능한 그룹 (Movable groups): key 함수와 함께 사용되어 리스트 아이템의 순서가 변경되었을 때 UI 노드를 파괴하고 새로 만드는 대신, 기존 노드를 효율적으로 재정렬(이동)할 수 있게 함.
    • 재시작 가능한 그룹 (Restartable groups): 상태(State)를 읽는 모든 Composable 함수를 감싸는 가장 중요한 그룹임. 상태 값이 변경되었을 때, 전체 UI를 재구성하는 것이 아니라 이 그룹으로 감싸진 Composable만 독립적으로 Recomposition('재시작') 될 수 있도록 하는 스코프(scope)를 제공함. 이는 컴포즈의 효율적인 Recomposition의 핵심 원리임.

요약 및 결론

Jetpack Compose 컴파일러는 단순히 Kotlin 코드를 바이트코드로 변환하는 도구가 아님. 이는 개발 과정에 능동적으로 참여하는 파트너와 같음.

  1. 정적 분석을 통해 코드의 유효성을 검사하고,
  2. 어노테이션이라는 '계약'을 기반으로 IR(중간 표현)을 정교하게 변형하는 '낮추기(Lowering)' 과정을 수행함.
  3. 이 과정을 통해 개발자가 선언한 UI를 런타임이 효율적으로 생성, 관리, 업데이트할 수 있도록 최적화된 코드를 재창조하는 컴포즈 아키텍처의 핵심 엔진이라 할 수 있음.
profile
Tech Phase-smith, Karax wannabe

0개의 댓글