주맨의 개발노트

[Jetpack Compose] val인데 왜 Unstable일까? Compose Stability 이해하기 본문

안드로이드

[Jetpack Compose] val인데 왜 Unstable일까? Compose Stability 이해하기

JooMan 2026. 6. 10. 23:54

Compose로 화면을 만들다 보면 Recomposition이라는 말을 자주 듣게 됩니다. 상태가 바뀌면 Composable이 다시 실행되고, Compose는 변경된 상태를 기준으로 UI를 최신 상태로 맞춥니다.

처음에는 이 정도로 이해해도 개발하는 데 큰 문제는 없습니다. 하지만 화면이 복잡해지고 리스트가 커지고 상태가 여러 계층으로 전달되기 시작하면, 단순히 "상태가 바뀌면 다시 그린다"는 설명만으로는 부족해집니다.

특히 UiState를 잘게 나누고, data classval만 사용했는데도 특정 Composable이 계속 다시 실행되는 경우가 있습니다. 이때 문제의 원인이 되는 개념이 바로 Compose의 Stability입니다.

이런 코드 쓴 적 있지 않나요?

Compose 화면에서 ViewModel이 노출하는 상태를 하나의 UiState로 묶는 패턴은 꽤 자연스럽습니다. 저도 대부분의 화면에서 이런 형태를 사용합니다.

HomeUiState.kt Kotlin
data class HomeUiState(
    val user: UserUiModel,
    val products: List<ProductUiModel>,
)

겉으로 보면 문제가 없어 보입니다. 모든 프로퍼티가 val이고, List도 Kotlin에서는 읽기 전용 인터페이스로 사용됩니다.

하지만 Compose 관점에서는 이 타입이 안정적이라고 단정하기 어렵습니다. List는 읽기 전용 API를 제공할 뿐, 실제 구현체가 내부적으로 변경되지 않는다는 보장은 하지 않기 때문입니다.

val은 참조가 바뀌지 않는다는 의미에 가깝습니다. 참조가 가리키는 객체의 내부 상태까지 반드시 불변이라는 뜻은 아닙니다.

이 차이를 놓치면 val로 선언했는데도 왜 Compose가 Unstable로 판단하는지 이해하기 어렵습니다. Compose 안정성 문제는 보통 여기서 시작됩니다.

Recomposition은 언제 발생하나

Recomposition은 상태 변경에 반응해서 이미 구성된 UI의 일부를 다시 실행하는 과정입니다. Compose는 상태가 바뀌었을 때 필요한 Composable을 다시 실행하고, 변경되지 않은 부분은 가능하면 건너뛰려고 합니다.

일반적으로 Recomposition은 두 가지 상황에서 발생합니다. 하나는 Composable이 읽고 있던 상태가 변경되는 경우이고, 다른 하나는 부모 Composable이 다시 실행되면서 자식에게 전달하는 파라미터가 변경되는 경우입니다.

1State

Composable 내부 또는 주변에서 관찰하는 상태가 변경됨

remember { mutableStateOf(...) }, collectAsStateWithLifecycle()처럼 Compose가 관찰하는 값이 바뀌면 해당 값을 읽는 범위가 다시 실행될 수 있습니다.

2Param

Composable 파라미터가 변경됨

부모가 다시 실행되면서 자식 Composable에 전달되는 값이 달라지면 자식도 다시 실행될 수 있습니다. 이때 Compose는 파라미터가 이전과 같은지 판단하려고 합니다.

여기서 Stability가 개입합니다. Compose가 "이 파라미터는 이전과 같고, 바뀌지 않았다고 믿어도 된다"고 판단할 수 있으면 해당 Composable을 Skip할 수 있습니다.

반대로 타입이 Unstable하면 값이 실제로 바뀌지 않았더라도 Compose가 그 사실을 확신하기 어렵습니다. 그러면 Skip하지 않고 다시 실행하는 쪽을 선택할 수 있습니다.

Recomposition이 성능에 영향을 주는 방식

