
지난주 화요일 새벽 두 시, LazyColumn 한 칸이 41번 재구성된 화면을 보면서 멍하니 앉아있었습니다. Skip 카운트는 0이었어요.
Compose 1.7부터 Strong Skipping Mode가 기본으로 켜져있다는 얘기를 워낙 많이 들어서, 재구성은 거의 없을 거라고 무방비 상태였습니다. 막상 Layout Inspector를 띄워보니 카드 하나가 스크롤 한 번에 41번씩 그려지고 있더라고요. Janky frame 비율은 14%, 평균 프레임 타임은 28ms. 스크롤이 끊기는 게 손끝에 느껴질 정도였습니다.
스크롤 60fps가 깨진 자리에서 처음 의심한 세 가지
문제 화면은 홈 피드의 진행률 카드 리스트였습니다. 스크롤하면서 빠르게 위아래로 흔들면 프레임 타임이 38~52ms로 튀어 올랐어요. 60fps 기준선이 16.6ms이니까, 거의 두세 배가 깨진 셈입니다.
머릿속에 떠오른 가설은 세 개였어요.
LazyListScope의items블록 안 람다가 매번 새 인스턴스로 만들어져서, Strong Skipping이 참조 비교(===)에서 떨어진다.- ViewModel이 들고 있는
MutableList가 참조는 그대로인 채 내용만 바뀌어서, Compose가 어디까지 다시 그려야 할지 판단을 못 한다. - 카드 내부에서
lazyListState.firstVisibleItemScrollOffset같은 값을 직접 읽고 있어서, 스크롤 한 프레임마다 다시 그린다.
세 개가 다 의심스러웠는데 뭐부터 잡을지 감이 안 오더라고요. 그래서 안드로이드 스튜디오(Android Studio) Quail 빌드로 갈아탔습니다.

Quail Layout Inspector State Inspection 패널이 진짜 본 자리
이번에 크게 의지한 기능이 State Inspection 패널입니다. Panda 3 캐너리부터 들어와 Quail에도 그대로 자리 잡은 기능인데요, Component Tree에서 카드를 찍으면 그 옆에 composition: 41 / skip: 0 같은 카운트가 보이고, 거기 숫자 부분이 링크처럼 클릭됩니다.
링크를 누르면 그 컴포저블이 마지막 재구성에서 어떤 state 변수를 읽었는지, 어떤 변수가 invalidated 됐는지, 그리고 stack trace가 한 화면에 정리돼서 떠요. 패널 헤더의 화살표를 누르면 이전 재구성 사이클로 거슬러 올라가서 그때 값이 뭐였는지도 볼 수 있습니다.
요구 버전이 좀 빡빡한데, compose.ui:ui:1.10.0 (BOM 2025.12.01) 이상이 필요한 것으로 알려져 있어요. 저는 마침 라이브러리를 최근에 올려둔 상태라 바로 붙었는데, 레거시 모듈에 적용하려는 분들은 의존성부터 점검하셔야 합니다.
저는 패널 열자마자 진범 두 개가 바로 보이더라고요. firstVisibleItemScrollOffset이 카드 컴포저블의 읽기 목록에 떡하니 올라와 있었고, 또 다른 변수 하나가 매 사이클마다 새 참조로 갱신되고 있었습니다.

Explain with AI로 stack trace 한 번 더 훑기
State Inspection 패널 옆에는 'Explain with AI' 버튼이 붙어 있는데, stack trace를 자연어로 풀어주는 기능입니다. 저는 stack trace는 평소에 직접 읽는 편이지만, Compose 내부 스택은 워낙 길고 람다 wrapping이 겹쳐서 한 번에 안 들어올 때가 많아요.
AI 설명이 짚어준 자리는 HomeCard 본체가 아니라 그 안의 ProgressBadge였습니다. 부모 카드가 다시 그려지는 이유 자체가 자식 컴포저블에서 시작된 무효화 체인 때문이라는 얘기였어요. 가설 수립 시간이 확실히 줄어들었습니다.
다만 AI가 "이렇게 고치세요"를 직접 짜주는 건 아니에요. 어디서 어떤 state 읽기가 문제인지까지만 친절하게 알려주고, 수정은 결국 제 손으로 해야 했습니다. 보조 도구로 보시면 적당할 것 같아요.
Strong Skipping Mode가 안 먹히는 진짜 이유
여기서 잠깐 짚고 갈 부분이 있는데요. Strong Skipping Mode를 켰다고 해서 Unstable 타입이 갑자기 Stable로 둔갑하는 게 아닙니다. 동작 방식은 참조 동일성(===)으로 skip 여부를 판단하는 구조예요.
이게 뭐가 문제냐면, 매번 새 인스턴스가 만들어지는 람다나 Unstable 객체를 파라미터로 넘기면 참조가 늘 달라지니까 skip이 안 됩니다. 제 HomeCard가 딱 그 경우였어요. onClick 람다를 호출부에서 { viewModel.onCardClick(item.id) } 식으로 그냥 박아두니까, 부모가 한 번 다시 그려질 때마다 새 람다 인스턴스가 만들어졌습니다.
그리고 LazyListScope의 items { ... } 블록 안쪽은 @Composable 함수가 아니라서 자동 메모이제이션 대상이 아니에요. 이 안에서 만든 람다나 객체는 따로 remember(key) { ... }로 감싸야 재사용이 됩니다. 이걸 처음 알았을 때는 솔직히 좀 황당하더라고요.

