Jetpack Compose 성능 최적화를 위한 Stability 이해하기

skydoves·2024년 6월 10일
23

원문은 Optimize App Performance By Mastering Stability in Jetpack Compose에서 확인하실 수 있습니다.

이 포스트의 내용을 심층적으로 다루는 발표(Compose 성능 최적화를 위한 Stability 마스터하기)가 2024 DroidKnights 에서 진행될 예정입니다. 해당 발표를 통해 Jetpack Compose의 구조 및 역사와 Stability 메커니즘에 대하여 더 자세히 학습하실 수 있습니다.

저자가 운영 중인 구독형 리파지토리인 Dove Letter를 통해 Android와 Kotlin에 대한 다양한 소식과 학습자료를 접하실 수 있습니다. 관심 있으신 분들은 Dove Letter를 방문해 주시길 바랍니다.

Google에서 개발한 모던 안드로이드 UI 툴킷인 Jetpack Compose는 1.0 stable 릴리스 이후 산업에서 다양한 가능성을 보여주었습니다. Google에서 보고한 바와 같이 Google Play 스토어에 출시된 앱 중 125,000개의 앱이 Jetpack Compose를 활용하여 성공적으로 앱을 출시하였고, 최근들어 생산성 향상 목적으로서의 채택이 급증하고 있습니다.

Jetpack Compose에는 최적화 기능이 내장되어 있지만, 개발자는 Compose가 UI 요소를 렌더링하는 방법과 다양한 시나리오에서 Jetpack Compose의 성능을 최적화하기 위한 전략을 이해하는 것이 좋습니다. 이를 통해 잠재적으로 애플리케이션의 성능에 영향을 미치는 요인들을 최소화하고, 더 나은 사용자 경험을 기대하실 수 있습니다.

이 포스트에서는 Jetpack Compose의 내부 동작 방식과 안정성(Stability) 메커니즘을 이해하여 애플리케이션 성능을 향상시키는 방법을 안내합니다.

Jetpack Compose 페이즈 (Jetpack Compose Phases)

Stability에 대하여 살펴보기 전에, Compose UI가 어떤 단계를 통해서 노드를 화면에 렌더링하는지 간략하게나마 파악하는 것이 중요합니다.

Jetpack Compose는 프레임에 대한 렌더링을 아래 세 가지 단계에 걸쳐 실행합니다.

  • 구성(Composition): 이 단계에서는 Composable 함수의 설명을 생성하고 여러 메모리 슬롯을 할당하는 과정이 시작됩니다. 여기서 생성된 슬롯들은 각 Composable 함수를 메모이즈하여(memoize) 런타임 동안 효율적인 호출 및 실행을 가능하게 합니다.

  • 레이아웃(Layout): 레이아웃 단계에서는 Composable 트리 내에서 각 Composable 노드의 위치가 설정됩니다. 레이아웃 단계는 주로 각 Composable 노드의 측정 및 적절한 배치를 포함하며, UI의 전체 구조 내에서 모든 요소가 정확하게 배치되도록 보장합니다.

  • 그리기(Drawing): 마지막 단계에서는 Composable 노드가 캔버스(일반적으로 기기의 화면)에 렌더링됩니다. Composable 함수들에 대한 정보를 사용하여 사용자 상호작용이 가능하도록 시각적 표현을 생성하는 단계입니다.

내부 메커니즘은 훨씬 더 복잡하지만 기본적으로 Composable 함수를 작성할 때 위와 같은 단계를 거쳐 화면에 나타납니다.

여기서 이미 렌더링이 끝난 Composabla 함수의 크기 및 색상과 같은 UI 요소를 수정한다고 가정해 보겠습니다. 그리기(Drawing) 단계가 이미 완료되었으므로, Compose는 새로운 업데이트 사항을 적용하기 위해 첫 번째 과정인 구성(Composition) 부터 다시 실행해야합 니다. 이 과정을 통틀어 recomposition(재구성) 이라고 부릅니다.

Recomposition은 입력(매개변수)의 변화에 반응하여 Composable 함수를 새롭게 실행할 때 발생하며, 이 과정은 구성(Composition) 단계부터 시작합니다. Recomposition은 상태(State) 관찰과 같은 다양한 요인에 의해 트리거될 수 있으며, 내부적으로는 Compose 런타임 및 컴파일러 메커니즘과 밀접하게 연관되어 있습니다.

예상할 수 있듯이, 전체 UI 트리와 그 요소들을 다시 구성하는 것은 상당히 computational한 자원을 요구하며, 이는 앱의 성능에 직접적으로 좋지 않은 영향을 미칩니다. 불필요한 경우 recomposition을 건너뛰고 필요한 경우에만 recomposition을 트리거함으로써 오버헤드를 최소화하고 UI 성능을 향상시킬 수 있습니다.

따라서, recomposition 과정에 대한 깊은 이해와 Compose 런타임의 작동 방식, recomposition을 건너뛸 수 있는 조건들을 식별하고, 리컴포지션을 트리거하는 요인을 잘 이해하는 것으로 Compose의 성능 최적화를 기대하실 수 있습니다.

이제 Stability의 개념과 recomposition 비용을 최적화하여 애플리케이션 성능을 향상시키는 방법을 살펴보도록 하겠습니다.

안정성 이해하기 (Understanding Stability)

이전 섹션에서 설명한 대로, 이미 렌더링된 UI를 업데이트하기 위해 recomposition을 트리거하는 여러 방법이 있습니다. Composable 함수의 매개변수 안정성은 Compose 런타임 및 컴파일러의 작동과 깊이 관련된 recomposition을 유발하는 중요한 요소입니다.

Compose 컴파일러는 Composable 함수의 매개변수를 안정(stable)과 불안정(unstable) 두 가지 상태로 분류합니다. 매개변수의 안정성 분류는 Compose 런타임에 의해 Composable 함수가 recomposition을 거쳐야 하는지 등의 여부를 결정하는 데 사용됩니다.

