Compose Smart Recomposition & Stability

이진영·2025년 6월 20일
post-thumbnail

Jetpack Compose는 기존 Kotlin 코드로는 구현하기 어려웠던 UI 동작을 컴파일러 플러그인을 통해 가능하게 합니다. 이 덕분에 Compose는 선언형 UI 프레임워크로서, 효율적이고 직관적인 방식으로 UI를 구성할 수 있게 되었습니다.

이 중에서도 특히 중요한 요소는 바로 @Composable 어노테이션이 붙은 컴포저블 함수입니다.


🧩 컴포저블 함수란?

컴포저블 함수는 Compose 컴파일러 플러그인에 의해 특수 처리되는 함수입니다. 일반 Kotlin 함수와 달리, 컴파일 시점에 내부적으로 Compose 런타임 코드가 삽입되어 특정 상태가 변경될 때마다 UI가 갱신될 수 있도록 설계됩니다.

즉, 컴포저블 함수는 Compose 생명주기에 따라 다음과 같은 흐름으로 동작합니다:

컴포저블의 수명 주기. 컴포저블은 컴포지션에 진입하여 0회 이상 재구성된 후 컴포지션을 종료합니다.


⚠️ 리컴포지션의 문제점?

Compose는 상태 기반으로 동작하기 때문에, 어떤 상태가 바뀌면 이를 사용하는 UI는 자동으로 리컴포지션됩니다.
하지만, 이 리컴포지션이 너무 자주 발생하거나, 불필요하게 많은 UI를 다시 그리게 되면 성능 저하로 이어질 수 있습니다.


🧠 Smart Recomposition이란?

이 문제를 해결하기 위해 Compose는 스마트 리컴포지션(Smart Recomposition) 기능을 도입했습니다.

스마트 리컴포지션은 다음과 같은 원칙으로 작동합니다:

  • 실제로 영향을 받은 Composable만 다시 실행
  • 변하지 않은 부분은 아예 리컴포지션을 건너뜀

즉, Compose는 내부적으로 변경된 상태와 관련된 UI 범위를 분석하여 정말 필요한 부분만 리컴포지션하고 나머지는 그대로 유지합니다. 이를 통해 UI 갱신의 효율을 극대화할 수 있습니다.


🛡️ 안정성 시스템 (Stability System)

Jetpack Compose는 컴파일 타임에 각 객체가 안정적인지(Stable) 여부를 판단합니다.
그리고 어떤 컴포저블 함수의 모든 인자가 안정적이라면, 그 함수는 "건너뛸 수 있는 함수(Skippable)"로 간주됩니다.

즉, 상태 변화로 인해 리컴포지션이 발생하더라도, 인자 값들이 이전과 동일하고 모두 안정적이라면, 해당 함수는 리컴포지션에서 제외됩니다.
이것이 바로 스마트 리컴포지션(Smart Recomposition)의 핵심 원리입니다.


🔍 Compose에서 말하는 “안정성(Stable)”이란?

Compose에서는 대부분의 객체를 아래와 같은 두 상태 중 하나로 분류합니다:

  • Stable (안정적): 상태가 바뀌지 않았음을 Compose가 믿고 넘어갈 수 있는 객체
  • Unstable (불안정): 변경 가능성이 있거나 구조상 판단이 어려운 객체

컴포저블 함수 안에서 사용하는 객체들이 Stable해야, 성능 최적화 측면에서 큰 이점을 가져올 수 있습니다.


✅ Compose에서 판단하는 안정성 기준

Compose는 아래 세 가지 기준을 통해 객체의 안정성을 평가합니다:

  1. 두 인스턴스가 같은 상태라면 equals()의 결과도 항상 같아야 한다

    • 즉, 동일한 데이터를 가진 객체는 동일한 것으로 인식할 수 있어야 합니다.
  2. 객체가 가진 모든 public 필드는 안정적이어야 한다

    • 내부 속성 중 하나라도 불안정한 상태라면 해당 객체는 Unstable로 간주됩니다.
  3. 값이 변경될 경우 Compose에게 이를 알려야 한다

    • 즉, 변경이 일어났을 때 Compose가 그 사실을 인지할 수 있어야 합니다.

