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

소스코드 -> (프로트엔드 분석) -> 중간 표현(IR) -> 바이트코드 생성 -> 실행
Kotlin과 JVM 진영에서는 보통 kapt 를 통한 어노테이션 프로세서를 사용하는 것이 일반적입니다.
Compose는 kapt 나 어노테이션 프로세서를 전혀 사용하지 않습니다.
대신 Kotlin 컴파일러 플러그인 방식으로 작동합니다.
Compose Compiler = Kotlin 컴파일러 플러그인
즉, Kotlin의 컴파일 과정 안쪽에 직접 들어가는 확장입니다.
주의할 점으로 IDE 연동은 따로 처리됩니다.
Kotlin은 소스 코드를 분석한 뒤 IR(Intermediate Representation) 이라는 중간 코드 구조로 바꿔서
컴파일 합니다.
| 구분 | 어노테이션 프로세서 (kapt/KSP) | Kotlin 컴파일러 플러그인 |
|---|---|---|
| 개입 위치 | 소스 코드 수준 | IR(중간 표현) 수준 |
| 목적 | 코드 생성 | |
| 원래 작성한 코드를 건드리지 않음 | 코드 변형 및 삽입 | |
| 기존 코드의 구조를 통째로 바꾸거나 삽입/삭제 가능 | ||
| 위험도 | 낮음 (새 파일만 생성) | 높음 (기존 코드까지 수정) |
| 범위 | 파일 단위 | 전체 컴파일 단위 (전역 최적화 가능) |
| 예 | Room, Hilt, Moshi | Jetpack Compose, Serialization, Parcelize |
Composable 함수는 실행 시 트리에 내보내지는 노드로 데이터를 매핑하는 것
Compose Compiler와 어노테이션 프로세서의 가장 큰 차이저은, Compose의 경우 실제로 어노테잉션이 붙어있는 선언이나 표현식을 변형한다는 것입니다. 대부분의 어노테이션 프로세서는 표현식을 변형하는 행위 등은 할 수 없으며, 추가적이거나 동등한 선언만을 제공할 수 있습니다.
그렇기 때문에 Compiler는 IR 변환을 사용합니다. @Composable 어노테이션은 실제로 어노테이션이 붙은 대상의 타입을 변경하며, 컴파일러 플러그인은 프론트엔드에서 Composable 타입이 일반적인 함수들과 동일한 취급을 받지 않도록 모든 종류의 규칙을 강제하는 데 활용합니다.
@Composable을 통해 선언이나 표현식의 타입을 변경하는 것은 대상에게 메모리 를 부여하는 것을 의미합니다.
즉, remember 를 호출하고 Composer 및 슬롯 테이블 을 활용할 수 있는 능력을 의미합니다.
또한, Composable의 본문 내에서 구동된 이펙트들(effects)이 준수할 수 있는 라이프사이클을 제공합니다.
Composable 함수들은 메모리에 보존 될 수 있도록 각각의 정체성(ID 값)을 할당받고, 완성된 트리에서
위치(위치 기억법)가 지정됩니다.
즉, Composable 함수들은 노드를 composition으로 방출하고 CompositionLocals를 처리할 수 있습니다.
Compose에서 컴파일러에 의해서만 사용된다는 의도를 나타내기 위해 쓰입니다.
잠재적 사용자들에게 해당 사실을 알리고, 주의해서 사용해야 함을 알리기 위한 목적을 가집니다
API는 외부에 공개되어 있긴 하지만, Compose 내부적으로는 계속 바뀔 수 있음을 의미합니다
Kotlin의 internal은 컴파일러가 막아주는 진짜 접근 제한이지만, Compose 같은 라이브러리 개발자 입장에서는 다음 문제가 있습니다
함수 내에서 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 이점
| 항목 | 설명 |
|---|---|
| 성능 | 함수 호출 비용 없음 (스택 프레임 생성 제거) |
| 람다 성능 최적화 | 람다 객체 생성 없이 코드 복사 |
| 상위 컨텍스트 “상속” | 인라인된 코드가 바깥의 컨텍스트에서 실행되므로 @Composable 여부도 따라감 |
상속을 하는 근거로 1장에서 본 forEach문을 볼 수 있습니다.
@Composable
fun MyList(items: List<Item>) {
items.forEach {
Text(it.name)
}
}
forEach문의 람다는 @Composable로 마킹되어 있지 않지만 Composable 함수 내에서 호출되어 Composable 함수를 호출할 수 있게 됩니다.
하지만 remember와 같은 다른 일부 API의 경우에는 바람직하지 않습니다.
remember는 최초의 composition에만 호출됩니다. 그 내부 람다는 이후에 호출되지 않습니다.
그런데 그 안에서 Composable을 호출하면
🛡️ 그래서 이걸 컴파일러 수준에서 막아야 합니다 → @DisallowComposableCalls
만약 remember { someFunc() }인데 someFunc() 내부에서도 또 다른 인라인 람다를 받는다면, 그 안에서도 Composable 호출이 금지됩니다.
DisallowComposableCalls는 호출 체인을 타고 전파
Composable 함수가 상태를 변경하지 않고, 단순히 값을 읽기만 한다는 걸 컴파일러에게 알려주는
표시(어노테이션)입니다.
Compose Runtim은 Composable 함수가 앞선 가정을 충족하는 경우, 필요하지 않은 코드 생성을 사전에 방지합니다(Composer 주입 X).
Jetpack Compose의 세계에서는 모든 UI와 관련된 상태 접근도 Composable 컨텍스트 안에서만 허용됩니다.
@ReadOnlyComposable
@Composable
fun getDarkModeStatus(): Boolean {
return isSystemInDarkTheme()
}
Composable 함수나 블록이 호출될 때, Compose는 해당 위치와 상태를 Slot Table에
그룹 단위로 저장
이 그룹들은:
같은 태그를 가질 수 있습니다.
if (condition) {
Text("Hello")
} else {
Text("World")
}
Composable 함수가 composition에 쓰이지 않으면, 데이터가 교체되거나 이동되지 않으므로 아무런 가치도 제공하지 않습니다. 따라서 ReadOnlyComposable 어노테이션은 이와 같은 상황을 방지하는 데 활용됩니다.
| 함수 | 설명 |
|---|---|
| isSystemInDarkTheme() | 다크 모드 여부만 반환 |
| LocalContext.current | 현재 context만 조회 |
| LocalConfiguration.current | 디바이스 설정 정보 반환 |
| MaterialTheme.colors | 컬러 팔레트 정보 읽기 |
Compose가 불필요한 recomposition을 건너뛸 수 있도록 도움
Compose는 recomposition시 상태가 변하지 않았으면 불필요한 UI 갱신을 건너 뜁니다.
이걸 하려면, Compose는 이 값이 변했는지 아닌지를 판단할 수 있어야 합니다.
다른 어노테이션에 붙이는 어노테이션
@Stable이나 @Immutable에 붙여서 이건 안정성 관련 어노테이션이다라고 표시해주는
메타 어노테이션입니다.
@StableMarker
annotation class Stable
자체는 기능이 없고, “이 어노테이션은 Compose가 인식할 안정성 표시야” 라고 알려주는 마크입니다.
@StableMarker가 붙은 어노테이션이 표시된 클래스는 다음 조건을 만족해야 합니다.
| 추상 클래스나 인터페이스 | 이 타입을 구현하는 모든 클래스가 안정성을 지켜야 한다는 “약속”을 명시할 때 |
|---|---|
| 내부는 가변이지만 외부에선 안정한 경우 | 내부에 캐시나 상태가 있지만, 외부에서 볼 때 항상 같은 동작을 한다면@Stable로 표시 가능 |
인스턴스 생성 이후에 모든 외부로 노출된 프로퍼티의 필드가 변경되지 않을 것이다라는 것을
컴파일러와 엄격하게 약속합니다.
이는 Kotlin의 언어 차원에서 제공하는 val 키워드 보다 더 강력한 약속 입니다.
Kotlin의 val은 재할당은 안 될 뿐이지, 그 객체가 진짜로 불변인지는 보장하지 않습니다.
val list = mutableListOf(1, 2, 3)
list.add(4) // 리스트 내부는 변함
외부에서는 val list로 보이지만, 내부는 가변적입니다.
@Immutable 어노테이션은 Compose Runtime에게 이미 불변인 타입을 더 강력하게 안정적이다라는 사실을 전달하기 위해 존재합니다.
즉, 값이 변경되지 않기 때문에 실제로 composition에게 값 변경을 알려야 할 필요가 없고, 어쨋거나 이는 @StableMarker에 나열된 요구 사항을 충족하는 결과입니다.
값이 변할 수 있지만 바뀌면 Compose가 그걸 감지할 수 있다는 약속
| 구분 | @Immutable | @Stable |
|---|---|---|
| 값이 바뀌는가? | ❌ 안 바뀜 (진짜 불변) | ✅ 바뀔 수 있음 |
| 변경 감지 가능? | 의미 없음 (어차피 안 바뀌니까) | ✅ 가능해야 함 |
| 예시 | data class(val a: Int, val b: String) | class Counter { var count by mutableStateOf(0) } |
이 어노테이션이 타입에 적용되면 해당 타입이 가변적임(mutable)을 의미하고(그 외의 경우는 @Immutable을 사용해야 함), @StableMarker에 의한 상속의 의미만 지니게 됩니다.
UI를 다시 그릴 필요가 있는지 판단하기 위해서입니다. 즉 스마트 리컴포지션을 위해서 입니다.
@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
}
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++
}
}
어노테이션의 의미가 충족될 것이라는 확신이 없다면 이 어노테이션을 절대 사용하면 안됩니다. 그렇지 않으면 Compose Compiler에게 잘못된 정보를 제공하게 되어 쉽게 런타임 오류가 발생할 수 있습니다.