Jetpack Compose 컴파일러가 부리는 마법 완전히 파헤치기

A fully diving into Jetpack Compose compiler

Ji Sungbin
성빈랜드
9 min readMay 18, 2022

--

Photo by Rod Long on Unsplash

우리는 컴포저블 함수를 만들고 사용하기 위해 항상 @Composable 을 사용합니다. 컴포즈에 대해 조금 공부를 해 보신 분이라면 컴포즈가 컴파일 되면서 @Composable 이 $composer 로 변경된다는걸 아실겁니다. 어노테이션을 사용해 코드를 새로 생성하려면 kapt 나 ksp 같은 어노테이션 프로세서가 필요합니다. 하지만 컴포즈는 어떠한 어노테이션 프로세서도 갖지 않습니다. 여기서 의문점이 생깁니다. 이번 글에서는 이 의문점을 시작으로 컴포즈가 컴파일 타임에서 어떤 마법을 부리는지에 대해 알아보겠습니다.

우선 위 의문점에 대한 직접적인 해답을 먼저 제시함으로써 글을 시작해보려 합니다.

컴포즈는 자체의 코틀린 컴파일러 플러그인을 사용합니다.

이로 인해 컴포즈 컴파일 과정을 코틀린 언어의 컴파일 과정으로 임배드하여 처리 속도를 높이고, 어노테이션 프로세서는 할 수 없는 높을 수준의 코드 접근을 하여 컴파일됩니다. 예를 들어, 코틀린과 컴포즈는 멀티플랫폼을 대상으로 하므로 컴파일 과정에서 생성되는 IR 을 현재 플랫폼에 맞는 환경으로 수정하고 lowering 을 진행합니다. 컴포즈 코틀린 컴파일러 플러그인 활성화는 예상 하셨듯이 buildFeatures 에서 compose 를 true 로 지정함으로써 진행됩니다. 이 부분을 지우면 컴파일에서 컴포즈 관련 코드들 생성이 안되고, IR 관련 에러 혹은 compose 관련 메서드들을 못 찾는 에러가 발생하는걸 확인할 수 있습니다.

이제 컴포즈 코틀린 컴파일러가 어떤 순서로 동작하는지에 대해 알아보겠습니다.

1. 코틀린 컴파일러 버전 체크

컴포즈는 코틀린 플러그인으로 작동하므로 프로젝트에서 사용하는 코틀린 버전이 컴포즈에서 사용하는 코틀린 버전과 같아야 합니다. 그렇지 않으면 코틀린 컴파일러 백엔드의 변경 사항이 일치하지 않아 컴파일 에러가 날 수 있습니다.

2. 코드 정적 분석 및 기존 경고 억제

컴포즈 코틀린 컴파일러는 프론트단에서 코드 정적 분석 뿐만 아니라 표준 코틀린 규칙에 의해 발생하는 경고를 없애는 역할도 합니다. 예를 들어 Retention 이 Source 가 아닌 어노테이션을 인라인 람다에 사용하면 인라인되면서 어노테이션이 적용될 수 없어 경고가 발생하지만, @Composable 은 이를 허용합니다.

또한 람다식은 메타데이터가 없어 항상 정렬된 인자를 요구하기 때문에 named arguments 를 허용하지 않습니다. 하지만 @Composable 람다는 이를 허용합니다.

3. 컴포즈 런타임 버전 체크

컴포즈에서 사용하기로 한 코틀린 컴파일러가 현재 컴포즈 런타임 버전과 호환되는지 체크합니다. 이 단계 이후로 코드 생성 단계가 진행됩니다.

4. IR 생성 및 lowering 시작

컴포즈와 코틀린 코드들에 대해 기본 IR 을 생성합니다. 생성 이후, 생성한 IR 을 lower level 로 바꾸는 작업을 시작합니다.

5. 클래스 안정성 추론

리컴포지션 최적화를 위해 Stability 를 체크하는 과정이 실행됩니다. 이 시스템에 대해서는 Optimizing Recomposition in Jetpack Compose: Stability 에서 다루고 있습니다.

