Compose annotations

동키·2025년 6월 3일

안드로이드

목록 보기
13/14

해당 내용은 Compose Internals 2장 Compose 어노테이션 내용을 공부하며 기록한 내용입니다.


2.1 Compose 컴파일러

일반적으로 아는 컴파일 흐름

소스코드 -> (프로트엔드 분석) -> 중간 표현(IR) -> 바이트코드 생성 -> 실행

Kotlin과 JVM 진영에서는 보통 kapt 를 통한 어노테이션 프로세서를 사용하는 것이 일반적입니다.

Compose는 kapt를 안쓴다

Compose는 kapt 나 어노테이션 프로세서를 전혀 사용하지 않습니다.
대신 Kotlin 컴파일러 플러그인 방식으로 작동합니다.

Compose Compiler = Kotlin 컴파일러 플러그인

즉, Kotlin의 컴파일 과정 안쪽에 직접 들어가는 확장입니다.

  • kapt는 컴파일 전에 별도로 실행돼야 해서 느립니다.
  • Compose Compiler는 Kotlin 컴파일의 “프로늩엔드” 단계에서 진단 제공
  • 컴파일러 플러그인은 컴파일 과정에 직접 내장되어 있습니다

주의할 점으로 IDE 연동은 따로 처리됩니다.

  • Compose Compiler가 컴파일러 플러그인으로 돌아가기 때문에, IntelliJ / Android Studio의 에디터에서는 별도 IDEA 플러그인이 필요합니다
  • 에디터가 바로 실시간으로 Copmose 오류를 잡아주는 기능은 Compose Compiler와는 다른 경로로 구현되어 있습니다.

IR(중간표현) 단계에서 소스 코드를 마음대로 조작 가능

Kotlin은 소스 코드를 분석한 뒤 IR(Intermediate Representation) 이라는 중간 코드 구조로 바꿔서
컴파일 합니다.

  • IR 단계에서 소스 코드를 수정함으로써 Compose Compiler는 Compose Runtime이 요구하는 대로 Composable 함수를 마음대로 변형시킬 수 있습니다(Composer 삽입 등)
  • 즉, 개발자가 작성한 Composable 함수는 컴파일러가 자동으로 구조를 변형해서 런타임에 필요한 코드로 재구성 합니다.

어노테이션 프로세서와 Kotlin 컴파일러 플러그인 차이점

구분어노테이션 프로세서 (kapt/KSP)Kotlin 컴파일러 플러그인
개입 위치소스 코드 수준IR(중간 표현) 수준
목적코드 생성
원래 작성한 코드를 건드리지 않음코드 변형 및 삽입
기존 코드의 구조를 통째로 바꾸거나 삽입/삭제 가능
위험도낮음 (새 파일만 생성)높음 (기존 코드까지 수정)
범위파일 단위전체 컴파일 단위 (전역 최적화 가능)
예Room, Hilt, MoshiJetpack Compose, Serialization, Parcelize

2.2 @Composable

Composable 함수는 실행 시 트리에 내보내지는 노드로 데이터를 매핑하는 것

Compose Compiler와 어노테이션 프로세서의 가장 큰 차이저은, Compose의 경우 실제로 어노테잉션이 붙어있는 선언이나 표현식을 변형한다는 것입니다. 대부분의 어노테이션 프로세서는 표현식을 변형하는 행위 등은 할 수 없으며, 추가적이거나 동등한 선언만을 제공할 수 있습니다.

그렇기 때문에 Compiler는 IR 변환을 사용합니다. @Composable 어노테이션은 실제로 어노테이션이 붙은 대상의 타입을 변경하며, 컴파일러 플러그인은 프론트엔드에서 Composable 타입이 일반적인 함수들과 동일한 취급을 받지 않도록 모든 종류의 규칙을 강제하는 데 활용합니다.

@Composable을 통해 선언이나 표현식의 타입을 변경하는 것은 대상에게 메모리 를 부여하는 것을 의미합니다.
즉, remember 를 호출하고 Composer 및 슬롯 테이블 을 활용할 수 있는 능력을 의미합니다.

또한, Composable의 본문 내에서 구동된 이펙트들(effects)이 준수할 수 있는 라이프사이클을 제공합니다.
Composable 함수들은 메모리에 보존 될 수 있도록 각각의 정체성(ID 값)을 할당받고, 완성된 트리에서
위치(위치 기억법)가 지정됩니다.

즉, Composable 함수들은 노드를 composition으로 방출하고 CompositionLocals를 처리할 수 있습니다.


2.3 @ComposableCompilerApi

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

잠재적 사용자들에게 해당 사실을 알리고, 주의해서 사용해야 함을 알리기 위한 목적을 가집니다