🧱 기본적으로 안정적인 객체들

Compose 컴파일러는 아래 타입들을 기본적으로 안정적이라고 판단합니다:

  • 원시 타입 (Int, Boolean, Float 등)
  • 문자열 (String)
  • 람다 함수

이외의 사용자 정의 객체는 위의 조건을 만족하지 않으면 불안정한 객체로 처리되어 항상 리컴포지션 대상이 됩니다.


🧠 “변경을 알려야 한다”는 것은 무슨 의미일까?

조금 모호할 수 있는 세 번째 기준인 "값이 바뀌면 Compose에게 알려야 한다"는 조건은 다음과 같은 의미입니다:

객체의 상태가 바뀌었을 때, 해당 객체가 Compose에 직접 혹은 간접적으로 변경 사실을 알릴 수 있어야 한다는 것.

Jetpack Compose에서는 이 역할을 State<T>가 수행합니다.


🧪 예시: 변경을 알릴 수 없는 경우 vs 알릴 수 있는 경우

❌ 변경을 감지하지 못하는 예

@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의 변화 여부를 감지할 수 없기 때문입니다.


두 번째 예제는 countremembermutableStateOf로 감싼 상태로 선언했습니다.
이 상태 객체는 내부 값이 변경될 경우 Compose에게 "값이 바뀌었다"는 사실을 알려주고,
그 결과 increaseCount() 컴포저블 함수는 자동으로 리컴포지션되어 UI가 즉시 갱신됩니다.


✅ 정리

  • 값만 바뀌고 UI가 반응하지 않는다면, 그 값은 Compose가 추적할 수 없는 단순 변수일 가능성이 높습니다.
  • Compose에서 UI를 갱신하려면, 그 값을 State로 감싸야 합니다.
  • 이를 위해 사용하는 대표적인 도구는 mutableStateOf, remember, rememberSaveable 등입니다.

📌 가변적인 값은 State로 관리해야 Compose가 리컴포지션을 통해 UI를 자동으로 업데이트할 수 있습니다.

🧩 인터페이스의 안정성

이번에는 인터페이스의 안정성(Stability)에 대해 생각해보겠습니다.
Jetpack Compose에서 자주 사용되는 대표적인 인터페이스는 List, Map 등이 있습니다.
그렇다면 이런 인터페이스들은 Compose 관점에서 안정적일까요, 불안정적일까요?


🤔 인터페이스는 안정적인가?

Compose에서 어떤 객체가 안정적이려면 다음 조건 중 하나 이상을 만족해야 합니다:

  • public 필드들이 모두 안정적일 것
  • 값이 바뀌면 Compose에게 알려줄 수 있을 것

하지만 인터페이스는 단지 계약(Contract)만 정의할 뿐,
그 인터페이스를 구현하는 클래스의 필드나 내부 동작까지 보장하지 않습니다.

즉, 인터페이스 자체는 안정성을 판단할 수 있는 정보가 부족합니다.


예를 들어 List 인터페이스를 생각해봅시다.
이것을 구현한 MutableList는 값을 추가/삭제할 수 있는 다양한 변경 함수들을 제공합니다.
만약 어떤 MutableList 인스턴스를 컴포저블 바깥에서 참조한 뒤 내부 값을 수정하게 된다면,
Compose는 그 변화를 감지할 방법이 없습니다.

이처럼 구현체가 어떤 구조로 되어 있는지 모르기 때문에,
Compose는 인터페이스 자체는 신뢰하지 않고, 모두 "불안정(Unstable)"하다고 판단합니다.


✅ 예외: kotlinx.collections.immutable

Compose 1.2 이후부터는 예외적으로
kotlinx.collections.immutable 라이브러리의 컬렉션들은 안정적으로 취급합니다.

이는 JetBrains에서 공식적으로 관리하는 오픈소스 라이브러리이며,
내부적으로 완전히 불변(Immutable)하도록 설계된 컬렉션들을 제공합니다.

  • 예: ImmutableList, ImmutableMap, PersistentList, PersistentMap
  • 이들은 구조상 값이 절대 변경되지 않기 때문에, Compose 입장에서도 안심하고 리컴포지션을 생략할 수 있습니다.

