
동기
Jetpack Compose 를 활용한 개발 도중, StableMarker 를 잘 작성해왔음에도 불구하고 화면 렌더링 시의 Janky Frame 이 발생하는 것을 확인했습니다. Jetpack Compose 외부 라이브러리가 그 원인이었는데, 내부 데이터 클래스에 StableMarker 가 작성되어 있지 않았기 때문입니다.
이를 해결하기 위해 Stability Configuration File 을 정의 및 적용 하였고, 이에 대한 문제를 해결할 수 있었습니다. 동작 과정에 의문이 생겼고, 이에 대해 학습했습니다.
[Jetpack Compose] 불필요한 Recomposition 을 줄여 앱 퍼포먼스 개선하기
동기 Jetpack Compose 를 활용하여 개발 중인 앱 에는 드래그 앤 드랍과 같은 유저 인터랙션이 존재합니다. 다만 문제가 좀 있었습니다. 드래그 앤 드랍 시 화면이 버벅거린다는 점이었고, 이는 매우
blothhundr.tistory.com
@Stability, @Immutable 을 통한 Jetpack Compose 최적화에 관해서는 위 포스트에 자세히 기술하였습니다.
짧게 정리하자면, 특정 타입이 불변하다면 불필요한 Recomposition 을 건너 뛰어 렌더링을 최적화할 수 있도록 고안된 Annotation 입니다.
StableMarker 를 잘 작성한다면 불필요한 Recomposition 을 스킵할 수 있기 때문에, StableMarker 를 잘 붙이면 되는 것은 맞지만, 그럼에도 불구하고 Stability Configuration File 이 필요한 이유는, 외부 라이브러리에 선언된 데이터 클래스에는 StableMarker 를 붙일 수 없기 때문입니다.
Stability Configuration File
빌드 타임에 Compose 가 컴파일러 플러그인을 통해 안정성 여부를 판단하는데, 이 때 데이터 클래스 등에 직접 작성한 StableMarker 를 확인합니다. 이에 대해 Compose Compiler 는 해당 데이터 클래스는 불변하는 것으로 판단하고 이에 대한 Recomposition 을 건너뛰게 됩니다.
적용 방법은 간단한데, 별도의 stability_config.conf 파일을 프로젝트 루트에 생성하고, 해당 파일의 경로를 Gradle 에 작성해주면 됩니다.
android {
kotlinOptions {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${project.rootDir}/compose/stability_config.conf"
)
}
}
com.example.stabilityconf.StableUser
클래스 단위로 stable 을 작성할 수도 있고, 패키지 단위로 stable 을 작성할 수도 있습니다. 와일드카드 문법을 통해 패키지에 존재하는 모든 클래스를 stable 하게 처리할 수도 있습니다.
개인적으로는 클래스 단위에 적용하는 것을 선호하는데, 잘못된 안정성 설정은 의도한 Recomposition 이 발생하지 않게 될 수도 있기 때문입니다.
어떻게 적용되는가?
kotlin/plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/StabilityConfigParser.kt
The Kotlin Programming Language. . Contribute to JetBrains/kotlin development by creating an account on GitHub.
github.com
Stability Config File 을 파싱하는 Parser 클래스 소스 코드입니다. 자세히 보실 분은 확인해보시면 좋겠습니다.
init {
val matchers: MutableSet<FqNameMatcher> = mutableSetOf()
lines.forEachIndexed { index, line ->
val l = line.trim()
if (!l.startsWith(COMMENT_DELIMITER) && !l.isBlank()) {
if (l.contains(COMMENT_DELIMITER)) { // com.foo.bar //comment
error(
errorMessage(
line,
index,
"Comments are only supported at the start of a line."
)
)
}
try {
matchers.add(FqNameMatcher(l))
} catch (exception: IllegalStateException) {
error(
errorMessage(line, index, exception.message ?: "")
)
}
}
}
stableTypeMatchers = matchers.toSet()
}
매우 간단한데, 한 줄 씩 읽어서 유효성 검사 이후에 필드에 선언된 Set 에 추가합니다. 이 Set 은 아래 깃헙에 작성된 코드에서 사용됨을 알 수 있습니다.
kotlin/plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/Stability.kt at master ·
The Kotlin Programming Language. . Contribute to JetBrains/kotlin development by creating an account on GitHub.
github.com
if (canInferStability(declaration) || declaration.isExternalStableType()) {
val fqName = declaration.fqNameWhenAvailable?.toString() ?: ""
val typeParameters = declaration.typeParameters
val stability: Stability
val mask: Int
if (KnownStableConstructs.stableTypes.contains(fqName)) {
mask = KnownStableConstructs.stableTypes[fqName] ?: 0
stability = Stability.Stable
} else if (declaration.isExternalStableType()) {
mask = externalTypeMatcherCollection
.maskForName(declaration.fqNameWhenAvailable) ?: 0
stability = Stability.Stable
} else if (declaration.isInterface && declaration.isInCurrentModule()) {
// trying to avoid extracting stability bitmask for interfaces in current module
// to support incremental compilation
return Stability.Unknown(declaration)
} else {
val bitmask = declaration.stabilityParamBitmask() ?: return Stability.Unstable
val knownStableMask =
if (typeParameters.size < 32) 0b1 shl typeParameters.size else 0
val isKnownStable = bitmask and knownStableMask != 0
mask = bitmask and knownStableMask.inv()
// supporting incremental compilation, where declaration stubs can be
// in the same module, so we need to use already inferred values
stability = if (isKnownStable && declaration.isInCurrentModule()) {
Stability.Stable
} else {
Stability.Runtime(declaration)
}
}
return when {
mask == 0 || typeParameters.isEmpty() -> stability
else -> stability + Stability.Combined(
typeParameters.mapIndexedNotNull { index, irTypeParameter ->
if (index >= 32) return@mapIndexedNotNull null
if (mask and (0b1 shl index) != 0) {
val sub = substitutions[irTypeParameter.symbol]
if (sub != null)
stabilityOf(sub, substitutions, analyzing)
else
Stability.Parameter(irTypeParameter)
} else null
}
)
}
}
안정성 추론이 가능한 선언, 외부에서 선언된 안정적 타입인지 확인을 하고, 이에 따라 Stable 처리 해주는 코드입니다.
else if (declaration.isExternalStableType()) {
mask = externalTypeMatcherCollection
.maskForName(declaration.fqNameWhenAvailable) ?: 0
stability = Stability.Stable
}
이 부분을 자세히 보면 나오는데, declaration.fqNameWhenAvailable 을 참조, Stability Configuration File 에서 매핑된 마스크(타입 파라미터별 안정성 추적을 위해 비트마스크를 이용하므로 비트마스크의 마스크)를 찾아서 가져옵니다. 이후 해당 마스크를 적용하여 stability 를 Stable 로 처리합니다.
전체 흐름을 요약하자면 다음과 같습니다.
- stability_config.conf 정의
- build.gradle.kts - kotlinOptions - freeCompilerArgs 로 경로 전달
- Compose Compiler Plugin 작동
- ComposeConfiguration.kt -> stabilityConfigurationPath 로 전달 받음
- StabilityConfigParser.parse() 로 파싱
- StabilityConifg 객체 생성
- StabilityInferencer 에서 stability 분석 시 우선적으로 참조
- IR Lowering 단계에서 recomposition skip 등 최적화 반영
테스트
data class StableUser(
val list: List<Int>,
val name: String,
val dynamicValue: Int,
)
data class UnstableUser(
val list: List<Int>,
val name: String,
val dynamicValue: Int,
)
인터페이스인 List<T> 를 포함시켜 두 데이터 클래스 모두 의도적으로 Unstable 한 상태가 되도록 구성하였습니다.
이후 이전에 작성했던 stability_config.conf 파일을 적용시키면, StableUser 만 Stable 한 상태가 되어야 합니다.

