본문 바로가기

Compose

[ComposeInternals] Compose 컴파일러(1) - 어노테이션

Jetpack Compose Internals를 참고해 작성하였습니다

 

 

Jetpack Compose는 다양한 라이브러리로 구성되어 있다

대표적으로 Compose Compiler, Compose Runtime, Compose UI가 있다

compose architecture

 

Compose 코드는 Compose Compiler에 의해 Compose Runtime이 이해할 수 있는 코드로 변환되고, Runtime은 이 코드를 실행하여 UI를 구성하고 관리한다

 

먼저 Compose Compiler에 대해 알아보자

 

Compose Compiler

Kotlin 함수에 Composable 어노테이션을 선언하면 해당 함수는 Composable 함수로 변형된다

Kotlin에서는 어노테이션을 처리할 때 보통 kapt, ksp를 사용한다

 

하지만 Compose에서는 어노테이션을 일반 어노테이션 프로세서로 처리하지 않고 Compose 컴파일러를 통해 처리한다

Compose Compiler는 Compose Runtime에 맞게 코드를 생성하는 실제 Kotlin 컴파일러 플러그인이다

 

Compose Compiler가 Kotlin Compiler 플러그인이기에 생기는 장점

  • 컴파일러의 프론트엔드 단계에서 진단을 보고할 수 있다
  • 정적 분석을 통해 코드를 마음대로 수정할 수 있다
    • IR 단계에서 소스 코드를 수정함으로써 컴파일러는 런타임이 요구하는 대로 Composable 함수를 마음대로 변형시킬 수 있다

 

Compose Annotation

Compose Compiler가 필요한 요소를 분석하고 활용할 수 있도록 사용 가능한 Compose 어노테이션에 대해 알아보자

모든 Compose 어노테이션은 Compose Runtime 라이브러리에 의해 제공된다

 

Compose Compiler는 어노테이션이 붙어있는 선언이나 표현식을 변형한다

 

@Composable

함수가 데이터를 하나의 노드로 변환하여 Composable 트리에 올리겠다는 의미

@MustBeDocumented
@Retention(AnnotationRetention.BINARY)
@Target(
    // function declarations
    // @Composable fun Foo() { ... }
    // lambda expressions
    // val foo = @Composable { ... }
    AnnotationTarget.FUNCTION,

    // type declarations
    // var foo: @Composable () -> Unit = { ... }
    // parameter types
    // foo: @Composable () -> Unit
    AnnotationTarget.TYPE,

    // composable types inside of type signatures
    // foo: (@Composable () -> Unit) -> Unit
    AnnotationTarget.TYPE_PARAMETER,

    // composable property getters and setters
    // val foo: Int @Composable get() { ... }
    // var bar: Int
    //   @Composable get() { ... }
    AnnotationTarget.PROPERTY_GETTER
)
annotation class Composable

 

@Composable 어노테이션이 선언되면

어노테이션이 붙은 대상의 타입을 변경하고 Compiler 플러그인은 함수를 일반 함수와는 다른 특별한 타입으로 변환되도록 한다

메모리를 부여하고(함수가 상태를 저장하고 유지할 수 있는 능력, remember 사용) 메모리에 보존 될 수 있도록 각각의 정체성(ID 값)을 할당받고, 완성된 트리에서 위치가 지정된다

또한, 컴파일러에 의해 라이프사이클을 관리하는 기능을 갖게 되어 재구성시에도 Effects가 올바르게 작동되도록 한다

 

@ComposableCompilerApi

Compose에서 컴파일러에 의해서만 사용된다는 의도를 나타내기 위해 사용된다

컴파일러 외부에서 사용하려고 하면 오류 발생

 

@InternalComposeApi

stable 버전에 포함되었음에도 내부적으로는 변화가 발생할 수 있는 API

Kotlin이 지원하지 않는 개념인 모듈 전체에서의 사용을 허용하므로 Kotlin의 internal 키워드보다 더 넓은 범위

 

@DisallowComposableCalls

함수 내에서 Composable 함수의 호출이 발생하는 것을 방지

Composable 함수를 안전하게 호출할 수 없는 Composable 함수의 인라인 람다 매개변수 에서 유용하게 사용된다