2.4 @InternalComposeApi

API는 외부에 공개되어 있긴 하지만, Compose 내부적으로는 계속 바뀔 수 있음을 의미합니다

  • 공식적으론 “써도 되지만 책임은 니가 져”라는 뜻입니다.
  • 안정성 보장이 없는, 내부적인 성격을 가진 API라는 의도 표시입니다.

Kotlin의 internal은 컴파일러가 막아주는 진짜 접근 제한이지만, Compose 같은 라이브러리 개발자 입장에서는 다음 문제가 있습니다

  • 어떤 API는 외부에 공개해야만 하지만, 아직 변경될 가능성이 높거나, 내부 동작을 위해 쓰이는 것이면, 외부 개발자에게 “조심해서 써라, 나중에 깨질 수 있다”는 경고가 필요합니다.

2.5 @DisallowComposableCalls

함수 내에서 Composable 함수의 호출이 발생하는 것을 방지하기 위해 사용됩니다.

이 어노테이션은 Composable 함수를 안전하게 호출할 수 없는 Composable 함수의 인라인 람다 매개변수에서 유용하게 사용될 수 있습니다. 주로 recomposition 마다 호출되면 안 되는 람다식에 가장 적합하게 사용됩니다.

예시로 Compose Runtime의 일부인 remember 함수에서 찾아볼 수 있습니다.

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

remember는 오직 첫 composition 단계에서만 수행되며, 이후의 모든 recomposition 단계에서는 항상 이미 계산된 값을 반환합니다.

만약 Composable 함수 호출이 허용된다면, Composable 함수의 노드 방출 시 슬롯 테이블 에서 공간을 차지하고 람다가 더 이상 호출되지 않으므로 첫 composition 단계 후에 삭제됩니다.

때문에 remember 람다 식 안에 Composable을 호출을 하면 안되며 이를 가능케 해주는 것이 @DisallowComposableCalls 어노테이션 입니다.

조건부로 호출되는 inline 람다에서 가장 적합하게 사용된다

조건부 호출 = 람다가 실제로 항상 실행되는 것이 아니라 상황에 따라 실행될 수도, 안 될 수도 있는 경우

inline 이점

항목설명
성능함수 호출 비용 없음 (스택 프레임 생성 제거)
람다 성능 최적화람다 객체 생성 없이 코드 복사
상위 컨텍스트 “상속”인라인된 코드가 바깥의 컨텍스트에서 실행되므로 @Composable 여부도 따라감
  • inline은 함수의 본문이 호출한 위치에 복사되도록 컴파일합니다(함수 호출 비용 없음)
  • 성능 최적화뿐만 아니라 람다에서 상위 컨텍스트(Composable)를 “상속”할 수 있게 해줍니다.

상속을 하는 근거로 1장에서 본 forEach문을 볼 수 있습니다.

@Composable
fun MyList(items: List<Item>) {
    items.forEach {
        Text(it.name) 
    }
}

forEach문의 람다는 @Composable로 마킹되어 있지 않지만 Composable 함수 내에서 호출되어 Composable 함수를 호출할 수 있게 됩니다.

하지만 remember와 같은 다른 일부 API의 경우에는 바람직하지 않습니다.

remember는 최초의 composition에만 호출됩니다. 그 내부 람다는 이후에 호출되지 않습니다.
그런데 그 안에서 Composable을 호출하면

  • Compose Runtime은 해당 Composable을 Slot Table에 등록하려고 시도합니다.
  • 이후 recomposition에서 해당 노드는 실제로 존재하지 않는데도 존재하는 것으로 간주됨
  • 결과: 상태 구조 붕괴, Slot 테이블 충돌, 심각한 버그

🛡️ 그래서 이걸 컴파일러 수준에서 막아야 합니다 → @DisallowComposableCalls

전파성?

만약 remember { someFunc() }인데 someFunc() 내부에서도 또 다른 인라인 람다를 받는다면, 그 안에서도 Composable 호출이 금지됩니다.

DisallowComposableCalls는 호출 체인을 타고 전파


2.6 @ReadOnlyComposable

Composable 함수가 상태를 변경하지 않고, 단순히 값을 읽기만 한다는 걸 컴파일러에게 알려주는
표시(어노테이션)입니다.

Compose Runtim은 Composable 함수가 앞선 가정을 충족하는 경우, 필요하지 않은 코드 생성을 사전에 방지합니다(Composer 주입 X).

Jetpack Compose의 세계에서는 모든 UI와 관련된 상태 접근도 Composable 컨텍스트 안에서만 허용됩니다.

