Jetpack Compose Mechanisms, Part 3: 리컴포지션 성능, 그리고 안정성이 여전히 중요한 이유
Jetpack Compose Mechanisms, Part 3: 리컴포지션 성능, 그리고 안정성이 여전히 중요한 이유
Part 1과
Part 2에서는 컴포저블(composable) 하나가 컴파일러와 런타임을 거쳐 화면의 픽셀이 되기까지 따라가 봤습니다. 이제 이 메커니즘은 충분히 빨라져서, Google이 직접 측정한 벤치마크에서도 스크롤 성능은 Compose와 뷰가 같은 수준으로 나옵니다. 게다가 Strong Skipping Mode가 기본값이 된 뒤로는 안정성(stability)도 더는 신경 쓰지 않으셔도 되는 것처럼 보입니다. 그런데도 메시지 하나를 읽음으로 표시했을 뿐인데 화면에 보이는 모든 행이 리컴포즈되는 리스트는 여전히 쉽게 만들어집니다. 이런 일이 일어나느냐는 프레임워크보다, 작성하신 코드가 어디에서 새 객체를 만드느냐에 더 크게 달려 있습니다.
이번 글에서는 Compose가 어디에 시간을 쓰는지 깊이 파고듭니다. 세 가지 페이즈에서 출발해 직접 작성하신 모델에 이르기까지, 리컴포지션(Recomposition)에 드는 비용, 스마트 리컴포지션(smart recomposition)이 건너뛰기를 결정하는 방식, Strong Skipping Mode가 바꾼 것, 그리고 새 인스턴스를 만들어 내는 일상적인 세 가지 사례를 차례로 살펴봅니다. 세 사례 가운데 실제로 리컴포지션 비용을 치르는 것은 일부뿐입니다.
이 글은 DroidKaigi 2026 & Droidcon Berlin 2026에서 발표한 Jetpack Compose Mechanisms 세션을 바탕으로 한 3부작 시리즈의 세 번째이자 마지막 글입니다. 발표 자료를 함께 보셔도 좋습니다. 이 시리즈의 다이어그램도 모두 발표 자료에서 가져왔습니다.
히어로 벤치마크: Compose와 뷰 비교
Google은 히어로 벤치마크(hero benchmarks)로 Compose의 성능을 추적합니다. 이 벤치마크는 안드로이드 12가 설치된 Pixel 3a에서 실행되며, 실행 결과를 서로 비교할 수 있도록 CPU와 GPU 클럭을 고정해 둡니다. 비교 대상은 오픈소스 Pokedex 앱의
뷰 기반 버전과
Compose 기반 버전입니다. 이 앱은 제가 만든 것으로, 해당 페이지에도 적혀 있듯이 Compose 팀이 개발한 앱이 아닙니다.

스크롤 벤치마크에서 Compose와 뷰는 Compose 1.9.0부터 0.21%라는 같은 버벅임(jank) 비율을 기록하고 있습니다. 485프레임 중 1프레임에서 버벅임이 생기는 수준입니다. 시작 시간에는 아직 오버헤드가 조금 남아 있습니다. 콜드 스타트(cold start) 기준으로 Compose 1.11은 처음 표시하는 데 걸린 시간(time to initial display)이 뷰보다 2.5% 느리고, 완전히 표시하는 데 걸린 시간(time to full display)은 13.0% 느립니다. 그러니 스크롤에서는 더 이상 프레임워크가 병목이 아닙니다. 남은 것은 작성하신 코드가 요구하는 작업이며, 이를 이해하려면 Compose가 언제 일하는지부터 알아야 합니다.
세 가지 페이즈: Composition, Layout, Drawing

