안드로이드 위클리 공부 노트

#750

Compose 화면이 왜 다시 그려졌는지 로그로 보기 — 감시 코드를 컴파일할 때 자동으로 끼워 넣는 방법

원문 Improving the Smoothness of Android Compose Screens, rayford

한 줄 요약

Compose 화면이 버벅일 때 가장 흔한 원인은 상태가 너무 자주 바뀌어 화면 일부가 불필요하게 다시 그려지는 것(재구성, recomposition) 이다. 그래서 "이 함수가 왜 다시 그려졌는가 — 어떤 파라미터나 상태가 무엇에서 무엇으로 바뀌었는가"를 알면 원인을 바로 찾을 수 있다. 이 일을 해 주는 라이브러리 Rebugger는 감시 코드를 손으로 함수마다 넣어야 한다. 저자는 함수에 @ComposableMonitor 어노테이션 하나만 붙이면 컴파일할 때 감시 코드가 자동으로 들어가는 도구를 만들었다. 첫 판은 Android 바이트코드를 ASM으로 고치는 방식이었다. 그러나 Compose 컴파일러가 바뀔 때마다 깨질 수 있고 Android에서만 돌았다. 그래서 둘째 판은 Kotlin 컴파일러 플러그인으로 다시 만들었다. 컴파일러가 코드를 각 플랫폼용으로 바꾸기 전 단계(IR)에서 고치니, Android·iOS·데스크톱·웹에서 모두 돈다. 그 결과물이 Android Weekly #750 라이브러리 목록에도 실린 ComposeStateMonitor다.

원문은 Medium에 실렸고 자동 접근이 막혀 있어(403), 저자의 RSS 피드에 실린 전문을 읽고 정리했다. 원문 글 자체에는 라이선스 표시가 없다. 그래서 원문의 그림 6장(IDE 화면 3장, Rebugger 예시 2장, KMP 컴파일 흐름도 1장)은 복사하지 않고 원문 서버(Medium 이미지 서버) 주소를 걸어 보여 주고, 그림마다 무엇이 보이는지 글로도 적었다. 원문의 글자 도식 3개 중 ASM 두 단계 도식은 한국어 그림으로 다시 그렸다. 나머지 두 도식은 저자의 저장소 hellsong/ComposeStateMonitor에 같은 내용이 있다. 이 저장소는 Apache-2.0 라이선스라서 그 문서와 코드를 인용하고 출처를 붙였다(2026-10-26 기준 커밋 f167a53).

먼저 알아 둘 말

서론 — View 시대의 진단 방법이 Compose에서는 잘 통하지 않는다

1. Compose 화면 성능에 영향을 주는 것

IDE 편집기에서 MainScreen 함수의 text: String 파라미터 옆에 초록색 stable 표시가 붙어 있는 화면
Compose Stability Analyzer가 켜진 편집기. MainScreen(text: String)의 파라미터 옆에 초록색 stable 표시가 붙고(빨간 화살표), 줄 번호 옆에도 초록 칸이 보인다. 함수 안에는 text2와 clicks 두 상태가 있고, 글자를 누르면 둘 다 바뀐다. 원문 이미지, 원문 서버에서 불러옴 (원문)

2. Compose가 일으키는 화면 갱신을 감시하기

VehicleUi 함수 안에서 Rebugger를 부르며 car, bike, human 세 값을 trackMap으로 넘기는 코드
Rebugger 사용 예. VehicleUi(car, bike) 함수 안에서 Rebugger(trackMap = mapOf("car" to car, "bike" to bike, "human" to human))를 부른다. 추적할 값의 이름과 값을 손으로 하나씩 적어 넘긴다. Rebugger README의 그림, 원문 서버에서 불러옴 (원문)
Logcat에 VehicleUi recomposed because car changed from Car(name=Audi) to Car(name=Audi - 1670844528602) 가 찍힌 화면
Rebugger의 Logcat 출력. 처음에는 Rebugger activated on `VehicleUi`가 찍히고, 다음 줄에 VehicleUi recomposed because `car` changed from `Car(name=Audi)` to `Car(name=Audi - 1670844528602)`가 찍혔다(빨간 테두리). 어떤 값이 무엇에서 무엇으로 바뀌어 다시 그려졌는지가 한 줄에 나온다. 원문 이미지(2022년 화면), 원문 서버에서 불러옴 (원문)

