[ComposeInternals] Compose 컴파일러(2)
Jetpack Compose Internals를 참고해 작성하였습니다
Compose Compiler 플러그인의 작동 방식
1. 컴파일러 확장 등록
Kotlin 컴파일러가 제공하는 메커니즘인 ComponentRegistrar를 사용하여 Kotlin 컴파일러 파이프라인에 자신을 등록ComposeComponentRegistrar는 다양한 목적을 위해 일련의 컴파일러 익스텐션을 등록
등록된 익스텐션은 Kotlin 컴파일러와 함께 실행된다
Compose Compiler는 활성화된 컴파일러 플래그에 따라 몇 가지 익스텐션도 등록 (Live Literal ..)
2. Kotlin Compiler 버전
Kotlin Compiler 버전이 요구되는 버전과 일치하는지 확인
suppressKotlinVersionCompatibilityCheck을 사용해 검사를 우회할 수 있지만 위험성 증가
3. 정적 분석 & 정적 검사
가장 먼저 해야 하는 분석은 linting이다
linting (린팅)
소스코드를 분석해 스타일 오류를 자동으로 검사하는 과정
소스를 분석해서 라이브러리 어노테이션을 찾고, 그것들이 런타임이 기대하는 방식으로 사용되고 있는지 몇 가지 중요한 사항을 검사한다
정적 코드 분석을 통한 경고나 오류는 컴파일러 플러그인이 접근할 수 있는 컨텍스트 추적(context trace)을 통해 보고된다
정적 검사 종류
등록된 익스텐션 중 일부는 정적 검사기(Static Checkers) 형태로 제공
Compose Compiler에 의해 함수 호출, 타입 및 선언에 대한 검사기(checker)가 등록된다
익스텐션으로 등록된 모든 검사기는 Kotlin 컴파일러의 프론트엔드 단계에서 실행되므로 속도가 매우 빠르고, 대체로 CPU를 많이 소모하는 작업이 포함되지 않는다
호출 검사 (Call checks)
정적 검사기 중 하나인 호출 검사기, 호출을 검증하는 데 사용된다
코드 전체를 분석하여 각 함수 호출이 적절한 컨텍스트에서 이루어지는지 확인
호출 검사의 핵심 기능
1. 컴파일 타임에 Composable 함수 호출을 다양한 컨텍스트에서 검증
(@DisallowComposableCalls, @ReadOnlyComposable 등)
2. 방문자 패턴을 사용하여 PSI 트리 요소를 방문해 함수 호출을 찾는다
PSI
파일을 구문 분석하고 플랫폼의 다양한 기능을 지원하는 문법(syntatic) 및 의미론적(semantic) 모델을 생성하는 IntelliJ 플랫폼의 계층 구조
(코드의 구조를 나타내는 트리 형태의 표현)
3. Context trace(컨텍스트 추적)
더 넓은 컨텍스트가 필요한 경우, 방문한 엘리먼트의 정보를 비트 단위로 기록하고 추가 검증에 활용
해당 정보를 Context Trace (컨텍스트 추적)을 통해 검증 범위를 넓히고 람다식, try/catch 구문등을 탐색
예시) @DisallowComposableCalls로 마킹된 컨텍스트 내에서 Composable 함수 호출 시 관련 정보를 context 추적을 통해 기록하고 오류를 보고하는 코드
4. 재귀적 분석
더 복잡한 검증이 필요한 경우 상위 노드로 재귀하여 더 많은 정보 수집
검사 대상
- @DisallowComposableCalls 어노테이션이 달린 컨텍스트 내 Composable 호출 금지
- @ReadOnlyComposable 어노테이션이 달린 Composable에서 다른 읽기 전용 Composable 함수만 호출하는지
- try/catch 블록 내에서의 Composable 호출이 발생하지 않았는지
- 인라인 람다의 호출 컨텍스트 확인 (호출자가 Composable일 경우 인라인 람다에서 Composable 함수를 호출할 수 있어야 한다)
- 잠재적으로 누락된 @Composable 어노테이션을 감지하고 개발자에게 Composable 어노테이션을 추가하도록 편하게 요청
검사 과정
- 각 노드의 엘리먼트 타입 식별
- 해당 타입에 맞는 검사 적용
- 필요할 경우 오류 보고
- Context 정보 기록 및 추가 검증 진행
Element (엘리먼트)
프로그래밍 언어에서 사용되는 타입, 함수 따위의 정보를 포함하는 하나의 단위
타입 검사 (Type Checks)
@Composable 어노테이션은 함수뿐만 아니라 타입에도 지정할 수 있다
이와 관련해 타입 추론에 대한 검사를 수행한다
@Composable 어노테이션이 달린 타입을 예상했지만 실제로 달려있지 않은 타입이 발견된 경우 오류를 보고할 수 있다
선언 검사 (Declaration Checks)
호출 위치와 타입에 대한 검사도 필요하지만 코드 선언 자체에 대한 검사도 진행
(프로퍼티, 프로퍼티 접근자, 함수 선언, 함수 매개변수 등등)
- 프로퍼티, 프로퍼티의 getter 및 함수들은 @Composable 어노테이션이 있어도 재정의 가능하다
Compose Compiler는 재정의 되는 경우 Composable로 어노테이션 되어 있는지를 확인하여 일관성을 유지하는 검사를 수행 - Composable 함수가 suspend인지 아닌지 확인, Composable 함수에는 suspend를 지원하지 않기 때문
- main 함수에 @Composable 어노테이션 사용할 수 없도록 확인
- Composable 프로퍼티의 backing 필드가 선언될 수 없도록 확인
진단 제지기 (Diagnostic Suppression)
컴파일러 플러그인이 등록할 수 있는 익스텐션으로, 특정 컴파일러 경고나 오류를 선택적으로 음소거할 수 있다ComposeDiagnosticSuppressor를 활용해 컴파일에 실패할 수 있는 일부 언어적인 제한을 우회한다
"non-source 어노테이션"으로 어노테이션 된 인라인 람다의 경우 제약이 적용된다
인라인 람다의 경우 컴파일 시점에 호출 위치로 코드가 직접 삽입되므로, 해당 어노테이션을 저장할 독립적인 바이너리 객체가 남지 않는 문제가 발생한다. Kotlin이 인라인 람다에 Binary, Runtime 보존 유형을 가진 어노테이션을 허용하지 않는 이유이다
non-source Annotation
binary 또는 runtime 보존 유형을 가진 어노테이션
어노테이션은 기본적으로 메타 정보를 담고 있으며 source, binary, runtime 3가지 보존 유형이 존재하고
source의 경우 코드의 바이너리 출력에 정보를 포함하지 않는다
binary, runtime 유형은 바이너리 출력 결과에도 정보가 포함된다
예시) inline 함수인 myFun은 컴파일 시에 함수 본문이 호출 위치에 직접 삽입되기 때문에 에러 발생
// Retention이 적용되지 않아 기본값인 Runtime으로 지정
// 바이너리와 런타임에도 정보가 보존되어야 하는 FunAnn 어노테이션
@Target(AnnotationTarget.FUNCTION)
annotation class FunAnn
inline fun myFun(a: Int, f: (Int) -> String): String = f(a)
// @FunAnn 오류 발생
// The lambda expression here is an inlined argument so this annotation cannot be stored anywhere
fun main() {
myFun(1) @FunAnn { it.toString() } // Call site annotation
}
예시)
// 일반 함수
fun normalFunc(callback: () -> Unit) {
callback()
}
// 인라인 함수
inline fun inlineFunc(callback: () -> Unit) {
callback()
}
fun main() {
normalFunc { println("normal lambda") }
inlineFunc { println("inlined lambda") }
}
// 디컴파일된 코드
public final void normalFunc(@NotNull Function0 callback) {
Intrinsics.checkNotNullParameter(callback, "callback");
callback.invoke();
}
public final void inlineFunc(@NotNull Function0 callback) {
Intrinsics.checkNotNullParameter(callback, "callback");
int $i$f$inlineFunc = 0;
callback.invoke();
}
public final void main() {
this.normalFunc(null.INSTANCE);
int $i$f$inlineFunc = 0;
int var3 = 0;
String var4 = "inlined lambda";
System.out.println(var4);
}
예시) Kotlin에서는 인라인 람다에 binary, runtime 타입 어노테이션을 사용할 수 없지만
Compose에서는 ComposeDiagnosticSuppressor가 @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)
}
}
런타임 버전 검사 (Runtime version check)
첫 번째로는 Kotlin Compiler 버전을 확인했고, 다음으로는 Compose Runtime의 버전 검사를 수행한다
코드 생성 직전, 가장 먼저 Compose Runtime 버전을 확인한다
코드 생성 (Code Generation)
Compose Compiler는 마지막으로 코드 생성 단계로 넘어간다
코틀린 IR (The Kotlin IR)
컴파일러 플러그인은 최종적인 코드를 생성하기 전에 중간 표현(IR)에 접근하고 활용해서 코드를 변환한다
코드를 수정하고, 매개변수를 조작하고, 코드 구조를 재구성할 수 있다
Compose에서는 Compiler가 Composable 함수에 암시적인 Composer 매개변수를 주입한다
낮추기 (Lowering)
컴파일러가 고수준의 추상적인 프로그래밍 개념을 더 낮은 수준의 기본적인 연산으로 변환하는 과정
Lowering은 다양한 플랫폼에서 실행 가능한 코드로 변환하도록 하고 정규화의 한 형태로 코드를 표준화된 형태로 변환한다
Compose Compiler는 lowering을 통해 작성한 선언적 UI 코드를 실제 런타임이 이해할 수 있는 형태로 변환한다
Compose Compiler의 낮추기 작업
- 클래스 안정성 추론
- 라이브 리터럴 변환
- 암시적 매개변수 삽입
- 클래스 안정성 추론
- 클래스가 안정적인지(동일 입력에 항상 동일 출력 생성) 분석
- 이에 필요한 메타데이터를 런타임에 제공
- 라이브 리터럴 변환
- 소스 코드 변경 시 전체 리컴파일 없이도 변경사항을 반영할 수 있는 가변 상태 인스턴스로 변환
- 개발 과정에서 빠른 UI 업데이트 지원
- 암시적 매개변수 삽입
- 모든 Composable 함수에 Composer 매개변수 자동 삽입
- 이 매개변수를 모든 Composable 호출에 전달
- 함수 본문 래핑
- 컨트롤 플로우 그룹화: 다양한 유형의 그룹(교체 가능, 이동 가능 등) 생성
- 커스텀 디폴트 매개변수 처리: 함수 그룹 범위 내에서 실행되는 별도 디폴트 매개변수 지원
- 리컴포지션 최적화: 함수가 불필요한 리컴포지션을 생략하도록 최적화
- 상태 변경 전파: 상태 변경 정보를 컴포지션 트리 아래로 전달
이러한 낮추기 과정은 Compose 컴파일러 플러그인의 코드 생성 단계에서 IR 트리의 모든 요소를 방문하면서 진행됩니다. 플러그인은 IR을 조작하여 Compose 런타임이 이해할 수 있는 형태로 변환함으로써, 개발자는 간결하고 선언적인 UI 코드를 작성하면서도 효율적으로 실행되는 애플리케이션을 구현할 수 있습니다.