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개의 댓글