
Recomposition Count가 0이라고 잰크가 없는 게 아닙니다. 오히려 Layout Inspector가 0을 찍는 그 순간, 진짜 비용은 measure 와 layout 단계로 옮겨가 있는 경우가 많거든요.
지난 4월에 사이드 프로젝트 상품 리스트 화면을 손보다가 정확히 이 함정에 빠졌습니다. 스크롤할 때 한 박자가 묘하게 끊기는데, Android Studio Layout Inspector 의 Recomposition Count 는 거의 0이었어요. "그럼 도대체 뭐가 문제지" 하면서 한참 헤맸는데, 결국 범인을 찾아준 건 Composition tracing 이었습니다. 그 과정을 정리해뒀어요.
Layout Inspector Recomposition Count 가 0이어도 잰크가 생기는 이유
Layout Inspector 의 Recomposition Count 는 누적 카운터입니다. 스크롤 한 번에 짧게 터지는 리컴포지션 폭주는 평균값에 묻혀서 안 보이는 일이 많아요. 60프레임 중 한두 프레임만 16.67ms 를 넘겨도 사람 눈에는 "딱 끊기네" 로 잡히는데, 카운터는 평탄하게 0~1을 가리키고 있는 식입니다.
더 큰 문제는 이 도구가 debuggable=true 빌드에서만 동작한다는 점이에요. JIT 컴파일과 디버깅 훅이 끼어들어서 릴리즈 빌드와 성능 특성이 전혀 다른 것으로 알려져 있어요. 즉, 사용자가 실제로 겪지 않을 "디버그용 잰크" 를 들여다보고 있을 가능성이 큽니다.
그리고 결정적인 한 가지. Recomposition Count 는 이름 그대로 컴포지션 단계만 잽니다. BoxWithConstraints 처럼 내부에서 SubcomposeLayout 을 쓰는 컴포저블은 measure/layout 단계에 비용이 몰리는데, 이 도구로는 그 자리가 안 보입니다. 제 케이스가 정확히 거기였습니다.
profileable 빌드로 Composition tracing 셋업하기
해법은 androidx.compose.runtime:runtime-tracing 의존성을 추가하는 겁니다. 이 의존성을 끼우면 컴포저블 함수명이 시스템 트레이스의 슬라이스 이름으로 직접 찍혀 나옵니다. Perfetto 에서 MyItemComposable::measure 같은 슬라이스를 눈으로 보게 된다는 얘기예요.
다만 측정 신뢰도를 챙기려면 빌드 설정이 더 중요합니다. release 빌드 타입에 debuggable false 를 유지한 채 profileable true 만 따로 켜야 해요. 매니페스트에는 <profileable android:shell="true" tools:targetApi="29"/> 한 줄. 이렇게 만든 빌드여야 R8 최적화와 AOT 가 적용된 진짜 사용자 환경에 가까운 측정값이 나옵니다.
android {
buildTypes {
release {
isDebuggable = false
isProfileable = true
isMinifyEnabled = true
}
}
}
dependencies {
// tracing 은 release 가 아닌 별도 benchmark variant 에 거는 것도 OK
implementation("androidx.compose.runtime:runtime-tracing:1.11.0")
}
오버헤드가 큰 Method Tracing 은 가급적 회피하시고, System Tracing 으로 가셔야 합니다. Method Tracing 은 모든 함수 호출을 후킹해서 측정값 자체가 왜곡되는 경우가 많거든요. CI 에서 자동화하려면 Macrobenchmark 에 androidx.benchmark.perfettoSdkTracing.enable=true 인자를 넘기면 트레이스가 자동으로 캡처됩니다. (구 이름 androidx.benchmark.fullTracing.enable 도 fallback 으로 동작은 하지만, 신규 코드라면 새 이름 쪽이 안전합니다.)

Perfetto 트레이스에서 잰크 슬라이스 읽는 법
녹화한 시스템 트레이스를 Perfetto 에 띄우면 상단에 Expected Frame 과 Actual Frame 두 트랙이 보입니다. 16.67ms 가 어느 정도냐면, 60Hz 화면이 한 프레임을 그릴 수 있는 전체 예산이에요. Actual Frame 이 그 예산을 넘기면 빨갛게 표시되는데, 이 빨간 슬라이스가 바로 사용자가 "한 박자 끊겼다" 로 느끼는 자리입니다.
빨간 프레임 위치를 잡았으면 그 시점의 Choreographer#doFrame 트랙을 확장합니다. 여기에 runtime-tracing 이 심어놓은 컴포저블 슬라이스가 줄줄이 보이는데, 이름·길이·위치를 같이 봐야 해요.
- 길이가 비정상적으로 긴
::measure슬라이스 — 이게 보이면SubcomposeLayout기반 컴포저블이 의심됩니다. Recompose슬라이스가 짧은 간격으로 반복 등장 — 불안정한 파라미터로 인한 리컴포지션 폭주 신호예요.Recompose슬라이스 사이의 빈 공간 — 메인 스레드가 어딘가에 블로킹되고 있다는 단서입니다.LaunchedEffect내 동기 I/O 가 가장 흔한 범인이더라고요.

