[Compose Internals] 2.Compose 컴파일러

벼리·2025년 12월 14일

Compose

목록 보기
2/9

Jetpack Compose는 다양한 라이브러리들로 구성되어 있습니다. 이번 장에서는 Compose 컴파일러, Compose 런타임, Compose UI 세가지에 중점을 둘 것입니다.

Compose Compiler와 Runtime은 Jetpack Compose의 핵심 요소입니다. 엄밀하게 말하자면, Compose UI는 Compose 아키텍처의 일부가 아닙니다. Runtime과 Compiler는 어떤 라이브러리에서든 사용될 수 있도록 포괄적으로 디자인되어있는데 반해, Compose UI는 Runtime과 Compiler를 활용하는 클라이언트에 불과하기 때문입니다.

그리고 이제부터 Compose UI를 살펴보며, Compose가 Composable 트리의 런타임 인모메리 표현을 어떻게 제공하는지 그리고 궁극적으로 해당 인메모리 표현을 어떻게 실제 요소로 구체화했는지 이해해봅시다.

이제 컴파일러를 이해하는 것부터 시작해 보도록 하겠습니다.

Compose 컴파일러

Jetpack Compose는 코드 생성에 다소 의존합니다. 예를 들어서, 앞의 1장에서 Compose Compiler가 Composable 함수의 정보를 읽고 조작하여 Composer라는 새로운 매개변수를 주입하는 동작이 이에 해당합니다.

Kotlin과 JVM 진영에서는 보통 kapt를 통한 어노테이션 프로세서(annotation processor)를 사용하는 것이 일반적이지만, Jetpack Compose는 다릅니다. Compose Compiler는 실제의 Kotlin 컴파일러 플러그인입니다. 이 플러그인은 코드가 기계어로 번역되는 컴파일 과정 도중에 직접 개입할 수 있습니다. 그렇기 때문에 개발자가 쓴 코드(@Composable)를 보고, 그 코드가 실행되는 방식을 실시간으로 바꿀 수 있습니다. 즉, Composer 매개변수를 추가하는 것과 같이 개발자가 쓴 함수의 내부를 뜯어 고칠 수 있습니다. 이는 컴파일 전에 작업을 수행하는 kapt가 가지는 단점을 극복할 수 있습니다. kapt는 기존 코드를 수정할 수 없지만, Compose Compiler는 구조를 직접적으로 변경하여 전체 프로세스를 가속화할 수 있습니다.

그렇다면 컴파일러(빌드 버튼을 눌렀을 때) Compose Compiler가 작동하므로 컴파일 이전에는 코드가 잘못되었는지 미리 알 수 없지 않을까? 라는 생각이 들 수 있습니다. 이런 문제를 해결하기 위해 Compose 팀에서는 IDE 전용 플러그인을 만들어 안드로이드 스튜디오 안에서 실시간으로 코드를 보고 빨간 줄을 띄워줍니다. 예를 들어, Compose 안에서 remember를 안 쓸 경우 경고가 뜨는 사례가 이에 해당합니다.

Compose 어노테이션들

Compose의 작업 순서를 다시 떠올려봅시다. Compose는 먼저 어노테이션을 활용해 Compose Compiler가 필요한 요소를 스캔합니다. 그렇다면 Compose 어노테이션을 먼저 알아보아야 합니다.

컴파일러 플러그인과 어노테이션 프로세서 사이에는 공통점이 몇 가지 있습니다. 그 공통점 중 하나는 정적 분석과 검증에 자주 사용되는 프론트엔드 단계입니다.

Compose Compiler는 Kotlin 컴파일러의 프론트엔드에서 후크 및 확장점을 활용하여 강제 제약 조건을 충족하고 있는지 확인합니다. 또한 타입 시스템이 Composable 함수나 선언 및 표현식을 일반 함수와는 다르게 처리하고 있는지 확인합니다.

그 외에도, Compose는 특정 상황에서 추가적인 검사와 다양한 런타임 최적화 또는 숏컷을 활성화하기 위해 추가적인 어노테이션들을 제공합니다. 모든 Compose 어노테이션들은 Compose Runtime에 의해 제공됩니다.

@Composable

Composable 어노테이션은 이미 1장에서 깊이 다뤘습니다. 그럼에도 불구하고, 가장 중요한 어노테이션이기에 별도의 섹션에서 다룰 필요가 있습니다.

Compose Compiler와 어노테이션 프로세서의 가장 큰 차이점은, Compose의 경우 Compose의 경우 실제 어노테이션이 붙어있는 선언이나 표현식을 변형한다는 점입니다. 앞서 말했듯이, 대부분의 어노테이션은 컴파일 이전에 작업을 수행하기 때문에 추가하거나 동등한 선언만 할 수 있습니다. 그래서 Compose Compiler는 직접 변형할 수 있도록 IR 변환을 사용합니다. @Composable 어노테이션은 실제로 어노테이션이 붙은 대상의 타입을 변경하며, 컴파일러 플러그인은 프론트엔드에서 Composable 타입이 일반적인 함수들과 동일한 취급을 받지 않도록 하는데 사용됩니다.

@Composable을 통해 선언이나 표현식의 타입을 변경하는 것은 대상에게 메모리를 부여하는 것을 의미합니다. 즉, remember를 호출하고 Composer 및 슬롯 테이블을 활용할 수 있는 능력을 가지게 됨을 의미합니다. 또한, Composable의 스코프 내에서 이펙트들(effects)들이 구동될 수 있는 라이프사이클을 제공합니다. 예를 들어 recompoisiton을 수행해도 기존 작업을 유지하는 것이 이에 해당합니다. Composable 함수들은 메모리에 보존될 수 있도록 각각 고유 ID를 할당받고, 완성된 트리에서 위치가 지정됩ㄴ디ㅏ. 즉, Composable 함수들은 노드를 recomposition으로 방출하고 CompositionLocals를 처리할 수 있습니다.

💡 Composable 함수는 실행 시 트리에 내보내지는 노드로 데이터를 매핑하는 것을 나타냅니다. 이 노드는 UI 노드일 수 있고, 우리가 Compose Runtime을 사용해 소비하는 라이브러리에 따라 다른 노드일 수 있습니다. Compose Runtime은 특정 사용 사례나 의미론에 묶이지 않은 일반적인 유형의 노드로서 동작합니다.

@DisallowComposableCalls

함수 내에서 Composable 함수를 호출할 수 없도록 막을 때 사용합니다. 주로 recomposition 마다 호출되면 안 되는 람다식에 사용됩니다.

가장 대표적인 예시로는 Compose Runtime의 일부인 remember 함수에서 찾아볼 수 있습니다. remember 함수는 calculation 블록에 의해 제공된 값을 기억합니다. 이 블록은 최초의 composition 단계에서만 수행되며, 이후의 모든 recomposition 단계에서는 항상 이미 계산된 값을 반환합니다.

@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls ()> T): T =
	currentComposer.cache(false, calculation)

@DisallowComposableCalls는 조건부로 호출되는 인라인 람다에서 가장 적합하게 사용할 수 있습니다.