성능 문제는 바뀌지 않아도 되는 영역까지 계속 다시 실행될 때 드러납니다. Composable 함수가 다시 실행되면 그 안의 조건 분기, 리스트 순회, 객체 생성, 문자열 포맷팅, 계산 로직도 함께 반복될 수 있습니다.

이 작업이 한 프레임 안에 많이 쌓이면 스크롤이나 애니메이션에서 프레임 드랍으로 이어질 수 있습니다. 특히 리스트, 복잡한 화면 상태, 자주 변경되는 입력 값이 함께 있는 화면에서는 불필요한 Recomposition 비용이 더 눈에 띄게 됩니다.

불필요한 Recomposition

값이 바뀌지 않은 리스트 영역까지 다시 실행됩니다. 화면 결과는 같지만 리스트 순회, 아이템 람다 생성, 조건 계산 같은 비용은 반복됩니다.

Skip 가능한 Recomposition

파라미터가 안정적이고 이전 값과 같다면 Compose가 해당 Composable 실행을 건너뛸 수 있습니다. 변경된 영역에 더 집중할 수 있습니다.

Compose에서 Stable하다는 것

Compose에서 Stable하다는 것은 Compose 컴파일러와 런타임이 해당 타입의 변경 여부를 예측할 수 있다는 뜻입니다. 값이 불변이거나, 값이 바뀌더라도 Compose가 그 변경을 알 수 있어야 합니다.

공식 문서에서는 타입을 크게 Immutable, Stable, Unstable 관점으로 설명합니다. 실무에서는 다음 정도로 이해하면 판단하기 쉽습니다.

구분 의미 예시
Immutable 생성 이후 public 상태가 바뀌지 않는 타입입니다. Int, Long, Boolean 같은 primitive 타입, String, 모든 public property가 immutable인 data class
Stable 상태가 바뀔 수 있지만, 바뀌면 Compose가 알 수 있는 타입입니다. MutableState, SnapshotStateList, 올바르게 작성된 state holder
Unstable 값이 바뀌었는지 Compose가 안전하게 판단하기 어려운 타입입니다. var 프로퍼티가 있는 클래스, 일반 List, Set, Map

Stable과 Skippable의 관계

Stable은 타입에 대한 정보이고, Skippable은 Composable 함수에 대한 정보입니다. 파라미터들이 안정적이면 Compose는 해당 Composable을 Skippable하게 만들 수 있고, Recomposition 시점에 이전 값과 같다면 실행을 건너뛸 수 있습니다.

Immutable Objects

가장 다루기 쉬운 안정성은 불변성입니다. 생성 이후 값이 바뀌지 않는 객체라면 Compose가 예측하기 쉽습니다.

ContactUiModel.kt Kotlin
data class ContactUiModel(
    val name: String,
    val phoneNumber: String,
)

이 타입은 모든 public property가 val이고, 각 프로퍼티도 String처럼 안정적인 타입입니다. 값을 바꾸려면 기존 객체를 수정하는 대신 새로운 객체를 만들어야 합니다.

ContactRow.kt Kotlin
@Composable
fun ContactRow(contact: ContactUiModel) {
    var selected by remember { mutableStateOf(false) }

    Row {
        ContactDetails(contact)
        ToggleButton(
            selected = selected,
            onToggled = { selected = !selected },
        )
    }
}

위 코드에서 selected가 바뀌면 ContactRow는 다시 실행될 수 있습니다. 하지만 ContactDetails가 받는 contact는 바뀌지 않았고 타입도 안정적입니다. 이 경우 Compose는 ContactDetails를 건너뛸 수 있습니다.

이것이 Stability가 성능과 연결되는 지점입니다. 상태가 바뀌어도 모든 자식이 무조건 다시 실행되는 것이 아니라, 안정적인 경계에서는 Skip이 가능해집니다.

Mutable Objects

반대로 객체 내부 상태가 바뀔 수 있는데 Compose가 그 변경을 관찰할 수 없다면 문제가 됩니다. 대표적인 예시는 var 프로퍼티가 있는 모델입니다.