주로 recomposition 마다 호출되면 안 되는 람다식에 가장 적합하게 사용된다

 

예제) remember는 calculation 블록에서 계산된 값을 저장하고, 최초 composition시 실행되고 recomposition시 계산된 값을 반환

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

 

@ReadOnlyComposable

특징

  • Composable 함수가 composition에 쓰기 작업을 하지 않고 읽기만 함을 명시
  • 해당 함수와 그 내부의 모든 중첩된 Composable 호출에도 적용
  • Compose Runtime이 불필요한 코드 생성을 방지할 수 있게 함

Composable 함수의 그룹화

  • 일반 Composable 함수는 컴파일러가 함수 본문을 "그룹"으로 감싸서 Compose Runtime에 방출
  • 그룹은 Composable 함수에 대한 필수 정보를 composition에 제공
    • recomposition 시 데이터 정리 방법
    • Composable 함수의 고유성 유지 및 데이터 이동 방법
    • 재시작 가능한 그룹(restartable groups), 이동 가능한 그룹(movable groups) 등

그룹의 고유성

  • 모든 그룹은 소스 코드 위치 키(key)를 가짐
  • 조건부 논리(if, else)의 다른 분기에 있는 같은 Composable 함수도 서로 다른 고유성을 가짐
  • 이동 가능한 그룹은 고유한 키를 가져 부모 그룹 내에서 재정렬 가능
if (condition) {
  Text(”Hello”)
} else {
  Text(”World”)
}

 

필요성

  • Composable 함수가 composition에 쓰이지 않으면 데이터 교체나 이동이 불필요
  • 읽기 전용 Composable은 그룹 생성 없이 더 효율적인 코드 생성 가능

실제 사용 예시

  • CompositionLocal을 통해 읽는 필드의 기본값
  • Material 라이브러리: Colors, Typography
  • isSystemInDarkTheme() 함수
  • LocalContext 및 이에 의존하는 애플리케이션 리소스
  • LocalConfiguration
  • 프로그램 실행 시 한 번 설정되고 Composable 트리에서 일관되게 유지되는 값들

 

@NonRestartableComposable

특징

  • 함수나 프로퍼티의 getter에 적용되어 재시작이 불가능한 Composable 함수로 만듦
  • 모든 Composable이 기본적으로 재시작 가능한 것은 아니다
    • 인라인 된 Composable 함수
    • 환 타입이 Unit이 아닌 Composable 함수는 이미 재시작 불가능

컴파일러 동작

  • recomposition 동안 함수를 재구성하거나 생략하는 데 필요한 보일러플레이트(상용구) 코드를 생성하지 않음

사용 예시

  • 실제 사용 사례는 매우 드물고, 다른 Composable 함수에 의해 recomposition 될 가능성이 희박한 경우에 적합
  • 논리가 거의 포함되지 않아 자체 invalidation의 의미가 적은 경우
  • invalidation 및 recomposition은 주로 상위 Composable에 의해 발생해야 하는 경우

 

@StableMarker

특징

  • Compose Runtime에서 타입의 안정성을 나타내는 메타 어노테이션
  • @Immutable 및 @Stable 어노테이션에 사용됨
  • 어노테이션을 위한 어노테이션으로서 재사용성 제공

데이터 안정성 요구사항

@StableMarker로 마킹된 타입은 다음 요구사항을 충족해야 한다

  • equals 함수의 호출 결과는 동일한 두 인스턴스에 대해 항상 동일해야 함
  • 어노테이션이 적용된 public 프로퍼티에 변경이 발생하면 composition에 알려야 함
  • 어노테이션이 적용된 모든 public 프로퍼티는 안정적(stable)으로 간주

컴파일러 동작

  • 컴파일러와의 "약속"이며, 컴파일 시 유효성 검사는 이루어지지 않음
  • 요구사항 충족 여부는 개발자의 책임
  • Compose Compiler는 어노테이션 없이도 안정성 요구사항을 충족하는 타입을 추론하려 함

직접적으로 어노테이션을 사용해야 하는 경우