Compose는 세 가지 페이즈를 거쳐 데이터를 픽셀로 바꿉니다.
- Composition은 무엇을 보여 줄지 결정합니다. 런타임이 컴포저블 함수를 실행하고 컴포지션(composition)을 갱신합니다.
- Layout은 어디에 배치할지 결정합니다. Compose UI가 각
LayoutNode를 측정하고 트리 안에서 위치를 정합니다. - Drawing은 어떻게 렌더링할지 결정합니다. 각 노드가 자신의 콘텐츠를
Canvas에 그립니다.
Composition은 Part 2에서 다룬 런타임이 맡고, Layout과 Drawing은 Compose UI가 맡습니다. 변경이 생겼다고 해서 늘 세 페이즈를 모두 거쳐야 하는 것은 아닙니다. 뒤쪽 페이즈에서만 읽는 상태(state)는 앞쪽 페이즈를 건너뜁니다. Drawing 중에 읽은 상태가 바뀌면 Drawing만 다시 실행되고, Layout 중에 읽은 상태라면 Composition을 건너뜁니다. Modifier.offset(x, y)는 값을 Composition 중에 읽고 Modifier.offset { }는 Layout 중에 읽습니다. 그래서 오프셋을 애니메이션할 때는 Modifier.offset { } 쪽이 더 저렴합니다. 각 페이즈가 어떻게 무효화되는지는 세 가지 페이즈: Composition, Layout, Drawing에서 다룹니다. 이 글은 작성하신 컴포저블 함수가 실행되는 곳인 Composition에 집중합니다.
리컴포지션 비용: 함수 호출만으로 끝나지 않는다
Composition 페이즈를 확대해 보면, 함수를 호출하는 일은 여러 단계 가운데 하나일 뿐입니다. Part 2에서 살펴본 구성 요소로 순서대로 정리하면 다음과 같습니다.
- 런타임이 무효화된 리컴포즈 스코프(recompose scope)를 찾습니다.
- 슬롯 테이블(
SlotTable)에 있는 기존 컴포지션을 훑으며 해당 스코프를 하나씩 찾아갑니다. - 찾은 스코프를 각각 다시 실행하면서, 필요한 변경을 변경 목록(change list)에 기록합니다.
Applier가 기록된 변경을LayoutNode트리에 적용합니다.

첫 컴포지션은 모든 스코프를 실행하고, 테이블을 기록하고, 트리를 만듭니다. 리컴포지션은 무효화된 스코프에서만 시작하고, 테이블은 대부분 재사용하며, 트리에는 바꿀 것이 없는 경우도 많습니다. 그래도 공짜는 아닙니다. 실행되는 스코프마다 본문을 실행하고 테이블에서 자기 몫의 구간을 훑으며, 그 스코프가 호출하는 컴포저블은 하나하나 인자를 비교한 뒤에야 건너뛸 수 있습니다. 트리까지 닿은 변경은 Layout과 Drawing까지 일으킬 수 있습니다. 그러니 덜어 낼 가치가 있는 작업은 아무것도 바꾸지 못하는 리컴포지션입니다.
스마트 리컴포지션: 바뀌지 않은 것은 건너뛴다

이 메커니즘은 Part 1에서 이미 보셨습니다. 컴포저블이 지난번과 같은 입력을 받으면 멱등성(idempotence)에 따라 같은 결과가 나올 것이므로, 생성된 코드는 본문을 건너뜁니다. 흔히 스마트 리컴포지션이라고 부르는 것이 바로 이 동작입니다.