MutableContact.kt Kotlin
data class ContactUiModel(
    var name: String,
    var phoneNumber: String,
)

이 타입은 생성 이후에도 public property가 바뀔 수 있습니다. 하지만 단순한 var 변경은 Compose의 snapshot state가 아니므로 Compose가 자동으로 추적할 수 없습니다.

그래서 Compose는 이런 타입을 안전하게 Skip하기 어렵습니다. 잘못 Skip하면 실제 값이 바뀌었는데도 UI가 갱신되지 않을 수 있기 때문입니다.

Mutable Model

varMutableList로 내부 상태가 바뀔 수 있습니다. Compose가 변경을 관찰하지 못하면 UI 갱신 타이밍이 깨질 수 있습니다.

Immutable UiState

상태 변경 시 기존 객체를 직접 수정하지 않고 copy로 새 객체를 만듭니다. Compose가 이전 값과 새 값을 비교하기 쉬운 형태가 됩니다.

List는 왜 Unstable인가

Compose Stability에서 가장 자주 만나는 함정은 컬렉션입니다. Kotlin의 List, Set, Map은 읽기 전용 인터페이스이지만, 불변 컬렉션이라는 뜻은 아닙니다.

ListTrap.kt Kotlin
val products: List<ProductUiModel> = mutableListOf()

(products as MutableList).add(
    ProductUiModel(id = 1L, name = "Keyboard"),
)

productsval이고 타입도 List입니다. 하지만 실제 구현체가 MutableList라면 내부 값은 바뀔 수 있습니다.

Compose 컴파일러는 List<ProductUiModel> 안의 ProductUiModel이 안정적인 타입이더라도, List 자체를 안정적이라고 보장하지 않습니다. 그래서 일반 컬렉션 파라미터는 Unstable로 판단될 수 있습니다.

HomeScreen.kt Kotlin
@Composable
fun HomeScreen(uiState: HomeUiState) {
    UserProfile(uiState.user)
    ProductList(uiState.products)
}

여기서 user만 바뀌고 products는 그대로여도, products 타입이 일반 List라면 ProductList 경계가 Skip되지 않을 수 있습니다.

다만 이것이 곧바로 하위 트리 전체가 항상 다시 실행된다는 뜻은 아닙니다. ProductList 내부에서 각 ProductItem이 안정적인 파라미터를 받는다면 그 아래 경계에서는 다시 Skip이 가능할 수 있습니다. 그래도 ProductList 함수 자체의 실행 비용은 남습니다.

ImmutableList로 컬렉션 안정화하기

공식 문서에서 권장하는 해결책 중 하나는 kotlinx.collections.immutable의 immutable collection을 사용하는 것입니다. 타입 자체가 불변 컬렉션임을 표현하므로 Compose 컴파일러가 더 안전하게 판단할 수 있습니다.

build.gradle.kts Kotlin
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:<version>")
}

의존성을 추가한 뒤 UiState의 컬렉션 타입을 일반 List가 아니라 ImmutableList로 바꿉니다.

HomeUiState.kt Kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

data class HomeUiState(
    val user: UserUiModel = UserUiModel.Empty,
    val products: ImmutableList<ProductUiModel> = persistentListOf(),
)

API 응답이나 DB 결과처럼 일반 List로 들어온 값은 UI 상태로 올릴 때 immutable collection으로 변환할 수 있습니다.

HomeViewModel.kt Kotlin
import kotlinx.collections.immutable.toImmutableList

private fun updateProducts(products: List<Product>) {
    _uiState.update { state ->
        state.copy(
            products = products
                .map { it.toUiModel() }
                .toImmutableList(),
        )
    }
}

이렇게 하면 UiState가 "읽기 전용처럼 보이는 컬렉션"이 아니라 "타입으로 불변성을 표현하는 컬렉션"을 갖게 됩니다. Compose 입장에서도 해당 경계를 더 안정적으로 다룰 수 있습니다.

