
지난달에 Compose 화면 하나가 자꾸 버벅이길래 레이아웃 인스펙터(Layout Inspector)로 리컴포지션 카운트를 켰는데요. 버튼 한 번 탭했더니 관련도 없는 컴포저블 카운트가 우르르 같이 올라가더라고요.
처음엔 "내 코드가 느린가" 싶어서 연산부터 뜯어봤어요. 근데 진짜 범인은 느린 코드가 아니었습니다. 상태를 어디서 읽느냐, 딱 그거였어요. 그날 추적한 과정을 그대로 정리해봤습니다.
Compose 리컴포지션, 진짜 문제는 넓은 재구성 범위
젯팩 컴포즈(Jetpack Compose) 성능 문제는 대부분 "한 번에 너무 많이 다시 그려서" 생깁니다. 상태 값 하나 바뀌었을 뿐인데, 그 상태를 읽는 위치가 위쪽에 있으면 그 아래 컴포저블이 통째로 재구성 대상에 들어가거든요.
제 경우가 딱 그랬어요. 리스트에서 아이템 하나 선택했는데, 화면 위쪽 헤더랑 옆 카드까지 같이 다시 그려지고 있었습니다. 60fps를 맞추려면 한 프레임을 16.6ms 안에 끝내야 하는데요. 16.6ms가 어느 정도냐면, 눈 한 번 깜빡이는 시간의 10분의 1 남짓이라 이걸 넘기는 순간 화면이 한 번 "툭" 끊겨 보이게 됩니다.
레이아웃 인스펙터로 리컴포지션 카운트 추적하기
가장 먼저 손댄 건 안드로이드 스튜디오(Android Studio)의 레이아웃 인스펙터였어요. 컴포넌트 트리에 재구성 횟수(recomposition count) 와 스킵 횟수(skip count) 가 컬럼으로 뜨거든요. 자주 재구성되는 컴포저블은 그라데이션 오버레이로 색이 점점 진해집니다.
요구 사항은 최소 API 29 이상, Compose 1.2.0 이상이에요. 버튼 한 번 탭하면서 어떤 컴포저블 카운트가 같이 뛰는지를 눈으로 보는 게 핵심입니다.
여기서 더 확실하게 잡으려고 커스텀 로그 카운터도 같이 썼어요. remember로 카운터를 붙들고 SideEffect로 증가시키는 방식인데요.
class Ref(var value: Int)
@Composable
inline fun LogCompositions(tag: String, msg: String) {
val ref = remember { Ref(0) }
// 재구성이 성공할 때마다 한 번씩 증가
SideEffect { ref.value++ }
Log.d(tag, "$msg: ${ref.value}")
}
이걸 의심 가는 컴포저블마다 박아두면, 버튼 한 번에 어떤 애들이 같은 재구성 범위에 묶여서 같이 올라가는지가 로그로 그대로 보입니다.

불안정 파라미터와 람다가 스킵을 깨던 자리
추적해보니 범인은 두 개였습니다.
1. 불안정한 파라미터
리스트를 그냥 List<Item> 으로 넘기고 있었는데요. 표준 컬렉션은 안에 값이 바뀔 여지가 있어서 컴파일러가 "불안정(unstable)" 하다고 봅니다. 그래서 상위 상태가 바뀔 때마다 새 인스턴스가 내려오고, 컴포저블이 스킵을 못 하고 매번 다시 그려지고 있었어요.
2. 매번 새로 생기는 람다
onClick = { vm.select(item.id) } 이런 람다요. 재구성될 때마다 새 인스턴스로 할당되니까, 컴포저블 입장에선 "파라미터가 바뀌었네?" 하고 또 다시 그리게 됩니다.

Strong Skipping Mode, 이제는 기본 규칙
여기서 중요한 게 하나 있어요. Compose 컴파일러 1.5.4부터 실험적으로 제공되다가 Kotlin 2.0.20부터 기본 활성화된 Strong Skipping Mode입니다.
이게 켜지면 불안정한 파라미터가 있어도 컴포저블을 스킵할 수 있게 됩니다. 불안정한 값은 인스턴스가 같은지(===)로, 안정한 값은 내용이 같은지(equals())로 비교하니까요. 거기에 불안정한 값을 캡처한 람다도 컴파일러가 알아서 remember 처리해줍니다.
그래서 예전처럼 모든 List를 ImmutableList로 갈아끼우거나 @Stable을 남발하던 작업의 필요성이 많이 줄었다고 합니다. 개인적으로는 이 변화 덕에, 무작정 어노테이션 붙이기 전에 "같은 인스턴스가 내려가고 있나"부터 확인하는 게 먼저라고 보게 됐어요.