6. Live Literals 활성화

컴포즈는 live literals 를 활성화하여 코드 내 모든 상수를 MutableState 의 getter 로 바꾸어 재컴파일 없이 실시간 변경 사항이 적용되게 합니다(hot-reload 구현). 이는 성능을 크게 저하시키기 때문에 디버그 빌드에서만 활성화 되며, 릴리즈 빌드에서 비활성화 됩니다. (릴리즈 빌드에서 컴포즈 성능이 향상되는 이유 중 하나)

7. 람다 최적화

컴포즈는 컴포저블 함수에 전달되는 람다식에 대해 최적화를 진행합니다. 이는 람다식의 2가지 유형(일반 람다, 컴포저블 람다)에 따라 방법이 달라집니다.

일반 람다식의 경우 코틀린은 이미 값을 캡처하지 않는 람다를 싱글톤으로 모델링하여 자체 최적화를 진행하고 있고, 이 최적화를 그대로 가져갑니다. 값을 캡처하는 경우엔 캡처하는 값들을 remember 의 인자로 보내고 람다식을 remember 로 감싸 최적화를 진행합니다. 하지만 이 remember 최적화는 캡처되는 값이 안정 상태일 때만 작동한다는 구조적 한계가 존재합니다.

참고로 람다에서 값 캡처의 의미는 람다식 내부에서 전역 변수를 참조하고 있다는걸 뜻 합니다.

컴포저블 람다식의 경우도 값을 캡처하지 않는다면 코틀린의 기본 최적화를 그대로 가져갑니다. 값을 캡처하는 경우에는 composableLambda 라는 컴포저블 팩토리 함수로 변환됩니다. 이 함수의 인자로는 모든 컴포저블의 기본인 Composer, 위치 메모이제이션을 위한 고유한 키로 사용하기 위해 컴포저블 람다의 fully qualified name 과 시작 오프셋의 합의 해시코드를 key 인자로, 캡처하는 값의 상태 변화를 추적해야하는지 여부를 나타내는 boolean 타입의 shouldBeTracked 인자로 항상 true가(false 라면 캡처하는 값이 없는 것이므로 코틀린 기본 최적화를 받음), 마지막으로 람다 표현식 자체를 인자로 가져갑니다. 이 최적화로 인해 컴포저블 람다가 State 로 감싸지게 되며 변경 사항이 있을 때 그 구역만 리컴포지션되는 도넛홀 건너뛰기 최적화를 받게 됩니다.

8. Composer 주입

드디어 Composer 주입이 진행됩니다. 이 Composer 에 대한 자세한 내용은 현재 이 글의 주제와는 벗어나므로 다른 글에서 다뤄보도록 하겠습니다. 간단히 말하자면 런타임에서 컴포저블과 통신하는 유일한 방법 정도로 말할 수 있습니다. 모든 컴포저블 함수에 Composer 를 추가하여 함수를 교체하고 컴포저블 트리를 만드는데 필요한 모든 정보를 주입합니다. Composer 를 포함한 함수가 새로 생성되는게 아닌 기존 함수를 수정하여 구현됩니다. 이는 어노테이션 프로세서로는 불가능한 영역중 하나입니다.

9. 비교 전파 활성화

컴포즈 컴파일러는 Composer 의외에 다른 메타데이터 인자들도 같이 추가합니다. 컴포저블 함수의 인자가 컴포지션 이후로 변경될 수도 있기 때문에 이를 파악하기 위한 $changed 인자가 추가됩니다. $changed 는 각각 인자들의 비트 조합으로 구성되며 아래와 같은 정보를 제공합니다.

  • 정적으로 주입된 값은 추후 변경될 수 없으므로 값 비교를 생략하게 알린다.
  • 항상 마지막 컴포지션 이후 값이 변경되지 않거나, 변경됐을 경우 이미 상위 컴포저블에 의해 비교가 됐다는 보장을 해서 비교를 생략하게 알린다. 이런 상태를 확실(certain)한 상태라고 부른다.
  • 이외 모든 상황은 값 비교를 하라 알리고, 이런 상태를 해당 인자는 비확실(uncertain)한 상태라고 부른다. ($changed 의 기본 값)