Stable vs. Unstable

이제 매개변수가 어떤 기준으로 안정적이거나 불안정한 것으로 분류되는지 궁금할 수 있습니다. 이 추론은 Compose 컴파일러에 의해 이루어집니다. 컴파일러는 Composable 함수에서 사용되는 매개변수의 유형을 검사하고 아래의 기준에 따라 안정성 여부를 분류합니다.

  • String을 포함한 기본 유형(Primitive)은 본질적으로 안정적입니다.
  • (Int) -> String과 같은 람다 표현식으로 표현된 함수 유형은 안정적인 것으로 간주됩니다. (엄밀하게 말하자면, 람다에서 unstable한 값을 캡처하는 경우 잠재적인 unstable로 분류되는데, 이 내용은 뒤에서 다루고 있습니다)
  • 모든 public property가 불변이고 (value로 정의된 경우) stable한 속성을 가진 클래스
  • @Stable, @Immutable과 같은 stability 어노테이션을 사용하여 명시적으로 표기된 클래스는 안정적인 것으로 간주됩니다. 어노테이션에 대한구체적인 내용은 다음 섹션에서 살펴보겠습니다.

가령, 아래와 같은 데이터 클래스를 떠올려보실 수 있습니다.

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

안정적으로 간주되는 primitive 타입과 불변인 value로 정의된 public property만을 가지는 User 데이터 클래스는 Compose 컴파일러에 의해 안정적인 것으로 간주됩니다.

반면, Compose 컴파일러는 Composable 함수 내의 매개변수 유형을 추론하여 아래 기준에 따라 불안정한 것으로 간주합니다.

  • Kotlin의 collections에서 제공하는 List, Map 등을 포함한 모든 인터페이스와 Any 타입과 같은 추상 클래스는 컴파일 시 구현(implementation)을 예측할 수 없기, 때문에 불안정한 것으로 간주됩니다.
  • 하나 이상의 가변적이거나 본질적으로 불안정한 public property를 포함하는 클래스는 불안정한 것으로 분류됩니다.

가령, 아래와 같은 데이터 클래스를 떠올려볼 수 있습니다.

data class User(
  val id: Int,
  var name: String,
)

User 데이터 클래스가 stable로 간주되는 primitive 타입으로 구성되어 있음에도 불구하고, 가변적인 name property가 존재하기 때문에 Compose 컴파일러는 이를 불안정한 것으로 간주합니다. 이러한 클래스의 안정성 추론은 모든 property의 전체적인 안정성을 평가함으로써 결정되기 때문에, 단 하나의 가변 property만으로도 클래스 전체를 불안정하게 만들 수 있습니다.

스마트 Recomposition (Smart Recomposition)

안정성 원칙과 Compose 컴파일러가 안정한 타입과 불안정한 타입을 구별하는 방법을 살펴보았는데, 이러한 추론이 실질적으로 recomposition을 트리거하는 데 어떻게 사용되는지 궁금할 수 있습니다. Compose 컴파일러는 Composable 함수의 각 매개변수의 안정성을 평가하여 Compose 런타임이 이 정보를 효율적으로 활용할 수 있는 토대를 마련합니다.

클래스의 안정성이 결정되면, Compose 런타임은 스마트 recomposition이라고 알려진 내부 메커니즘을 통해 recomposition을 시작합니다. 스마트 recomposition은 제공된 안정성 정보를 활용하여 불필요한 recomposition을 선택적으로 건너뛰어 Compose의 전체 성능을 향상시킵니다.

스마트 recomposition이 작동하는 몇 가지 원칙은 아래와 같습니다.

  • 안정성에 따른 결정: 매개변수가 안정적이고 그 값이 변경되지 않은 경우(equals()true를 반환), Compose는 관련 UI 컴포넌트의 recomposition을 건너뜁니다. 매개변수가 불안정하거나, 안정적이지만 그 값이 변경된 경우(equals()false를 반환), 런타임은 recomposition을 시작하여 UI 레이아웃을 무효화(invalidate)하고 다시 그립니다.
  • 동등성 검사: 위에서 설명한 equals() 함수를 통한 동등성 비교는 해당 타입이 안정적으로 간주되는 경우에만 수행합니다. 새로운 입력값이 Composable 함수에 전달될 때마다, 항상 해당 타입의 equals() 메서드를 사용하여 이전 값과 비교합니다.

위의 시나리오에서 불필요한 recomposition을 피하면 Compose 성능을 향상시킬 수 있습니다. 전체 UI 트리를 recomposition하는 것은 상당한 비용을 필요로 하고, 적절히 처리하지 않으면 성능에 부정적인 영향을 미칠 수 있습니다.

Jetpack Compose는 본질적으로 스마트 recomposition을 지원하지만, 개발자들은 Composable 함수에서 사용되는 클래스들을 안정적으로 만들고 recomposition을 최대한 줄이는 방법을 철저히 이해하는 것이 중요합니다.

Composable 함수 추론하기 (Inferring Composable Functions)

이제 Compose 컴파일러가 클래스 안정성을 결정하는 방법과 Compose 런타임이 스마트 recomposition이라는 내부 메커니즘을 통해 Stability에 대한 정보를 활용하는 방법을 이해하게 되었습니다. 여기서 이해해야 할 또 하나의 중요한 개념은 Composable 함수의 유형을 추론하는 것입니다.

Compose 컴파일러는 Kotlin Compiler 플러그인으로 구축되어 있어, 개발자가 작성한 소스 코드를 컴파일 시 분석할 수 있습니다. 또한, Composable 함수의 고유한 특성에 더 잘 맞도록 개발자가 작성한 원본 소스 코드를 조정할 수 있습니다.

