Jetpack Compose는 기존 Kotlin 코드로는 구현하기 어려웠던 UI 동작을 컴파일러 플러그인을 통해 가능하게 합니다. 이 덕분에 Compose는 선언형 UI 프레임워크로서, 효율적이고 직관적인 방식으로 UI를 구성할 수 있게 되었습니다.
이 중에서도 특히 중요한 요소는 바로 @Composable 어노테이션이 붙은 컴포저블 함수입니다.
컴포저블 함수는 Compose 컴파일러 플러그인에 의해 특수 처리되는 함수입니다. 일반 Kotlin 함수와 달리, 컴파일 시점에 내부적으로 Compose 런타임 코드가 삽입되어 특정 상태가 변경될 때마다 UI가 갱신될 수 있도록 설계됩니다.
즉, 컴포저블 함수는 Compose 생명주기에 따라 다음과 같은 흐름으로 동작합니다:
컴포저블의 수명 주기. 컴포저블은 컴포지션에 진입하여 0회 이상 재구성된 후 컴포지션을 종료합니다.
Compose는 상태 기반으로 동작하기 때문에, 어떤 상태가 바뀌면 이를 사용하는 UI는 자동으로 리컴포지션됩니다.
하지만, 이 리컴포지션이 너무 자주 발생하거나, 불필요하게 많은 UI를 다시 그리게 되면 성능 저하로 이어질 수 있습니다.
이 문제를 해결하기 위해 Compose는 스마트 리컴포지션(Smart Recomposition) 기능을 도입했습니다.
스마트 리컴포지션은 다음과 같은 원칙으로 작동합니다:
즉, Compose는 내부적으로 변경된 상태와 관련된 UI 범위를 분석하여 정말 필요한 부분만 리컴포지션하고 나머지는 그대로 유지합니다. 이를 통해 UI 갱신의 효율을 극대화할 수 있습니다.
Jetpack Compose는 컴파일 타임에 각 객체가 안정적인지(Stable) 여부를 판단합니다.
그리고 어떤 컴포저블 함수의 모든 인자가 안정적이라면, 그 함수는 "건너뛸 수 있는 함수(Skippable)"로 간주됩니다.
즉, 상태 변화로 인해 리컴포지션이 발생하더라도, 인자 값들이 이전과 동일하고 모두 안정적이라면, 해당 함수는 리컴포지션에서 제외됩니다.
이것이 바로 스마트 리컴포지션(Smart Recomposition)의 핵심 원리입니다.
Compose에서는 대부분의 객체를 아래와 같은 두 상태 중 하나로 분류합니다:
컴포저블 함수 안에서 사용하는 객체들이 Stable해야, 성능 최적화 측면에서 큰 이점을 가져올 수 있습니다.
Compose는 아래 세 가지 기준을 통해 객체의 안정성을 평가합니다:
두 인스턴스가 같은 상태라면 equals()의 결과도 항상 같아야 한다
객체가 가진 모든 public 필드는 안정적이어야 한다
값이 변경될 경우 Compose에게 이를 알려야 한다
Compose 컴파일러는 아래 타입들을 기본적으로 안정적이라고 판단합니다:
이외의 사용자 정의 객체는 위의 조건을 만족하지 않으면 불안정한 객체로 처리되어 항상 리컴포지션 대상이 됩니다.
조금 모호할 수 있는 세 번째 기준인 "값이 바뀌면 Compose에게 알려야 한다"는 조건은 다음과 같은 의미입니다:
객체의 상태가 바뀌었을 때, 해당 객체가 Compose에 직접 혹은 간접적으로 변경 사실을 알릴 수 있어야 한다는 것.
Jetpack Compose에서는 이 역할을 State<T>가 수행합니다.
@Composable
fun increaseCount() {
var count = 0
Button(
onClick = { count++ }
) {
Text(text = "Count: $count")
}
}
@Composable
fun increaseCount() {
var count by remember { mutableStateOf(0) }
Button(
onClick = { count++ }
) {
Text(text = "Count: $count")
}
}
위 두 예제는 버튼을 누르면 숫자가 증가하는 UI를 그리는 코드입니다.
하지만 두 코드의 작동 방식은 전혀 다릅니다.
첫 번째 예제에서는 count를 단순한 지역 변수로 선언했기 때문에, 버튼을 클릭해도 화면에는 아무런 변화가 없습니다.
디버깅을 해보면 count의 값은 실제로 증가하지만, UI는 그대로입니다.
이는 Compose가 count의 변화 여부를 감지할 수 없기 때문입니다.
두 번째 예제는 count를 remember와 mutableStateOf로 감싼 상태로 선언했습니다.
이 상태 객체는 내부 값이 변경될 경우 Compose에게 "값이 바뀌었다"는 사실을 알려주고,
그 결과 increaseCount() 컴포저블 함수는 자동으로 리컴포지션되어 UI가 즉시 갱신됩니다.
mutableStateOf, remember, rememberSaveable 등입니다.📌 가변적인 값은 State로 관리해야 Compose가 리컴포지션을 통해 UI를 자동으로 업데이트할 수 있습니다.
이번에는 인터페이스의 안정성(Stability)에 대해 생각해보겠습니다.
Jetpack Compose에서 자주 사용되는 대표적인 인터페이스는 List, Map 등이 있습니다.
그렇다면 이런 인터페이스들은 Compose 관점에서 안정적일까요, 불안정적일까요?
Compose에서 어떤 객체가 안정적이려면 다음 조건 중 하나 이상을 만족해야 합니다:
public 필드들이 모두 안정적일 것하지만 인터페이스는 단지 계약(Contract)만 정의할 뿐,
그 인터페이스를 구현하는 클래스의 필드나 내부 동작까지 보장하지 않습니다.
즉, 인터페이스 자체는 안정성을 판단할 수 있는 정보가 부족합니다.
예를 들어 List 인터페이스를 생각해봅시다.
이것을 구현한 MutableList는 값을 추가/삭제할 수 있는 다양한 변경 함수들을 제공합니다.
만약 어떤 MutableList 인스턴스를 컴포저블 바깥에서 참조한 뒤 내부 값을 수정하게 된다면,
Compose는 그 변화를 감지할 방법이 없습니다.
이처럼 구현체가 어떤 구조로 되어 있는지 모르기 때문에,
Compose는 인터페이스 자체는 신뢰하지 않고, 모두 "불안정(Unstable)"하다고 판단합니다.
Compose 1.2 이후부터는 예외적으로
kotlinx.collections.immutable 라이브러리의 컬렉션들은 안정적으로 취급합니다.
이는 JetBrains에서 공식적으로 관리하는 오픈소스 라이브러리이며,
내부적으로 완전히 불변(Immutable)하도록 설계된 컬렉션들을 제공합니다.
ImmutableList, ImmutableMap, PersistentList, PersistentMap 등kotlinx.collections.immutable 라이브러리의 컬렉션은 안정적으로 인정됩니다.📌 Compose에서 안정성을 확보하려면, 가능하다면 인터페이스보다 구체적인 불변 클래스나 State 기반 구조를 활용하는 것이 좋습니다.
Jetpack Compose에서 성능 최적화를 위해 중요한 개념 중 하나가 바로 "안정성(Stability)"입니다.
컴포저블 함수에 전달되는 객체가 안정적이면, 값이 바뀌지 않는 한 불필요한 리컴포지션을 건너뛸 수 있습니다.
그렇다면 Kotlin의 data class와 일반 class는 Compose 입장에서 어떻게 다르게 평가될까요?
data class User(val name: String, val age: Int)
data class는 다음과 같은 특성을 가집니다:
val 또는 불변 타입일 경우equals()와 hashCode()가 생성됨copy()를 통해 변경이 일어날 때 새로운 인스턴스가 생성됨이런 구조는 Compose에서 안정성 판정을 할 때 긍정적인 요소로 작용합니다.
즉, 대부분의 data class는 특별한 설정 없이도 Stable로 취급됩니다.
class User(val name: String, var age: Int)
일반 클래스는 다음과 같은 이유로 Compose가 기본적으로 불안정하게 판단할 가능성이 큽니다:
equals()와 hashCode()가 자동으로 정의되지 않음var 프로퍼티를 통해 외부에서 내부 값이 변할 수 있음@Stable 어노테이션 사용@Stable
class User(val name: String, val age: Int)
@Stable은 컴파일러에게 "이 객체는 내가 책임질 테니 안정적으로 봐줘"라고 선언하는 것입니다.equals() 구현 여부Compose에서는 람다 함수가 기본적으로 Stable로 간주됩니다.
따라서 클래스가 람다를 포함하고 있더라도 안정적으로 만들 수 있습니다.
@Stable
class ButtonAction(
val label: String,
val onClick: () -> Unit
)
label이 String, onClick이 () -> Unit이므로 모두 Stable한 타입입니다.@Stable을 붙이면 Compose는 해당 객체를 안정적으로 판단하고, 리컴포지션을 생략할 수 있습니다.💡 참고: Compose에서는 람다 타입(
() -> Unit,(Int) -> Boolean등)은 자동으로 Stable로 처리됩니다.
@Stable
class UiState(
val name: String,
val isLoading: Boolean,
val onRetry: () -> Unit
)
이 클래스는 모든 필드가 Stable하고, @Stable 어노테이션이 명시되어 있어 Compose가 신뢰할 수 있습니다.
값이 변경되지 않는다면 이 컴포저블을 다시 호출할 필요가 없으므로 성능 최적화에 유리합니다.
@Immutable 어노테이션 사용@Immutable
data class UiState(
val name: String,
val isLoading: Boolean
)
@Immutable은 불변 객체임을 명확하게 컴파일러에 알려주는 마커입니다.
이 어노테이션이 붙은 클래스는 내부 모든 필드가 불변(Immutable) 타입이어야만 합니다.
val만 사용해야 하며즉, @Immutable은 단순한 힌트 수준이었던 @Stable과는 다르게,
컴파일 타임에 실제 안정성 검사를 수행하는 강력한 선언입니다.
💡 Compose 내부에서는
rememberSaveableStateHolder나snapshotFlow같은 고급 기능에서도@Immutable이 붙은 클래스는 최적화된 방식으로 취급됩니다.
@Stable vs @Immutable 비교 요약| 항목 | @Stable | @Immutable |
|---|---|---|
| 안정성 표현 방식 | 개발자가 안정성 보장 약속 | 완전한 불변 객체만 허용 (컴파일 체크) |
| 내부 구조 제한 | 느슨함 (변경 가능 프로퍼티 가능) | 엄격함 (모든 필드 불변이어야 함) |
| 자동 적용 대상 | 람다, 기본 타입 등 | data class + 불변 필드 구조에 적합 |
| Compose 인식 방식 | 힌트 기반 | 강력하게 최적화 |
| 상황 | 추천 어노테이션 |
|---|---|
| 일반 클래스에 안정성 선언이 필요할 때 | @Stable |
| 절대적으로 필드가 바뀌지 않을때 | @Immutable |
| 내부에 var 또는 외부 참조가 있을 가능성 있음 | ❌ 어노테이션 사용 지양 또는 리팩토링 권장 |
지금까지 안정성(@Stable, @Immutable)을 확보하여 리컴포지션을 줄이는 방법을 알아봤습니다.
하지만 실제로 Compose가 내 코드를 어떻게 해석하고, 어느 부분에서 리컴포지션이 발생할 수 있다고 판단하는지는 어떻게 알 수 있을까요?
바로 그럴 때 사용하는 것이 Compose Compiler Metrics & Reports입니다.
Jetpack Compose는 컴파일 타임에 각 Composable 함수의 리컴포지션 가능 여부를 분석합니다.
이 정보를 개발자가 확인할 수 있도록 .txt 리포트 파일로 출력해주는 기능이 바로 compose-metrics와 compose-reports입니다.
| 디렉토리 | 내용 |
|---|---|
compose-metrics/ | 각 Composable 함수의 restartable, skippable, readonly 여부 등 메타 정보 |
compose-reports/ | 리컴포지션 트리, 그룹 구조, slot 상태 등 내부 Compose 작동 흐름을 시각화한 보고서 |
build.gradle(:app) 또는 build.gradle.kts에 아래와 같이 설정합니다:
android {
...
composeOptions {
kotlinCompilerExtensionVersion = "COMPOSE_VERSION"
kotlinCompilerPluginArguments += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=build/compose-metrics",
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=build/compose-reports"
]
}
}
빌드하면 프로젝트의 build/compose-metrics/ 와 build/compose-reports/ 디렉토리에 결과가 생성됩니다.

예: build/compose-metrics/com/example/MyComposable.txt
MyComposable.kt:12: Composable function MyComposable is restartable and skippable

이 정보를 통해 불필요한 리컴포지션을 유발하는 구조를 식별하고 개선할 수 있습니다.
예: build/compose-reports/com/example/MyComposable-composables.txt

Jetpack Compose의 리컴포지션을 효과적으로 관리하려면, 다음 세 가지를 기억하세요:
@Stable, @Immutable을 활용해 객체의 변경 가능성을 명확하게 정의하세요. data class, 람다, 인터페이스 등에서 Compose가 추적 가능한 형태로 구조를 설계하세요. compose-metrics, compose-reports를 통해 실제 리컴포지션 동작을 분석하고 최적화 포인트를 찾아보세요.Compose는 선언형 UI라는 장점이 있지만, 내부 구조를 이해하지 못하면 퍼포먼스 이슈가 발생할 수 있습니다.
안정성과 리컴포지션 흐름을 눈으로 확인하고, Compose가 "언제", "왜" 다시 그리는지를 이해하는 것이 최적화의 시작입니다.
📌 지금 작성한 Composable이 정말로 꼭 다시 그려져야 하는지, metrics 리포트에서 직접 확인해보세요!