Compose 컴파일러: 선언형 UI

Compose 컴파일러: 선언형 UI 

Compose의 전체 흐름을 한 줄로 요약하면 꽤 단순하다. 개발자가 @Composable을 사용해 Kotlin 코드를 작성하면, Compose 컴파일러가 이를 런타임이 이해할 수 있는 형태로 바꾸고, 런타임은 그 결과를 바탕으로 UI를 그리고 갱신한다. 하지만 이 변환 과정 안을 들여다보면, 생각보다 훨씬 많은 일이 벌어진다.

개발자가 작성하는 것은 단순한 Kotlin 소스 코드이지만, 컴파일 단계에서 Compose 컴파일러는 이 코드를 분석해 중간 표현(IR)을 재구성한다. 이때 런타임에 필요한 매개변수, 그룹 정보, 상태 추적을 위한 각종 메타데이터가 코드에 자동으로 주입된다. 이후 Compose Runtime은 이 변환된 코드를 실행하면서 슬롯 테이블이라는 인메모리 구조를 관리하고, 상태가 바뀌면 필요한 범위만 다시 그리는 Recomposition을 수행한다. 우리가 직접 다루는 Compose UI는 이 구조 위에서 동작하는 여러 클라이언트 중 하나일 뿐이다.

왜 Kotlin 컴파일러 플러그인인가

Compose가 기존의 kapt 기반 어노테이션 프로세서 대신 Kotlin 컴파일러 플러그인을 선택한 이유는 명확하다. kapt는 컴파일 이전 단계에서 새로운 코드를 생성할 수는 있지만, 이미 작성된 코드의 구조를 바꾸지는 못한다. 반면 컴파일러 플러그인은 컴파일 과정에 직접 개입해 IR을 분석하고 수정할 수 있다. 이 덕분에 Compose는 언어를 “사용”하는 수준을 넘어, 언어의 동작 방식을 확장하는 선택을 할 수 있었다.

이 방식의 가장 큰 장점은 빠른 피드백이다. Compose 컴파일러는 프론트엔드 단계에서 정적 분석을 수행하며, 잘못된 Composable 호출이나 타입 불일치를 컴파일 시점에 바로 알려준다. 우리가 IDE에서 즉시 보는 경고나 오류는 별도의 IDEA 플러그인이 보조하지만, 그 근본적인 판단은 컴파일러 플러그인에서 이루어진다.

어노테이션은 컴파일러와의 계약이다

Compose에서 어노테이션은 단순한 힌트가 아니라, 컴파일러와 개발자 사이의 계약에 가깝다. 이 계약을 정확히 지킬수록 컴파일러는 더 공격적인 최적화를 적용할 수 있다.

그 중심에 있는 것이 @Composable이다. 이 어노테이션이 붙는 순간, 함수는 일반 함수가 아니라 Composer를 통해 실행되는 특별한 함수가 된다. 내부에서 remember를 사용할 수 있게 되고, 슬롯 테이블을 기반으로 상태를 기억하며, 이펙트들이 올바른 라이프사이클을 갖도록 보장된다. 또한 UI 트리 안에서 고유한 위치와 정체성을 부여받아, 위치 기반 메모이제이션이 가능해진다.

여기에 더해, 컴파일러의 동작을 세밀하게 제어하는 보조 어노테이션들도 존재한다.
@DisallowComposableCalls는 특정 람다 안에서 Composable 호출을 금지해, Recomposition마다 실행되면 안 되는 코드 블록을 보호한다. 대표적으로 remember의 계산 블록이 여기에 해당한다.
@ReadOnlyComposable은 해당 함수가 UI를 생성하지 않고 읽기만 한다는 사실을 컴파일러에 알려, 불필요한 그룹 생성을 생략하게 만든다.
@NonRestartableComposable은 재시작 자체가 필요 없는 매우 단순한 Composable에서만 제한적으로 사용되며, 실제 현업에서는 거의 보기 어렵다.

안정성(Stability)이 왜 중요한가

Compose의 성능 최적화는 대부분 **“이 값이 바뀌었는가?”**라는 질문에서 출발한다. 이를 판단하기 위한 핵심 개념이 바로 타입 안정성이다.
@Immutable은 인스턴스 생성 이후 public 프로퍼티가 절대 변하지 않음을 보장하는 강한 약속이고, @Stable은 값이 바뀐다면 반드시 Composition에 그 사실을 알리겠다는 비교적 느슨한 약속이다. 컴파일러는 이 정보를 바탕으로 불필요한 비교나 Recomposition을 과감하게 생략한다.

흥미로운 점은, Compose 컴파일러가 어노테이션이 없어도 클래스의 구조를 분석해 안정성을 자동으로 추론한다는 것이다. 단, List<T>처럼 구현체를 확정할 수 없는 인터페이스 타입이나, 제네릭 타입의 경우에는 런타임 판단에 일부를 맡기게 된다.

정적 분석: 빠른 실패를 위한 단계

코드 생성에 앞서 Compose 컴파일러는 정적 분석 단계를 거친다. 이 단계에서는 Composable이 호출되면 안 되는 위치에서 호출되었는지, 타입이 올바르게 사용되었는지, 선언 자체가 규칙을 위반하지는 않았는지를 검사한다. 예를 들어 Composable 함수는 suspend가 될 수 없고, main 함수 역시 Composable로 선언할 수 없다.

또 하나 흥미로운 점은, 컴파일러가 때로는 일반 Kotlin 규칙을 의도적으로 무시한다는 것이다. 인라인 람다의 호출 위치에 @Composable을 붙이거나, Composable 함수 타입에 매개변수 이름을 지정할 수 있는 것 등이 대표적인 예다. 이는 컴파일러 플러그인이 언어 규칙을 우회할 수 있기 때문에 가능한 일이다.

코드 생성과 Lowering: 마법이 일어나는 구간

정적 분석이 끝나면, 컴파일러는 IR을 직접 수정하는 Lowering 단계로 들어간다. 이 과정에서 모든 Composable 함수에는 $composer, $changed, $default 같은 암시적 매개변수가 추가되고, 제어 흐름은 런타임이 이해할 수 있는 그룹 구조로 감싸진다.

람다 역시 예외가 아니다. 일반 람다는 필요할 경우 자동으로 remember 처리되고, Composable 람다는 슬롯 테이블에 저장되어 재사용된다. 이 덕분에 “도넛 홀 생략”이라 불리는 최적화가 가능해지고, 실제로 UI를 읽는 부분만 선택적으로 Recomposition된다.

또한 기본 매개변수 처리 방식도 Kotlin과 다르다. 디폴트 값이 반드시 Composition 컨텍스트 안에서 계산되어야 하기 때문에, Compose는 자체적인 비트마스크 방식을 사용해 이를 제어한다.

댓글

이 블로그의 인기 게시물