컴파일러는 Composable 함수들을 Restartable, Skippable, Moveable, Replaceable 등 여러 그룹으로 분류하여 실행을 최적화합니다. 이번 글에서는 recomposition에 중요한 역할을 하는 RestartableSkippable 유형에 대해 자세히 살펴보겠습니다.

재실행 가능한 (Restartable)

Restartable은 Compose 컴파일러에 의해 추론된 Composable 함수의 유형이며, recomposition 프로세스의 초석을 제공합니다. 앞서 살펴본 바와 같이, Compose 런타임이 입력의 변화를 감지하면, 이 새로운 입력을 반영하기 위해 함수를 다시 시작(또는 재호출)합니다.

특정한 어노테이션으로 Composable 함수를 명시적으로 주석 처리하지 않아도, 대부분의 함수는 기본적으로 restartable로 간주됩니다. 이는 입력이나 상태가 변경될 때마다 Compose 런타임이 Composable 함수에 대해 recomposition을 트리거할 수 있다는 것을 의미합니다.

생략 가능한 (Skippable)

Skippable은 Compsable 함수의 또 다른 특성을 나타내며, 이전 섹션에서 논의한 스마트 recomposition에 의해 설정된 적절한 조건 하에서 recomposition 프로세스를 완전히 건너뛸 수 있습니다. 따라서 skippable한 Composable 함수는 특정 상황에 따라 recomposition을 건너뛰고 UI 성능을 향상시킬 가능성과 직접적인 연관성이 있다고 볼 수 있습니다.

이 유형은 특히 함수 호출의 방대한 계층 구조의 정점에 위치한 루트 Composable 함수의 성능을 향상시키는 데 중요합니다. 이러한 루트 Composable 함수의 recomposition을 건너뜀으로써, Compose는 계층 구조의 하위 함수를 호출할 필요성을 효과적으로 제거하여 전체 recomposition 프로세스를 간소화합니다.

Composable 함수는 동시에 restartable과 skippable이 될 수 있다는 점 또한 흥미롭습니다. skippable이라는 것은 해당 함수가 restartable한 Composable 함수에 대해 recomposition을 건너뛸 수 있음을 의미하기 때문입니다. 이제 작성한 Composable 함수가 restartable인지 skippable인지 확인하는 방법을 살펴보겠습니다.

컴포즈 컴파일러 메트릭 (Compose Compiler Metrics)

Compose Compiler 플러그인을 사용하면 Compose가 가진 고유한 개념에 초점을 맞춘 상세한 보고서와 메트릭을 생성할 수 있습니다. 이를통해 Compose 코드의 개선점을 파악하고, 미시적인 수준에서 코드의 동작을 정확히 이해하는 데 유용합니다.

Compose 컴파일러 메트릭을 생성하려면 아래 예제와 같이 루트 모듈의 build.gradle 파일에 컴파일러 옵션을 추가하기만 하면 됩니다.

subprojects {
  tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {
    kotlinOptions.freeCompilerArgs += listOf(
      "-P",
      "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
        project.buildDir.absolutePath + "/compose_metrics"
    )
    kotlinOptions.freeCompilerArgs += listOf(
      "-P",
      "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
        project.buildDir.absolutePath + "/compose_metrics"
    )
  }
}

안드로이드 스튜디오에서 프로젝트를 동기화하고 빌드한 후, /build/compose_metrics 디렉터리에 생성된 세 개의 파일(module.json, composable.txt, classes.txt)에 접근할 수 있습니다. 이 파일 각각에 대해 자세히 살펴보겠습니다.

Top Level Metrics (modules.json)

이 보고서는 Compose에 특화된 고수준 메트릭을 제공하며, 주로 관리하는 프로젝트의 규모가 커짐에 따라 추적할 수 있는 Composable 함수의 특성을 numerical한 데이터로 지표 화하는 데 중점을 둡니다. 이러한 메트릭은 유용한 정보를 제공할 수 있습니다. 예를 들어, "skippableComposables"와 "restartableComposables"의 수를 비교하면 recomposition을 건너뛸 수 있는 Composable 함수의 비율을 퍼센티지로 얻을 수 있습니다.

아래는 foundation 모듈에 대한 샘플 보고서입니다.