앞서 배운 것처럼, inline 함수는 호출하는 쪽의 문맥을 그대로 이어받습니다. remember 또한 inline이므로, 원래대로라면 remember 안에서도 UI를 그릴 수 있어야 합니다.

하지만 remember는 값을 저장하는 곳이지, UI를 그리는 곳이 아닙니다. 만약 UI 노드가 슬롯 테이블에 기록된다면, 어떻게 될까요?

remember는 이전에 계산된 값을 재사용하므로, 다시 실행하지 않습니다. 그 결과 슬롯 테이블에는 UI 노드가 그려진 기록이 남아있는데, 실행 과정에서는 컴포저블 함수를 호출하지 않아서 혼란에 빠지게 됩니다.

또한 @DisallowComposableCalls이 붙은 구역 안에서 또 다른 함수를 부른다면, 그 함수도 반드시 @DisallowComposableCalls를 표시해야 합니다. Compose UI와 같은 프레임워크를 직접 만드는 사람이라면, remember 처럼 특수한 제약이 필요한 API를 설계할 때 반드시 이 규칙을 따라야 합니다.

@ReadOnlyComposable

@ReadOnlyComposable 어노테이션이 Composable 함수에 적용되면 해당 함수를 composition에 절대 쓰지 않고, 오직 읽기만 할 것이라는 의미입니다. 즉, composition 단계에서 Composable 함수의 정보를 인메모리에 저장 및 수정하지 않는다는 의미입니다. 이를 통해 Compose Runtime은 불필요한 코드 생성을 사전에 방지합니다.

Compose Runtime은 컴포저블 함수가 실행될 때, 메모리(슬롯 테이블)에 이 함수는 여기서 실행된다는 정보를 기록합니다. 이를 그룹이라고 표현할 수 있습니다. 나중에 리스트의 순서가 바뀌거나, 다시 그릴 때, 어디를 지워야할지 그리고 어디로 옮겨야 할지 정확히 알기 위해서 필요한 과정입니다.

하지만 기록하지 않고 단순히 읽어오면 되는 함수들이 있습니다. 예를 들어서 색깔, 폰트, 시스템 다크모드 등이 있습니다. 이는 ㅍ로그램을 실행할 때 단 한 번만 값이 설명되고, Composable 트리에서 읽을 수 있도록 값이 일관성있게 유지됩니다.

@NonRestartableComposable

@NonRestartableComposable 어노테이션은 함수나 프로퍼티의 getter에 적용하면 기본적으로 재시작이 불가능한 Composable 함수가 됩니다. (인라인 된 Composable 함수나 반환타입이 Unit이 아닌 Composable 함수는 재시작할 수 없으므로 모든 Composable이 기본적으로 재시작 가능한 것은 아닙니다. )

💡 값을 반환하는 Composable은 부모 Composable이 자식 Composable의 반환값에 의존하게 됩니다. 즉, 부모의 재구성이 필연적이므로 Restart Scope를 만들 필요가 없어 재시작 불가능한 함수로 취급됩니다.

@NonRestartableComposable 어노테이션이 추가되면 컴파일러는 함수가 recompositon이 되는 동안 함수를 재구성하거나 생략하는데 필요한 필수적인 보일러 플레이트 코드를 생성하지 않습니다. 왜냐하면 이 함수는 스스로 재시작할 능력이 없기 때문입니다. 오로지 상위 Compsable에 의해서만 재시작되기 때문에 Restart Scope가 필요하지 않습니다.

@StableMarker

Compose Runtime은 타입의 안정성을 나타내기 위해 몇 가지 어노테이션을 제공합니다. 대표적으로 @StableMarker라는 메타 어노테이션, @Immutable, 그리고 @Stable 어노테이션이 있습니다. 먼저 @StableMarker 부터 살펴보도록 하겠습니다.

@StableMarker는 @Immutable이나 @Stable에 사용되는 메타 어노테이션입니다. 재사용이라는 의미를 내포하고 있고, 어노테이션을 위한 어노테이션으로 작용합니다.

@StableMarker를 통해 데이터 안정성을 표시할려면 다음과 같은 요구사항을 만족해야 합니다.

  • equals 함수의 호출 결과는 동일한 두 인스턴스에 대해 항상 동일합니다.
  • 어노테이션이 적용된 public 프로퍼티 변경이 발생하면 해당 사실을 composition에 알립니다.
  • 어노테이션이 적용된 모든 public 프로퍼티는 안정적이라고 간주합니다.

이것은 컴파일러에게 제공하는 약속이므로 소스 코드를 처리할 때 몇 가지 추론을 해볼 수 있지만, 컴파일 시 이 약속에 대한 유효성 검사를 하지 않습니다. 즉, 모든 요구사항이 충족되는 시기를 결정짓는 것은 결국 개발자의 몫입니다.

대부분의 경우 안정성과 관련된 어노테이션을 사용하지 않아도 컴파일러 기능만으로 정확성이 보장되지만, 개발자가 직접 어노테이션을 달아줘야 하는 2가지 경우가 있습니다.

  • 인터페이스 또는 추상클래스의 경우
  • 구현체가 가변적이지만, 개발자의 판단하에 안정성을 가정하고 안정적인 타입으로 처리하고 싶은 경우
    • 타입이 내부적으로 캐시를 보유하고 있기 때문에 변경 가능하지만, 해당 타입의 Public API가 캐시 상태와 독립적인 경우

@Immutable

Kotlin의 언어 차원에서 제공하는 val 키워드 보다 훨씬 강력한 약속입니다. 가령, val은 프로퍼티가 setter을 통해 재할당될 수 없음을 보장하지만, 가변적인 데이터 구조를 참조할 수 있기 때문에 val로 정의되어 있어도 여전히 가변적일 수 있습니다. 이런 문제를 해결하기 위해, 어떤 데이터가 불변인지를 보장하는 @Immutable 어노테이션은 Compose에 필수적입니다.

Compose Runtime은 타입으로부터 읽는 값이 초기화된 후에는 변경되지 않는다고 가정하고, 스마트 recomposition 및 recomposition 생략 기능을 최적화할 수 있습니다.

@Stable

@Stable은 @Immutable보다 좀 더 가벼운 약속이라고 볼 수 있습니다. 어떤 언어 요소에 적용하느냐에 따라 의미가 달라집니다.

이 어노테이션이 타입에 적용되면 해당 타입이 가변적임을 의미하고, @StableMarker에 의한 상속 의미만 지니게 됩니다. 대신 @Stable 어노테이션을 함수나 프로퍼티에 적용하면 함수가 항상 동일한 입력값에 대해 동일한 결과를 반환한다는 사실을 컴파일러에 알립니다. 이 때, 함수의 매개변수가 @Stable 또는 @Immutable 으로 마킹되거나, primitive 타입인 경우에만 가능합니다.

컴파일러 확장 등록