적용 전의 경우, StableUserInfo 및 UnstableUserInfo 모두 발생하지 않는 Recomposition 을 건너뛰지 않습니다. 앞서 기술한 바와 같이, 프로퍼티에 Unstable 한 타입 List<T> 를 갖고 있기 때문입니다.
적용 후의 경우, StableUser 데이터 클래스는 Unstable 하지만 Stability Configuration File 에서 이를 Stable 하다고 인식하도록 강제로 처리해두었기 때문에, 실제로는 Unstable 한 상태이지만 발생하는 Recomposition 을 건너뛰고 있습니다.
다소 공수가 동반되지만, 부드러운 UI 렌더링을 위해서는 필수적인 최적화 과정입니다.
사실 소스 내부에 직접 작성하는 데이터 클래스들의 경우, StableMarker 를 작성해주는 것이 훨씬 간편한 해결 방법입니다만, 그러지 못하는 경우에 사용하면 꽤 큰 효과를 볼 수 있습니다.
한창 알고리즘 공부를 하던 시절, 비트마스크는 개인적으로 어려워했던 알고리즘 카테고리였습니다. 공부를 하면서도 비트마스크는 도대체 어디에 쓰일까 싶었는데... Compiler 같은 경우엔 성능이 최우선시 되어야 하다 보니, 이러한 플래깅에 사용되고 있었습니다. 역시 배움에는 다 쓸모가 있습니다.
'Android > Tech' 카테고리의 다른 글
| 3D 모델링으로 앱에 입체감 불어넣기 (feat.SceneView) (1) | 2026.01.17 |
|---|---|
| [Jetpack Compose] UI 테스트 작성하기 (0) | 2025.01.15 |
| UiEffect 를 위한 Channel<T> 사용법 (0) | 2025.01.15 |
| 안드로이드의 MVI 와 Reducer (0) | 2025.01.15 |
| [Jetpack Compose] composed {} 와 Modifier.Node 로 Modifier Chain 최적화하기 (0) | 2025.01.15 |