또한 $changed 는 각 인자별로 안정 상태인지에 대한 정보도 포함하여 제네릭과 같은 넓은 유형에도 최적화를 적용합니다. 모든 컴포저블에는 $changed 인자가 부모로 부터 전파되는데 이걸 비교 전파(comparison propagation) 라고 합니다.

10. Default Arguments 재구현

코틀린에서 default arguments 는 특정 스코프 안에서 실행을 지원하지 않지만, 컴포즈는 이를 필요로 합니다.(동일 함수여도 컴포저블 스코프에 따라 결과가 달라질 수 있음). 이를 극복하기 위해 $default 인자를 통해 default arguments 시스템을 재구현하게 됩니다. $default 는 각각 인자들의 비트로 구성됩니다.

11. 컴포저블 그룹 생성

이제 컴포즈 컴파일러의 꽃인 각 컴포저블별로 그룹화하는 작업이 시작됩니다. 이는 컴포저블을 효울적으로 제어하기 위해 각 컴포저블 함수 내부에 그룹을 생성하는 것이며, 그룹은 컴포저블 함수의 내부 구조에 따라 총 3가지로 결정됩니다.

  • ReplaceableGroup: if 문이나 when 분기와 같은 분기에 따라 재배치 될 수 있는 컴포저블 주위로 생성된다. 그룹을 교체해야 할 때 작성된 데이터를 정리하는 방법을 가르친다.
  • MovableGroup: key 로 감싸진 컴포저블 주위로 생성되며 각 컴포저블의 아이디를 잃지 않고 재배치 할 수 있게 해준다. 즉, 컴포저블의 아이디를 항상 보존하며 데이터를 이동하는 방법을 가르친다.
  • RestartableGroup: 리컴포지션이 일어날 수 있는 컴포저블 주위로 생성된다. 마지막에서 그룹을 닫는 함수가 nullable 을 반환하며 컴포저블 내에서 상태를 읽고 있다면 null 이 아닌 값을 반환한다. 이때, 런타임에 리컴포지션 하는 방법을 가르친다. 리컴포지션은 해당 함수를 재호출하여 진행된다. 즉, 상황에 따라 리컴포지션 방법을 가르친다.

상황별로 요약하자면 아래와 같습니다.

  • 컴포지션이 딱 한 번만 진행될 경우엔 그룹이 필요하지 않다.
  • if 문이나 when 에 의해 조건부 분기에 따라 컴포저블이 실행된다면 ReplaceableGroup 으로 감싸진다.
  • key 로 컴포저블이 감싸져 있다면 MovableGroup 으로 감싸진다.

12. Klib, decoy 생성

마지막으로 Kilb 및 decoy 를 생성하며 끝납니다. 이 단계는 Kotlin/JS 를 위한 함수 원본 시그니처를 복제하여 각 시그니처별로 여러개를 만드는 작업이며 우리는 안드로이드 개발자이기 때문에 이 과정의 자세한 설명은 생략하겠습니다.

끝!

마지막으로 이 과정을 다 합쳐보면

코틀린 컴파일러 버전 체크 → 코드 정적 분석 및 기존 경고 억제 → 컴포즈 런타임 버전 체크 → [코드 생성 단계 시작] → IR 생성 → [lowering 단계 시작] → 클래스 안정성 추론 → Live Literals 활성화 → 람다 최적화 → Composer 주입 → 비교 전파 활성화 → Default Arguments 재구현 → 컴포저블 그룹 생성 → Klib, decoy 생성

이렇게 총 12단계 과정을 거쳐 컴포즈 컴파일러의 마법이 구현됩니다.

지금까지 컴포즈 코틀린 컴파일러가 하는 일에 대해서 알아보았습니다. 끝까지 읽어주신 분들 모두 감사합니다.

[목차로 돌아가기]

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.