Compose Stability Analyzer: Compose는 클래스를 어떻게 불안정하다고 판단하고, 그 대가는 무엇인가?
Compose Stability Analyzer: Compose는 클래스를 어떻게 불안정하다고 판단하고, 그 대가는 무엇인가?
컴포저블(composable)을 열었더니 도구가 어떤 매개변수(parameter)를 불안정(unstable)하다고 알려 줍니다. 본능적으로 이것을 결함으로 받아들이고 고치러 가게 됩니다. 리스트를 감싸고, @Immutable을 붙이고, var를 val로 바꾸는 식으로 말이죠.
3년 전이었다면 그 본능이 옳았습니다. 하지만 강한 스키핑(strong skipping)이 이 판정의 의미를 바꿔 놓았고, 그 이전에 쓰인 조언 대부분은 이제 아무도 묻지 않는 질문에 답하고 있습니다. 불안정한 매개변수는 더 이상 컴포저블의 스키핑을 막지 않습니다. 그 매개변수가 어떻게 비교되는지를 바꿀 뿐이며, 그 결과는 매 프레임 비용을 치르는 경우일 수도 있고 아무 비용도 없는 경우일 수도 있습니다.
이 글은 그 판정을 만들어 내는 추론(inference)에 관한 것이자, 방금 말한 두 경우를 구분하는 방법에 관한 것입니다. Compose Stability Analyzer는 이를 살펴보기에 좋은 렌즈입니다. 판정과 그 근거, 그리고 런타임에서 실제로 벌어진 일을 한자리에서 보여 주는 유일한 도구이기 때문입니다. 이 글이 Compose 자체에 대해 주장하는 내용은 전해 들은 이야기가 아니라 Compose 컴파일러 소스와 대조해 확인한 것입니다.