3. 감시 코드를 자동으로 넣어 원본 코드를 깨끗하게 두기

debug 컴파일이 끝나면 1단계가 class 파일을 읽어 @ComposableMonitor 함수의 지역 변수 정보를 함수마다 JSON으로 남기고, 2단계가 그 JSON을 읽어 함수 시작에 목록 만들기와 파라미터 넣기, State 변수 저장 뒤에 넣기, 모든 끝나는 곳 앞에 CollectAndPrintValues 부르기를 끼워 넣는다. 실행하면 런타임 라이브러리가 이전 값과 비교해 바뀐 것을 로그로 찍는다
ASM으로 감시 코드를 넣는 두 단계. 원문의 글자 도식을 바탕으로 한국어로 다시 그림 (원문)
MainScreen 함수 위에 @ComposableMonitor 어노테이션이 붙어 있고 빨간 화살표가 그것을 가리키는 편집기 화면
감시하고 싶은 함수에 @ComposableMonitor만 붙인다(빨간 화살표). 함수 안의 text2, count 상태나 text 파라미터를 따로 적지 않는다. 원문 이미지, 원문 서버에서 불러옴 (원문)
MainScreen 함수를 디컴파일한 Java 코드. Composer $composer, int $changed 파라미터와 $dirty 계산, getSkipping 검사가 보인다
Compose 컴파일러를 거친 MainScreen을 Java로 디컴파일한 모습. 소스에는 없던 Composer $composer, int $changed 파라미터가 생겼다. startRestartGroup(…)을 부르고, $dirty 비트를 계산하고, ($dirty & 11) == 2 && $composer.getSkipping()이면 skipToGroupEnd()로 건너뛴다. 감시 코드는 이 구조 사이에 들어가야 한다. 원문 이미지, 원문 서버에서 불러옴 (원문)

4. 더 나은 방법 — Compose Multiplatform까지 생각해 컴파일러 단계에서 넣기

Kotlin Multiplatform 컴파일 흐름도. 공통 코드와 플랫폼 코드가 K2 프런트엔드로 들어가 Kotlin IR이 되고, Kotlin/JVM, Kotlin/Native, Kotlin/JS와 Wasm 백엔드로 갈라져 Android, iOS, Windows, Web 결과물이 된다
KMP 컴파일 흐름. 공통 코드(commonMain, expect)와 플랫폼 코드(androidMain, iosMain, actual)가 K2 프런트엔드(문법 분석, 타입 검사, expect/actual 연결)를 거쳐 Kotlin IR이 된다. IR은 세 백엔드로 갈라진다. Kotlin/JVM은 Android용 .class → D8/R8 → .dex(ART에서 실행)를, Kotlin/Native는 iOS용 Mach-O·XCFramework와 Windows용 .exe·.dll을, Kotlin/JS·Wasm은 웹용 .js·.wasm을 만든다. 원문 이미지, 원문 서버에서 불러옴 (원문)
Kotlin source ──► FIR (frontend) ──► IR
                                      │
                                      ▼
               ┌──────────────────────────────────────┐
               │ composemonitor IR extension          │  inserts plain @Composable calls
               │ (MonitorFunctionTransformer)         │  before every exit
               └──────────────────────────────────────┘
                                      │   -Xcompiler-plugin-order
                                      ▼
               ┌──────────────────────────────────────┐
               │ Compose compiler IR extension        │  adds $composer/$changed, groups,
               │                                      │  skipping, to user code AND ours
               └──────────────────────────────────────┘
                                      │
                                      ▼
           JVM / Android bytecode, Kotlin/Native, wasm, js backends

