skydoves 님께서 새로 출시하신 Compose Stability Analyzer 를 운영 중인 프로젝트에 적용해보며 경험한 장점과 적용 중 발생한 문제의 해결 방법을 정리하고자 한다.
Compose Stability Analyzer Plugin, CI 적용 PR은 아래에서 링크에서 확인할 수 있다.
feat: Compose Stability Analyzer Plugin 적용
chore: ComposeStabilityAnalyzerInitializer 추가
Compose Stability Analyzer 는 각 Composable 함수와 그 함수내에 매개변수들의 Stable 여부를 IDE에서 실시간으로 확인할 수 있고,
로그를 통해 각 Composable 함수의 Recomposition 횟수, 원인 등을 파악할 수 있으며,
터미널 명령어 또는 CI 과정 내에 Stability Validation 을 실행하여 각 모듈 내에 Composable 함수들이 안정한지 검사(판단)할 수 있는
Compose Stability, Recomposition 종합 분석 도구이다.
각각의 기능에 대해서 하나씩 소개하고 이를 적용했을 때의 결과를 공유하도록 하겠다.
더 자세한 설명은 깃허브 README 를 참고하면 도움이 될 듯 하다.
https://github.com/skydoves/compose-stability-analyzer
제일 먼저 소개할 기능은 Android Studio IDE, 현재 보고 있는 파일내에 Composable의 안정성을 시각적으로 확인할 수 있는 기능이다.

Android Studio Setting -> Plugins 에서 설치해서 사용 가능



매개변수인 UiState 내 모든 프로퍼티가 안정하다고 판단되어, RecordEditUi Composable 함수 옆에 연두색 stable 아이콘이 표시되었다!
UiState 내에 RecordEditArgs 라는 네비게이션을 통해 전달받는 객체에 @Immutable 어노테이션을 제거할 경우


위와 같이 RecordEditUi의 왼쪽에 아이콘이 주황색으로 바뀌면서 RecordEditUiState가 runtime 아이콘으로 변경되며, 마우스를 올려서 확인해보면 어떤 이유 때문에 RecordUiState 가 컴파일 타임에 안정하다고 판단이 되지 않는지 사유를 알려준다.(미쳤다 1)
Runtime stability check required for kotlin.collections.List; Extends android.os.Parcelable which has runtime stability
기존의 방식 처럼 빌드 후, 각 모듈별 build 패키지내에 compose-report/metrics 파일을 찾아 들어가 확인 해야하는 뎁스 없이 IDE에서 작업하면서 파일 내에서 바로 바로 확인 가능한게 정말 편하다고 생각했다. DX 향상 ㅎ_ㅎ
RecordEditArgs 클래스 내에 emotionTags 프로퍼티의 타입을 List가 아닌 ImmutableList로 변경해주어도 더 강력한 약속인 @Parcelize 어노테이션이 붙어 stable 하지 않기에,@Immutable 어노테이션을 붙여 클래스를 stable 하게 만들어 주었다.(네비게이션 전달 객체이기 때문에 변경될 경우의 수 X)
내용 정정
https://github.com/skydoves/compose-stability-analyzer/issues/3
@Parcelize어노테이션에 의해 stable 판정이 되지 않았던 문제는 Plugin에 버그였던 것으로 보인다.
0.4.2버전에 패치 업데이트가 이뤄져@Parcelize가 붙은 클래스도 stable 판정을 받을 수 있도록 수정되었다.


