Compose 컴파일러는 여러분의 `@Composable`을 어떻게 다시 쓰는가
Compose 컴파일러는 여러분의 @Composable을 어떻게 다시 쓰는가
여러분이 작성한 @Composable 함수는 실제로 실행되는 함수가 아닙니다. 코드가 런타임에 닿기 전에 Compose 컴파일러 플러그인이 모든 컴포저블을 다시 씁니다. 매개변수 목록을 바꾸고, 본문을 장부 기록용 호출로 감싸고, 눈에 보이지 않는 $composer를 호출 트리 전체에 꿰어 넣습니다. 입력이 바뀌지 않은 함수를 리컴포지션(Recomposition)이 건너뛸 수 있는 것도, 함수가 읽은 State 하나에 값이 쓰였을 때 그 함수만 다시 실행되는 것도 전부 이 다시 쓰기에서 나옵니다. 이 동작 가운데 어느 것도 여러분이 쓴 소스에는 없습니다. 전부 생성된 것입니다.
이번 글에서는 그 다시 쓰기가 정확히 어떻게 동작하는지를 실제 컴파일러 소스와 대조해 가며 깊이 파고듭니다. 소스는 이제 AndroidX가 아니라 Kotlin 저장소의 plugins/compose 아래에 있습니다. 함께 볼 또 하나의 근거는 컴파일러가 스스로 돌리는 골든 트랜스폼 테스트입니다. 빌드할 때마다 변환 전후 스냅샷을 대조해 검증합니다. 합성(synthetic) 매개변수인 $composer와 $changed, $default가 붙는 과정을 지켜보고, 함수에 이름을 붙이는 정수 durable 키를 따라가고, 본문을 감싸는 restart group과 건너뛰기 가드를 읽고, 클래스의 안정성(stability)이 그중 무엇을 건너뛸 수 있는지 어떻게 결정하는지까지 살펴봅니다. 이 글에 실린 변환 결과 조각은 전부 그 검증된 테스트 출력에서 가져와 가볍게 다듬은 것입니다. 선택적으로 붙는 sourceInformation과 추적용 표시는 제가 걷어 냈고, 덤프의 %는 바이트코드에서 합성 이름이 실제로 달고 있는 $로 되돌려 놓았습니다.
여러분이 작성한 함수는 실제로 실행되는 함수가 아니다
평범한 함수는 호출할 때마다 위에서 아래로 한 번 실행되고 끝납니다. 컴포저블은 그보다 할 일이 많습니다. 런타임은 다음번에 같은 상태를 다시 찾아내야 하므로, 지금 컴포지션의 어느 자리에 있는지를 알아야 합니다. 건너뛸지 말지 판단하려면 지난 패스 이후 인자가 바뀌었는지도 알아야 합니다. 그리고 함수가 읽은 값이 바뀌었을 때 다른 것은 그대로 두고 그 함수만 다시 실행할 방법도 필요합니다. 평범한 시그니처와 평범한 본문에는 이런 정보를 담아 둘 자리가 없습니다.
그래서 @Composable은 평범한 함수에 붙는 수식어가 아닙니다. 런타임의 프로토콜을 구사하는 함수로 다시 써 달라고 컴파일러에 알리는 표식입니다. 어노테이션은 선언에 그대로 남지만, 그 아래의 모양은 컴파일러가 통째로 갈아 끼웁니다. 바뀌는 것은 두 종류입니다. 하나는 호출 규약으로, 모든 컴포저블이 넘겨받아 자기가 호출하는 모든 컴포저블에 다시 넘겨주는 추가 매개변수입니다. 다른 하나는 본문 래퍼로, 런타임이 코드를 훑어 내려가며 읽는 그룹 호출과 비교 호출입니다. 이 글의 나머지는 컴파일러가 처리하는 순서대로 이 둘을 차례로 따라갑니다.
다시 쓰기는 어디에서 일어나는가
Compose 컴파일러는 Kotlin 컴파일러 플러그인이며, 정해진 순서대로 이어지는 IR 로우어링(lowering) 묶음으로 동작합니다(ComposeIrGenerationExtension). 가장 먼저 안정성을 추론합니다. 이어서 durable 키를 계산해 저장하고, 기본 매개변수와 컴포저블 람다를 로우어링합니다. 그다음 ComposerParamTransformer가 합성 매개변수를 붙이고, 마지막으로 ComposableFunctionBodyTransformer가 본문을 하나씩 다시 씁니다. 순서가 중요한 이유는 본문 변환이 앞선 패스들이 만들어 둔 결과, 곧 키와 안정성을 받아 쓰기 때문입니다.
이 이야기를 그냥 믿고 넘어가실 필요는 없습니다. 컴파일러가 증거를 훤히 드러내 놓기 때문입니다. 다시 쓴 컴포저블마다 @FunctionKeyMeta 어노테이션을 찍어 두고, 변환된 IR을 읽을 수 있는 Kotlin 코드로 다시 출력해 줍니다. 플러그인의 ...TransformTests가 검사하는 대상이 바로 그 출력물이고, 아래에 나오는 조각들도 거기서 가져왔습니다. 조각을 읽기 전에 표기 하나만 짚고 가겠습니다. 덤프는 합성 이름 안의 $를 %로 그려 냅니다. 그러니 거기 보이는 %composer의 실제 이름이 곧 $composer입니다. 이 글에서는 계속 $ 형태로 적습니다. 바이트코드를 디컴파일했을 때 보이는 형태이기도 하기 때문입니다.
시그니처 다시 쓰기: $composer, $changed, $default
ComposerParamTransformer는 @Composable이 붙은 모든 함수의 매개변수 목록을 다시 씁니다. 여러분이 직접 선언한 매개변수 뒤에 nullable인 $composer: Composer?를 붙이고, 이어서 $changed: Int를 하나 이상 붙입니다. 그리고 함수에 기본 인자가 있을 때에 한해 $default: Int를 하나 이상 더 붙입니다.
// 여러분이 작성하는 코드
@Composable fun Greeting(name: String, modifier: Modifier = Modifier) { }
// 컴파일러가 생성하는 코드 (덤프에서는 이 이름들이 %로 출력됩니다)
@Composable fun Greeting(
name: String,
modifier: Modifier,
$composer: Composer?,
$changed: Int,
$default: Int,
) { }
$composer는 이 시스템 전체를 끌어당기는 실입니다. 본문 안의 모든 컴포저블 호출로 함께 넘어가는데, 컴포저블은 다른 컴포저블 안에서만 호출할 수 있다는 규칙의 진짜 의미가 여기에 있습니다. 넘겨줄 $composer를 쥐고 있는 쪽이 곧 호출하는 쪽이기 때문입니다.
$changed 매개변수는 호출하는 쪽이 인자마다 이미 알고 있는 사실을 실어 나릅니다. 그래서 호출당하는 쪽은 같은 비교를 되풀이하지 않아도 됩니다. 개수는 ceil((valueParameters + receivers) / 10)이고 최소 하나입니다. 매개변수 하나에 3비트짜리 슬롯이 배정되는데, Int 하나에 그런 슬롯 열 개와 예약된 최하위 비트 하나가 들어가기 때문입니다. 그 3비트는 인자마다 붙는 작은 상태값입니다. 아는 것이 없으니 직접 비교하라는 Uncertain, Same, Different, 그리고 절대 변하지 않는 컴파일 타임 상수라는 Static입니다. 값 매개변수가 31개인 함수는 네 번째 $changed 정수로 넘어가는데, 테스트가 정확히 그 경계를 짚어 확인합니다.