ImmutableList로 LazyColumn 재구성 줄인 자리
ViewModel에서 MutableList<Item>을 그대로 들고 있다가 list.add(newItem) 해버리면, 참조는 그대로인데 내용만 바뀝니다. Compose 입장에서는 같은 객체를 다시 받았으니 "변한 게 없네" 하고 넘어가거나, 반대로 안전을 위해 통째로 다시 그려버리는 일이 생길 수 있어요.
저는 kotlinx.collections.immutable의 ImmutableList로 갈아탔습니다. 항목이 바뀌면 새 인스턴스가 만들어지니까 참조 비교가 명확해지고, 영향받는 자식만 다시 그려지게 돼요.
derivedStateOf와 remember로 끊고 잰 결과
진범을 잡은 다음 조치는 세 갈래로 정리했습니다.
- 카드에서
firstVisibleItemScrollOffset을 직접 읽던 자리는derivedStateOf로 감싸서, 계산된 진행률 값이 실제로 변할 때만 재구성되도록 바꿨습니다. items의key에 안정적인 ID를 넣고,onClick람다는remember(item.id) { ... }로 감쌌어요.- ViewModel의
MutableList는ImmutableList로 교체하고, 상태 갱신은update { it.add(newItem) }같은 식으로 처리했습니다.
빌드해서 다시 Layout Inspector를 띄웠더니, 카드당 재구성 횟수가 41회에서 2회로 떨어졌어요. Janky frame 비율은 14%에서 1.8%, 평균 프레임 타임은 28ms에서 14ms로 내려갔습니다. 60fps 기준선 안에 들어왔다는 얘기예요.

Quail Layout Inspector의 한계와 보완 도구
써보면서 느낀 한계도 있습니다. 일단 Quail 2 Canary 라인은 기능이 도중에 바뀌거나 안정성이 흔들릴 여지가 있어요. 저는 메인 IDE는 Quail 1 안정 빌드로 두고, 새 디버깅 기능 검증용으로 Quail 2 Canary를 따로 깔아 쓰고 있습니다.
그리고 Layout Inspector가 잘 보여주는 건 상호작용 중에 일어나는 재구성이에요. 화면 진입 시 초기 컴포지션 비용 같은 건 잘 안 잡히는 경우가 많거든요. 이쪽은 Composition Tracing(System trace)으로 따로 떠야 정확한 그림이 나옵니다.
개인적으로 Layout Inspector로 스크롤·인터랙션 재구성을 잡고, 초기 진입 비용은 Composition Tracing으로 보는 두 갈래 방식이 가장 깔끔하다고 봐요. 다음 글에서는 이 Composition Tracing으로 초기 진입 1.2초를 어디까지 줄였는지 따로 풀어볼 생각입니다.
마무리
이번에 가장 크게 배운 건, Strong Skipping이 켜져 있다는 사실 하나로 안심하면 안 된다는 점이었습니다. 참조 동일성으로 skip이 판단되는 구조라서, 람다·리스트·객체가 매번 새 인스턴스로 만들어지는 자리는 여전히 다 새는 구멍이지요.
카드당 41회에서 2회. 결국 이 숫자 하나로 일주일이 정리된 거죠.
'Android 개발 > Jetpack Compose' 카테고리의 다른 글
| Compose Composition tracing으로 Layout Inspector가 놓친 스크롤 잰크 잡기 (0) | 2026.06.28 |
|---|---|
| @PreviewWrapper 하나로 디자인 시스템 프리뷰 일관성 잡기 (0) | 2026.06.26 |
| Compose UI 테스트 느린 자리, runWithoutImplicitWait로 5초 잡은 일지 (0) | 2026.06.22 |
| 안드로이드 위젯 통합 테스트, remote-testing으로 뚫린 사각지대 (0) | 2026.06.03 |
| Jetpack Compose 2026.05.00 BOM 살펴보기 — MeshGradient·customizable shadows·defaultFontFamily (0) | 2026.05.31 |