{
 "skippableComposables": 36,
 "restartableComposables": 41,
 "readonlyComposables": 6,
 "totalComposables": 60,
 "restartGroups": 41,
 "totalGroups": 82,
 "staticArguments": 25,
  "certainArguments": 138,
  "knownStableArguments": 377,
  "knownUnstableArguments": 25,
  "unknownStableArguments": 24,
  ..

Composable Signatures (composables.txt)

이 보고서는 사람의 가독성을 높이기 위해 의사 코드 스타일로 함수의 시그니처를 나타냅니다. 모듈 내 모든 composable 함수를 상세히 분석하며 각 매개변수에 대한 구체적인 정보를 제공합니다.

보고서는 전체 Composable 함수가 재시작 가능(restartable), 건너뛰기 가능(skippable), 또는 읽기 전용(read-only)으로 분류되는지 여부를 식별합니다. 또한, 각 매개변수가 안정적(stable)인지 불안정적(unstable)인지, 그리고 각 기본 매개변수 표현식이 정적(static)인지 동적(dynamic)인지를 표시하여 Composable의 특성을 포괄적으로 개요합니다.

주로 이러한 시그니처는 Composable 함수가 Skippable 한지(건너뛰기 가능한지) 여부를 분석하고, 어떤 매개변수가 불안정하여 함수가 건너뛰기 가능하지 않게 하는지를 식별하는 데 사용됩니다.

아래는 Composable 함수에 대한 샘플 보고서입니다.

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Avatar(
  stable modifier: Modifier? = @static Companion
  stable imageUrl: String? = @static null
  stable initials: String? = @static null
  stable shape: Shape? = @dynamic VideoTheme.<get-shapes>($composer, 0b0110).circle
  stable textSize: StyleSize? = @static StyleSize.XL
  stable textStyle: TextStyle? = @dynamic VideoTheme.<get-typography>($composer, 0b0110).titleM
  stable contentScale: ContentScale? = @static Companion.Crop
  stable contentDescription: String? = @static null
)

Classes (classes.txt)

이 보고서 또한 사람의 가독성을 높이기 위해 의사 코드 스타일의 함수 시그니처를 사용합니다. 이 파일은 특정 클래스에 대해 안정성 추론 알고리즘이 어떻게 해석했는지 이해하는 데 도움이 되도록 설계되었습니다. 최상위 수준에서 각 클래스는 안정적(stable), 불안정적(unstable), 또는 런타임(runtime)으로 분류됩니다. "런타임"은 안정성이 다른 종속성에 의해 좌우되며, 이는 런타임 시 결정됨을 의미합니다 (가령, 타입 매개변수나 외부 모듈의 타입).

안정성 평가 기준은 클래스의 property에 기반하며, 각 property는 클래스 아래 나열되고 안정적, 불안정적, 또는 런타임 안정성(runtime stable)으로 표시됩니다. 최하단에는 런타임 시 이 안정성을 결정하는 데 사용된 "표현식"이 표시되어, 각 클래스의 안정성이 어떻게 평가되었는지 포괄적인 개요를 제공합니다.

stable class StreamShapes {
  stable val circle: Shape
  stable val square: Shape
  stable val button: Shape
  stable val input: Shape
  stable val dialog: Shape
  stable val sheet: Shape
  stable val indicator: Shape
  stable val container: Shape
}

컴포즈 컴파일러 메트릭을 생성하는 과정을 살펴보았고, 각 파일의 중요성을 이해하였습니다. 이 주제와 관련하여 더 많은 내용을 학습하고 싶으시다면, Interpreting Compose Compiler Metrics를 살펴보시길 바랍니다.

안정성 어노테이션 (Stability Annotations)

이제까지 컴포즈 컴파일러가 안정성을 처리하는 방식과 이러한 안정성 추론이 recomposition 및 잠재적으로 애플리케이션 성능에 어떻게 영향을 미치는지에 대해 살펴보았습니다.

Compose 런타임 라이브러리의 안정성 어노테이션을 사용하여 불안정한 클래스를 안정된 클래스로 변환하는 방법을 살펴보도록 하겠습니다. 클래스를 안정적이라고 간주되도록 표기할 수 있는 주요 어노테이션은 @Immutable@Stable 두 가지가 있습니다.

Immutable

@Immutable 어노테이션은 컴포즈 컴파일러에게 클래스의 모든 public property가 초기 생성 후 변경되지 않는다는(불변) 강력한 보증을 제공합니다. 이는 언어 수준에서 제공되는 val 키워드보다 더 엄격한 보증을 나타냅니다. val은 property가 setter를 통해 다시 할당될 수 없음을 보장하지만, 여전히 MutableList로 초기화된 List와 같은 가변 데이터 구조로 생성될 가능성을 허용합니다.

클래스가 @Immutable 어노테이션을 사용하여 효과적으로 안정성을 갖도록 하기 위해서는 아래 규칙을 준수하는 것이 중요합니다.

  1. 모든 public property에 val 키워드를 사용하여 불변성을 보장합니다.
  2. 커스텀 setter를 피하고 public property가 가변성을 지원하지 않도록 합니다.
  3. 모든 public property의 타입이 본질적으로 불변으로 간주되어도 되는지 확인합니다. 가령 Kotlin의 collection을 property로 사용하는 경우, 클래스가 생성된 이후 해당 collection에는 절대 수정이 발생하지 않는다는 확신이 서는 경우에 사용해야 합니다.

@Immutable 어노테이션은 위의 불변성 규칙을 준수하는 클래스에 효과적이며, 불필요한 recomposition을 건너뛰어 애플리케이션 성능을 향상시키는 데 중요한 역할을 합니다.

한편으로 @Immutable 어노테이션을 신중하게 적용하는 것이 중요합니다. 부적절하게 사용하면 recomposition을 의도하지 않게 건너뛰게 되어 Compose 레이아웃이 예상과는 다르게 업데이트되지 않을 수 있습니다.

Stable

@Stable 어노테이션 또한 컴포즈 컴파일러와의 강력한 약속이지만, @Immutable 어노테이션보다 느슨한 약속을 의미합니다. 함수나 클래스에 @Stable 어노테이션을 적용하면 해당 타입이 변경 가능하다는 것을 의미합니다. 처음에는 다소 역설적으로 보일 수 있지만, 여기서 "Stable"이라는 용어는 동일한 입력에 대해 항상 동일한 결과를 반환하여 예측 가능한 동작을 보장한다는 의미입니다.

따라서 @Stable 어노테이션은 public property는 불변이지만 클래스 자체는 안정적으로 간주될 수 없는 클래스에 가장 적합합니다. 예를 들어, Jetpack Compose의 State 인터페이스는 value라는 불변 속성만을 노출합니다. 그러나 기본 값은 일반적으로 MutableState를 통하여 생성되고, setValue 함수를 통해 여전히 수정될 수 있습니다.

StateMutableState의 예에서 볼 수 있듯이, MutableState에 의해 생성된 State 인스턴스는 getValue 함수(value 속성의 getter)를 통해 동일한 값을 일관되게 가져오며, setValue 함수에 동일한 입력을 제공하면 동일한 결과를 반환합니다. 아래 제공된 코드에서, StableMutableState 인터페이스는 아래와 같이 @Stable 어노테이션으로 표기되어 있습니다.

@Stable
interface State<out T> {
    val value: T
}

@Stable
interface MutableState<T> : State<T> {
    override var value: T
    operator fun component1(): T
    operator fun component2(): (T) -> Unit
}

Immutable vs. Stable

@Immutable 어노테이션과 @Stable 어노테이션의 차이점이나 언제 사용해야 할지 처음에는 혼란스러울 수 있지만 생각보다 간단합니다. 앞서 언급한 것처럼 @Immutable 어노테이션은 클래스의 모든 public property가 불변임을 의미하며, 이는 객체가 생성된 후 상태가 변경될 수 없음을 나타냅니다. 반면 @Stable 어노테이션은 가변성이 있는 객체에 적용될 수 있으며, 동일한 입력에 대해 일관된 결과를 생성해야 한다는 것을 요구합니다.

@Immutable 어노테이션은 특히 Kotlin 데이터 클래스를 사용할 때 비즈니스 모델에 주로 사용됩니다. 아래 예제에서 이를 확인할 수 있습니다.

@Immutable
public data class User(
  public val id: String,
  public val nickname: String,
  public val profileImages: List<String>,
)

반대로, @Stable 어노테이션은 여러 구현 가능성을 제공하고 내부적으로 변경 가능한 상태를 가질 수 있는 인터페이스에 주로 사용됩니다. 다음 예는 이 어노테이션을 이해하는 데 도움이 됩니다.

@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?
  
    val hasSuccess: Boolean
        get() = exception == null
}