출처: docs/implementation.md (hellsong/ComposeStateMonitor@f167a53, Apache-2.0). 원문 본문에도 같은 도식이 있다

Kotlin 버전마다 따로 만든 컴파일러 플러그인

/** The newest line not newer than {@code kotlinVersion}, or null if it is older than every line. */
static CompilerLine lineFor(String kotlinVersion) {
    CompilerLine result = null;
    for (CompilerLine line : LINES) {
        if (compare(kotlinVersion, line.minKotlin) >= 0) result = line;
    }
    return result;
}

출처: plugin/src/main/java/.../gradle/KotlinVersionSupport.java (hellsong/ComposeStateMonitor@f167a53, Apache-2.0)

저장소 문서에 정리된 줄 목록은 아래와 같다.

빌드 이름 맡는 Kotlin 버전 무엇이 다른가
composemonitor-compiler-k20 2.0.x를 맡는다. Compose 컴파일러가 옛 방식(ComponentRegistrar)으로 등록되므로, 이 빌드도 옛 방식으로 등록하며 "가장 먼저" 순서를 요청한다.
composemonitor-compiler-k21 2.1.0부터 2.1.10까지 맡는다. 확장들이 한 목록에 등록되므로, 목록의 맨 앞(0번)에 끼어들어 Compose보다 먼저 돈다.
composemonitor-compiler-k2120 2.1.20부터 2.1.x까지 맡는다. 2.1.20이 내부 인터페이스를 다르게 컴파일해서, 2.1.0용 빌드를 그대로 쓰면 없는 클래스를 부르다 죽는다. 그래서 따로 만든다.
composemonitor-compiler-k22 2.2.x를 맡는다. 파라미터를 다루는 API가 새 방식(parameters, arguments)으로 바뀌었다.
composemonitor-compiler-k23 2.3.x를 맡는다. -Xcompiler-plugin-order 옵션이 생겨, 플러그인 순서를 공식 옵션으로 정한다.
composemonitor-compiler-k24 2.4.x와 그 이후를 맡는다. 심볼(클래스·함수)을 찾는 API가 새 방식(finderForSource)으로 바뀌었다.

저장소 코드로 보기 ① — 쓰는 법

원문은 저장소 링크로 끝나므로, 쓰는 법은 저장소 README에서 가져왔다.

// build.gradle.kts of your app / shared module
plugins {
    id("org.jetbrains.kotlin.plugin.compose")
    id("io.github.hellsong.composemonitor") version "0.2.0"
}

composeStateMonitor {
    // Only instrument composables under these packages. Empty (default) = every @ComposableMonitor function.
    includeClassPackage = listOf("com.example.app")
}

출처: README.md (hellsong/ComposeStateMonitor@f167a53, Apache-2.0)

저장소 코드로 보기 ② — 감시 코드가 실제로 하는 일

IR 변환기가 함수의 끝마다 넣는 코드는 클래스 주석에 그대로 적혀 있다.

/**
 * Rewrites one `@ComposableMonitor` function so that every exit first calls
 *
 * ```
 * val values = monitorNewList()
 * monitorAdd(values, "param", param)          // each regular/context parameter
 * monitorAdd(values, "state", state)          // each State local declared so far
 * CollectAndPrintValues("FunctionName", values)
 * ```
 *
 * Runs before the Compose compiler, which then lowers the injected call like any other.
 */

출처: plugin/compiler/shared/.../compiler/MonitorFunctionTransformer.kt (hellsong/ComposeStateMonitor@f167a53, Apache-2.0)

비교와 로그는 런타임 라이브러리가 맡는다.