그밖에 Strong Skipping Mode을 활성화 하였을 때의 안정성 판단을 적용할지 여부도 활성화/비활성화 할 수 있으며(미쳤다 2), 아이콘 색상 커스텀, Stability configuration file 을 통해 특정 클래스나 패키지를 컴파일 타임에 stable로 명시 등의 부가 기능을 제공한다.
덕분에 Strong Skipping Mode 활성화 여부에 따른 Compose의 안정성 판단 기준 변경에 대한 오개념을 바로 잡을 수 있었다.
내가 잘못 알고 있던 건데 Plugin 버그인줄 알고 이슈를 보고 드렸다. 죄송합니다,,,
IDE Plugin 을 '딸깍' 하고 설치만 하면 바로 적용하여 사용할 수 있는 정말 강력한 기능이라, Compose 로 개발하는 사람들이라면 개발하는데 반드시 도움이 될 것이라 확신한다.
위 Plugin을 사용하면서 '라이브러리는 어떻게 만드는진 알겠는데, IDE Plugin 은 어떻게 만드는거지?' 라는 의문이 생겨 검색을 해봤는데 Jetbrains 측에서 Plugin 을 개발하기 위한 SDK를 제공하는 것을 확인할 수 있었다. 자세한 사항은 아래 링크를 참고하면 좋을 듯하다. 나도 언젠간 이런 유용한 플러그인을 만들어 보고싶다.
https://plugins.jetbrains.com/docs/intellij/creating-plugin-project.html
다음으론 소개할 기능은 Composable 함수의 Recomposition 발생 횟수, 원인을 로그를 통해 확인할 수 있는 기능이다.
Gradle Plugin 이기 때문에, build.gradle.kts에 plugin 의존성을 추가해서 사용해야 한다.
기존에 만들어 두었던 Compose 관련 ConventionPlugin이 있어 plugin을 추가해주었다.
// const val COMPOSE_STABILITY_ANALYZER = "com.github.skydoves.compose.stability.analyzer"
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
applyPlugins(
Plugins.ANDROID_LIBRARY,
Plugins.KOTLIN_COMPOSE,
Plugins.COMPOSE_STABILITY_ANALYZER,
)
extensions.configure<LibraryExtension> {
configureCompose(this)
}
}
}
}
다음으론 @TraceRecomposition 이라는 어노테이션을 로그를 통해 추적할 Composable에 추가해주면 된다.

'default 설정이 전체 Composable Recomposition 추적 로깅 활성화면 더 편하지 않나?' 라는 생각도 들었는데, 굳이 로그를 찍어보지 않아도 될 Root Composable 이나, 변화하지 않는 정적인 Composable 들에 대해선 Recomposition 로그를 추적하는게 불필요하기 때문에(성능적으로도 문제가 발생할 수 있음), 이 방식이 효율적인 면에선 더 낫다고 판단된다.
초기 버전에선 Kotlin 2.2.21 버전 이상으로 업그레이드해야 정상적으로 빌드가 가능하였다.
TMI) ksp의 경우 2.3 버전이 되면서 Kotlin 버전과 독립되었다!