📝 정리

  • Compose는 모든 인터페이스 타입을 기본적으로 불안정(Unstable)하다고 간주합니다.
  • 구현체가 어떤 동작을 할지 알 수 없기 때문에, 안전하게 리컴포지션을 생략할 수 없기 때문입니다.
  • 예외적으로 kotlinx.collections.immutable 라이브러리의 컬렉션은 안정적으로 인정됩니다.

📌 Compose에서 안정성을 확보하려면, 가능하다면 인터페이스보다 구체적인 불변 클래스나 State 기반 구조를 활용하는 것이 좋습니다.


🧱 data class와 일반 class의 안정성(Stable) 판정

Jetpack Compose에서 성능 최적화를 위해 중요한 개념 중 하나가 바로 "안정성(Stability)"입니다.
컴포저블 함수에 전달되는 객체가 안정적이면, 값이 바뀌지 않는 한 불필요한 리컴포지션을 건너뛸 수 있습니다.

그렇다면 Kotlin의 data class와 일반 class는 Compose 입장에서 어떻게 다르게 평가될까요?


✅ data class는 기본적으로 안정적인가?

data class User(val name: String, val age: Int)

data class는 다음과 같은 특성을 가집니다:

  • 모든 프로퍼티가 val 또는 불변 타입일 경우
  • 자동으로 equals()hashCode()가 생성됨
  • copy()를 통해 변경이 일어날 때 새로운 인스턴스가 생성됨

이런 구조는 Compose에서 안정성 판정을 할 때 긍정적인 요소로 작용합니다.
즉, 대부분의 data class는 특별한 설정 없이도 Stable로 취급됩니다.


⚠️ 일반 class는 불안정한가?

class User(val name: String, var age: Int)

일반 클래스는 다음과 같은 이유로 Compose가 기본적으로 불안정하게 판단할 가능성이 큽니다:

  • equals()hashCode()가 자동으로 정의되지 않음
  • var 프로퍼티를 통해 외부에서 내부 값이 변할 수 있음
  • 변경되더라도 Compose가 그것을 인지할 방법이 없음

🛠 안정성 보장하는 방법

1. @Stable 어노테이션 사용

@Stable
class User(val name: String, val age: Int)
  • @Stable은 컴파일러에게 "이 객체는 내가 책임질 테니 안정적으로 봐줘"라고 선언하는 것입니다.
  • 단, 이 어노테이션을 사용할 땐 직접 안정성을 보장해줘야 합니다.
    • equals() 구현 여부
    • 모든 필드가 안정적인 타입일 것
    • 값이 바뀌지 않거나, Compose가 추적 가능해야 함

2. 클래스 내 람다를 안정적으로 처리하기

Compose에서는 람다 함수가 기본적으로 Stable로 간주됩니다.
따라서 클래스가 람다를 포함하고 있더라도 안정적으로 만들 수 있습니다.

@Stable
class ButtonAction(
    val label: String,
    val onClick: () -> Unit
)
  • 위 클래스는 labelString, 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가 신뢰할 수 있습니다.
값이 변경되지 않는다면 이 컴포저블을 다시 호출할 필요가 없으므로 성능 최적화에 유리합니다.

3. @Immutable 어노테이션 사용

@Immutable
data class UiState(
    val name: String,
    val isLoading: Boolean
)

@Immutable불변 객체임을 명확하게 컴파일러에 알려주는 마커입니다.
이 어노테이션이 붙은 클래스는 내부 모든 필드가 불변(Immutable) 타입이어야만 합니다.

  • val만 사용해야 하며
  • 필드 타입들도 전부 Stable 또는 Immutable이어야 합니다
  • 그렇지 않으면 컴파일 시 경고 또는 실패가 발생할 수 있습니다

즉, @Immutable은 단순한 힌트 수준이었던 @Stable과는 다르게,
컴파일 타임에 실제 안정성 검사를 수행하는 강력한 선언입니다.

💡 Compose 내부에서는 rememberSaveableStateHoldersnapshotFlow 같은 고급 기능에서도 @Immutable이 붙은 클래스는 최적화된 방식으로 취급됩니다.