지금까지 Compose 런타임에서 사용하는 주요 어노테이션들을 살펴보았습니다. 이제는 이 어노테이션들을 실제로 처리하는 Compose 컴파일러 플러그인이 어떻게 작동하는지 알아볼 차례입니다.

Compose 컴파일러 플러그인의 첫 번째 임무는 코틀린 컴파일러에게 자신의 존재를 알리고 등록하는 것입니다. ComponentRegistrar라는 도구를 사용해 코틀린 컴파일 과정에 끼어드는 것인데, 이때 플러그인은 코드 생성이나 개발 편의를 돕는 다양한 확장 기능(Extensions)을 함께 등록합니다. 이렇게 등록된 기능들은 코틀린 컴파일러가 작동할 때 같이 실행됩니다.

또한, 개발자가 설정한 옵션(플래그)에 따라 다음과 같은 추가 기능들이 활성화되기도 합니다.

  1. 라이브 리터럴: 수치나 문자열을 바꾸면 앱 재실행 없이 즉시 반영되는 기능입니다.
  2. 디버깅 정보 포함: 안드로이드 스튜디오의 레이아웃 인스펙터 같은 도구가 UI 구조를 분석할 수 있도록 소스 정보를 코드에 심어줍니다.
  3. 성능 최적화: remember 함수의 성능을 개선하거나, 코틀린 버전 호환성 검사를 유연하게 처리합니다.
  4. 내부 처리 지원: 컴파일러 내부 변환 과정(IR)에서 필요한 임시 메서드(미끼 메서드) 등을 생성합니다.

요약하자면, Compose 컴파일러 플러그인은 코틀린 컴파일러의 조력자로서 우리가 작성한 코드를 런타임이 이해할 수 있게 변환하고, 최적화하며, 개발 도구들이 잘 작동하도록 돕는 역할을 합니다.

Kotlin 컴파일러 버전

Compose 컴파일러는 특정 Kotlin 버전과 정확히 일치해야 합니다. 버전이 다르면 심각한 문제가 발생할 수 있으니 가장 먼저 확인해야 합니다.

물론, suppressKotlinVersionCompatibilityCheck라는 옵션을 사용하면 버전 검사를 건너뛸 수 있습니다. 하지만 이는 개발자가 모든 위험을 감수해야 한다는 뜻입니다. 검사를 끄면 아무 Kotlin 버전이나 사용할 수 있게 되지만, 특히 빠르게 발전하는 최신 Kotlin 컴파일러와 충돌하여 컴파일 오류가 발생할 가능성이 매우 높습니다. 이 옵션은 주로 실험적인 Kotlin 버전을 미리 테스트해보려는 목적으로 만들어진 것으로 보입니다.

정적 분석

Compose 컴파일러 플러그인이 가장 먼저 하는 일은 정적 분석입니다. 코드를 실행하지 않고 소스 코드 자체를 훑어보면서(린팅), 라이브러리의 어노테이션들이 Compose 런타임이 의도한 대로 올바르게 쓰였는지 검사하는 것입니다.

이 과정에서 발견된 경고나 오류는 컴파일러가 수집한 문맥 정보를 통해 안드로이드 스튜디오 같은 도구에 즉시 전달됩니다. 덕분에 개발자는 코드를 입력하는 바로 그 순간에 빨간 줄이나 경고 메시지를 볼 수 있습니다. 이 모든 검사는 컴파일의 아주 초기 단계(프론트엔드)에서 이루어지므로 피드백이 매우 빠릅니다.

가장 중요한 정적 검사 몇 가지를 살펴보도록 하겠습니다.

정적 검사기

Compose는 개발자가 코드를 작성하는 동안 올바른 문법을 안내해 주는 여러 정적 검사기를 제공합니다. 함수 호출, 타입 선언, 어노테이션 사용법 등이 검사 대상입니다.

예를 들어, 앞서 배운 Composable 함수의 규칙들을 어기면 이 검사기들이 즉시 적발하여 알려줍니다. Kotlin 컴파일러에는 클래스 생성, 코루틴 호출, 연산자 사용 등 다양한 요소를 검사하는 분석기가 있는데, Compose 플러그인도 이를 활용해 소스 코드를 분석합니다.

이 검사기들은 매우 가볍게 설계되어 있습니다. 개발자가 코드를 타이핑하는 동안 컴퓨터가 버벅거리지 않도록, CPU를 많이 쓰는 복잡한 작업은 피하고 빠르게 문법만 체크합니다.

호출 검사 (Call Checks)

가장 대표적인 검사는 Composable 함수가 올바른 위치에서 호출되었는지 확인하는 호출 검사입니다. 예를 들어, DisallowComposableCalls가 붙은 곳에서는 Composable 함수를 부를 수 없도록 막는 것이죠.

컴파일러는 소스 코드의 모든 요소(PSI 트리)를 하나씩 방문하며 검사를 수행합니다. 단순히 현재 코드 한 줄만 보는 것이 아니라, 이 코드가 어떤 함수 안에 있는지, 그 함수는 또 어디서 호출되는지 같은 전체적인 문맥(Context Trace)을 파악합니다.

주요 검사 항목은 다음과 같습니다.

  1. 금지된 위치에서의 호출: try/catch 블록 내부나 Composable이 아닌 일반 함수 내부에서는 Composable 함수를 호출할 수 없습니다. 컴파일러는 이런 규칙 위반을 찾아냅니다.
  2. 인라인 함수 확인: 인라인 람다(예: forEach) 내부에서 Composable 함수를 호출할 때, 이 람다를 감싸고 있는 부모 함수가 Composable인지 확인하여 호출 가능 여부를 판단합니다.
  3. 어노테이션 누락 감지: Composable 함수를 호출하고 있는데 정작 자신에게는 Composable 어노테이션이 없다면, 컴파일러가 이를 감지하고 어노테이션을 붙이라고 제안해줍니다. 이는 개발자가 실수하지 않도록 돕는 친절한 가이드 역할도 합니다.
  4. 읽기 전용 함수 검사: ReadOnlyComposable 어노테이션이 붙은 함수 안에서는 오직 다른 읽기 전용 함수만 호출할 수 있습니다. 만약 무언가를 쓰거나 상태를 변경하는 일반 Composable 함수를 호출하면, 읽기 전용이라는 약속(최적화 계약)이 깨지므로 이를 오류로 처리합니다.

타입 검사 (Type Checks)

함수뿐만 아니라 변수의 타입에도 Composable 어노테이션을 붙일 수 있습니다. Compose 컴파일러는 Composable 타입이 와야 할 자리에 일반 타입이 오거나, 반대로 일반 타입이 와야 할 자리에 Composable 타입이 왔는지 검사합니다. 만약 서로 맞지 않는다면 컴파일러는 기대했던 타입과 실제 발견된 타입의 차이를 명확하게 에러로 알려줍니다.

선언 검사 (Declaration Checks)

함수를 호출할 때뿐만 아니라, 함수나 변수를 정의(선언)할 때 지켜야 할 규칙들도 검사합니다. 컴파일러는 프로퍼티, 함수, 매개변수 등이 올바르게 선언되었는지 확인합니다.