@Stable 어노테이션을 적용하면 UiState 클래스를 안정적으로 지정할 수 있습니다. 이는 최적화된 건너뛰기와 스마트 recomposition을 가능하게 하여 Compose 성능을 향상시킵니다.

NonRestartableComposable

Jetpack Compose의 @NonRestartableComposable 어노테이션은 특정 Composable 함수의 recomposition 동작을 최적화하기 위한 역할을 담당합니다. 이 어노테이션은 컴포즈 컴파일러에게 Composable 함수가 호출 매개변수의 변경으로 인한 recomposition 프로세스 중에 자동으로 다시 시작되지 않아야 함을 알립니다. 일반적으로 Composable 함수의 입력이 변경되면 컴포즈 런타임은 함수의 UI 출력을 새로운 입력에 맞게 조정하기 위해 함수를 다시 시작합니다.

그러나 이러한 Composable 함수의 재시작이 항상 필요하거나 바람직한 것은 아닙니다. 함수의 내부 상태나 사이드 이펙트가 recomposition 프로세스에서도 일관되게 유지되어야 하는 경우에 특히 그렇습니다. @NonRestartableComposable을 Composable 함수에 적용하면 런타임에게 함수를 다시 시작하지 않고 매개변수를 업데이트하도록 지시하여 내부 상태와 진행 중인 사이드 이펙트를 유지할 수 있습니다.

@NonRestartableComposable이 사용된 대표적인 예는 Compose 런타임 라이브러리의 Side-effect API 내에서 찾을 수 있습니다. 예를 들어, LaunchedEffect의 구현은 이 어노테이션을 사용하여 불필요하게 이펙트가 다시 시작되지 않도록 합니다. 아래 예시를 통해서 살펴볼 수 있습니다.

@Composable
@NonRestartableComposable
fun LaunchedEffect(
    key: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    // Implementation
}

불필요한 재시작을 방지함으로써, @NonRestartableComposable은 특히 사이드 이펙트나 recomposition을 통해 지속되어야 하는 상태와 관련된 시나리오에서 효율성과 일관성을 유지하는 데 도움이 됩니다. 그러나 @NonRestartableComposable 어노테이션을 사용할 때는 신중해야 하며, 단순히 앱 성능을 향상시키기 위한 수단으로 무분별하게 사용해서는 안 됩니다. 무분별한 사용은 바람직하지 않은 결과를 초래할 수 있습니다.

Composable 함수 안정화 (Stabilize Composable Functions)

애플리케이션 성능 최적화를 목표로 안정된 클래스를 작성하는 방법에 대해서 살펴보았습니다. 그러나 Composable 함수의 완전한 안정성을 달성하기 위해서는 Kotlin의 collections나 서드파티 라이브러리와 같이 개발자가 제어할 수 없는 외부 클래스의 제약을 넘어설 필요가 있습니다.

이전에 살펴본 바와 같이, 스마트 recomposition 중에 Composable 함수를 건너뛸 수 있는 능력은 각 매개변수의 안정성에 따라 결정됩니다. 스마트 recomposition을 최적화하려면 Composable 함수 내에서 사용되는 모든 매개변수가 안정적인지 확인하는 것이 중요합니다.

이 섹션에서는 효율적인 recomposition을 통해 성능을 향상시키기 위해 Composable 함수를 건너뛰게 만드는 네 가지의 전략에 대해 살펴볼 예정입니다.

Immutable Collections

처음에 Kotlin의 컬렉션, 특히 kotlin.collections 인터페이스가 Jetpack Compose에서 왜 불안정하다고 간주되는지 의문을 가질 수 있습니다. 특히 List가 내부 요소에 대한 수정을 허용하지 않는데도 불안정하다고 간주되기 때문입니다.

그 이유를 이해하기 위해 아래 예시를 살펴보겠습니다.

internal var mutableUserList: MutableList<User> = mutableListOf()
public val userList: List<User> = mutableUserList

userList 필드는 List로 선언되어 있어 본질적으로 요소의 수정을 허용하지 않습니다. 그러나 첫 번째 줄에서 볼 수 있는 것처럼, 해당 ListmutableListOf()로 인스턴스화되어 수정 가능한 타입의 리스트일 수 있습니다. 이는 List 인터페이스 자체가 수정을 제한하지만, 그 구현체는 수정 가능할 수 있다는 것을 의미합니다. Compose 컴파일러는 컴파일 타임에 구현 타입을 추론할 수 없기 때문에 이러한 인스턴스를 불안정한 것으로 처리하여 정확한 동작을 보장해야 합니다.