트리에서는 이 판단이 단계마다 반복됩니다. ChatScreen의 상태가 바뀌면 ChatScreen이 다시 실행되면서 자식들을 호출합니다. ChatHeader와 InputBar는 이전과 같은 입력을 받으므로 건너뛰고, 그 아래 자식들까지는 아예 내려가지 않습니다. MessageList는 바뀐 입력을 받아 실행되고, 그 안에서 MessageBubble 하나는 건너뛰지만 다른 하나는 리컴포즈됩니다. 서브트리의 꼭대기에서 건너뛰면 그 아래 전부를 아낄 수 있으므로, 비교 자체를 이해해 둘 가치가 있습니다. 모든 것은 Compose가 입력이 같다는 사실을 알아낼 수 있느냐에 달려 있습니다.
안정성: Strong Skipping Mode 이전과 이후
입력을 같다고 볼 수 있는지는 타입이 결정합니다. Compose가 equals()를 믿을 수 있고 공개 상태가 바뀔 때마다 알림도 받는 타입을 안정적(stable)이라고 합니다. 원시 타입(primitive type), String, 그리고 모든 프로퍼티가 안정적인 타입의 val인 클래스가 대표적입니다. 스냅샷 상태가 뒷받침하지 않는 평범한 var 프로퍼티가 있으면 클래스는 불안정(unstable)해집니다. Compose가 모르는 사이에 값이 다시 대입될 수 있기 때문입니다. List 같은 인터페이스는 더 까다롭습니다. List는 읽기 전용 뷰일 뿐이고, 그 뒤에 있는 객체는 다른 코드가 변경하는 MutableList일 수도 있습니다. 코틀린 2.3까지 컴파일러는 List를 포함해 다른 모듈의 인터페이스를 불안정하다고 판정했는데, 아래 Compose 컴파일러 보고서(compiler reports)에서 보시듯 코틀린 2.4에서 이 판정이 바뀌었습니다.
코틀린 2.0.20 이전에는 안정성이 건너뛰기 여부를 곧바로 결정했습니다.
- 본문에서 사용하고 기본값이 없는 불안정한 매개변수가 하나라도 있으면 컴포저블을 건너뛸 수 없었습니다. 비교 코드는 생성되지 않았고, 함수는 호출한 쪽이 실행될 때마다 함께 실행되었습니다.
- 안정적인 매개변수는 모두
equals()로 비교했고, 아무것도 바뀌지 않았으면 함수를 건너뛰었습니다.
코틀린 2.0.20부터 기본으로 켜진 Strong Skipping Mode는 첫 번째 규칙을 바꿨습니다.
- 불안정한 매개변수는
changedInstance를 거쳐 참조, 즉===로 비교합니다. - 안정적인 매개변수는 여전히
changed를 거쳐equals()로 비교합니다.
다음 두 종류의 매개변수는 이 규칙을 따르지 않습니다. 람다 같은 함수 타입은 언제나 참조로 비교합니다. 안정성을 런타임에야 알 수 있는 매개변수 타입도 있습니다. 다른 모듈에서 Compose 컴파일러로 컴파일된 클래스가 그렇고, 코틀린 2.4부터는 다른 파일에 선언된 클래스와 List 같은 인터페이스도 여기에 속합니다. 이런 타입이라면 생성된 코드가 두 가지 비교 방식 중 하나를 런타임에 고릅니다. 호출한 쪽은 인자마다 비트 하나를 넘기는데, 이 비트는 컴파일러가 자신이 컴파일하는 public 또는 internal 클래스마다 생성해 두는 필드에서 읽어 옵니다. 그 필드가 불안정하다고 알리면 참조로, 그렇지 않으면 equals()로 비교합니다. 인터페이스에는 이런 필드가 없고, ArrayList처럼 Compose 컴파일러가 컴파일하지 않은 클래스에도 없습니다. 그래서 List 매개변수로 넘어온 인자는 equals()로 비교합니다. Strong Skipping Mode는 불안정한 값을 캡처하는 람다도 기억해 둡니다. 예전에는 컴포저블이 실행될 때마다 이런 람다를 새로 만들었지만, 이제는 캡처한 값이 같은 인스턴스라면 람다도 같은 인스턴스로 전달됩니다.
저는 이 동작을 JVM 위에서 도는 작은 Robolectric 테스트 두 개(코틀린 2.3.21, Compose 1.11.2)로 확인했습니다. 두 테스트 모두 부모가 첫 컴포지션 이후 6번 리컴포즈되었고, 한 테스트에서는 같은 불안정한 객체를, 다른 테스트에서는 그 객체를 캡처하는 람다를 자식에게 넘겼습니다. Strong Skipping Mode를 끈 상태에서는 두 테스트 모두 자식이 7번, 즉 부모가 실행될 때마다 한 번씩 실행되었습니다. 켜 둔 상태에서는 매번 같은 인스턴스가 전달되었으므로, 자식은 한 번만 실행되었습니다.