LazyColumn 스크롤 잰크의 진짜 범인 3가지
제 화면 케이스에서 트레이스를 한참 들여다본 결과 세 군데가 동시에 터지고 있었습니다. 하나만 잡아서는 95퍼센타일이 안 떨어졌고, 결국 셋 다 손봐야 했어요.
1. LazyColumn key 람다 안의 비싼 계산
// 잰크 유발 — 스크롤마다 문자열 결합 + 해시 재계산
items(products, key = { "${it.category}_${it.id}_${it.variant}" }) { ... }
// 수정본 — 안정적인 단일 ID 만 사용
items(products, key = { it.id }) { product -> ... }
key 람다는 스크롤할 때마다 가시 영역의 모든 아이템에 대해 호출됩니다. 여기서 문자열을 합치고 있으면 비용이 그대로 메인 스레드에 누적되는 구조예요. ID 하나로 줄이는 게 정답입니다.
2. List 타입을 그대로 넘긴 불안정한 파라미터
Strong Skipping Mode 가 켜져 있어도 List<T> 는 컴파일러가 안정성을 보장할 수 없어서 불필요한 리컴포지션이 새는 일이 있습니다. kotlinx.collections.immutable 의 ImmutableList 또는 PersistentList 로 바꾸면 컴파일러가 안정 타입으로 인식해줍니다.
@Composable
fun ProductList(products: ImmutableList<Product>) { ... }
3. BoxWithConstraints 가 묶어버린 measure 비용
각 아이템 카드의 루트로 BoxWithConstraints 를 썼는데, 이게 내부적으로 SubcomposeLayout 입니다. 제약 조건을 측정한 뒤 자식 컴포지션을 다시 돌리는 구조라 measure 단계 비용이 일반 Layout 대비 몇 배로 뜁니다. 정작 제 코드에선 constraints 값을 분기 한 군데에만 쓰고 있었어요. 그 정도면 Layout 으로 갈아엎는 게 맞습니다.
개인적으로는 BoxWithConstraints 는 정말 필요한 자리, 그러니까 화면 폭에 따라 컴포저블 트리 자체를 갈아치워야 하는 반응형 분기가 아니면 처음부터 안 쓰는 쪽이 안전하다고 봅니다. 한 번 리스트 아이템 루트에 들어가면 거의 무조건 잰크 후보가 되더군요.

성능 PR 에 Perfetto 트레이스 첨부하기
세 군데를 손본 뒤 같은 단말에서 다시 잰 95퍼센타일 프레임 시간이 21.3ms 에서 9.8ms 로 내려왔습니다. 빨간 Actual Frame 슬라이스도 스크롤 구간에서 사라졌어요. 측정 자체보다 더 큰 수확은 그다음이었습니다.
성능 관련 PR 에 Perfetto 트레이스 파일(.perfetto-trace)을 첨부하는 걸 팀 약속으로 만들었어요. "체감상 부드러워졌어요" 가 아니라 "Before/After 트레이스에서 ProductCard::measure 슬라이스가 8ms 에서 1.2ms 로 줄었습니다" 라고 적으니까 리뷰 사이클이 확연히 짧아졌습니다. 숫자와 트레이스 첨부 두 가지가 들어가니, 리뷰어가 "이게 진짜 개선인가" 를 검증하는 시간 자체가 사라진다는 얘기입니다.
스크롤이 한 박자 끊기는데 Layout Inspector 가 평탄한 0을 가리킬 때, 진짜 비용은 거의 대부분 measure 와 layout 슬라이스 안에 숨어 있었습니다. 다음에 같은 자리가 오면 Recomposition Count 보다 먼저 profileable 빌드부터 띄우게 됐습니다.
21.3ms 가 9.8ms 로. 이번 잰크 디버깅의 답이 들어 있던 두 숫자였습니다.
'Android 개발 > Jetpack Compose' 카테고리의 다른 글
| Navigation 3로 갈아타 봤습니다 — 화면 백스택을 상태로 직접 들고 있게 된 자리 (1) | 2026.07.06 |
|---|---|
| 안드로이드 스크린샷 테스트 3종 비교 — Roborazzi·Paparazzi·Compose Preview 갈라 써본 한 주 (0) | 2026.07.05 |
| @PreviewWrapper 하나로 디자인 시스템 프리뷰 일관성 잡기 (0) | 2026.06.26 |
| LazyColumn 41번 재구성, Quail Layout Inspector로 진범 잡은 일주일 (0) | 2026.06.23 |
| Compose UI 테스트 느린 자리, runWithoutImplicitWait로 5초 잡은 일지 (0) | 2026.06.22 |