@Immutable 사용 방법

@Immutable은 이 타입이 생성 이후 public 상태가 바뀌지 않는다는 계약을 Compose 컴파일러에 알려주는 어노테이션입니다. 주로 UiState, UiModel, DTO처럼 값 객체에 사용하기 좋습니다.

ProductUiModel.kt Kotlin
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

@Immutable
data class ProductUiModel(
    val id: Long,
    val name: String,
    val price: Long,
)

@Immutable
data class HomeUiState(
    val isLoading: Boolean = false,
    val products: ImmutableList<ProductUiModel> = persistentListOf(),
)

다만 @Immutable은 객체를 실제로 불변으로 만들어주는 마법이 아닙니다. 개발자가 "이 타입은 불변성 계약을 지킨다"고 컴파일러에 약속하는 것입니다.

계약을 어기면 Compose는 변경을 감지하지 못한 채 Composable을 Skip할 수 있고, 그 결과 UI가 예상대로 갱신되지 않을 수 있습니다.

잘못된 @Immutable 사용

다음처럼 내부에 mutable collection을 둔 타입은 @Immutable을 붙여도 실제 불변 객체가 되지 않습니다. 어노테이션은 컴파일러 판단만 바꾸기 때문에, 타입의 실제 동작이 계약을 따라야 합니다.

WrongImmutable.kt Kotlin
import androidx.compose.runtime.Immutable

@Immutable
data class UserUiModel(
    val id: Long,
    val tags: MutableList<String>,
)

위 코드는 @Immutable을 붙였지만 실제로는 안전하지 않습니다. tags 내부 값은 언제든 바뀔 수 있고, Compose가 그 변경을 자동으로 알 수 없습니다.

@Stable 사용 방법

@Stable@Immutable보다 약한 계약입니다. 상태가 바뀔 수는 있지만, 바뀌면 Compose가 반드시 알 수 있어야 합니다. 그래서 일반적인 값 객체보다는 상태를 들고 있는 state holder나 controller에 더 가깝습니다.

상태 변경을 Compose에 알려야 한다

@Stable 타입이 mutable한 상태를 가진다면, 그 변경은 Compose가 관찰 가능한 방식으로 일어나야 합니다. 가장 단순한 예시는 내부 상태를 mutableStateOf로 관리하는 방식입니다.

SearchState.kt Kotlin
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

@Stable
class SearchState {
    var keyword by mutableStateOf("")
        private set

    var isLoading by mutableStateOf(false)
        private set

    fun updateKeyword(value: String) {
        keyword = value
    }

    fun updateLoading(value: Boolean) {
        isLoading = value
    }
}

이 클래스는 mutable한 상태를 갖고 있지만, 그 상태가 mutableStateOf로 관리됩니다. 값이 바뀌면 Compose runtime이 변경을 관찰할 수 있습니다.

반대로 단순한 var만 두고 @Stable을 붙이면 계약 위반입니다.

WrongStable.kt Kotlin
import androidx.compose.runtime.Stable

@Stable
class CounterState {
    var count: Int = 0
}

count가 바뀌어도 Compose는 이 변경을 알 수 없습니다. 그런데 @Stable로 안정적이라고 약속했기 때문에, 오히려 Recomposition이 기대한 시점에 일어나지 않는 버그를 만들 수 있습니다.

어노테이션을 붙이기 전에 볼 것

Stability 문제를 보면 @Immutable이나 @Stable을 바로 붙이고 싶어질 수 있습니다. 하지만 어노테이션은 컴파일러의 추론을 보완하는 도구이지, 타입 설계를 대신하는 도구가 아닙니다.

가능하다면 먼저 타입 자체를 안정적으로 만드는 편이 좋습니다. var를 제거하고, mutable collection을 immutable collection으로 바꾸고, UI 계층에서 사용할 모델을 별도로 정의하는 방식입니다.