따라서 Android 공식 문서에서는 Composable 함수의 컬렉션 매개변수의 안정성을 보장하기 위해 kotlinx.collections.immutable 라이브러리 또는 Guava의 Immutable Collections를 사용하는 것을 권장합니다.

kotlinx.collections.immutable 라이브러리는 ImmutableListImmutableSet과 같은 다양한 컬렉션을 제공하며, 이는 표준 kotlin.collections의 동작을 반영하지만 가장 큰 차이점은 이들이 불변(immutable)이라는 점입니다. 이러한 컬렉션은 읽기 전용(read-only)이며, 생성 후에는 수정이 발생할 수 없습니다.

이제 Compose 컴파일러가 kotlinx.collectionskotlinx.collections.immutable를 왜 안정적으로 처리하는지 궁금할 수 있습니다. 여기에는 Compose 컴파일러가 불변 컬렉션을 이해하는 방식에 있습니다.

컴파일러가 해당 컬렉션들을 안정적이라고 간주하는 이유를 더 깊게 이해하기 위해 Compose 컴파일러 라이브러리의 KnownStableConstructs.kt 파일을 참조해보실 수 있습니다. 아래 코드에서 볼 수 있듯이, Compose 컴파일러는 안정적으로 간주해야 하는 클래스의 패키지 이름 목록을 수동으로 정의하고 있습니다.

package androidx.compose.compiler.plugins.kotlin

val knownStableClasses = listOf(
    "kotlin.collections.ImmutableList",
    "kotlin.collections.ImmutableSet",
    ...
)

아래 코드는 Composable 함수의 매개변수 안정성을 분석하는 Compose 컴파일러의 일부입니다. 해당 코드는 KnownStableConstructs 클래스에 나열된 매개변수 타입의 안정성을 컴파일러가 따로 추론하지 않고 곧 바로 안정적으로 간주한다는 점을 명확히 보여줍니다.

val fqName = declaration.fqNameWhenAvailable?.toString() ?: ""
val typeParameters = declaration.typeParameters
val stability: Stability
val mask: Int
if (KnownStableConstructs.stableTypes.contains(fqName)) {
    mask = KnownStableConstructs.stableTypes[fqName] ?: 0
    stability = Stability.Stable
} else
  // infer stability
}

Lambda

Compose 컴파일러가 Kotlin의 람다 표현식을 처리할 때는 조금 독특한 접근 방식을 취합니다. 앞서 살펴본 바와 같이, Compose 컴파일러는 IR(Intermediate Representation) 변환을 통해 개발자가 작성한 소스 코드를 수정합니다. 따라서 컴파일러는 특정 규칙을 생성하여 람다 표현식의 실행을 최적화하는 방법을 Compose 런타임에 알려줍니다.

Compose 컴파일러는 람다 표현식이 값을 캡처하는지 여부에 따라 이를 구분하여 처리합니다. 클로저의 맥락에서 값을 캡처한다는 것은 람다 표현식이 자신의 범위 외부의 변수에 의존한다는 것을 의미합니다. 반면, 람다가 외부 변수에 의존하지 않으면 값을 캡처하지 않는다고 할 수 있습니다. 아래 예제를 통해 살펴볼 수 있습니다.

modifier.clickable {
    Log.d("Log", "This Lambda doesn't capture any values")
}

람다 매개변수가 어떠한 값도 캡처하지 않으면, Kotlin은 이러한 람다를 싱글톤으로 취급하여 불필요한 할당을 최소화합니다. 반면, 클로저 외부의 변수에 의존하는 람다는 값을 캡처하는 것으로 간주됩니다. 아래 예제를 통해 살펴볼 수 있습니다.

var sum = 0
ints.filter { it > 0 }.forEach {
    sum += it
}

람다 매개변수가 외부 값을 캡처하면, 그 실행 결과는 캡처된 값에 따라 달라질 수 있습니다. 이를 해결하기 위해 Compose 컴파일러는 기억(memorization) 전략을 사용하여 람다를 remember 함수 호출 내에 캡슐화합니다. 캡처된 값은 rememberkey 매개변수로 사용되어, 캡처된 값이 변경될 때마다 람다가 적절하게 다시 호출되도록 보장합니다.

따라서 람다가 값을 캡처하든 하지 않든, Compose 컴파일러 매트릭 상으로 이는 Composable 함수 내에서 안정적인 것으로 표기됩니다. 하지만 실제 런타임에서는 캡처하는 값이 unstable이라면 해당 Composable 함수가 skippable임에도 recomposition을 수행할 여지가 있습니다.

예를 들어, Composable 함수가 Any 타입의 매개변수를 받는 상황을 고려해봅시다. Any는 불변 객체를 포함하여 다양한 값을 포괄할 수 있기 때문에, Compose 컴파일러는 이를 불안정한 것으로 간주합니다.

@Composable
fun MyComposable(model: Any?) {
  ..
}

// compose compiler metrics
[androidx.compose.ui.UiComposable]]") fun MyComposable(
  unstable model: Any?,
  ..

하지만 아래 예제와 같이 람다 표현식을 사용하여 값을 제공하면 Compose 컴파일러는 일단 람다 매개변수를 안정적인 것으로 처리합니다.

@Composable
fun MyComposable(model: () -> Any?) {
  ..
}

// compose compiler metrics
[androidx.compose.ui.UiComposable]]") fun MyComposable(
  stable model: Function0<Any?>,
  ..
)

여기서 한 가지 주목해야할 부분은, model: () -> Any? 람다식이 어떠한 값을 캡처하느냐에 따라 Composable 함수가 skippable 함에도 런타임에서 recomposition을 수행할지 수행하지 않을지가 결정됩니다. 가령 아래와 같이 갭처하는 값이 stable로 간주되는 경우는 recomposition을 생략합니다.

MyComposable {
  "something string"
}