주요 검사 항목은 다음과 같습니다.

  1. 재정의(Override) 규칙: 부모 클래스의 Composable 함수나 프로퍼티를 자식 클래스에서 재정의할 때, 자식 쪽에도 반드시 Composable 어노테이션이 붙어 있어야 합니다.
  2. suspend 혼용 불가: Composable 함수에는 suspend 키워드를 붙일 수 없습니다. 두 개념 모두 비동기 처리를 다루지만 작동 방식이 완전히 다르기 때문에 현재는 함께 사용할 수 없도록 막혀 있습니다.
  3. 기타 금지 사항: 앱의 시작점인 main 함수는 Composable 함수가 될 수 없습니다. 또한 Composable 프로퍼티는 값을 저장하는 뒷받침 필드(backing field)를 가질 수 없고, 오직 게터(getter)로만 동작해야 합니다.

진단 제지기(진단 무시)

Compose 컴파일러 플러그인은 코틀린의 기본 규칙을 잠시 꺼두는 진단 무시 기능을 사용하기도 합니다. Compose가 마법 같은 기능을 제공하기 위해 코틀린이 평소에는 허용하지 않는 코드를 생성하거나 허용해야 할 때가 있기 때문입니다. 즉, 의도적으로 특정 에러를 발생시키지 않도록 막는 것입니다.

대표적인 예가 인라인 람다에 어노테이션을 붙이는 경우입니다.

원래 코틀린 규칙대로라면 인라인 람다에는 런타임까지 살아남는 어노테이션(예: @Composable)을 붙일 수 없습니다. 인라인 람다는 컴파일 과정에서 코드가 호출 위치로 복사되어 람다 자체의 실체가 사라지기 때문에, 어노테이션을 붙일 대상도 함께 사라진다고 판단하여 에러를 냅니다.

하지만 Compose는 UI를 구성하기 위해 인라인 람다에도 @Composable 어노테이션이 반드시 필요합니다. 그래서 Compose 컴파일러는 이 상황에서 코틀린이 에러를 내지 않도록 막고(Suppression), 내부적으로 알아서 처리하도록 만듭니다.

아래 예시는 평범한 Kotlin 문법을 사용하여 오류를 발생시키는 예시입니다.

@Target(AnnotationTarget.FUNCTION)
annotation class FunAnn
inline fun myFun(a: Int, f: (Int)> String): String= f(a)
fun main() {
	myFun(1) @FunAnn { it.toString() } // Call site annotation
}

Compose Compiler는 사용된 어노테이션이 @Composable인 경우에만 해당 검사를 제지하므로 다음과 같은 코드를 작성할 수 있습니다

@Composable
inline fun MyComposable(@StringRes nameResId: Int, resolver: (Int)> String) {
	val name = resolver(nameResId)
	Text(name)
}

@Composable
fun Screen() {
	MyComposable(nameResId = R.string.app_name) @Composable {
		LocalContext.current.resources.getString(it)
	}
}

Compose 컴파일러가 허용해 주는 또 다른 특별한 규칙은 함수 타입(람다의 타입)을 정의할 때 매개변수 이름을 지을 수 있다는 점입니다.

원래 코틀린 문법에서는 변수의 타입을 적을 때 (String) -> Unit 처럼 타입만 적어야 하며, (name: String) -> Unit 처럼 매개변수의 이름을 붙이는 것은 금지되어 있습니다.

하지만 해당 함수 타입에 Composable 어노테이션이 붙어 있다면 예외적으로 이름을 붙일 수 있습니다.

interface FileReaderScope {
	fun onFileOpen(): Unit
	fun onFileClosed(): Unit
	fun onLineRead(line: String): Unit
}

object Scope : FileReaderScope {
	override fun onFileOpen() = TODO()
	override fun onFileClosed() = TODO()
	override fun onLineRead(line: String) = TODO()
}

@Composable
fun FileReader(path: String, content: @Composable FileReaderScope.(path: String)> Unit) {
	Column {
		//...
		Scope.content(path = path)
	}
}

런타임 버전 검사 (Runtime Version Check)

정적 분석과 각종 예외 처리를 모두 마쳤다면, 코드를 생성하기 직전에 Compose 런타임 버전을 확인합니다.

컴파일러가 제대로 작동하려면 일정 수준 이상의 기능을 갖춘 런타임 라이브러리가 필요하기 때문입니다. 만약 런타임 라이브러리가 아예 없거나 버전이 너무 낮다면 문제가 생길 수 있으므로 이를 미리 감지합니다.

앞서 가장 먼저 확인했던 것이 코틀린 컴파일러의 버전이었다면, 이 과정은 두 번째로 수행하는 필수 버전 검사입니다.

코드 생성 (Code Generation)

모든 검사가 끝나면 드디어 코드를 생성하는 단계로 넘어갑니다. 이 단계에서 컴파일러 플러그인은 런타임 라이브러리가 앱을 실행할 때 필요한 코드들을 자동으로 만들어내거나 합성합니다.

코틀린 IR (The Kotlin IR)

이 단계의 핵심은 컴파일러 플러그인이 최종 실행 코드를 만들기 전에 코틀린 IR(중간 표현)이라는 단계에 접근한다는 점입니다.

IR 단계에서는 우리가 작성한 소스 코드를 자유롭게 뜯어고칠 수 있습니다. 단순히 새로운 코드를 추가하는 것을 넘어, 기존 코드의 구조를 재구성하거나 개발자 몰래 새로운 파라미터를 끼워 넣는 것도 가능합니다.

우리가 작성한 Composable 함수에는 보이지 않지만, 실제로는 Composer라는 파라미터가 몰래 주입되어 있다는 이야기를 들어보셨을 겁니다. 바로 이 작업이 컴파일러의 백엔드 단계인 IR 변환 과정에서 일어납니다.

왜 바이트코드가 아니라 IR을 사용할까요?

만약 안드로이드(JVM)만 목표로 했다면 바로 자바 바이트코드를 생성했을지도 모릅니다. 하지만 코틀린과 Compose는 멀티플랫폼을 지향합니다.

IR은 특정 플랫폼에 종속되지 않는 중간 언어입니다. 따라서 이 단계에서 코드를 생성하면 안드로이드뿐만 아니라 iOS, 웹 등 다양한 플랫폼에서 동작하는 코드를 만들 수 있습니다. 즉, Compose가 멀티플랫폼을 지원할 수 있는 강력한 이유가 바로 이 IR 생성 방식을 채택했기 때문입니다.

Compose 컴파일러는 이를 위해 IrGenerationExtension이라는 도구를 사용하여 코틀린 컴파일 과정에 개입하고 IR을 생성합니다.

낮추기 (Lowering)

낮추기(Lowering)란 우리가 작성한 고급 프로그래밍 언어를 컴퓨터가 이해하기 쉬운 더 단순하고 구체적인 명령어로 쪼개는 과정을 말합니다. 마치 추상적인 레시피를 아주 구체적인 조리 지시서로 바꾸는 것과 같습니다.