대부분 어노테이션 없이도 컴파일러가 안정성을 추론할 수 있지만, 2가지 경우 개발자가 직접 어노테이션을 사용해야 한다

  • 인터페이스나 추상클래스에서 필수 구현 계약 및 요구사항을 명시할 때
    • 이 경우 어노테이션은 컴파일러와의 약속일 뿐만 아니라 구현에 대한 요구사항이 된다 (요구사항은 실제로 검증되지는 않음)
  • 구현체가 가변적(mutable)이지만 안정적인 타입으로 처리하고 싶을 때
    • 일반적인 예시로, 타입이 내부적으로 캐시(cache)를 사용하여 값이 변경될 수 있지만 public API가 캐시 상태와 독립적인 경우

 

@Immutable

특징

  • 클래스에 적용할 수 있는 어노테이션
  • 인스턴스 생성 후 모든 외부로 노출된 프로퍼티의 필드가 변경되지 않음을 컴파일러와 약속
  • @StableMarker를 상속받아 해당 요구사항들을 모두 준수

Kotlin val과의 차이점

  • val 키워드보다 더 강력한 불변성 보장
  • val은 단순히 재할당만 방지하지만, 참조하는 객체가 가변적일 수 있음
  • Kotlin 언어 자체는 데이터 구조의 완전한 불변성을 보장하는 메커니즘이 없음
  • @Immutable 어노테이션이 Compose에서 필수적임

Compose Runtime에서의 활용

  • Runtime은 해당 타입의 값이 초기화 후 변경되지 않는다고 가정
  • 이를 바탕으로 스마트 recomposition 및 recomposition 생략 기능을 최적화

사용 예시

  • 모든 속성이 val인 data 클래스
  • 사용자 정의 getter가 없는 클래스 (매 호출마다 다른 결과를 반환할 수 있기 때문)
  • 모든 프로퍼티가 원시 타입(primitive type)이거나 @Immutable로 표시된 타입인 클래스

불변성과 안정성의 관계

  • 불변으로 간주되는 타입은 외부로 노출된 값이 절대 변경되지 않음
  • @StableMarker에 명시된 기준들을 자연스럽게 준수
  • @Immutable은 Compose Runtime에게 타입이 불변이므로 강력하게 안정적임을 알려주는 역할

 

@Stable

특징

  • @Immutable보다 좀 더 가벼운 약속을 제공하는 어노테이션
  • @StableMarker를 상속
  • 어노테이션 적용 대상에 따라 의미가 달라짐

타입에 적용될 시

  • 해당 타입이 가변적(mutable)임을 의미
  • 불변 타입은 @Immutable을 사용해야 함
  • @StableMarker의 요구사항 모두 적용
    • equals 함수 결과는 동일한 인스턴스에 대해 항상 동일
    • public 프로퍼티 변경 시 composition에 알림
    • 모든 public 프로퍼티는 안정적으로 간주

함수나 프로퍼티에 적용될 때

  • 동일한 입력값에 대해 항상 동일한 결과를 반환함을 컴파일러에 알림
  • 함수의 매개변수가 다음 중 하나인 경우만 가능
    • @Stable 또는 @Immutable로 마킹된 타입
    • 기본 유형(primitive 타입, 기본적으로 안정적)

Compose Runtime에서의 의미

  • Composable 함수에 전달된 매개변수가 모두 안정적인 타입일 경우
    • 위치 기억법을 통해 이전 호출과 매개변수 값 비교
    • 모든 값이 동일하면 recomposition 생략 가능

사용 예시

  • public 프로퍼티가 변경되지 않지만 완전한 불변 객체로 간주될 수 없는 타입
    • private한 가변 상태(state)를 가진 객체
    • MutableState 객체에 내부적으로 프로퍼티를 위임하지만 외부적으로는 불변인 객체

주의사항

  • 어노테이션의 의미가 충족된다는 확신이 없다면 사용하지 말아야 함
  • 잘못된 정보 제공 시 런타임 오류 발생 가능성
  • 모든 안정성 관련 어노테이션은 조심히 사용할 것을 권장

 

@Immutable과 @Stable 비교
현재 Compose Compiler는 두 어노테이션을 동일하게 취급
스마트 recomposition과 recomposition 생략 기능에 동일하게 활용
미래에는 서로 다른 의미로 차별화될 가능성을 위해 별도로 유지