반면에, androidx의 ViewModel과 같이 unstable로 간주되는 값을 캡처하는 경우는 람다식이 stable로 간주되었음에도 recomposition을 생략하지 않고 그대로 수행됩니다.

MyComposable {
  viewmodel.fetchSomething()
}

이는 예측 된 결과인데, 람다식 또한 결국 하나의 함수(Function)로 컴파일되고 캡처하는 값이 해당 함수의 매개변수로 사용되기 때문에, recomposition이 발생하면 해당 람다식은 새로운 값으로 할당되는 과정에서 unstable한 매개변수로 인해 값이 다르다고 판단하고 recomposition을 생략하지 않는 것입니다. 이를 해결하는 방법은 람다식 자체를 remember를 사용하여 적절하게 감싸고(함수의 함수) 안정적인 것으로 만드는 것입니다.

val fetchData = remember(name) { { myViewModel.fetchData(name = name) } } // 중괄호는 오타가 아닙니다.

MyComposable {
  fetchData.invoke()
}

Wrapper Class

다른 효과적인 방법으로는 직접적으로 stability 주석을 적용할 수 없는 제어할 수 없는 불안정한 클래스에 대해 wrapper 클래스를 생성하는 것입니다. 아래 예제를 통해서 확인할 수 있습니다.

@Immutable
data class ImmutableUserList(
   val user: List<User>
)

그런 다음 아래 코드와 같이 이 wrapper 클래스를 Composable 함수의 매개변수 타입으로 사용하여 Composable 함수를 skippable하게 만들 수 있습니다.

@Composable
fun UserAvatars(
    modifier: Modifier,
    userList: ImmutableUserList,
)

Stability Configuration File

Compose 컴파일러 버전 1.5.5부터 stable로 간주하고 싶은 클래스 및 인터페이스 패키지를 configuration file에 정의하여 Compose 컴파일러와 약속을 채결할 수 있습니다. 이렇게 나열된 클래스는 Compose 컴파일러에 의해 안정적인 것으로 인식됩니다. 이는 제어할 수 없는 클래스, 가령 서드파티 라이브러리의 클래스를 안정적으로 처리해야 할 때 매우 유용합니다.

Configuration file 기능은 아래와 같이 앱 모듈의 build.gradle.kts 파일에 Compose 컴파일러 구성을 추가하여 활성화할 수 있습니다.

kotlinOptions {
  freeCompilerArgs += listOf(
      "-P",
      "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=" +
      "${project.absolutePath}/compose_compiler_config.conf"
  )
}

다음으로, 아래와 같이 앱 모듈의 루트 디렉터리에 compose_compiler_config.conf 파일을 생성합니다.

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider kotlin collections stable
kotlin.collections.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

프로젝트를 빌드하고 Compose 컴파일러 메트릭을 생성하면 stability configuration file에 지정된 클래스가 안정적인 것으로 인식되어 recomposition을 건너뛸 수 있습니다.

공식 Android 가이드에 따르면, Compose 컴파일러는 각 프로젝트 모듈에서 독립적으로 작동하기 때문에, 필요한 경우 서로 다른 모듈에 대해 별도의 구성을 제공할 수 있습니다. 또는 프로젝트의 루트 수준에 단일 구성을 선택하고 각 모듈에 해당 경로를 지정할 수도 있습니다.

여기서 중요한 점은 stability configuration file 자체가 정의된 클래스를 본질적으로 안정적으로 만들지는 않는다는 것입니다. 대신, configuration file을 사용하여 Compose 컴파일러와 계약을 맺는 것입니다. 따라서 특정 시나리오에서 스마트 재구성 프로세스를 건너뛰지 않도록 이 기능을 신중하게 사용하는 것이 중요합니다.

멀티 모듈 아키텍처에서의 안정성 (Stability In Multi-Module Architecture)

Gradle 모듈의 모듈화는 재사용성 향상, 병렬 빌드, 팀의 집중 분산 등 많은 이점을 제공하는 좋은 전략입니다. 안드로이드 공식 가이드에서도 프로젝트 규모에 맞춰 확장성을 높이고, 가독성을 향상시키며, 전체적인 코드 품질을 높이기 위해 모듈화를 권장하고 있습니다.

모듈화는 Jetpack Compose의 stability 로직에 한 가지 문제를 불러일으킵니다. 독립된 모듈의 클래스는 해당 클래스가 원칙대로라면 안정적인 것으로 간주되어야 함에도 불구하고 불안정한 것으로 간주됩니다. 구글에서 공식적으로 제안한 방법은, 이를 해결하기 위해 compose-runtime 라이브러리를 데이터 모듈에 가져와서 데이터 클래스에 안정성 주석을 추가하는 것이 좋습니다.

하지만, 순수하게 도메인 데이터에만 집중하는 Kotlin/JVM 라이브러리에 compose compiler를 활성화하고 compose runtime 의존성을 갖도록하는 것이 이상적이지 않은 경우도 있습니다. 이러한 경우, compose stable marker 라이브러리를 사용하거나 configuration file을 활용하여 compose-runtime 라이브러리에 직접 의존하지 않고 안정성을 보장하는 방법이 있습니다.

Compose Stable Marker