@ReadOnlyComposable
@Composable
fun getDarkModeStatus(): Boolean {
    return isSystemInDarkTheme()
}
  • 이 함수는 어떤 UI를 생성하지 않고, 오직 시스템 상태만 읽습니다.
  • 화면에 아무것도 그리지 않음, 값만 반환함

일반 Composable 함수

  • 내부에서 UI를 그리면 Compose는 그룹 이라는 구조로 SlotTable에 기록합니다.
  • 이 그룹은 추후에 recomposition을 위해 사용됩니다 (재시작, 이동 가능성 등 포함)

@ReadOnlyComposable함수

  • 그룹 생성을 하지 않음
  • SlotTable에 기록도 거의 없음
  • 단순히 상태 조회나 상수 반환 같은 함수일 경우, 성능 최적화 가능

그룹이란 무엇인가?

Composable 함수나 블록이 호출될 때, Compose는 해당 위치와 상태를 Slot Table에

그룹 단위로 저장

이 그룹들은:

  • 재시작 가능한 그룹(restartable)
  • 이동 가능한 그룹(movable)
  • 스킵 가능한 그룹(skippable)

같은 태그를 가질 수 있습니다.

if (condition) {
    Text("Hello")
} else {
    Text("World")
}
  • 여기서 Text("Hello")와 Text("World")는 서로 다른 그룹으로 간주됩니다.
  • 이유는? → condition 값에 따라 위치와 의미가 달라지기 때문입니다.
  • 이동 가능한 그룹은 의미론적으로 고유한 키를 가지고 있기 때문에, 각자의 부모 그룹 내에서 재정렬될 수 있습니다.

그러면, 왜@ReadOnlyComposable이 필요한가?

  • 이런 그룹을 생성하지 않는 함수라면:
    • Slot Table에 기록할 필요도 없음
    • UI 노드도 방출하지 않음
    • 재컴포지션 대상도 아님

Composable 함수가 composition에 쓰이지 않으면, 데이터가 교체되거나 이동되지 않으므로 아무런 가치도 제공하지 않습니다. 따라서 ReadOnlyComposable 어노테이션은 이와 같은 상황을 방지하는 데 활용됩니다.

함수설명
isSystemInDarkTheme()다크 모드 여부만 반환
LocalContext.current현재 context만 조회
LocalConfiguration.current디바이스 설정 정보 반환
MaterialTheme.colors컬러 팔레트 정보 읽기

2.7 @StableMarker

Compose가 불필요한 recomposition을 건너뛸 수 있도록 도움

Compose는 recomposition시 상태가 변하지 않았으면 불필요한 UI 갱신을 건너 뜁니다.
이걸 하려면, Compose는 이 값이 변했는지 아닌지를 판단할 수 있어야 합니다.

@StableMarker

다른 어노테이션에 붙이는 어노테이션
@Stable이나 @Immutable에 붙여서 이건 안정성 관련 어노테이션이다라고 표시해주는
메타 어노테이션입니다.

@StableMarker
annotation class Stable

자체는 기능이 없고, “이 어노테이션은 Compose가 인식할 안정성 표시야” 라고 알려주는 마크입니다.

@StableMarker가 붙은 어노테이션이 표시된 클래스는 다음 조건을 만족해야 합니다.

  1. equals()의 호출 결과는 동일한 두 인스턴스에 대해 항상 동일합니다
  2. 어노테이션이 적용된 public 프로퍼티가 바뀌면 Compose가 감지 가능해야 함(composition에 알립니다)
  3. 어노테이션이 적용된 모든 public 프로퍼티는 안정적(stable)이라고 간주합니다.

❗ 컴파일러가 검사하진 않는다

  • 주의: 위 사항은 컴파일러와의 약속일 뿐, 실제로는 강제 검사는 하지 않아요
  • 즉, 개발자가 이 어노테이션을 붙였으면, 실제로 이 타입이 안정성을 보장해야 합니다.

사용 상황

추상 클래스나 인터페이스이 타입을 구현하는 모든 클래스가 안정성을 지켜야 한다는 “약속”을 명시할 때
내부는 가변이지만 외부에선 안정한 경우내부에 캐시나 상태가 있지만, 외부에서 볼 때 항상 같은 동작을 한다면@Stable로 표시 가능

2.8 Immutable

인스턴스 생성 이후에 모든 외부로 노출된 프로퍼티의 필드가 변경되지 않을 것이다라는 것을
컴파일러와 엄격하게 약속합니다.

이는 Kotlin의 언어 차원에서 제공하는 val 키워드 보다 더 강력한 약속 입니다.

val만으로는 부족한가?