@Composable
@NonSkippableComposable
fun ComposeMonitorInspector(
    trackMap: Map<String, Any?>,
    logger: (tag: String, message: String) -> Unit = { tag, message ->
        ComposeMonitorInspectorConfig.logger.invoke(
            tag,
            message
        )
    },
    composableName: String? = null
) {
    val previous = remember { Ref<Map<String, Any?>?>(null) }
    val message = describeRecomposition(composableName, previous.value, trackMap)
    SideEffect {
        previous.value = trackMap
        logger(ComposeMonitorInspectorConfig.tag, message)
    }
}

출처: composemonitorhelper/src/commonMain/.../helper/ComposeMonitorInspector.kt (hellsong/ComposeStateMonitor@f167a53, Apache-2.0)

비교 규칙은 describeRecomposition 안에 있다.

val reason = when {
    key !in old -> "`$key` added: `$newValue`"
    oldValue != newValue -> "`$key` changed from `$oldValue` to `$newValue`, "
    // Boxed primitives and strings can be new instances of an unchanged value; Compose compares them by
    // equality, so a new instance never causes a recomposition.
    oldValue !== newValue && !oldValue.isValueLike() ->
        "`$key` instance changed, but content remains the same -> `$oldValue`"

    else -> null
}

출처: 같은 파일 ComposeMonitorInspector.kt (hellsong/ComposeStateMonitor@f167a53, Apache-2.0)

원문을 읽을 때 주의할 점

코드로 보기 — Aide 구조로 옮긴 스케치

Aide는 Android 전용이고 Kotlin 2.2.10을 쓴다. 이 도구를 쓴다면 k22 빌드가 골라진다. 그러나 화면이 아직 빈 껍데기라 지금 넣을 이유는 없다(아래 "Aide에 적용하면" 1번). 그보다 이 글을 읽고 바로 확인할 것은 안정성이다. Aide의 core:model은 Compose 컴파일러를 거치지 않는 순수 Kotlin 모듈(aide.jvm.library)이다. Compose 컴파일러는 자기가 컴파일하지 않은 모듈의 클래스를, 속이 val뿐이어도 불안정으로 본다. Task 7·8에서 Task, Event 같은 모델이 그대로 화면 파라미터가 되므로 이 점이 바로 걸린다.

아래는 aide.android.compose 플러그인에 안정성 설정 파일과 필요할 때만 켜는 컴파일러 보고서를 더하는 직접 쓴 스케치다. 실제 코드는 사용자가 쓰고, stabilityConfigurationFiles 이름은 Kotlin 2.2.10의 Compose Gradle 플러그인에서 확인하지 않았다.

// build-logic/.../AndroidComposeConventionPlugin.kt — 직접 쓴 스케치
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension

class AndroidComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("org.jetbrains.kotlin.plugin.compose")

            extensions.configure<CommonExtension> {
                buildFeatures.compose = true
            }

            extensions.configure<ComposeCompilerGradlePluginExtension> {
                // core:model 은 Compose 컴파일러를 거치지 않으니, 그 모델들을 "안정"이라고 직접 알려 준다
                stabilityConfigurationFiles.add(
                    rootProject.layout.projectDirectory.file("compose-stability.conf"),
                )
                // ./gradlew assembleDebug -PcomposeReports=true 로 빌드할 때만 보고서를 만든다
                if (providers.gradleProperty("composeReports").orNull == "true") {
                    reportsDestination.set(layout.buildDirectory.dir("compose-reports"))
                    metricsDestination.set(layout.buildDirectory.dir("compose-metrics"))
                }
            }

            dependencies { /* 지금과 같다 */ }
        }
    }
}
// compose-stability.conf — 직접 쓴 스케치
// core:model 의 모든 클래스는 val 만 가진 불변 data class 로 만든다는 약속
com.giwon.aide.core.model.*

배운 점

Aide에 적용하면