compose-stable-marker 라이브러리는 compose-runtime 라이브러리와 유사한 기능을 제공하는 @Immutable@Stable과 같은 안정성 주석을 제공합니다. compose-runtime 라이브러리를 직접 사용하는 것보다 compose-stable-marker 라이브러리를 사용하면 얻을 수 있는 두 가지 주요 이점은 다음과 같습니다.

  • 경량화: compose-runtime 라이브러리는 클래스, 함수 및 확장이 풍부하여 애플리케이션의 크기를 증가시킬 수 있습니다. 반면에 compose-stable-marker 라이브러리는 안정성 주석에만 집중하여 더 가벼운 대안을 제공합니다. 이는 애플리케이션 크기를 줄이고 전체 compose-runtime 라이브러리를 사용하는 것보다 빌드 시간을 단축할 수 있습니다.
  • 의존성 자유: compose-runtime 라이브러리는 SideEffect, LaunchedEffect, snapshotFlow 및 Compose 컴파일러와 관련된 다양한 주석을 포함한 Compose 런타임 기능을 실행하는 데 필요한 기능으로 가득 차 있습니다. 이 설정은 데이터 모듈에 필요하지 않더라도 모듈이 이러한 API에 액세스할 수 있게 할 수 있습니다. compose-stable-marker 라이브러리를 선택하면 이러한 전문 API에 대한 의도하지 않은 액세스를 방지하여 모듈이 집중되고 간소화된 상태를 유지할 수 있습니다.

이 라이브러리의 좋은 사용 사례는 Stream의 ChatVideo SDK에서 찾을 수 있습니다. 해당 SDK들의 코어 모듈은 compose-stable-marker를 사용하여 도메인 클래스를 안정적으로 만들고 성능을 최적화하고 있습니다.

compose-stable-marker 라이브러리에 대해서는 GitHub repository에서 더 자세히 확인하실 수 있습니다.

Stability Configuration File

이전 섹션에서 살펴본 바와 같이, stability configuration file은 Compose 컴파일러와의 계약 역할을 하여 지정된 클래스를 출처나 변경 가능성에 관계없이 안정적으로 처리할 수 있도록 합니다. 이는 파일 구성에 다른 모듈의 클래스를 나열하면 컴파일러가 자동으로 이를 안정적으로 인식하게 됨을 의미합니다.

구글의 공식 가이드에 의하면 configuration file은 클래스를 자체적으로 안정적으로 만드는 것이 아니라, Compose 컴파일러와 일종의 계약을 맺습니다. Compose 컴파일러는 이러한 클래스를 항상 안정적으로 간주하므로 스마트 recomposition 동작을 조정함으로써 의도하지 않은 동작을 초래할 수 있기 때문에, 주의 깊게 사용해야 함을 경고하고 있습니다.

Small Talk

국문 포스트를 출시하기 전에 아래의 Compose의 stability에 관한 원문 포스트를 두 편을 먼저 게시했었고, 해외 커뮤니티에서 나누었던 discussion에서 나온 의미 있는 2가지의 질문과 답변을 공유드려보고자 합니다.

1. 모든 Composable 함수는 Skippable이 되어야하는가요?

정답은 No입니다. 앞서 소개드린 Stability의 이해를 통한 Composable 함수의 전반적인 성능을 최적화하는 방법은, Compose의 성능을 극한으로 끌어올리기 위한 여러 가지 전략들에 불과합니다. 모든 Composable 함수를 강박적으로 skippable 하게 만들기 위해 파생되는 스트레스와 부담을 가지실 필요가 전혀 없습니다.

상황에 따라 smart recomposition의 최적화 전략을 사용하지 못하는 경우도 발생할 수 있기 때문에, 팀에서 어느 정도의 기준까지만 적용할 것인지에 최소한의 바운더리와 규칙을 정해놓고 하나씩 적용해보는 것을 권장드립니다.

마지막으로, 최근에 출시된 Strong Skipping Mode에서는 기본적으로 모든 Composable 함수에 대해서 skippable 한 전략을 채택하고 있기 때문에, Skippable 한 Composable 함수를 만들어야 하는 것에 대한 부담을 줄여보실 수 있습니다.

2. Recomposition을 잘 이해하는 것이 중요한가요?

정답은 Yes입니다. 사실 기존의 XML 체제는 개발자들이 수동적으로 UI 시스템에 invalidation을 요청해주어야 했던 반면에, Jetpack Compose는 선언형 UI 패러다임을 가져오면서 state와 input parameter의 변경을 통해 자동적인 invalidation을 수행하고 있습니다. 앞선 섹션들에서 다루었듯이, 개발자가 의도하지 않은 상황에서도 UI를 업데이트하기 위한 invalidation(recomposition)이 지속적으로 수행되는 경우가 다분하고, worst case scenario는 UI hierarchy의 root node에서 발생한 recomposition이 최하위 node까지 전이되는 경우입니다.

선언형 UI 패러다임이라는 것을 통해 가져오는 장점도 크지만, stability와 UI 업데이트라는 또 다른 고민거리를 가져다주었습니다. 이를 선택하는 것은 오로지 개발자의 몫입니다. 이는 Jetpack Compose 뿐만 아니라, 선언형 UI를 주도하는 React나 Flutter에서 마찬가지로 발생하는 고민거리이기 때문에 Compose만의 문제라기 보다는 선언형 UI를 사용하는 모든 프레임워크의 원론적인 고민거리이기도 합니다. 이에 대한 내용은 제가 삼성의 MX 모바일 팀에서 발표했던 Jetpack Compose Mechanism의 Declarative UI 파트를 참조하시면 도움이 될 수 있습니다.

Conclusion

여기서 stability에 대한 글을 마무리합니다. 안정성의 개념, 안정성 추론 및 스마트 recomposition의 메커니즘, 클래스 및 Composable 함수의 안정화를 위한 효과적인 전략, 그리고 애플리케이션 성능 향상에 대해 다루었습니다.

Compose의 안정성 추론 로직을 이해하는 것은 UI 노드를 화면에 렌더링하는 메커니즘에 영향을 미치고, 궁극적으로 애플리케이션의 성능에 영향을 미치기 때문에 중요합니다. 이 포스트를 통하여 실제 프로젝트의 성능 향상으로 이어지기를 바랍니다.

즐거운 코딩 되시길 바랍니다!

엄재웅 (skydoves)

profile
http://github.com/skydoves

1개의 댓글

comment-user-thumbnail
2024년 6월 12일

좋은 글 감사합니다!

답글 달기