Kotlin 컴파일러는 우리가 작성한 코드를 JVM이나 자바스크립트 등이 이해할 수 있는 가장 낮은 수준의 코드로 변환하기 위해 이 과정을 거칩니다.

Compose 컴파일러도 이 단계를 통해 우리가 작성한 아름다운 Composable 함수들을 런타임 라이브러리가 실제로 실행하고 이해할 수 있는 형태로 변환(정규화)합니다. 이 작업은 코드 생성 단계에서 일어나며, 컴파일러가 코드의 모든 요소를 훑어보면서 런타임이 필요한 기능을 몰래 추가하거나 구조를 변경합니다.

아래는 낮추기 단계에서 일어나는 핵심 작업들입니다.

컴파일러는 이 단계에서 다음과 같은 마법을 부립니다.

  1. 클래스 안정성 추론: 데이터가 변하기 쉬운지(가변) 혹은 안정적인지(불변)를 분석하고, 런타임이 이를 알 수 있도록 정보를 추가합니다. 이를 통해 불필요한 재구성을 막습니다.
  2. 라이브 리터럴 지원: 코드의 숫자나 문자열을 바꾸면 앱을 다시 빌드하지 않아도 즉시 화면에 반영되도록(라이브 리터럴), 고정된 값을 가변적인 상태 저장소로 바꿔치기합니다.
  3. Composer 파라미터 주입: 모든 Composable 함수에 Composer라는 파라미터를 몰래 끼워 넣습니다. 이 파라미터가 있어야 런타임과 소통할 수 있습니다.
  4. 함수 본문 재구성: Composable 함수의 내부 코드를 감싸서 다음과 같은 기능을 추가합니다.
    • 그룹 생성: 조건문이나 반복문 같은 흐름을 추적하기 위해 그룹이라는 단위를 만듭니다.
    • 디폴트 매개변수 처리: Compose의 그룹 시스템 안에서도 기본값(Default Parameter)이 잘 동작하도록 별도의 로직을 심습니다.
    • 건너뛰기(Skipping) 학습: 입력값이 변하지 않았다면 함수 실행을 건너뛰어도 된다고 함수에게 가르칩니다.
    • 상태 변경 전파: 상태가 바뀌었을 때 자동으로 화면을 다시 그릴 수 있도록 연결 고리를 만듭니다.

이제부터 Compose 컴파일러가 사용하는 구체적인 낮추기 기법들을 하나씩 자세히 알아보겠습니다.

클래스 안정성 추론 (Inferring class stability)

스마트 리컴포지션 (Smart Recomposition)

스마트 리컴포지션이란 컴포저블 함수가 다시 그려질 때(리컴포지션), 입력받은 데이터가 변하지 않았다면 그리기 작업을 건너뛰는 최적화 기술입니다. 이 기술이 작동하려면 컴포즈 런타임이 해당 데이터는 안전하다(Stable)고 확신할 수 있어야 합니다. 즉, 안정성은 컴포즈가 불필요한 작업을 줄이고 성능을 높이는 데 가장 중요한 핵심 개념입니다.

안정적인 타입의 조건

어떤 클래스가 안정적이라고 인정받으려면 다음 조건들을 만족해야 합니다.

  1. equals 함수의 결과가 항상 일정해야 합니다. 같은 데이터라면 언제 비교해도 항상 같다고 나와야 컴포즈가 이를 신뢰할 수 있습니다.
  2. 공개된 속성(public property)이 변하면, 컴포즈가 그 사실을 즉시 알 수 있어야 합니다. 만약 몰래 값이 바뀌면 화면과 데이터가 서로 다른 상태가 되므로, 컴포즈는 이런 불안정한 값을 믿지 않고 무조건 다시 그립니다.
  3. 모든 공개 속성 자체가 안정적인 타입이어야 합니다.

기본적으로 String이나 Int 같은 원시 타입들은 값이 변하지 않기 때문에(불변), 컴포즈는 이를 안정적인 타입으로 간주합니다. 또한 MutableState처럼 값이 바뀔 때마다 컴포즈에게 즉시 알려주는 타입도 안정적이라고 봅니다.

개발자가 만든 클래스나 데이터 클래스의 경우, 개발자가 직접 @Stable이나 @Immutable 어노테이션을 붙여서 "이건 안전해"라고 보증할 수 있습니다. 하지만 사람이 직접 관리하다 보면 실수가 생기기 마련입니다. 그래서 컴포즈 컴파일러는 코드를 분석해서 클래스의 안정성을 스스로 추론하는 기능을 갖추고 있습니다.

안정성 추론 방식

컴파일러는 클래스를 분석한 뒤, 안정적이라고 판단되면 내부에 $stable이라는 숨겨진 숫자를 심어 둡니다. 런타임은 이 숫자를 보고 리컴포지션을 건너뛸지 말지 결정합니다.

이 추론 기능은 우리가 만든 일반 클래스나 데이터 클래스에 주로 적용됩니다. 인터페이스나 열거형(Enum) 등은 제외됩니다.

컴파일러가 안정성을 판단하는 기준은 다음과 같습니다.

  1. val과 var의 차이
    클래스의 모든 속성이 val(읽기 전용)이고 그 속성들의 타입도 안정적이라면, 컴파일러는 이 클래스를 안정적이라고 판단합니다. 반면 var(가변) 속성이 하나라도 있다면, 값이 언제 바뀔지 모르기 때문에 불안정한 타입으로 간주합니다.
  2. 제네릭 타입 (Generics)
    클래스가 제네릭()을 사용하는 경우, 안정성은 T에 어떤 타입이 들어오느냐에 따라 달라집니다. 컴파일러는 실행 시점(런타임)에 T의 정체를 파악하고 안정성을 계산할 수 있도록 준비해 둡니다.
  3. 내부 상태의 변경
    클래스 외부에는 보이지 않는 비공개(private) 변수라도 내부에서 값이 변할 수 있다면(var), 컴포즈는 이를 불안정하다고 봅니다. 시간이 지남에 따라 상태가 변할 수 있다면 결과값을 신뢰할 수 없기 때문입니다.
  4. 인터페이스와 컬렉션 (List 등)
    List 같은 컬렉션이나 인터페이스는 기본적으로 불안정한 타입으로 취급됩니다. 예를 들어 List는 변할 수 없는 목록일 수도 있지만, 나중에 아이템을 추가할 수 있는 ArrayList일 수도 있기 때문입니다. 컴파일러 입장에서는 구현체가 무엇인지 확신할 수 없으므로 일단 안전하지 않다고 가정하고, 리컴포지션을 수행합니다.

안정성 강제하기

List나 인터페이스처럼 컴파일러가 불안정하다고 판단하더라도, 개발자가 보기에 확실히 값이 변하지 않거나 관리가 가능하다면 @Stable 어노테이션을 붙여서 강제로 안정적인 타입으로 지정할 수 있습니다. 이는 "내가 책임질 테니 최적화해"라고 컴파일러에게 지시하는 것과 같습니다.