Step 1 · Mutable 제거 var, MutableList, 내부 변경 가능한 참조가 있는지 확인합니다.
Step 2 · Collection 점검 UI 상태에 일반 List, Set, Map이 있다면 immutable collection을 고려합니다.
Step 3 · Annotation 판단 타입의 계약을 실제로 지킬 수 있을 때만 @Immutable 또는 @Stable을 사용합니다.

실무에서의 기준

Compose 안정성을 다룰 때 저는 다음 기준으로 판단하는 편입니다. 대부분의 화면 상태는 불변 값 객체로 두고, 상태를 변경해야 하는 객체는 Compose가 관찰 가능한 상태 API를 사용합니다.

대상 권장 방식 이유
UiState, UiModel @Immutable + val + immutable collection 화면 상태를 값으로 다루고, 변경 시 새 객체를 만들기 쉽습니다.
State Holder @Stable + mutableStateOf 상태가 바뀔 수 있지만 Compose가 변경을 관찰할 수 있어야 합니다.
Domain Model UI 전용 모델로 변환 다른 모듈이나 외부 라이브러리 타입은 Compose 컴파일러가 안정성을 추론하기 어려울 수 있습니다.
일반 컬렉션 ImmutableList, ImmutableSet, ImmutableMap List, Set, Map은 불변 보장이 아니므로 Unstable로 판단될 수 있습니다.

특히 UiState는 화면의 입력값이자 Composable 파라미터로 자주 전달됩니다. 이 타입이 안정적이면 하위 Composable 경계에서 Skip이 가능해질 여지가 커집니다.

RecommendedUiState.kt Kotlin
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

@Immutable
data class HomeUiState(
    val isLoading: Boolean = false,
    val user: UserUiModel = UserUiModel.Empty,
    val products: ImmutableList<ProductUiModel> = persistentListOf(),
)

@Immutable
data class UserUiModel(
    val id: Long,
    val name: String,
) {
    companion object {
        val Empty = UserUiModel(id = 0L, name = "")
    }
}

이 패턴은 특별하지 않습니다. 오히려 Compose 화면 상태를 예측 가능하게 유지하는 기본기에 가깝습니다. 상태는 값으로 만들고, 변경은 새 값으로 표현하고, 컬렉션의 불변성은 타입으로 드러냅니다.

정리하며

Recomposition은 Compose가 상태 변경을 UI에 반영하기 위한 핵심 메커니즘입니다. 그래서 Recomposition 자체를 피해야 할 대상으로 보면 안 됩니다. 중요한 것은 바뀌지 않은 영역까지 불필요하게 다시 실행되지 않도록 경계를 잘 설계하는 것입니다.

Stability는 그 경계를 판단하기 위한 Compose의 기준입니다. 타입이 안정적이면 Compose는 파라미터가 이전과 같을 때 해당 Composable을 Skip할 수 있습니다. 반대로 타입이 Unstable하면 값이 실제로 같아도 Compose가 확신하기 어렵기 때문에 다시 실행될 가능성이 커집니다.

실무에서는 val만 믿기보다, 불변성을 타입으로 표현하는 습관이 중요했습니다. 특히 List, Set, Map은 읽기 전용 인터페이스일 뿐 불변 컬렉션이 아니므로, UI 상태에서는 ImmutableList 같은 타입을 적극적으로 고려하는 편이 안전합니다.

Key Points
  • Recomposition은 상태 변경을 반영하기 위해 필요한 Composable을 다시 실행하는 과정입니다.
  • Stability는 Compose가 Composable을 Skip해도 되는지 판단하는 기준입니다.
  • val만으로 충분하지 않습니다. 참조가 고정되어도 내부 객체가 mutable하면 안정적이라고 보기 어렵습니다.
  • 일반 컬렉션은 주의해야 합니다. List, Set, Map은 불변 보장이 아니므로 ImmutableList 같은 타입을 고려합니다.
  • 어노테이션은 계약입니다. @Immutable, @Stable은 타입을 자동으로 안정적으로 만드는 기능이 아니라 컴파일러와의 약속입니다.
Comments