Jetpack Compose에서는 상태 기반의 선언적 UI 덕분에 UI를 직관적으로 작성할 수 있습니다.
하지만 상태 변경에 따라 발생하는 리컴포지션(Recomposition)이 너무 잦다면,
불필요한 UI 재그림으로 성능 저하가 발생할 수 있습니다.
이번 글에서는 Compose의 안정성(Stable) 시스템과 @Stable 애너테이션을 활용해
리컴포지션을 최소화하고 성능을 높이는 방법을 정리해보겠습니다.
@Stable은 Compose 컴파일러에게 “이 객체는 안정적인 상태를 가진다”라고 알려주는 애너테이션입니다.
즉,
“이 객체의 상태가 변하지 않으면 다시 그릴 필요가 없다.”
라고 Compose에 명시적으로 알려주는 역할을 합니다.
@Stable을 올바르게 사용하면 Compose가 불필요한 UI 갱신을 방지하고,
성능을 최적화할 수 있습니다.
Compose는 객체가 “안정적인지”를 자동으로 판별합니다.
하지만 완전히 추론되지 않는 경우가 많기 때문에, 개발자가 직접 @Stable을 붙여야 할 때가 있습니다.
| 항목 | 자동으로 Stable로 인식됨 | 직접 @Stable 필요 |
|---|---|---|
Int, String, Float 등 기본 타입 | ✅ | ❌ |
모든 프로퍼티가 불변인 data class | ✅ | ❌ |
@Immutable로 표시된 클래스 | ✅ | ❌ |
var가 있는 클래스 | ❌ | ⭕ |
| 외부 라이브러리 객체 (Compose가 내부 구조를 모름) | ❌ | ⭕ |
mutableStateOf로 감싸진 상태 | ⚠️ (조건부) | ⭕ (보통 권장) |
두 애너테이션은 비슷해 보이지만, 의미가 다릅니다.
| 구분 | @Immutable | @Stable |
|---|---|---|
| 의미 | 완전히 불변 (내부 값이 절대 안 변함) | 내부 상태는 변하지만 Compose가 변화를 추적 가능 |
| 변경 가능성 | ❌ 불가능 | ⭕ 가능 |
| 리컴포지션 발생 | 값이 바뀌면 새 객체 생성 → 리컴포즈 | 내부 변경을 Compose가 감지 가능 |
| 사용 예시 | DTO, UI 모델 | StateHolder, ViewModel 상태 |
@Stable
class CounterState {
var count by mutableStateOf(0)
private set
fun increment() {
count++
}
}
이 클래스는 count라는 상태가 변하더라도,
CounterState 객체 자체는 안정적(Stable)입니다.
즉, Compose는 CounterState의 내부 값이 바뀔 때만 필요한 부분만 리컴포즈하고,
다른 UI는 다시 그리지 않습니다.
class CounterState {
var count by mutableStateOf(0)
}
이 클래스엔 @Stable이 없습니다.
Compose는 내부 상태 추적은 하더라도,
CounterState 객체 자체가 "불안정"하다고 간주할 수 있습니다.
그 결과:
Compose는 @Stable 객체를 다음과 같이 다룹니다:
@Stable 객체는 내부 필드가 변하지 않으면 다시 그리지 않음 mutableStateOf로 감싸진 필드는 자동으로 Compose에 변경을 알림 즉, @Stable과 mutableStateOf를 조합하면
UI는 변화가 있는 부분만 효율적으로 갱신됩니다.
@Composable
fun CounterView(count: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Count: $count")
}
}
이 경우 count 값이 바뀔 때마다 CounterView 전체가 리컴포즈됩니다.
규모가 커질수록 성능 부담이 커질 수 있습니다.
@Stable
class CounterState {
var count by mutableStateOf(0)
}
@Composable
fun CounterView(state: CounterState) {
Button(onClick = { state.count++ }) {
Text("Count: ${state.count}")
}
}
이렇게 하면 CounterState 내부 값이 변경될 때만
해당 Text 부분만 리컴포즈되고,
다른 UI 구조는 그대로 유지됩니다.
| 주의 항목 | 설명 |
|---|---|
| ⚠️ 실제로 변하지 않는 객체에만 사용 | 내부 값이 자주 바뀌면 캐시 효과가 없음 |
| ⚠️ 상태 추적이 불가능한 필드 포함 금지 | Compose가 변화 감지 불가 |
| ⚠️ 외부 라이브러리 객체를 감쌀 때 신중히 | Compose는 해당 타입의 안정성 판단 불가 |
✅ mutableStateOf와 함께 사용 | Compose가 자동으로 변화를 추적 |
| 전략 | 설명 |
|---|---|
✅ @Stable 사용 | Compose가 불필요한 리컴포지션을 피하도록 명시 |
✅ mutableStateOf로 상태 추적 | 내부 값 변화를 Compose가 감지하도록 처리 |
✅ @Immutable 병행 | 완전 불변 데이터는 Immutable로 처리 |
| ⚡ 상태 객체 단일화 | 여러 개의 작은 상태 대신 안정적인 StateHolder로 묶기 |
| ⚠️ Stable을 남발하지 말 것 | 잘못 쓰면 Compose가 변화를 감지하지 못함 |
Compose 성능 최적화의 핵심은 “언제 다시 그릴지를 Compose에 정확히 알려주는 것”입니다.
@Stable은 Compose에게 “이 객체는 안전하게 유지해도 된다”는 신호를 주어
불필요한 리컴포지션을 줄이고,
UI 갱신을 정확하고 효율적으로 제어할 수 있게 합니다.
@Immutable @Stable mutableStateOf이 원칙을 지키면, Compose UI는 빠르고 부드럽게 동작합니다 🚀
💡 TIP
@Stable을 붙이면 “리컴포지션을 안 한다”가 아니라,
“필요할 때만 정확히 리컴포지션한다”는 의미입니다.
즉, 안정성(Stable)은 리컴포지션의 빈도 제어 도구라고 생각하세요.