컴파일러 보고서: 컴파일러가 내린 판단
그렇다면 안정성은 여전히 중요할까요? 대부분의 경우에는 Strong Skipping Mode만으로 충분합니다. 다만 이 모드는 어떤 타입의 안정성도 바꾸지 않습니다. 바뀌는 것은 생성된 코드가 불안정한 값을 비교하는 방식뿐입니다. 컴파일러가 무엇을 결정했는지는 Compose 컴파일러 보고서로 확인할 수 있습니다. 모듈의 빌드 파일에서 다음과 같이 켜 보세요.
composeCompiler {
reportsDestination.set(layout.buildDirectory.dir("compose_compiler"))
}
보고서는 코틀린 컴파일 태스크가 실행되는 동안 기록됩니다. 그래서 태스크가 최신 상태(up to date)라 실행되지 않으면 아무것도 기록되지 않습니다. 보고서를 비교하실 때는 클린 빌드를 실행하거나 컴파일 태스크를 강제로 다시 실행해서, 전체 컴파일에서 나온 보고서끼리 비교하세요. 그런 다음 마지막 매개변수가 List인 컴포저블을 컴파일해 보세요. 보고서는 본문이 한 번도 읽지 않는 매개변수를 unused로 표시하고, 사용하지 않는 매개변수는 건너뛰기를 막지 않습니다. 그러니 본문에서 반드시 badges를 사용해야 합니다.
@Composable
fun Avatar(
modifier: Modifier = Modifier,
imageUrl: String? = null,
initials: String? = null,
shape: Shape = CircleShape,
badges: List<String>,
) {
Box(modifier.clip(shape)) { badges.forEach { Text(it) } }
}
보고서의 각 항목은 컴파일러가 함수에 대해 내린 판단으로 시작하고, 그 뒤로 매개변수마다 한 줄씩 이어집니다. restartable은 함수에 Part 1에서 보신 restart group이 생겼다는 뜻이고, skippable은 생성된 코드가 이 함수를 건너뛸 수 있다는 뜻입니다. 각 매개변수에는 stable 또는 unstable이 붙으며, 안정성을 런타임에야 알 수 있는 매개변수에는 아무 표시도 붙지 않습니다. scheme(...)은 컴포저블이 방출(emit)하는 대상 트리의 종류를 나타내고, @static과 @dynamic은 기본값의 성격을 설명합니다. 둘 다 여기서는 신경 쓰지 않으셔도 됩니다. 코틀린 2.3에서 Strong Skipping Mode를 끈 채 컴파일하면, *-composables.txt 보고서는 Avatar를 다음과 같이 기술합니다. 패키지 이름은 생략했습니다.
restartable scheme("[androidx.compose.ui.UiComposable]") fun Avatar(
stable modifier: Modifier? = @static <expression>
unused stable imageUrl: String? = @static null
unused stable initials: String? = @static null
stable shape: Shape? = @dynamic <expression>
unstable badges: List<String>
)
Strong Skipping Mode를 켜면 코틀린 2.3에서는 첫 줄만 바뀝니다. restartable 뒤에 skippable이 붙고, unstable badges는 그대로 남습니다. 이제 함수를 건너뛸 수는 있지만, 같은 List 인스턴스가 다시 전달될 때만 그렇습니다. 발표에서 강조했듯이 Strong Skipping Mode는 불안정한 값을 비교하는 방식을 바꿀 뿐, 그 값이 불안정한지 여부는 바꾸지 않습니다.
코틀린 2.4에서는 인터페이스에 대한 판정이 바뀌었습니다. 이제 컴파일러는 List를 비롯해, 안정적이라고 표시되지도 알려지지도 않은 인터페이스를 모두 런타임까지 알 수 없는 타입으로 취급합니다. 그래서 보고서의 마지막 줄도 아무 표시 없이 badges: List<String>으로 출력됩니다. 이런 매개변수라면 생성된 코드는 호출한 쪽이 넘기는 비트를 보고 비교 방식을 정합니다. 그런데 인터페이스에는 그 비트를 채울 필드가 없으므로, map으로 만든 List는 equals()로 원소 하나하나를 비교합니다.
참조로 비교되는 타입의 새 인스턴스가 컴포저블에 도달할 때는 안정성이 여전히 중요합니다. 이어지는 세 가지 사례에서는 그런 인스턴스가 만들어지는 곳이 점점 멀어집니다. 컴포저블 자신의 본문, ViewModel, 그리고 리포지토리 순입니다. 첫 번째 사례의 문제 코드와 세 번째 사례의 해결책에서는 코틀린 2.4가 결과를 바꾸는 지점도 확인하실 수 있습니다.
사례 1: 리컴포지션마다 새로 만들어지는 리스트
첫 번째 출처는 컴포저블 자신의 본문으로, 실행될 때마다 리스트를 매핑합니다.
@Composable
fun UserScreen(users: List<User>) {
UserList(
users = users.map { user ->
UserProfile(name = user.name, avatarUrl = user.avatarUrl)
},
)
}
UserScreen이 어떤 이유로든 리컴포즈될 때마다 map()은 완전히 새로운 List를 만듭니다. 코틀린 2.3까지는 List가 불안정하므로 Strong Skipping Mode는 이를 참조로 비교합니다. 새 인스턴스는 곧 다른 참조라서, 리스트 안의 내용이 하나도 바뀌지 않았는데도 UserList가 다시 실행됩니다. 해결책은 인스턴스를 유지하는 것입니다.
@Composable
fun UserScreen(users: List<User>) {
val profiles = remember(users) {
users.map { user -> UserProfile(name = user.name, avatarUrl = user.avatarUrl) }
}
UserList(users = profiles)
}
remember(users)는 Part 2에서 보신, 키를 받는 remember입니다. users가 지난번에 저장된 키와 더 이상 같지 않을 때까지 매핑된 리스트를 유지합니다. UserScreen이 같은 users로 리컴포즈되면 UserList는 같은 인스턴스를 받고, 참조 비교에서도 같다고 판정됩니다. 같은 테스트 환경에서 코틀린 2.3.21로 UserScreen을 강제로 5번 리컴포즈했더니, UserList는 map을 인라인으로 호출한 버전에서는 6번, remember(users)를 쓴 버전에서는 단 1번 실행되었습니다. 타입은 바뀌지 않았습니다. 같은 인스턴스를 유지하는 것만으로 충분했습니다.
코틀린 2.4.20에서는 인라인 map 버전도 UserList를 1번만 실행했습니다. 새 리스트를 equals()로 비교하는데, UserProfile이 data class라서 내용이 같은 두 리스트는 같다고 판정되기 때문입니다. 그래도 remember는 남겨 둘 만합니다. remember가 없으면 리컴포지션마다 리스트를 다시 만들고 원소 하나하나를 비교해야 합니다. 게다가 원소가 equals()를 오버라이드하지 않는다면 새로 만든 원소로 이루어진 두 리스트는 결코 같을 수 없으므로, 어느 버전에서든 UserList가 다시 실행됩니다.
이 해결책에는 데이터가 바뀔 때마다 users가 새 리스트로 교체된다는 전제도 깔려 있습니다. 리스트가 StateFlow에서 올 때가 그렇습니다. 반면 mutableStateListOf로 만든 스냅샷 상태 리스트는 그 자리에서 직접 변경됩니다. 변경 전후가 같은 객체이므로 저장된 키와 여전히 같다고 판정되고, 매핑은 다시 계산되지 않습니다. 이런 리스트는 derivedStateOf 안에서 읽어, 매핑이 리스트의 내용을 따라가도록 하세요.
val profiles by remember(users) {
derivedStateOf {
users.map { user -> UserProfile(name = user.name, avatarUrl = user.avatarUrl) }
}
}
사례 2: Compose까지 닿지 않는 새 인스턴스
두 번째 출처는 ViewModel입니다. MVI 스타일의 ViewModel에서는 업데이트할 때마다 상태를 복사하고 그 안의 리스트를 다시 만듭니다.
data class ItemUi(val id: Int, val title: String)
data class UiState(val items: List<ItemUi> = emptyList())
class DemoViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun updateItems() {
_uiState.update { state ->
state.copy(items = state.items.map { it.copy() })
}
}
}
화면은 상태를 수집해 리스트를 아래로 넘깁니다.
@Composable
fun DemoScreen(viewModel: DemoViewModel) {
val uiState by viewModel.uiState.collectAsState()
ItemList(items = uiState.items)
}
updateItems()를 호출할 때마다 새 UiState와 새 리스트, 새 항목이 만들어지므로, 불안정한 새 객체가 쉴 새 없이 ItemList로 흘러가는 것처럼 보입니다. 그런데 무엇이든 Compose에 닿으려면 한 단계를 더 거쳐야 합니다. StateFlow는 distinctUntilChanged()와 마찬가지로, 이미 갖고 있는 값과 같은 새 값은 무시합니다. kotlinx.coroutines의 StateFlowImpl.updateState를 살펴보시면, 첫 번째 검사는 compareAndSet을 지원하고, 여기서 눈여겨볼 부분은 두 번째 검사입니다.
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS 지원
if (oldState == newState) return true // 값이 바뀌지 않으면 아무것도 하지 않지만, CAS에는 true를 반환
_state.value = newState
UiState와 ItemUi는 data class이므로 내용이 같은 사본은 원본과 같다고 판정되고, 업데이트는 oldState == newState에서 멈춥니다. 이전 값이 그대로 남고, 아무것도 방출되지 않습니다. 테스트 환경에서 updateItems()를 5번 호출해도 DemoScreen과 ItemList는 한 번도 리컴포즈되지 않았고, Flow는 여전히 원래 인스턴스를 담고 있었습니다. 새 인스턴스가 생긴다고 해서 늘 새 리컴포지션이 일어나는 것은 아닙니다. 그러니 파이프라인 끝에 있는 컴포저블만 보지 말고 파이프라인 전체를 살펴보세요.
사례 3: LazyColumn 안의 불안정한 모델
세 번째 출처는 가장 멀리 떨어진 리포지토리이며, 이번에는 새 인스턴스가 실제로 끝까지 전달됩니다. 리포지토리는 방출할 때마다 데이터베이스 엔티티를 Message 모델로 매핑하고, ViewModel은 그 Flow를 StateFlow로 바꿉니다.
class MessageRepository(private val dao: MessageDao) {
fun observeMessages(): Flow<List<Message>> =
dao.observeMessages()
.map { entities -> entities.map { it.toMessage() } }
}
class MessageViewModel(repository: MessageRepository) : ViewModel() {
val messages = repository.observeMessages()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
}
Message는 변경 가능한 프로퍼티와 평범한 리스트를 가진 data class이며, 이 때문에 불안정합니다.
data class Message(
val id: String,
var isRead: Boolean,
val reactions: List<String>,
)
화면은 지연 목록(lazy list)으로 메시지를 렌더링합니다.
@Composable
fun MessageScreen(viewModel: MessageViewModel) {
val messages by viewModel.messages.collectAsState()
LazyColumn {
items(items = messages, key = { it.id }) { message ->
MessageBubble(message = message)
}
}
}
메시지 하나가 읽음으로 표시될 때 일어나는 일을 차례로 따라가 보겠습니다.
- Room의 방출: Room 쿼리 Flow는 테이블에 쓰기가 일어날 때마다 다시 방출하고, 리포지토리는 모든 행을 새
Message로 매핑합니다. - 동등성 검사: 바뀐 행이 하나도 없었다면
Message가data class이므로 새 리스트는 이전 리스트와 같았을 것이고, 사례 2에서처럼stateIn뒤의StateFlow가 이를 버렸을 것입니다. 실제로는 한 행이 바뀌었으므로 리스트가 방출됩니다. - 새 인스턴스: 방출된 리스트의 모든 원소는 새
Message이며, 바뀌지 않은 메시지도 예외가 아닙니다. - 비교:
key덕분에LazyColumn은 각 항목을 이전 컴포지션과 짝지을 수 있지만, 각MessageBubble이 받는 것은 여전히 새 객체입니다.Message가 불안정하므로 Strong Skipping Mode는 참조로 비교하고, 모든 비교가 실패합니다.
테스트 환경에서는 Room과 같은 방식으로 방출하는 Flow를 같은 map과 stateIn 단계에 흘려보냈습니다. 화면에 보이는 메시지 12개 중 하나를 읽음으로 표시하자, 코틀린 2.3.21과 2.4.20 모두에서 버블 12개가 전부 리컴포즈되었습니다. var 하나만으로도 Message는 어느 버전에서든 불안정합니다.
사례 3 고치기: 모델에서 안정성 확보하기
문제가 모델에 있으니 해결책도 모델에 있어야 합니다. 변경 가능한 프로퍼티를 읽기 전용으로 바꾸고, 평범한 리스트는 불변 컬렉션으로 교체하세요.
data class Message(
val id: String,
val isRead: Boolean,
val reactions: ImmutableList<String>,
)
kotlinx.collections.immutable의 ImmutableList<T>는 T가 안정적이기만 하면 컴파일러가 안정적이라고 알고 있는 타입 중 하나입니다. 그래서 이제 Message는 안정적이라고 추론되고 equals()로 비교됩니다. 바뀌지 않은 메시지는 새 인스턴스이더라도 같다고 판정되므로, 그 버블은 건너뜁니다. 이 모델로 메시지 하나를 읽음으로 표시하자 버블 12개 중 1개만 리컴포즈되었습니다.
List가 더 이상 불안정하지 않은 코틀린 2.4에서는 프로퍼티를 읽기 전용으로 바꾸는 데서 멈추고 싶어지는데, 이것으로 충분한지는 Message를 어디에 선언했느냐에 따라 달라집니다. isRead를 val로 바꾸고 평범한 List는 그대로 둔 채 코틀린 2.4.20으로 실행해 보니, Message가 컴포저블과 같은 파일에 있을 때는 버블 12개 중 1개가 리컴포즈되었습니다. 반면 모델이 보통 그렇듯 Message를 별도 파일로 옮기자 12개 모두 리컴포즈되었습니다. 코틀린 2.4부터는 이 Message처럼 어노테이션이 없는 클래스가 다른 파일에 선언되어 있으면, 그 안정성은 컴파일러가 클래스에 생성해 둔 필드로 런타임에 확인됩니다. 이 모델에서는 그 필드가 불안정하다고 알리므로 버블은 다시 참조로 비교됩니다. 코틀린 2.3.21에서는 이 모델이 두 가지 배치 모두에서 12개를 전부 리컴포즈했습니다. ImmutableList를 쓰면 두 버전, 두 가지 배치 모두에서 12개 중 1개만 리컴포즈되었습니다. 모델을 별도 파일에 두면 코틀린 2.4.20의 composables 보고서는 세 가지 Message 모두에서 message 매개변수를 아무 표시 없이 출력하므로, 셋을 구별하려면 리컴포지션 횟수를 세어 보세요.
놓치기 쉬운 조건이 하나 있습니다. 안정성 추론은 Compose 컴파일러가 직접 컴파일한 클래스에만 적용됩니다. 흔히 그렇듯 Message가 Compose 컴파일러를 적용하지 않은 데이터 모듈에 있다면, 필드가 어떻든 불안정한 상태로 남습니다. 이럴 때 통하는 방법은 아래 두 가지입니다.
평범한 List를 그대로 두고 싶으시다면, 대신 클래스를 안정적이라고 선언하실 수 있습니다.
@Immutable
data class Message(
val id: String,
val isRead: Boolean,
val reactions: List<String>,
)
컴파일러는 @Immutable을 검증하지 않으며, 문서에도 그렇게 적혀 있습니다. "The immutability of the class is not validated and is a promise by the type that all publicly accessible properties and fields will not change after the instance is constructed." 클래스의 불변성은 검증되지 않으며, 인스턴스가 생성된 뒤 외부에서 접근할 수 있는 프로퍼티와 필드가 모두 바뀌지 않는다는 것은 타입이 하는 약속이라는 뜻입니다. 이 약속을 지키면 결과는 같습니다. 버블 12개 중 1개만 리컴포즈되었습니다. 약속을 어기면, 갱신되어야 할 컴포저블을 Compose가 건너뛸 수도 있습니다.
패키지 전체의 안정성: 설정 파일
모듈이 Compose 런타임 어노테이션에 의존할 수만 있다면, Compose 컴파일러를 실행하지 않는 모듈에서도 @Immutable은 동작합니다. 다만 어노테이션은 클래스마다 하나씩 붙여야 합니다. 계층 전체에 적용하려 하거나, 직접 작성하지 않은 클래스이거나, Compose에 아예 의존하면 안 되는 모듈이라면 안정성 설정 파일(stability configuration file)을 쓸 수 있습니다. Now in Android는 데이터 모델 패키지 전체에 이 파일을 사용합니다.
// 데이터 모델에는 항상 불변 클래스를 사용합니다. 해당 모듈에서 Compose 컴파일러를 실행하지 않도록
// 여기서 안정적이라고 선언합니다.
com.google.samples.apps.nowinandroid.core.model.data.*
주석에 적힌 대로 모델 모듈은 Compose 컴파일러를 실행하지 않는 평범한 코틀린 라이브러리이므로, 이 클래스들의 안정성을 추론해 줄 주체가 없습니다. 설정 파일은 패턴과 일치하는 모든 클래스를 안정적이라고 표시합니다. 타입 매개변수가 없는 클래스라면 어노테이션을 붙였을 때와 같은 판정이며, 이를 한곳에서 패키지 전체에 적용하는 셈입니다. * 하나는 이름의 한 구간(segment)과 일치하므로 data.*는 그 패키지에 바로 들어 있는 클래스만 포함하고, **를 쓰면 하위 패키지까지 포함합니다. 빌드 로직은 Compose 컴파일러 확장(extension)을 거쳐 모든 Compose 모듈에 이 파일을 추가합니다. composeCompiler { } 블록이 설정하는 바로 그 확장입니다.
stabilityConfigurationFiles
.add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
Gradle의 Isolated Projects 기능을 쓰지 않는 빌드라면 rootProject.layout.projectDirectory.file("compose_compiler_config.conf")로 같은 일을 할 수 있습니다. stabilityConfigurationFiles는 코틀린 2.1.0부터 사용할 수 있으며, 코틀린 2.0.0부터 2.0.21까지는 stabilityConfigurationFile로 파일 하나만 지정합니다. 안정성을 깨뜨리는 패턴과 각각의 해결 방법은 Jetpack Compose의 안정성(Stability)을 활용한 앱 성능 최적화에서 더 깊이 다룹니다.
안정성 눈으로 확인하기: Compose Stability Analyzer