Aide는 Android 전용이고, 화면은 Task 4의 빈 탭 3개뿐이다. 첫 실제 화면은 Task 8(첫 화면 대시보드)이다. 이미 정한 결정 「쓰지 않는 의존을 미리 넣지 않는다」(2026-10-20, Task 4), 「Kotlin·SDK 최신화는 Task 4 뒤 별도 PR」(2026-10-20), 「모든 변경은 브랜치 → PR → CI 초록 → 머지」(2026-10-15)와 맞춰 썼다.

  1. ComposeStateMonitor를 지금 넣는 것 — 지금은 안 한다.
    • 감시할 화면이 없다. 「쓰지 않는 의존을 미리 넣지 않는다」 결정과도 맞지 않는다.
    • 제3자 컴파일러 플러그인은 일반 라이브러리보다 위험이 크다. 컴파일러 안에서 돌고, 0.2.0이 나온 지 열흘 남짓이다. 또 Aide는 AGP 9의 내장 Kotlin을 쓰는데(2026-10-14 결정 기록, aide.jvm.library 항목), 이 도구가 그 구성에서 도는지는 확인하지 않았다.
    • 다시 볼 때: Task 8 이후 실기기에서 버벅임이 보이고, 아래 2번의 앞 단계로 원인이 안 잡힐 때. 그때도 main에 넣기 전에 따로 브랜치에서 시험한다. Kotlin 최신화 PR이 끝났다면 k22가 아니라 그 버전에 맞는 줄이 골라지는지도 본다.
  2. 버벅임을 진단하는 순서를 정해 둔다 — 가져온다.
    • 원문의 핵심인 "자주 바뀌는 상태를 찾는다"를 Aide의 순서로 옮긴다.
    • ① Android Studio Layout Inspector의 재구성 횟수로 어느 함수가 많이 도는지 본다. 도구를 따로 넣을 필요가 없다.
    • ② 위 스케치의 컴파일러 보고서(-PcomposeReports=true)로 그 함수의 파라미터가 안정한지, 건너뛸 수 있는지 본다.
    • ③ 그래도 이유를 모르면 그 함수에 Rebugger나 ComposeStateMonitor를 임시로 붙여 "무엇이 무엇으로 바뀌었나"를 본다.
    • 이 순서는 decisions.md에 남길지 사용자가 정한다.
  3. core:model의 모델이 Compose에 "불안정"으로 보이는 문제 — 바꿔서 가져온다. 시점은 Task 7(모델) 뒤, Task 8(화면) 전이다.
    • 원문은 "안정성이 건너뛰기를 정한다"까지만 말한다. Aide에서는 이것이 모듈 구조 때문에 바로 걸린다. core:model은 Compose 컴파일러가 없는 순수 Kotlin 모듈이라, Task·Event가 val만 가진 data class여도 불안정으로 보인다.
    • 강한 건너뛰기가 기본이라(Kotlin 2.2.10이므로 켜져 있다) 같은 객체를 넘기면 그래도 건너뛴다. 문제는 HomeUiState가 바뀔 때마다 sortOpenTasks가 새 리스트를 만든다는 점이다. 할 일 하나를 완료하면 리스트 전체가 새 객체가 되어, 바뀌지 않은 행까지 다시 그려질 수 있다.
    • 할 일: 위 스케치의 안정성 설정 파일(com.giwon.aide.core.model.*)을 둘지 정하고, 둔다면 Konsist로 "core:model에 var 프로퍼티 없음"을 같이 검사한다. 할 일 목록은 LazyColumn의 key = { it.id }를 꼭 준다. 그러면 리스트가 새 객체여도 각 행은 자기 Task만 비교한다.
    • 이것이 실제로 문제가 되는지는 Task 8 화면이 생긴 뒤 2번의 ②(컴파일러 보고서)로 확인한다. 지금은 확인하지 않았다.
  4. 진단용 설정을 Gradle 속성으로만 켜는 방식 — 가져온다.
    • 원문의 "release에서 빼고, 스위치를 켰을 때만"과 같다. 컴파일러 보고서든 감시 플러그인이든 -P 속성이 있을 때만 켜면, 평소 빌드와 CI(./gradlew assembleDebug test lint)는 느려지지 않는다.

출처