아티클 목록으로 가기

Jetpack Compose의 안정성(Stability)을 활용한 앱 성능 최적화

skydovesJaewoong Eum (skydoves)||13분 소요

Jetpack Compose의 안정성(Stability)을 활용한 앱 성능 최적화

Compose의 성능 모델은 하나의 핵심 원칙에 기반합니다. 바로 불필요한 작업을 건너뛰는 것입니다. 런타임이 컴포저블의 입력값이 변경되지 않았음을 증명할 수 있으면, 해당 컴포저블의 재실행을 통째로 건너뜁니다. 이 최적화를 **스킵(skipping)**이라고 하며, Compose가 기본적으로 빠른 성능을 제공하는 핵심 원리이기도 합니다. 하지만 작은 코딩 패턴 하나가 UI 트리의 넓은 영역에서 스킵을 조용히 비활성화할 수 있으며, 적절한 도구 없이는 사용자가 프레임 드롭을 체감하기 전까지 이러한 성능 저하를 발견하기 어렵습니다.

이 글에서는 스킵을 구동하는 안정성(stability) 시스템의 원리, 컴파일러가 타입의 안정성을 추론하는 방식, 안정성을 깨뜨리는 대표적인 패턴(가변 컬렉션, var 프로퍼티, 람다 캡처, 잘못된 페이즈에서의 상태 읽기), 수정 전후 코드를 통한 실용적인 해결법, Compose Stability Analyzer를 활용한 불안정 타입 탐지 방법, 그리고 CI/CD에서 안정성 기준선(baseline)을 적용하여 프로덕션 배포 전에 성능 저하를 방지하는 방법까지 폭넓게 살펴봅니다.

근본적인 문제: 눈에 보이지 않는 리컴포지션 낭비

모든 컴포저블 함수는 자신이 읽는 상태(state)가 변경될 때마다 재실행될 수 있습니다. 상태 값이 바뀌면 Compose는 트리를 순회하면서 해당 상태에 의존하는 모든 컴포저블을 다시 실행합니다. 이 과정을 효율적으로 만들어 주는 메커니즘이 바로 스킵입니다. 컴포저블의 매개변수가 마지막 실행 시점과 비교하여 변경되지 않았다면, Compose는 해당 컴포저블을 건너뛰고 이전 출력을 그대로 재사용합니다.

스킵이 정상적으로 동작하려면 두 가지 조건이 모두 충족되어야 합니다. 첫째, 매개변수의 타입이 **안정적(stable)**이어야 합니다. 이는 해당 값의 관찰 가능한 상태가 Compose에 통지 없이 변경되지 않을 것임을 컴파일러가 보장할 수 있어야 한다는 의미입니다. 둘째, 현재 값이 equals()를 통해 이전 값과 동일하다고 판정되어야 합니다. 두 조건이 모두 충족되면 해당 컴포저블은 **스킵 가능(skippable)**으로 표시되며, 컴파일러는 매번 재실행 전에 비교 검사 코드를 생성합니다.

문제는 매개변수 타입이 **불안정(unstable)**한 경우에 발생합니다. 컴파일러가 안정성을 보장할 수 없으면, 실제 값이 변경되었는지 여부와 관계없이 해당 컴포저블을 매번 재실행할 수밖에 없습니다. 불안정한 매개변수가 단 하나만 있어도 해당 컴포저블의 스킵은 비활성화됩니다. 더 심각한 것은 이 영향이 하위 트리로 연쇄적으로 확산된다는 점입니다. 부모 컴포저블이 재실행되면 모든 자식에게 새로운 매개변수 인스턴스를 전달하게 되어, 하위 트리 전체에 걸쳐 리컴포지션이 발생합니다.

데이터를 List<Item>으로 전달하는 리스트 화면을 예로 들어 보겠습니다.

@Composable
fun ItemList(items: List<Item>) {
    LazyColumn {
        items(items) { item ->
            ItemCard(item)
        }
    }
}