결론적으로, 컴포즈 컴파일러는 최대한 자동으로 안정성을 파악하려 노력하지만, List나 var 변수 같은 불확실한 요소가 있으면 안전을 위해 리컴포지션을 수행합니다. 성능 최적화가 필요하다면 이러한 원리를 이해하고 @Stable 등을 적절히 활용해야 합니다.

라이브 리터럴 활성화 (Enabling live literals)

컴파일러 플래그 설정 라이브 리터럴 기능을 사용하려면 컴파일러에 특정 옵션(플래그)을 전달해야 합니다. 예전에는 liveLiterals라는 플래그를 사용했지만, 최신 버전에서는 liveLiteralsEnabled 플래그를 사용하여 활성화합니다.

라이브 리터럴이 무엇인가요? 안드로이드 스튜디오의 프리뷰(Preview)를 사용할 때, 코드에 있는 문자열이나 숫자를 수정하면 다시 빌드(리컴파일)하지 않아도 화면에 즉시 반영되는 것을 본 적이 있을 겁니다. 이것이 바로 라이브 리터럴 기능입니다.

원리는 간단합니다. 컴파일러가 우리가 작성한 고정된 값(상수)들을 몰래 MutableState(변할 수 있는 상태)로 바꿔치기하는 것입니다. 상수가 상태(State)로 변했기 때문에, 값이 바뀌면 즉시 감지하여 화면을 다시 그리는(리컴포지션) 기능을 활용하는 것입니다.

주의사항 이 기능은 오직 개발자가 편하게 개발하도록 돕기 위한 기능입니다. 단순한 값들을 복잡한 상태 객체로 변환하는 과정이 들어가므로 앱의 성능이 크게 떨어질 수 있습니다. 따라서 실제 사용자에게 배포하는 릴리스(Release) 빌드에서는 절대로 이 기능을 켜서는 안 됩니다.

내부 동작 원리 라이브 리터럴이 활성화되면, 컴파일러는 파일마다 LiveLiterals...로 시작하는 싱글톤 객체(전역 저장소)를 하나씩 만듭니다. 그리고 코드에 있는 모든 상수 값에 고유한 ID를 붙여서 이 객체 안에 저장해두고 관리합니다.

Composable 람다식

Compose 컴파일러는 우리가 작성한 Composable 람다식(함수 블록)을 기억하고 관리해야 합니다. 이를 위해 일반적인 람다식과는 조금 다른 특별한 방식으로 처리하지만, 결론적으로는 컴포즈의 메모리 공간인 슬롯 테이블에 저장하고 읽어온다는 목표는 같습니다.

컴파일러는 이를 위해 composableLambda라는 특수한 Composable 팩토리 함수를 호출하도록 코드를 변환합니다. 이 함수에는 다음과 같은 중요한 정보들이 매개변수로 전달됩니다.

  1. 현재 컨텍스트($composer): 컴포즈의 상태를 관리하는 핵심 객체입니다.
  2. 고유 키($key): 소스 코드 상의 위치 등을 조합해 만든 고유한 ID입니다. 람다식을 정확히 찾아내기 위한 주민등록번호와 같습니다.
  3. 추적 여부($shouldBeTracked): 이 람다식이 외부 변수를 참조(캡처)하고 있는지, 그래서 변경될 가능성이 있는지 알려주는 값입니다. 만약 외부 변수를 전혀 쓰지 않는다면 굳이 추적할 필요가 없으므로 최적화 대상이 됩니다.
  4. 람다식 본문: 실제 실행할 코드입니다.

최종적으로는 다음과 같은 형태로 변환하게 됩니다.

composableLambda($composer, $key, $shouldBeTracked, $arity, expression)

Composable 팩토리 함수의 목적은 명확합니다. 람다식을 저장하기 위해 생성된 키를 사용하여
composition에 교체 가능한 그룹(replaceable group)을 추가합니다. 여기서 Compose는 런타
임에 Composable 람다식을 저장하고 검색하는 방법을 가르칩니다.

싱글톤 최적화 (ComposableSingletons)

만약 람다식이 외부의 어떤 변수도 가져다 쓰지 않는다면(캡처하지 않는다면), 이 람다식은 언제 실행해도 항상 똑같은 동작을 합니다.

Compose는 이런 경우 굳이 매번 람다 객체를 새로 만들지 않고, 딱 한 번만 만들어서 재사용하는 싱글톤 패턴으로 최적화합니다. 컴파일러는 ComposableSingletons라는 내부 저장소를 만들어서 이런 정적인 람다식들을 보관해 두고 필요할 때마다 꺼내 씁니다. 이는 메모리와 성능을 아끼는 좋은 방법입니다.

도넛 홀 건너뛰기 (Donut-hole Skipping) 최적화

이 부분은 Compose 최적화의 꽃이라 불리는 도넛 홀 건너뛰기 개념입니다.

Compose는 내부적으로 Composable 람다식을 마치 MutableState처럼 상태 객체로 감싸서 관리합니다. 즉, 단순한 함수가 아니라 값이 변할 수 있는 상태 변수(State)처럼 취급하는 것입니다.

이렇게 하면 무엇이 좋을까요?

보통 상위 컴포넌트에서 하위 컴포넌트로 람다식을 전달할 때, 람다식이 새로 생성되면 이를 전달받는 중간 단계의 컴포넌트들도 모두 영향을 받아 불필요하게 다시 그려질(리컴포지션) 수 있습니다.

하지만 람다식을 상태 객체로 감싸버리면, 람다식 자체의 내용이 바뀌더라도 껍데기인 상태 객체는 그대로 유지됩니다. 따라서 람다식을 단순히 전달만 하는 중간 컴포넌트들은 "어? 껍데기가 그대로네? 난 다시 그려질 필요 없겠군" 하고 리컴포지션을 건너뛸 수 있습니다.

결국 람다식이 실제로 실행되는(호출되는) 가장 안쪽의 컴포넌트만 갱신됩니다. 도넛의 반죽 부분(중간 전달자들)은 그대로 있고, 구멍 부분(실제 호출자)만 바뀌는 것 같다고 해서 이를 도넛 홀 건너뛰기라고 부릅니다.

Composer 주입하기 (Injecting the Composer)

이 단계는 Compose 컴파일러가 우리가 작성한 Composable 함수를 런타임이 이해할 수 있는 코드로 완전히 개조하는 과정입니다.

컴파일러는 모든 Composable 함수에 Composer라는 특별한 매개변수를 몰래 추가합니다.

이 매개변수는 단순히 추가되는 것에 그치지 않고, 모든 Composable 함수 호출과 람다식에 꼬리에 꼬리를 물고 전달됩니다. 덕분에 UI 트리의 가장 깊은 곳에서도 이 Composer 객체에 접근할 수 있게 됩니다. 또한 Composable 트리를 구체화하여 업데이트된 상태로 유지하는 데 필요한 모든 정보를 제공합니다.상태로 유지한