플러그인은 JetBrains Marketplace에서 설치하시거나, Android Studio > Settings > Plugins > Marketplace에서 설치하실 수 있습니다. Gradle 쪽은
com.github.skydoves.compose.stability.analyzer이며 설정 방법은 문서에 정리되어 있습니다.
지금 이 판정이 의미하는 것
강한 스키핑 이전에는 불안정한 매개변수가 하나만 있어도 컴포저블이 스키핑 불가능 상태가 되었습니다. 안정성(stability)이 함수의 스키핑 여부를 결정했으므로, 불안정 판정은 실제로 성능 버그였습니다.
기본값으로 켜져 있는 강한 스키핑에서는 재시작 가능한(restartable) 컴포저블이라면 매개변수 타입과 무관하게 스키핑 가능합니다. 이 변화는 플러그인이 자체적으로 skippable 플래그를 계산하는 방식에 그대로 드러납니다.
// 모든 매개변수와 리시버가 안정적인지 확인합니다
val isNaturallySkippable =
parameters.all { it.stability == ParameterStability.STABLE } &&
receivers.all { it.stability == ParameterStability.STABLE }
// 강한 스키핑 모드에서는 모든 컴포저블이 스키핑 가능합니다
val isStrongSkippingEnabled = settings.isStrongSkippingEnabled
val isSkippable = if (isStrongSkippingEnabled) {
true
} else {
isNaturallySkippable
}
안정성이 중요하지 않게 된 것이 아니라, 역할이 옮겨 갔습니다. 이제 안정성은 Compose가 무언가 바뀌었는지 물을 때 각 매개변수를 어떻게 비교할지를 결정합니다.
- 안정적인 매개변수는
equals()로 구조적 비교를 합니다 - 불안정한 매개변수는
===로 동일성(identity) 비교를 합니다
이 한 문장이 여러분이 읽어 온 모든 헷갈리는 안정성 이야기를 설명해 줍니다. 불안정하더라도 매번 같은 인스턴스가 전달되는 매개변수는 === 검사를 통과해 문제없이 스키핑됩니다. 반대로 호출부에서 매번 새로 만들어지는 불안정한 매개변수는 새 객체가 이전 객체와 equals하더라도 리컴포지션(recomposition)마다 === 검사에 실패하고, 그 컴포저블은 계속 다시 실행됩니다.
컴파일러의 판정은 동일한데 결과는 정반대입니다. 그래서 첫 번째로 던져야 할 유용한 질문은 "이것이 불안정한가"가 아니라 "이 값이 어디에서 오는가"입니다.
판정을 만들어 내는 규칙들
안정성 추론은 재귀적인 순회입니다. 어떤 타입이 주어지면, 그 타입의 값이 Compose 모르게 바뀔 수 있는지 판단합니다. 대부분은 예상 그대로입니다. 원시 타입과 String은 안정적이고, var는 그렇지 않으며, MutableList도 그렇지 않습니다.
알아 둘 만한 부분은 그 답이 불리언이 아니라는 점입니다. 답은 네 가지입니다.
internal fun toParameterStability(): ParameterStability {
return when (this) {
is Certain -> if (stable) ParameterStability.STABLE else ParameterStability.UNSTABLE
is Runtime -> ParameterStability.RUNTIME
is Unknown -> ParameterStability.UNKNOWN
is Parameter -> ParameterStability.RUNTIME
is Combined -> {
val stabilities = elements.map { it.toParameterStability() }
when {
stabilities.all { it == ParameterStability.STABLE } -> ParameterStability.STABLE
stabilities.any { it == ParameterStability.UNSTABLE } -> ParameterStability.UNSTABLE
else -> ParameterStability.RUNTIME
}
}
}
}
RUNTIME은 List의 원소 타입처럼 컴파일러가 아직 볼 수 없는 타입에 답이 달려 있다는 뜻입니다. UNKNOWN은 구체적인 구현을 아예 알 수 없다는 뜻이며, 인터페이스가 받는 판정이 이것입니다. 둘 다 비교 시점에는 불안정한 것처럼 동작하지만, 서로 다른 진단이고 처방도 다릅니다.
이제 사람들이 실제로 놀라는 규칙들을 보겠습니다.
위임된 var는 클래스를 불안정하게 만들지 않습니다
var는 보통 클래스를 불안정하게 만듭니다. 그런데 아래는 그렇지 않습니다.
class SearchState {
var query by mutableStateOf("")
}
이 예외는 술어 하나로 처리되며, 그중 뒷부분이 핵심입니다.
val mutableProperties = properties.filter { !it.isVal && !it.isDelegatedPropertyCompat() }
위임 프로퍼티는 자기 소유의 가변 필드를 갖지 않습니다. 대신 MutableState를 갖는데, 이것은 @Stable이며 값이 바뀔 때 Compose에 알려 줍니다. 프로퍼티 대신 델리게이트를 채점하는 이 방식이 상태 홀더(state holder) 패턴 전체를 성립시킵니다.
by를 빼고 val query: MutableState<String>이라고 쓰면 미묘한 이유로 같은 결론에 도달합니다. 이제 프로퍼티가 안정적인 타입을 담은 val이므로 클래스는 여전히 안정적입니다. 다만 알림 기반의 편리한 사용성을 포기한 셈입니다. 판정은 같지만 코드는 더 나빠집니다.
computed 프로퍼티는 아예 채점되지 않습니다
data class User(
val first: String,
val last: String,
) {
val display: Spanned get() = SpannableString("$first $last")
}
Spanned는 불안정합니다. 하지만 User는 그렇지 않습니다. computed 게터는 백킹 필드(backing field)가 없어 아무것도 저장하지 않기 때문입니다.
// 클래스에서 상태를 저장하는 프로퍼티만 가져옵니다. computed 게터만 있는 프로퍼티는
// 백킹 필드가 없어 상태를 저장하지 않으므로 무시합니다. Compose 컴파일러와 동일한 처리입니다(issue #178).
val properties = classSymbol.declaredMemberScope.callables
.filterIsInstance<KaPropertySymbol>()
.filterNot { it.isComputedGetterOnly() }
.toList()
이 규칙은 흔한 직관을 뒤집기 때문에 익혀 둘 가치가 있습니다. 사람들은 안정성 경고를 피하려고 get()을 붙이면서 편법을 쓴다고 생각합니다. 그렇지 않습니다. 읽을 때마다 다시 계산되는 값은 클래스가 들고 있는 상태가 아니므로, 실제로 클래스가 Compose 모르게 바뀌게 만들 수 없습니다.
open 클래스는 막다른 길이 아니지만, 그 필드는 여전히 여러분을 구속합니다
abstract나 open 클래스를 매개변수로 넘기면 구체적인 하위 타입을 알 수 없으므로 정직한 판정은 UNKNOWN입니다. 다만 그 필드들은 모든 하위 클래스에 그대로 존재하므로, 분석기는 필드부터 살펴봅니다.
if (!hasStabilityAnnotation) {
val fieldStability = analyzeClassProperties(classSymbol, currentlyAnalyzing)
// 불안정하게 만드는 상태가 없다면 → 구체 하위 타입은 여전히 알 수 없으므로 → UNKNOWN.
// 그렇지 않다면(var / unstable / runtime 필드) 그 판정을 그대로 전파합니다. 구체 하위 타입이
// 무엇이든 성립하는 판정이며, 이것이 하위 클래스가 불안정성을 물려받게 하는 지점입니다.
return if (fieldStability.isStable()) {
KtStability.Unknown(fqName ?: simpleName)
} else {
fieldStability
}
}
실질적인 결론은 이렇습니다. 추상 베이스 클래스에 var가 하나 있으면 모든 하위 클래스가 불안정해지며, 하위 클래스에서 아무리 조심해도 이를 되돌릴 수 없습니다.
인터페이스는 unknown이며, 이는 unstable과 다릅니다
인터페이스 매개변수는 채점할 수 없습니다. 구현이 호출부에서 결정되기 때문입니다. 판정은 UNKNOWN입니다. 해법은 인터페이스에 애노테이션을 붙이는 것이 아니라, 컴파일 타임의 답을 유연성과 맞바꾸었다는 사실을 받아들이는 것입니다. 그 매개변수가 성능에 민감한 경로에 있다면 구체 타입을 받도록 바꾸시면 됩니다.
컬렉션은 세 종류, 답도 세 가지
| 타입 | 판정 |
|---|---|
MutableList, MutableSet, MutableMap | UNSTABLE |
ImmutableList, PersistentList 등 kotlinx.collections.immutable 전반 | STABLE |
List, Set, Map | RUNTIME |
가운데 경우가 Compose 성능 조언에 kotlinx.collections.immutable이 계속 등장하는 이유입니다. 사람들이 잘못 읽는 것은 세 번째입니다. List<String>은 불안정한 것이 아니라 RUNTIME입니다. 인터페이스 자체는 읽기 전용이지만 런타임 인스턴스는 ArrayList일 수 있으므로 Compose가 판단을 미루는 것입니다. 결과적으로 불안정한 매개변수처럼 동일성으로 비교되며, 그래서 새로 만든 리스트는 여전히 비용을 발생시킵니다.
값 클래스, enum, object
값 클래스는 감싸고 있는 타입의 안정성을 그대로 따릅니다. 따라서 @JvmInline value class UserId(val raw: String)은 안정적이고 value class Holder(val items: MutableList<String>)은 그렇지 않습니다. enum은 항상 안정적입니다. object도 마찬가지인데, 그 이유는 짚어 둘 만합니다. object는 싱글턴이므로 동일성이 절대 바뀌지 않으며, 그것이 들고 있는 어떤 프로퍼티도 두 리컴포지션 사이에서 해당 타입의 매개변수를 달라지게 만들 수 없습니다. Compose 컴파일러는 enum 처리 바로 다음 줄에서 정확히 이 점을 근거로 단축 평가합니다.
if (declaration.isEnumClass || declaration.isEnumEntry) return Stability.Stable
if (declaration.isObject) return Stability.Stable
다른 모듈에서 온 것은 증명되기 전까지 불안정합니다
가장 혼란스러운 경고를 만들어 내는 규칙이며, Compose의 규칙이라기보다는 의도적인 보수성입니다.
if (isFromDifferentModule(classSymbol)) {
val stabilityInferredParams = getStabilityInferredParameters(classSymbol)
if (stabilityInferredParams == null) {
return KtStability.Certain(
stable = false,
reason = "External class without stability annotation",
)
}
// ...
}
:core:model 모듈에 있는 완벽하게 불변인 data class도, 그 모듈이 Compose 컴파일러를 돌리지 않는다면 :feature:home 모듈에서는 불안정하다고 나옵니다. 예상 밖의 판정을 만드는 가장 흔한 원인이며, 해법은 보통 모델 모듈에 Compose 컴파일러 플러그인을 적용해 경계를 넘어 답을 전달하는 애노테이션을 생성하게 하는 것입니다.
이 판정들은 각 매개변수 옆에 인라인 힌트(inline hint)로 표시되며, 네 가지 구분을 실제로 확인하기에 가장 빠른 방법입니다.