[Recomposition #29] FilterChip
├─ option: com.ninecraft.booket.feature.library.LibraryFilterOption changed (COMPLETED → TOTAL)
├─ count: kotlin.Int changed (0 → 1)
├─ isSelected: kotlin.Boolean stable (false)
├─ onChipClick: kotlin.Function1<com.ninecraft.booket.feature.library.LibraryFilterOption, kotlin.Unit> changed (com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@598583b → com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@61dd846)
└─ modifier: androidx.compose.ui.Modifier stable (Modifier)
[Recomposition #30] FilterChip
├─ option: com.ninecraft.booket.feature.library.LibraryFilterOption changed (TOTAL → BEFORE_READING)
├─ count: kotlin.Int changed (1 → 0)
├─ isSelected: kotlin.Boolean stable (false)
├─ onChipClick: kotlin.Function1<com.ninecraft.booket.feature.library.LibraryFilterOption, kotlin.Unit> changed (com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@61dd846 → com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@ebe9a07)
└─ modifier: androidx.compose.ui.Modifier stable (Modifier)
[Recomposition #31] FilterChip
├─ option: com.ninecraft.booket.feature.library.LibraryFilterOption changed (BEFORE_READING → READING)
├─ count: kotlin.Int changed (0 → 1)
├─ isSelected: kotlin.Boolean changed (false → true)
├─ onChipClick: kotlin.Function1<com.ninecraft.booket.feature.library.LibraryFilterOption, kotlin.Unit> changed (com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@ebe9a07 → com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@27fe734)
└─ modifier: androidx.compose.ui.Modifier stable (Modifier)
[Recomposition #32] FilterChip
├─ option: com.ninecraft.booket.feature.library.LibraryFilterOption changed (READING → COMPLETED)
├─ count: kotlin.Int changed (1 → 0)
├─ isSelected: kotlin.Boolean changed (true → false)
├─ onChipClick: kotlin.Function1<com.ninecraft.booket.feature.library.LibraryFilterOption, kotlin.Unit> changed (com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@27fe734 → com.ninecraft.booket.feature.library.component.FilterChipGroupKt$FilterChipGroup$1$1$1$1$1@f1f795d)
└─ modifier: androidx.compose.ui.Modifier stable (Modifier)
FilterChip Composable의 Recomposition 횟수(Recomposition #Count 로 명시), 원인(매개변수의 값 changed)등을 로그를 통해 확인이 가능하였다.
왜 화면에 진입해서 Chip 버튼을 몇 번 누르기만 했는데 30번이 넘게 Recomposition 이 발생했는지는 이제부터 알아봐야겠다,,
현재는 PR에서는 Plugin 을 적용해보는게 목적이었기 때문에, 다소 @TraceRecomposition 어노테이션이 필요 없는 Composable 에도 추가해놓은 경향이 없지 않은데, 이후 개발하면서 팀내에서@TraceRecompisition 어노테이션을 붙여서 추적해야하는 Composable의 기준을 세워보면 좋을듯 하다.
또한, README에 언급된 것 처럼, 특정 횟수 이상 불필요하게 Recomposition이 발생할 경우, 이를 로컬 개발 환경에서 로그로만 추적하기엔 유실이 되는 등 한계가 있는 관계로 Firebase Event로 정의하여 Analytics로 수집하여 프로덕션 환경에서의 실제 성능 이슈를 모니터링 해봐도 좋을 것 같다.
@TraceRecomposition어노테이션엔 threshold 라는 임계값을 설정할 수 있어 그 값보다 더 많은 Recomposition이 발생할 경우, event를 발생시키는 등의 조건 분기를 설정할 수 있다.
Timber, Logger와 마찬가지로 Gradle Plugin을 적용할 경우 Debug 모드에서만 로그가 출력되도록 하는 설정은 필히 해둬야 할 듯 하다. TODO 추가
그외에 로그 출력 방식을 커스텀 할 수 있는 기능을 지원하는데 직접 적용해보진 않아, 다음으로 넘어가도록 하겠다.
마지막으로 Stability Validation 기능이다.
기존에 ktlint, detekt와 같은 린트 검사를 CI step에 추가하여 팀내 코드 컨벤션을 만족하지 않는 코드 포맷이나, 코드 스멜이 감지될 경우 CI 단계에서 실패 시켰던 것 처럼,
각 모듈 내에 Composable 함수 코드를 검사하여 안정성 변경을 추적 -> 의도치 않은 성능 감소(퇴화)를 CI 단계에서 차단하기 위한 장치라고 이해하면 될듯하다.
린트 검사와 마찬가지로 로컬 터미널에서
./gradlew stabilityCheck명령어를 통해 CI 단계 이전에 빠르게 검사를 진행할 수 있다.
사전 작업으로는 아래 두 단계가 있다.
composeStabilityAnalyzer {
enabled.set(true)
}
./gradlew stabilityDump 명령어를 통해 각 Compose 모듈에 .stability 파일을 생성.stability 파일은 모든 Composable 함수의 안정성 상태를 기록한 스냅샷으로, stabilityCheck 명령 수행시 기존의 .stability 파일과 비교를 통해 성능 감소(퇴회)를 감지한다.
// This file was automatically generated by Compose Stability Analyzer
// https://github.com/skydoves/compose-stability-analyzer
//
// Do not edit this file directly. To update it, run:
// ./gradlew :onboarding:stabilityDump
@Composable
public fun com.ninecraft.booket.feature.onboarding.OnboardingPresenter.present(): com.ninecraft.booket.feature.onboarding.OnboardingUiState
skippable: true
restartable: true
params:
@Composable
private fun com.ninecraft.booket.feature.onboarding.OnboardingScreenPreview(): kotlin.Unit
skippable: true
restartable: true
params:
@Composable
internal fun com.ninecraft.booket.feature.onboarding.OnboardingUi(state: com.ninecraft.booket.feature.onboarding.OnboardingUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit
skippable: true
restartable: true
params:
- state: STABLE
- modifier: STABLE (marked @Stable or @Immutable)
@Composable
internal fun com.ninecraft.booket.feature.onboarding.component.OnboardingPage(imageRes: kotlin.Int, titleRes: kotlin.Int, highlightTextRes: kotlin.Int, descriptionRes: kotlin.Int, modifier: androidx.compose.ui.Modifier): kotlin.Unit
skippable: true
restartable: true
params:
- imageRes: STABLE (primitive type)
- titleRes: STABLE (primitive type)
- highlightTextRes: STABLE (primitive type)
- descriptionRes: STABLE (primitive type)
- modifier: STABLE (marked @Stable or @Immutable)
@Composable
private fun com.ninecraft.booket.feature.onboarding.component.OnboardingPagePreview(): kotlin.Unit
skippable: true
restartable: true
params:
@Composable
internal fun com.ninecraft.booket.feature.onboarding.component.PagerIndicator(pageCount: kotlin.Int, pagerState: androidx.compose.foundation.pager.PagerState, modifier: androidx.compose.ui.Modifier): kotlin.Unit
skippable: true
restartable: true
params:
- pageCount: STABLE (primitive type)
- pagerState: STABLE (marked @Stable or @Immutable)
- modifier: STABLE (marked @Stable or @Immutable)
@Composable
private fun com.ninecraft.booket.feature.onboarding.component.PagerIndicatorPreview(): kotlin.Unit
skippable: true
restartable: true
params:
CI yml 파일에 Stability Check를 추가하는 방법은 skydoves 님의 landscapist 깃허브를 참고하였다.
https://github.com/skydoves/landscapist/blob/main/.github/workflows/android.yml
이렇게 세팅은 마무리 했고, 기존에 이미 Compose Stability Analyzer IDE Plugin 을 통해 사용하는 모든 Composable 들을 Stable 하게 만들어 주었기 때문에, CI 를 돌리기만 하면 되는데 치명적인 문제가 발생하였다.

CI 소요 시간 2배 이벤트 당첨!
사실 기존의 CI도 보통 8~12분 정도 걸리는 관계로, 너무 오래걸린다고 판단하여 이에 대한 시급한 조치가 필요하다고 생각을 하였는데(귀찮음 이슈로 방치), 20분이 넘는 소요 시간은 너무나도 치명적이라고 생각이 들었다.
따라서, 당장은 Compose Stability Validation 의 경우는 로컬에서만 수행하는 방식으로 다시 롤백을 진행하려고 생각하였다.
생각해보니 기존의 적용해 두었던 CI 작업들이 끝나고 Stability Check 작업을 수행할 필요가 없었다.
Github Action을 통한 CI는 각 작업들을 병렬로 실행할 수 있기 때문에, 공통 환경 설정 작업을 따로 빼두고 각각의 job 들을 병렬 실행한다면, CI 소요 시간은 기존과 크게 차이가 나지 않을 것이라 판단하였다.
name: Android CI
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false"
GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true
on:
pull_request:
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
ci-build:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ci') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
distribution: 'corretto'
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-home-cache-cleanup: true
- name: Generate local.properties
run: echo '${{ secrets.LOCAL_PROPERTIES }}' | base64 -d > ./local.properties
- name: Generate keystore.properties
run: echo '${{ secrets.KEYSTORE_PROPERTIES }}' | base64 -d > ./keystore.properties
- name: Generate google-services.json
run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > ./app/google-services.json
- name: Code style checks
run: |
./gradlew ktlintCheck detekt
- name: Run build
run: ./gradlew buildDebug --stacktrace
stability_check:
name: Compose Stability Check
runs-on: macos-latest
needs: ci-build
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Generate local.properties
run: echo '${{ secrets.LOCAL_PROPERTIES }}' | base64 -d > ./local.properties
- name: Generate keystore.properties
run: echo '${{ secrets.KEYSTORE_PROPERTIES }}' | base64 -d > ./keystore.properties
- name: Generate google-services.json
run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > ./app/google-services.json
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Compose Stability Check
run: ./gradlew stabilityCheck
name: Android CI
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false"
GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true
on:
pull_request:
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
ci-build:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ci') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: 'corretto'
java-version: 21
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-home-cache-cleanup: true
- name: Generate local.properties
run: echo '${{ secrets.LOCAL_PROPERTIES }}' | base64 -d > ./local.properties
- name: Generate keystore.properties
run: echo '${{ secrets.KEYSTORE_PROPERTIES }}' | base64 -d > ./keystore.properties
- name: Generate google-services.json
run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > ./app/google-services.json
- name: Code style checks
run: |
./gradlew ktlintCheck detekt
- name: Run build
run: ./gradlew buildDebug --stacktrace
stability_check:
name: Compose Stability Check
runs-on: ubuntu-latest
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ci') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: 'corretto'
java-version: 21
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-home-cache-cleanup: true
- name: Generate local.properties
run: echo '${{ secrets.LOCAL_PROPERTIES }}' | base64 -d > ./local.properties
- name: Generate keystore.properties
run: echo '${{ secrets.KEYSTORE_PROPERTIES }}' | base64 -d > ./keystore.properties
- name: Generate google-services.json
run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > ./app/google-services.json
- name: Compose Stability Check
run: ./gradlew stabilityCheck

전체 소요시간이 20분에서 8분으로 대략 60% 단축되어, StabilityCheck 실행을 CI 과정에 추가할 수 있었다.
아직 최적화 할 요소들이 무궁무진하기 때문에, Github Action에 대한 추가 학습(with Claude Code)을 통해 캐싱 등의 방식을 적용하여 소요 시간을 더 단축시켜 봐야겠다.
Compose Stability Analyzer Plugin 을 프로젝트에 적용해보고 각각의 기능들이 제공하는 장점들을 직접 체감해볼 수 있었다.
나름 Android 개발자 중에 얼리 어답터로서(요새도 이런 말 쓰는진 모르겠는데), 최신 기술들을 찾아 적용해보고 있는데 혹시나 기술 적용의 어려움이 있다면, 이 블로그 글이 도움이 되면 좋겠다.

매우 영광스럽게도 skydoves님의 간택(?)을 받아 Compose Stability Analyzer 의 테스터로 참여하게 되어, 다른 분들 보다 먼저 Plugin을 사용해볼 수 있었다.
초기 버전을 사용해보면서, 미리 Plugin의 장점들을 체험해볼 수 있었고, 피드백을 드릴 수도 있었다.
그 과정에서 흔히 사용되는 아키텍처 기반의 Compose 프로젝트엔 문제가 없었으나, Circuit이 적용된 다소 매니악한 프로젝트에서는 IDE Plugin이 Composable의 안정성을 제대로 판단하지 못했던 문제가 발생하기도 했다.
Circuit 특성상 KSP 기반으로 빌드 타임에 동적으로 생성되는 코드가 많기 때문에, IDE 분석 타임(코드를 타이핑 하거나, 수정하는 시점)에 Composable이 안정한지 판단하는데에 있어 더 어려움이 있었을 것으로 추정된다.
이에 대해 피드백을 드렸고, 바로 바로 수정 버전을 제공해주셔서 역시,,, 대단하시다고 생각이 들었다.
그밖에 여러 피드백들을 통해 Compose Stability Analyzer 에 정식 버전 출시에 보탬이 될 수 있어 영광이기도 했고 매우 보람찬 경험이었다.

또 한편으로는, 이러한 Stability, Recomposition 분석 도구를 만들기 위해서 얼마나 깊은 학습을 하셨을지 감이 오질 않는다...
왜냐하면 이 Compose Stability Analyzer는 Kotlin Compiler(K2 부터 Compose Compiler가 Kotlin에 병합됨)의 내부 코드까지 하나하나 확인하면서, Compiler가 Composable의 안정성을 판단하는 메커니즘을 전부 파악해야지만 만들 수 있는 도구이기 때문이다.
또한 Kotlin은 빠르게 발전하고 있고 글을 작성하는 시점에선 벌써 2.3 버전을 바라보고 있기에 버전 업데이트마다 변경점의 대한 트래킹을 통해 호환성까지 고려해야한다...
이러한 경지는 현업에서 몇년 더 굴러서 도달할 수 있다기 보단, 정말로 Kotlin 그리고 Compose을 좋아해야지만 가능한 일이라 생각한다.

Compose Stability Analyzer 개발을 위해(추측) Compose Compiler가 안정성을 판단하는 메커니즘을 정리하신 Github 문서가 있는데, 이를 통해 학습하여 거인의 어깨 위에 올라서 보기라도 해야겠다.
reference)
https://github.com/skydoves/compose-stability-analyzer
https://github.com/skydoves/landscapist/blob/main/.github/workflows/android.yml
https://github.com/skydoves/compose-stability-inference
공유해주셔서 감사합니다. 매우 도움이 되는 설명이었어요.