제가 관리하고 있는 Android Studio 및 IntelliJ 플러그인인 Compose Stability Analyzer는 코드를 작성하시는 동안 에디터에서 바로 각 컴포저블과 매개변수의 안정성을 표시합니다. 무엇이 안정적이고 무엇이 불안정한지, 무엇이 런타임에야 결정되는지, 무엇이 건너뛰기에 영향을 주는지 한눈에 확인하실 수 있습니다. 런타임 수치가 필요하다면 이 프로젝트의 Gradle 플러그인이 제공하는
@TraceRecomposition을 쓰시면 됩니다. 컴포저블에 이 어노테이션을 붙이고 앱을 실행하시면 리컴포지션이 Logcat에 기록되고, IDE 플러그인의 히트맵(heatmap)이 이를 각 함수 위에 실시간 횟수로 겹쳐 표시할 수 있습니다. Android Studio의 Layout Inspector도 리컴포지션 횟수를 알려 주지만, 이 분석 도구는 여기에 더해 코드 바로 옆에 안정성 정보까지 띄워 줍니다. 이 도구가 어떻게 판정을 내리는지는 Compose Stability Analyzer: Compose는 클래스를 어떻게 불안정하다고 판단하고, 그 대가는 무엇인가?에서 설명합니다.
결론
실무에서 중요한 것은 모든 곳에서 안정성을 좇는 일이 아니라, 새 인스턴스가 어디에서 오는지 아는 일입니다. 같은 객체가 바뀌지 않은 채 아래로 흘러가는 흔한 경우는 Strong Skipping Mode가 이미 처리해 줍니다. 낭비는 코드가 패스(pass)마다 객체를 새로 만들어, 참조로 비교하는 컴포저블에 넘기는 곳에서 생깁니다. 데이터가 바뀌는 것보다 더 자주 리컴포즈되는 컴포저블이 있다면, 그 인자를 거슬러 올라가 인자가 만들어진 곳을 찾고 거기서 문제를 고치세요. 고치기 전과 후에는 컴파일러 보고서와 리컴포지션 카운터로 측정해 보세요.
세 편을 관통하는 원리는 하나입니다. Compose는 안전하다고 증명할 수 있는 한도에서 최소한의 일만 하고, 그 증명은 언제나 비교입니다. Part 1에서는 모든 컴포저블에 생성되는 코드 안에서 그 비교를 찾았고, 이번 편에서는 StateFlow와 remember 키처럼 더 위쪽에서도 찾았습니다. 해결책이 리컴포즈되는 컴포저블이 아닌 다른 곳에 있는 경우가 많은 것도 그래서입니다.
이 시리즈보다 더 깊이 들어가 보고 싶으시다면 Jetpack Compose Mechanisms 책을 참고해 보세요. 이 시리즈와 같은 방식으로 실제 소스 코드를 읽어 가며 컴파일러와 런타임, Compose UI를 훨씬 자세히 다룹니다.