🔄 @Stable vs @Immutable 비교 요약

항목@Stable@Immutable
안정성 표현 방식개발자가 안정성 보장 약속완전한 불변 객체만 허용 (컴파일 체크)
내부 구조 제한느슨함 (변경 가능 프로퍼티 가능)엄격함 (모든 필드 불변이어야 함)
자동 적용 대상람다, 기본 타입 등data class + 불변 필드 구조에 적합
Compose 인식 방식힌트 기반강력하게 최적화

✅ 어떤 마커를 언제 써야 할까?

상황추천 어노테이션
일반 클래스에 안정성 선언이 필요할 때@Stable
절대적으로 필드가 바뀌지 않을때@Immutable
내부에 var 또는 외부 참조가 있을 가능성 있음❌ 어노테이션 사용 지양 또는 리팩토링 권장

📊 Jetpack Compose 메트릭스와 리포트로 리컴포지션 분석하기

지금까지 안정성(@Stable, @Immutable)을 확보하여 리컴포지션을 줄이는 방법을 알아봤습니다.
하지만 실제로 Compose가 내 코드를 어떻게 해석하고, 어느 부분에서 리컴포지션이 발생할 수 있다고 판단하는지는 어떻게 알 수 있을까요?

바로 그럴 때 사용하는 것이 Compose Compiler Metrics & Reports입니다.


🧩 compose-metrics와 compose-reports란?

Jetpack Compose는 컴파일 타임에 각 Composable 함수의 리컴포지션 가능 여부를 분석합니다.
이 정보를 개발자가 확인할 수 있도록 .txt 리포트 파일로 출력해주는 기능이 바로 compose-metricscompose-reports입니다.

디렉토리내용
compose-metrics/각 Composable 함수의 restartable, skippable, readonly 여부 등 메타 정보
compose-reports/리컴포지션 트리, 그룹 구조, slot 상태 등 내부 Compose 작동 흐름을 시각화한 보고서

✅ 설정 방법 (Gradle 설정)

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/ 디렉토리에 결과가 생성됩니다.


📄 compose-metrics에서 확인 가능한 정보

예: build/compose-metrics/com/example/MyComposable.txt

MyComposable.kt:12: Composable function MyComposable is restartable and skippable

  • restartable: 상태가 바뀌면 재호출될 수 있음
  • skippable: 인자가 변하지 않으면 리컴포지션 생략 가능
  • readonly: 외부 상태에 영향 없이 값만 렌더링 (side-effect 없음)

이 정보를 통해 불필요한 리컴포지션을 유발하는 구조를 식별하고 개선할 수 있습니다.


📂 compose-reports에서 확인 가능한 정보

예: build/compose-reports/com/example/MyComposable-composables.txt

  • Compose 내부의 SlotTable, Group 구조, 리컴포지션 범위 등을 분석한 정보
  • Recompose Scope가 어디까지 영향을 미치는지 확인 가능
  • UI 구조가 복잡할수록 유용한 정보

✨ 마무리

Jetpack Compose의 리컴포지션을 효과적으로 관리하려면, 다음 세 가지를 기억하세요:

  1. 안정성 확보: @Stable, @Immutable을 활용해 객체의 변경 가능성을 명확하게 정의하세요.
  2. 데이터 구조 설계: data class, 람다, 인터페이스 등에서 Compose가 추적 가능한 형태로 구조를 설계하세요.
  3. 리포트 기반 분석: compose-metrics, compose-reports를 통해 실제 리컴포지션 동작을 분석하고 최적화 포인트를 찾아보세요.

Compose는 선언형 UI라는 장점이 있지만, 내부 구조를 이해하지 못하면 퍼포먼스 이슈가 발생할 수 있습니다.
안정성과 리컴포지션 흐름을 눈으로 확인하고, Compose가 "언제", "왜" 다시 그리는지를 이해하는 것이 최적화의 시작입니다.

📌 지금 작성한 Composable이 정말로 꼭 다시 그려져야 하는지, metrics 리포트에서 직접 확인해보세요!

profile
Android Developer

0개의 댓글