fun NamePlate(name: String, lastname: String, $composer: Composer) {
	$composer.start(123)
	Column(modifier = Modifier.padding(16.dp), $composer) {
		Text(
			text = name,
			$composer
		)
		Text(
			text = lastname,
			style = MaterialTheme.typography.subtitle1,
			$composer
		)
	}
	$composer.end()
}

타입 재설정 (Type Remapping)

함수에 매개변수가 하나 더 늘어났으니, 당연히 그 함수의 타입(서명)도 달라집니다.

예를 들어 원래 (String) -> Unit 타입이었던 함수가, Composer가 추가되면서 (String, Composer, Int) -> Unit 처럼 변하는 것입니다. 컴파일러는 코드 전체에서 이 변화된 타입을 문제없이 인식할 수 있도록 타입을 다시 연결해 주는 작업(리매핑)을 수행합니다.

이렇게 주입된 Composer는 UI 트리를 실제로 그리고, 상태가 변했을 때 화면을 업데이트하는 데 필요한 모든 정보를 담고 있는 핵심 열쇠 역할을 합니다.

하지만 모든 함수가 변환되는 것은 아닙니다. 두 가지 중요한 예외가 있습니다.

  1. Composable이 아닌 인라인 람다
    인라인 람다는 컴파일 과정에서 코드가 호출하는 쪽으로 그대로 복사되어 들어가기 때문에, 함수(객체)로서의 실체가 사라집니다. 실체가 사라지니 매개변수를 주입할 대상도 없으므로 변환하지 않습니다.
  2. expect 함수 (멀티플랫폼)
    코틀린 멀티플랫폼에서 사용하는 expect 함수는 껍데기(선언)일 뿐입니다. 따라서 이 껍데기를 변환하지는 않고, 나중에 실제로 구현되는 actual 함수가 컴파일될 때 변환 작업이 이루어집니다.

비교 전파 (Comparison propagation)

앞서 우리는 컴파일러가 모든 Composable 함수에 $composer라는 필수 파라미터를 주입한다는 것을 배웠습니다. 하지만 컴파일러가 추가하는 것은 이뿐만이 아닙니다. 바로 $changed라는 아주 중요한 매개변수도 함께 추가됩니다.

이 $changed 파라미터는 리컴포지션을 건너뛸지 결정하는 핵심 단서가 됩니다. 쉽게 말해 입력값이 변했는지 안 변했는지에 대한 족보를 넘겨주는 것입니다.

비트마스크를 활용한 상태 압축

$changed는 비트마스크(Bitmask) 방식을 사용합니다. 즉, 하나의 Int 숫자 안에 여러 입력 매개변수의 상태 정보를 비트 단위(0과 1)로 쪼개서 담습니다.

보통 하나의 $changed로 약 10개 정도의 매개변수 상태를 표현할 수 있습니다. 만약 매개변수가 아주 많다면 $changed1, $changed2처럼 추가 매개변수가 더 붙기도 합니다. 비트 연산은 컴퓨터(프로세서)가 가장 빠르게 처리할 수 있는 연산이라 성능에 유리합니다.

@Composable
fun Header(text: String, $composer: Composer<*>, $changed: Int)

비교를 생략하는 원리

런타임은 $changed에 담긴 정보를 보고 다음과 같이 똑똑하게 대처합니다.

  1. 정적(Static)인 경우
    만약 입력값이 "Hello" 같은 고정된 문자열이나 상수라면, 컴파일러는 이 값은 영원히 변하지 않는다는 것을 알고 있습니다. 따라서 런타임에게 굳이 비교하지 마라고 신호를 보냅니다.
  2. 확실히 변하지 않은 경우
    상위 컴포넌트에서 이미 비교를 마쳤고 값이 같다고 판단했다면, 하위 컴포넌트는 이를 신뢰하고 다시 비교할 필요가 없습니다.
  3. 불확실한 경우 (기본값 0)
    아직 변경 여부를 모른다면 0(Default)이 전달됩니다. 이때는 런타임이 직접 equals() 함수를 실행해 값을 비교하고, 최신 값을 슬롯 테이블에 저장합니다.

아래의 예시는 Composable 함수의 본문에 $changed 매개변수와 이를 처리하기 위한 로직을 주입한
이후의 모습을 보여주고 있습니다.

@Composable
fun Header(text: String, $composer: Composer<*>, $changed: Int) {
	var $dirty = $changed
	if ($changed and 0b0110 === 0) {
		$dirty = $dirty or if ($composer.changed(text)) 0b0010 else 0b0100
	}
	if ($dirty and 0b1011 xor 0b1010 !== 0 ││ !$composer.skipping) {
		f(text) // executes body
	} else {
		$composer.skipToGroupEnd()
	}
}

$dirty 변수와 실행 흐름

변환된 코드를 보면 $dirty라는 지역 변수가 등장합니다.

이 변수는 외부에서 받은 $changed 정보와 내부 상황을 종합하여 최종 결정을 내리는 변수입니다. 이름 그대로 데이터가 더러워졌는지(변경되었는지) 판단합니다.

  • Dirty(변경됨): 함수 본문을 실행하여 화면을 다시 그립니다(리컴포지션).
  • Clean(변경 안 됨): skipToGroupEnd()를 호출하여 함수 실행을 통째로 건너뜁니다(Skipping).

비교 전파 (Comparison Propagation)

Compose는 효율성을 위해 불필요한 비교를 극도로 싫어합니다. 비교 작업 자체가 메모리를 차지하고 시간을 쓰기 때문입니다.

그래서 상위 함수는 자신이 알고 있는 정보를 하위 함수에게 적극적으로 알려줍니다. 이를 비교 전파라고 합니다.

예를 들어 부모가 내 입력값은 상수라서 절대 안 변해라는 것을 알고 있다면, 자식 함수를 호출할 때도 야, 이거 안 변하는 값이니까 너도 비교하지 말고 그냥 써라고 $changed를 통해 알려줍니다.

또한 이 정보에는 List 같은 컬렉션이 안정적인지(Stable)에 대한 힌트도 포함되어 있어, 상황에 따라 List도 리컴포지션을 건너뛸 수 있게 도와줍니다.

디폴트 매개변수 (Default Parameters)

컴파일러는 $changed 외에도 $default라는 또 다른 정보를 매개변수에 추가합니다.

코틀린의 기본 매개변수(Default Parameter) 기능은 Composable 함수에서 조금 독특하게 동작해야 합니다. 일반 함수와 달리, Composable 함수는 디폴트 값에 할당된 표현식이 UI 그룹 안에서 안전하게 실행되어야 하기 때문입니다.

이를 해결하기 위해 Compose는 $default라는 비트마스크 매개변수를 사용합니다. 원리는 $changed와 비슷합니다. $default는 각 매개변수에 대해 호출자가 직접 값을 넘겨줬는지, 아니면 값을 안 넘겨줘서 디폴트 값을 써야 하는지를 0과 1로 표시합니다.

아래 예시를 보면 이해가 쉽습니다.

// Before compiler (sources)
@Composable fun A(x: Int = 0) {
	f(x)
}