재구성 범위를 좁힌 세 가지 처방
Strong Skipping이 많이 막아주긴 해도, 범위 자체를 쪼개는 작업은 여전히 효과가 큽니다. 제가 적용한 순서대로 정리해볼게요.
derivedStateOf로 파생 상태 감싸기. 스크롤 위치 같은 값에서 "버튼 보일까 말까(Boolean)"를 뽑아낼 때, 결과가 그대로면 원본이 바뀌어도 재구성을 안 일으킵니다.LazyColumn아이템에 고유key지정하기. 위치가 아니라key기준으로 아이템을 추적해서, 리스트가 바뀌어도 해당 아이템만 다시 그립니다.- 상태 읽기 지연하기. 값을 직접 넘기지 말고
() -> Value람다로 넘겨서, 진짜 값이 필요한 최하위 컴포저블에서만 읽게 하는 방식이에요.
derivedStateOf는 이런 모양입니다.
val showScrollToTop by remember {
// firstVisibleItemIndex 가 출렁여도 결과가 같으면 재구성 안 함
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
이렇게 손보고 나니까 버튼 탭 한 번에 다시 그려지던 컴포저블이 10여 개에서 2개로 줄었어요. 프레임 시간도 16.6ms 안으로 다시 들어오면서 버벅임이 사라지더라고요.

카운트 줄었다고 다 빨라진 건 아닙니다
여기서 제가 한 번 헷갈렸던 부분을 짚어둘게요. 재구성 횟수가 곧 성능은 아니에요. 횟수가 많아도 안에서 하는 일이 가벼우면 괜찮고, 반대로 한 번 재구성에 정렬·파싱·무거운 객체 생성이 끼어 있으면 횟수가 적어도 프레임을 잡아먹습니다.
게다가 리컴포지션이 일어나도 화면 내용이 그대로면 Draw 단계까지 안 갈 수도 있어요. 그러니 "카운트 줄이기" 자체를 목표로 삼기보다, 실제로 프레임이 끊기는 자리를 측정하고 거기만 손대는 게 맞다고 봅니다.
그리고 레이아웃 인스펙터는 디버그 빌드 기준이라 릴리즈 성능을 그대로 보장하진 않아요. 진짜 비용을 정밀하게 재고 싶으면 릴리즈에 가까운 환경에서 Composition Tracing(Perfetto)으로 함수 단위까지 들여다보시는 게 정확합니다.
저는 이번 일로 습관 하나를 바꿨어요. 새 화면을 만들면 일단 레이아웃 인스펙터 카운트부터 켜고, 버튼 몇 개 눌러보면서 "엉뚱한 애가 같이 뛰나"를 먼저 봅니다. 어노테이션이든 람다 정리든 그건 그 다음 문제였더라고요. 측정 없이 최적화부터 들어가던 예전의 저와, 카운트 먼저 보고 범위부터 좁히는 지금의 저. 제 답은 후자로 정해졌습니다. 화면이 버벅일 때 코드부터 뜯을지, 측정부터 켤지는 한번 골라보셔도 좋을 질문이에요.
'Android 개발 > Jetpack Compose' 카테고리의 다른 글
| 안드로이드 화면을 iOS에 그대로? Compose Multiplatform 1.11 공유 일지 (0) | 2026.09.16 |
|---|---|
| 값을 화면 깊은 곳까지 파라미터로 줄줄이 내리던 자리 — CompositionLocal 제대로 쓴 일지 (0) | 2026.08.29 |
| 비밀번호칸만 자동완성이 안 뜬다면, Compose 1.12 Credential Manager 연동 (0) | 2026.08.16 |
| Compose adaptive 라이브러리(material3-adaptive)의 ListDetailPaneScaffold로 태블릿·폴더블 적응형 UI 구현 (0) | 2026.08.13 |
| Compose 1.11 대화면 대응 일지: 트랙패드가 따로 놀던 화면 손보기 (0) | 2026.08.09 |