Kotlin의 val은 재할당은 안 될 뿐이지, 그 객체가 진짜로 불변인지는 보장하지 않습니다.

val list = mutableListOf(1, 2, 3)
list.add(4) // 리스트 내부는 변함

외부에서는 val list로 보이지만, 내부는 가변적입니다.

Immutable이 보장하는 조건

  1. 모든 속성은 val이어야 함
  2. 사용자 정의 getter가 없어야 함 (매번 값이 달라질 수 있음)
  3. 모든 속성도 @Immutable 또는 원시 타입(Int, String, Boolean 등)이어야 함
  4. 속성 값은 객체 생성 이후 절대 변하지 않아야 함

@Immutable 어노테이션은 Compose Runtime에게 이미 불변인 타입을 더 강력하게 안정적이다라는 사실을 전달하기 위해 존재합니다.

즉, 값이 변경되지 않기 때문에 실제로 composition에게 값 변경을 알려야 할 필요가 없고, 어쨋거나 이는 @StableMarker에 나열된 요구 사항을 충족하는 결과입니다.


2.9 @Stable

값이 변할 수 있지만 바뀌면 Compose가 그걸 감지할 수 있다는 약속

구분@Immutable@Stable
값이 바뀌는가?❌ 안 바뀜 (진짜 불변)✅ 바뀔 수 있음
변경 감지 가능?의미 없음 (어차피 안 바뀌니까)✅ 가능해야 함
예시data class(val a: Int, val b: String)class Counter { var count by mutableStateOf(0) }

이 어노테이션이 타입에 적용되면 해당 타입이 가변적임(mutable)을 의미하고(그 외의 경우는 @Immutable을 사용해야 함), @StableMarker에 의한 상속의 의미만 지니게 됩니다.

Compose가 왜 알아야 할가?

UI를 다시 그릴 필요가 있는지 판단하기 위해서입니다. 즉 스마트 리컴포지션을 위해서 입니다.

  • @Immutable: 어차피 안 바뀜 → recomposition 필요 없음
  • @Stable: 바뀔 수도 있음 → 바뀌면 recomposition 필요

@Stable이 필요한 대표적 상황

@Stable
class UserProfile {
    var name by mutableStateOf("John")
}

이 클래스는 가변적임 (name이 바뀔 수 있음)하지만 Compose는 mutableStateOf 덕분에 변경을 추적 가능

그래서 안정적(stable)이라고 간주할 수 있습니다

아래와 같은 경우는 붙이지 않습니다.

class Risky {
    var name = "John"
}

함수에도 붙일 수 있다

클래스 외에 함수나 프로퍼티에 적용할 수 있으며 함수가 항상 동일한 입력값에 대해 동일한 결과(멱등성)를 반환한다는 사실을 컴파일러에게 알립니다. 이는 함수의 매개변수가 @Stable 또는 @Immutable으로 마킹 되어있거나, 기본 유형(primitive 타입은 기본적으로 안정적인 타입으로 간주됨)인 경우에만 가능합니다.

@Stable
fun computeResult(input: Int): Int {
    return input * 2
}

Compose Runtime과 관련성

Composable 함수에 매개변수로 전달된 모든 타입이 안정적인 타입으로 마킹되면, 위치 기억법을 기반으로 이전 함수 호출과의 매개변수 값이 동일한지 비교하고, 모든 값이 동일하다면 recomposition을 생략합니다.

언제 사용?

@Stable을 사용할 수 있는 타입의 예로 public프로퍼티가 변경되지는 않지만 불변의 객체로 간주될 수 없는 경우 입니다. 예를 들어, private한 가변적인 상태(state)를 소유하고 있거나, MutableState 객체에 대해서 내부적으로 프로퍼티를 위임하고 외부에서 사용되는 형태는 불가변적인 상태인 경우입니다.

@Stable
class Counter {
    private var _count = mutableStateOf(0) // 내부 상태는 바뀜

    val count: Int
        get() = _count.value // 외부에선 읽기 전용처럼 보임

    fun increment() {
        _count.value++
    }
}
  • 내부에선 _count가 mutableStateOf라서 값이 변할 수 있습니다.
  • 외부에선 count만 보임 → 불변처럼 보입니다.
  • 변경은 항상 mutableStateOf를 통해 발생 → Compose가 추적 가능합니다.

주의점

어노테이션의 의미가 충족될 것이라는 확신이 없다면 이 어노테이션을 절대 사용하면 안됩니다. 그렇지 않으면 Compose Compiler에게 잘못된 정보를 제공하게 되어 쉽게 런타임 오류가 발생할 수 있습니다.

profile
오늘 하루도 화이팅

0개의 댓글