// After compiler
@Composable fun A(x: Int, $changed: Int, $default: Int) {
	// ...
	val x = if ($default and 0b1 != 0) 0 else x
	f(x)
	// ...
}

컴파일러는 $default 값을 확인해서, 만약 호출자가 값을 주지 않았다면 미리 정해둔 기본값(0)을 변수에 할당하고, 값을 줬다면 그 값을 그대로 사용합니다.

컨트롤 플로우 그룹 생성 (Control Flow Group Generation)

Compose 컴파일러는 Composable 함수의 본문 곳곳에 그룹(Group)이라는 눈에 보이지 않는 경계선을 심어둡니다. 코드의 흐름(제어문)에 따라 크게 세 가지 종류의 그룹이 만들어집니다.

  1. 교체 가능한 그룹 (Replaceable Group)
    • if-else 문처럼 UI 구조가 완전히 바뀔 때 사용합니다. 구조가 달라지면 기존 데이터를 버리고 새로운 데이터로 갈아끼우기 위함입니다.
  2. 이동 가능한 그룹 (Movable Group)
    • 리스트 아이템처럼 데이터의 위치가 바뀔 때 사용합니다. 아이템 순서가 바뀌더라도 "아, 쟤는 아까 걔네" 하고 알아보고 상태를 유지한 채로 위치만 옮기기 위함입니다.
  3. 재시작 가능한 그룹 (Restartable Group)
    • 리컴포지션이 일어날 때 함수를 처음부터 다시 실행할 수 있는 지점입니다.

이 그룹들은 현재 UI의 상태 정보와 소스 코드 상의 위치 정보를 모두 담고 있습니다. 덕분에 런타임은 이 그룹 정보를 보고 "아, 이 부분은 갈아끼워야겠군", "이건 순서만 바꾸면 되겠네", "여긴 다시 그려야겠다"라고 판단하여 복잡한 실행 흐름을 정확하게 제어할 수 있습니다.

교체 가능한 그룹 (Replaceable Groups)

앞서 우리는 컴파일러가 Composable 람다식을 composableLambda라는 팩토리 함수로 감싼다는 것을 배웠습니다. 이 함수의 내부를 들여다보면 교체 가능한 그룹이 어떻게 작동하는지 알 수 있습니다.

핵심은 startReplaceableGroup과 endReplaceableGroup이라는 함수가 코드의 앞뒤를 감싸고 있다는 점입니다. 마치 괄호를 여닫는 것처럼, 이 두 함수 사이에서 UI 구성을 위한 정보를 업데이트하고 저장합니다.

이 그룹은 이름 그대로 내용을 통째로 교체해야 할 때 사용됩니다. 가장 대표적인 예가 if-else 문과 같은 조건부 로직입니다.

if (condition) {
	Text(Hello)
} else {
	Text(World)
}

위 코드에서 조건이 바뀌면 Hello를 보여주던 UI 구조는 더 이상 유효하지 않습니다. 이때 Compose는 슬롯 테이블에 저장된 기존 그룹을 제거하고, World를 보여주는 새로운 그룹으로 교체합니다. 교체 가능한 그룹은 이렇게 UI의 구조적 변화를 처리하는 기본 단위입니다.

아래 예시는 방금 이야기한 팩토리 함수가 실제 어떻게 생겼는지를 보여줍니다.

fun composableLambda(
	composer: Composer,
	key: Int,
	tracked: Boolean,
	block: Any
): ComposableLambda {
	composer.startReplaceableGroup(key)// 교체 가능한 그룹을 시작. 만약 key가 달라지면 새로운 그룹 교체
	val slot = composer.rememberedValue()
	val result = if (slot === Composer.Empty) {
		val value = ComposableLambdaImpl(key, tracked)
		composer.updateRememberedValue(value)
		value
	} else {
	  slot as ComposableLambdaImpl
	}
	result.update(block)
	composer.endReplaceableGroup()
	return result
}

이동 가능한 그룹 (Movable Groups)

이동 가능한 그룹은 데이터의 순서가 바뀌어도 정체성을 잃지 않게 해주는 그룹입니다. 주로 key 함수와 함께 사용됩니다.

리스트를 보여줄 때 아이템의 순서가 바뀐다고 상상해 봅시다. 만약 Compose가 이 아이템들을 단순히 순서대로만 기억한다면, 순서가 바뀔 때마다 기존 아이템을 삭제하고 새로 만들어야 할 것입니다. 이는 매우 비효율적입니다.

하지만 key(ID) { ... }를 사용하여 아이템에 고유한 주민등록번호(ID)를 부여하면, 컴파일러는 이를 이동 가능한 그룹으로 변환합니다.

이제 Compose는 순서가 바뀌더라도 ID를 확인하고, 새로 만들 필요 없이 위치만 옮기면 되겠네라고 판단합니다. 즉, 데이터의 정체성을 유지하면서 위치만 효율적으로 재정렬할 수 있게 됩니다.

재시작 가능한 그룹 (Restartable Groups)

가장 흥미롭고 중요한 그룹입니다. 이 그룹은 상태(State)가 변했을 때 함수를 다시 실행(Recomposition)할 수 있게 만듭니다.

재시작 가능한 Composable 함수는 컴파일 후 코드 형태가 조금 달라집니다. 함수의 끝부분에 endRestartGroup()이라는 코드가 추가되는데, 이 함수는 아주 특별한 역할을 합니다.

만약 이 함수가 읽고 있는 상태(State)가 없다면 null을 반환하여 아무 일도 일어나지 않습니다.
하지만 상태를 읽고 있다면, 런타임에게 나중에 이 값이 변하면 나를 다시 실행해줘라고 부탁하는 람다식을 반환합니다.

변환된 코드를 보면 updateScope라는 함수가 등장합니다. 여기서 함수 자신(A)을 다시 호출하도록 등록해 둡니다.

// Before compiler (sources)
@Composable fun A(x: Int) {
	f(x)
}

// After compiler
@Composable
fun A(x: Int, $composer: Composer<*>, $changed: Int) {
	$composer.startRestartGroup()
	// ...
	f(x)
	$composer.endRestartGroup()?.updateScope { next ‑>
		A(x, next, $changed or 0b1)
	}
}

결국 재시작 가능한 그룹은 상태 변화를 감지하고, 필요할 때 자기 자신을 다시 호출하여 화면을 갱신하는 리컴포지션의 원동력이 됩니다.

[그룹 생성 규칙 요약]

컴파일러는 코드의 흐름을 분석하여 다음과 같은 규칙으로 알맞은 그룹을 생성합니다.

  1. 그룹이 필요 없는 경우: 코드가 무조건 1번만 실행되는 단순한 블록이라면 그룹을 만들지 않습니다.
  2. 교체 가능한 그룹: if나 when처럼 여러 블록 중 하나만 실행되는 경우, 구조가 바뀔 수 있으므로 교체 가능한 그룹을 만듭니다.
  3. 이동 가능한 그룹: key 함수 내부처럼 순서 재정렬이 필요한 경우 이동 가능한 그룹을 만듭니다.
profile
코딩일기

0개의 댓글