List는 코틀린 인터페이스입니다. MutableListList를 구현하고 있기 때문에, 컴파일러는 실제 구현체가 불변인지 증명할 수 없습니다. 결과적으로 items는 불안정한 매개변수가 되며, Strong Skipping Mode가 비활성화된 상태에서는 컴파일러가 ItemList에 대한 스킵 검사 코드를 생성할 수 없습니다. 이 경우 부모에서 어떤 상태가 변경되든, 리스트 내용이 실제로 바뀌지 않았더라도 전체 리스트가 리컴포지션됩니다.

실무에서 이런 상황을 만나면 '리스트가 왜 이렇게 버벅이지?'라고 느끼게 되는데, 바로 이러한 보이지 않는 리컴포지션 낭비가 원인인 경우가 많습니다.

컴파일러의 안정성 판정 방식

Compose 컴파일러는 컴포저블 매개변수로 사용되는 모든 타입을 분석하여 안정성 분류(stability classification)를 부여합니다. 이 분류 방식을 이해하면, 특정 패턴이 왜 성능 문제를 일으키는지 파악하고 효과적으로 수정할 수 있습니다.

기본적으로 안정적인 타입: 원시 타입(Int, Boolean, Float 등), String, Unit, 함수 타입, enum class는 모두 본질적으로 안정적입니다. 컴파일러는 별도의 어노테이션 없이도 이러한 타입을 안정적으로 인식합니다.

**모든 val 프로퍼티가 안정적인 data class**는 안정적으로 추론됩니다. 클래스의 모든 프로퍼티가 val로 선언되어 있고 각 프로퍼티의 타입 자체도 안정적이면, 컴파일러가 자동으로 해당 클래스를 안정적으로 표시합니다.

// 모든 프로퍼티가 val이며 원시 타입/String이므로 stable
data class User(val name: String, val age: Int)

기본적으로 불안정한 타입: var 프로퍼티를 하나라도 가진 클래스는 즉시 불안정합니다. Compose에 통지 없이 값이 변경될 수 있기 때문입니다. 코틀린 표준 라이브러리의 컬렉션 인터페이스(List, Set, Map)도 가변 구현체가 뒤에 있을 수 있으므로 불안정합니다. Compose 컴파일러가 처리하지 않은 외부 모듈의 타입 역시 기본적으로 불안정한 것으로 간주됩니다.

컴파일러는 안정성 정보를 각 클래스에 생성되는 $stable 정적 필드에 인코딩합니다. 이 필드는 런타임이 안정성을 판단할 때 읽는 비트마스크(bitmask) 값입니다.

// 컴파일러가 생성하는 코드
@StabilityInferred(parameters = 0)
data class User(val name: String, val age: Int) {
    companion object {
        val `$stable`: Int = 0  // 0 = stable (안정적)
    }
}

제네릭 타입의 경우, 비트마스크는 어떤 타입 매개변수가 안정성에 영향을 미치는지를 인코딩합니다. 가령 Wrapper<T> 클래스는 T가 안정적일 때에만 안정적이며, 비트마스크에 이 의존성이 기록됩니다. 이렇게 해 두면 호출 시점에 구체적인 타입 인자가 확정될 때 컴파일러가 안정성을 올바르게 결정할 수 있습니다.

Compose Stability Analyzer를 활용한 불안정 타입 탐지

프로젝트 전체에서 안정성을 수동으로 추론하는 것은 현실적으로 불가능합니다. Compose Stability Analyzer는 코드를 작성하는 에디터 단계, 테스트 중인 런타임 단계, 병합 전 CI 단계 등 모든 수준에서 불안정 타입을 시각적으로 확인할 수 있는 도구를 제공합니다.

IDE 플러그인: 실시간 피드백

Compose Stability Analyzer Android Studio IDE 플러그인은 에디터에서 모든 컴포저블 함수 옆에 거터 아이콘(gutter icon)을 표시합니다. 초록색 점은 해당 컴포저블이 스킵 가능(모든 매개변수가 안정적)함을 의미하고, 노란색 점은 안정성이 런타임에 결정됨(제네릭 매개변수)을 나타내며, 빨간색 점은 해당 컴포저블이 스킵 불가능하여 항상 재실행됨을 의미합니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기