때로는 클래스의 문제가 아닙니다
타입을 탓하기 전에, 그 함수가 애초에 스키핑 후보이기는 했는지 확인하셔야 합니다. 재시작 그룹(restart group)을 가진 컴포저블만 스키핑될 수 있는데, Compose가 재시작 그룹을 만드는 규칙은 안정성과 아무 관련이 없습니다. 다음은 컴파일러 자신의 shouldBeRestartable()입니다.
protected fun IrFunction.shouldBeRestartable(): Boolean {
// 본문이 있는 컴포저블 함수에만 observe 스코프를 넣습니다
if (body == null || this !is IrSimpleFunction)
return false
// ...
// inline 함수에는 observe 스코프를 넣지 않습니다
if (isInline)
return false
// ...
// 반환값이 있는 함수에는 observe 스코프를 넣지 않습니다
if (!returnType.isUnit())
return false
// ...
// 재시작 로직이 가상 호출을 하므로 open 함수는 재시작 가능할 수 없습니다 (todo: b/329477544)
if (modality == Modality.OPEN && parentClassOrNull?.isFinalClass != true) {
return false
}
// ...
}
마지막 조건을 주의 깊게 읽어 보시기 바랍니다. 비용이 크면서도 눈에 보이지 않는 함정이기 때문입니다. 인터페이스에 본문까지 있는 @Composable 멤버는 정의상 open입니다.
interface Screen {
@Composable
fun Content(state: UiState) { /* ... */ }
}
이 함수에는 재시작 그룹이 없습니다. UiState가 아무리 안정적이어도 부모가 리컴포지션될 때마다 다시 실행됩니다. 구현 클래스에서 final로 표시하면 복구됩니다.
깨뜨릴 만한 오해가 하나 더 있습니다. 위 함수 어디에도 @ReadOnlyComposable은 등장하지 않습니다. 흔히 보는 read-only 컴포저블들이 재시작 그룹을 잃는 이유는 값을 반환하기 때문에 !returnType.isUnit()에 걸리는 것이지, 애노테이션 때문이 아닙니다. 플러그인도 이 점을 명시적으로 적어 두었고, 컴파일러 소스가 이를 확인해 줍니다.
컴포저블이 재시작 불가능하면 플러그인은 거기서 멈추고 매개변수 판정을 아예 보고하지 않습니다. 그 경우 매개변수의 안정성이 결과를 바꿀 수 없기 때문입니다.
판정은 어디에서 오며, 왜 두 도구가 어긋날 수 있는가
이 플러그인을 단순히 편리한 도구가 아니라 흥미로운 대상으로 만드는 지점이 여기입니다.
Compose 컴파일러는 빌드 도중에 안정성을 계산합니다. IDE 플러그인은 여러분이 열어 둔 파일에 Compose lowering을 돌릴 수 없으므로, 타이핑하는 동안 답하려면 알고리즘을 다시 구현하는 수밖에 없습니다. IR 대신 Kotlin Analysis API 위에서 심볼을 순회하면서 말이죠. 위에서 인용한 모든 규칙이 바로 그 재구현입니다.
재구현은 원본과 일치해야 하며, 어긋났을 때 조용히 틀린다는 점이 문제입니다. 사례 하나만으로도 이 절의 값어치가 충분합니다.
@StabilityInferred는 Compose 컴파일러가 클래스의 안정성을 바이너리에 기록해 다른 모듈이 읽을 수 있게 하는 수단입니다. 그 parameters 인자는 불리언처럼 보이지만 불리언이 아닙니다. 0부터 n-1까지의 비트는 클래스가 가진 n개의 타입 매개변수 중 어느 것에 안정성이 의존하는지를 표시하고, 인덱스 n의 비트는 별도의 "known stable" 감시 비트(sentinel)입니다. 타입 매개변수가 없는 클래스라면 그 감시 비트는 그냥 0번 비트이므로, parameters = 1이 안정, parameters = 0이 불안정을 뜻합니다.
이를 당연해 보이는 방식인 parameters == 0 -> STABLE로 읽으면 정확히 반대 답이 나옵니다. 그리고 이쪽이 하필 사람을 오도하는 방향입니다. 컴파일러가 불안정하다고 표시한 모듈 간 클래스가 STABLE로 돌아오고, 경고하는 것이 본업인 도구가 침묵하게 됩니다. 프로젝트의 주석에는 실제 바이트코드와 대조해 StableUser = 1, UnstableUser = 0임을 확인했다고 기록되어 있습니다.
수정된 해석은 이 프로젝트의 두 구현 모두에서 동일한 표현식입니다.
typeParameterCount < 32 && ((bitmask shr typeParameterCount) and 1) == 1
두 구현이 무언가를 포기하는 방식으로 해결한, 더 미묘한 일치 문제도 있습니다. 지금 컴파일 중인 모듈에 속한 클래스에서는 @StabilityInferred가 Compose 컴파일러 자신의 lowering이 돈 뒤에야 존재하는데, 플러그인들이 도는 순서를 고정해 주는 장치가 없습니다. 그래서 양쪽 모두 그 경우에는 애노테이션을 읽기를 거부하고, 바이너리에서 온 클래스에 대해서만 신뢰합니다. 애초에 그것이 이 애노테이션이 설계된 용도입니다.
이 규칙을 강제하게 만든 버그도 좋은 이야깃거리입니다. 소스 클래스에서 애노테이션을 읽으면 판정이 kotlinCompilerPluginClasspath의 해석 순서에 좌우되어, 같은 코드가 기기마다 다르게 채점될 수 있었습니다. 한쪽에는 있고 다른 쪽에는 없는 정보였기에, 에디터와 빌드가 서로 모순되게 두느니 양쪽 모두 그것을 버린 것입니다.
아직 어긋나 있는 한 곳
이 일치를 강제하는 장치는 없으며, 최소한 한 군데 차이가 남아 있습니다. 플러그인과 Compose 컴파일러 모두 재귀적인 타입을 방어하는데, 순환을 서로 반대 방향으로 해소합니다. Compose는 불안정하다고 판정합니다.
if (currentlyAnalyzing.contains(symbol)) return Stability.Unstable
플러그인은 안정적이라고 판정합니다.
// 순환 참조를 확인합니다
if (declaration in currentlyAnalyzing) {
return KtStability.Certain(
stable = true,
reason = StabilityConstants.Messages.CIRCULAR_REFERENCE,
)
}
따라서 class Node(val value: Int, val next: Node?)에서는 거터(gutter)와 빌드 리포트가 일치하지 않습니다. 어느 쪽이 명백히 틀렸다고 하기는 어렵습니다. Compose는 보수적이며 실제로는 안정적인 재귀 타입 일부를 불안정하다고 표시할 수 있음을 감수합니다. 플러그인은 대체로 문제없는 형태에 겁나는 경고를 띄우지 않는 쪽을 택합니다. 정적 판정이 측정이 아니라 모델이라는 사실을 상기시켜 주는 좋은 사례입니다.
어떤 불안정 경고가 실제로 비용을 발생시키는가
처음의 질문으로 돌아가 보겠습니다. 판정이 나왔습니다. 그것이 중요한가요?
정적 분석은 답해 줄 수 없습니다. 답이 타입이 아니라 호출부에서 그 값이 어디에서 오는지에 달려 있기 때문입니다. 정적 분석이 할 수 없는 일을 대신하는 방법은 관찰입니다. 플러그인의 런타임은 각 리컴포지션마다 매개변수별로 값이 구조적으로 바뀌었는지, 그리고 인스턴스가 바뀌었는지를 기록합니다.
val hasPrevious = name in previousParameters
val previousValue = previousParameters[name]
val changed = hasPrevious && previousValue != value
val referenceChanged = !isStable && hasPrevious && !changed && previousValue !== value
마지막 줄의 모든 조건이 제 역할을 합니다. !isStable은 관문입니다. 동일성 검사는 추론이 불안정하다고 판정한 매개변수에 대해서만 돌아가는데, 강한 스키핑이 ===로 비교하는 대상이 그것들뿐이기 때문입니다. 이 관문은 판독을 정직하게 유지하는 장치이기도 합니다. JVM의 small-integer 캐시 범위를 벗어난 박싱된 Int는 값이 같아도 새 인스턴스이므로, 관문이 없으면 모든 안정적인 원시 타입이 영원히 허위 동일성 변경을 보고하게 됩니다. hasPrevious는 비교 대상이 없는 최초 컴포지션을 걸러 내고, !changed는 이미 집계된 실제 변경을 걸러 냅니다. 네 조건을 모두 통과해 남는 것은 컴파일러가 결코 예측할 수 없었던 좁은 경우입니다. 값은 같은데 객체는 새것인 상황이죠.
정적 판정과 런타임 관찰을 나란히 놓으면 하나의 경고가 셋으로 갈라집니다.
// 불안정한 매개변수는 동일성(===)으로 비교됩니다. equals는 같지만 새 인스턴스라면 리컴포지션됩니다.
ParameterStability.UNSTABLE -> when {
equalsChanged > 0 -> RealityGrade.JUSTIFIED
refChanged > 0 -> RealityGrade.SILENT_WASTE
else -> RealityGrade.FALSE_ALARM
}
- Justified(정당함). 값이 실제로 바뀌었습니다. 그 리컴포지션은 여러분이 요청한 작업입니다. 고칠 것이 없습니다.
- Silent waste(조용한 낭비). 값은
equals로 같은데 새 인스턴스로 도착해서===가 실패했고, 컴포저블이 아무 이유 없이 다시 실행되었습니다. 이것이 진짜 버그이며, 컴파일러 리포트에는 보이지 않습니다. - False alarm(거짓 경보). 매개변수는 불안정했지만 아무것도 바뀌지 않았습니다. 그 경고는 소음이니 무시하시면 됩니다.
하나의 컴파일러 판정에서 세 가지 결론이 나옵니다. 이것이 강한 스키핑 이후 안정성 작업의 실질적인 모습이며, 분류 기준이 "이 타입에 애노테이션이 붙어 있는가"가 아니라 "이것이 호출부에서 할당되는가"여야 하는 이유입니다. 각 등급의 판정 방식과 처방은 그 자체로 별도의 주제이며,
비교 메커니즘 자체도 마찬가지입니다.
마치며
이 글에서는 안정성 판정을 만들어 낸 규칙부터 그것이 실제로 치르게 하거나 치르게 하지 않는 대가까지 따라가 보았습니다.
규칙들은 평판보다 관대합니다. 위임된 var는 괜찮습니다. computed 프로퍼티는 상태가 아닙니다. enum, object, String을 감싼 값 클래스는 모두 안정적입니다. 실제로 발목을 잡는 것은 통념보다 훨씬 좁습니다. 진짜 var 필드, 가변 컬렉션, Compose 컴파일러를 돌리지 않는 모듈에서 온 애노테이션 없는 타입, 그리고 모든 하위 클래스로 불안정성을 흘려보내는 추상 베이스 클래스의 필드 정도입니다.
그리고 판정 자체는 더 이상 성능에 대한 판정이 아닙니다. 강한 스키핑은 그것을 비교 방식에 대한 진술로 바꿔 놓았고, 그 결과 같은 "불안정" 꼬리표가 완벽하게 스키핑되는 컴포저블과 매 프레임 리컴포지션되는 컴포저블을 동시에 가리킵니다. 둘을 구분하려면 추론이 아니라 관찰이 필요하며, 그것은 빌드 리포트가 줄 수 없는 단 하나입니다.
여기서 습관 하나만 가져가신다면 분류 질문을 택하시기 바랍니다. 불안정한 매개변수를 보면 @Immutable부터 집지 마시고, 호출부를 열어 그 값이 매번 다시 만들어지는지 확인해 보세요. 호이스팅되었거나 remember되어 있다면 그 경고는 공짜입니다. 인라인으로 할당되고 있다면 고칠 가치가 있는 것을 찾으신 것이며, 타입만 봐서는 결코 어느 쪽인지 알 수 없었을 것입니다.

