Jetpack Compose에서 안전성을 최적화하는 새로운 방법

HEETAE HEO·2024년 11월 27일
post-thumbnail

Strong Skipping Mode와 Jetpack Compose에서의 클래스 안정성 관리

Jetpack Compose에서는 클래스 안전성을 제어하기 위한 새로운 강력한 스킵 모드는 앱에서 재구성(recomposition)을 최적화하는 방법을 변경해줍니다.

해당 블로그 리스트는 아래의 레퍼런스 글을 번역참고하여 작성한 글 입니다.

Strong Skipping Mode 이전의 안전성

Compose 컴파일러가 클래스를 불안정하게 처리하는 데에는 여러가지 이유가 있습니다.

  • 가변 클래스인 경우: 예를 들어, 가변 속성을 포함하고 있지만, 스냅샷 상태로 지원되지 않는 경우를 말합니다.
  • Compose를 사용하지 않는 Gradle 모듈에 정의된 클래스일 경우: Compose 컴파일러에 대한 의존성이 없는 Gradle 모듈에 정의된 클래스는 불안정하게 처리될 수 있습니다.
  • 불안정한 속성을 포함하는 클래스일 경우: 불안정한 속성이 중첩된 클래스도 불안정하게 간주됩니다.

다음과 같은 경우를 고려해보겠습니다.

data class Subscription(          // class is unstable
    val id: Int,                  // stable
    val planName: String,         // stable
    val renewalOn: LocalDate      // unstable
)

id와 planName 속성은 원시 타입 (primitive type)이기 때문에 안정적입니다. 원시 타입은 불변이기 때문에 안정성 문제가 없습니다. 그러나 renealOn 속성은 불안정한데, 그 이유는 java.time.LocalDate가 Java 표준 라이브러리에서 제공되며 Compose 컴파일러에 대한 의존성이 없기 때문입니다. 이로 인해 전체 Subscription 클래스가 불안정하게 간주됩니다.

다음 예시에서는 Subscription 클래스를 사용하는 상태 속성을 정의하고, 이를 SubscriptionComposable에 전달하는 상황을 고려해봅니다.

// create in a state holder (for example, ViewModel)
var state by mutableStateOf(Subscription(
  id = 1,
  planName = "30 days",
  renewalOn = LocalDate.now().plusDays(30)
))

@Composable
fun SubscriptionComposable(input: Subscription) {
    // always recomposed regardless if input changed or not
}

입력 파라미터로 이와 같은 불안정한 클래스를 사용하는 Composable 함수는 건너뛸 수 있는 함수로 간주되지 않았습니다. 입력값이 변경되지 않았더라도 항상 재구성(recomposition)되곤 했습니다.

Strong Skipping Mode 그리고 안전성

Jetpack Compose 컴파일러 1.5.4 이상에서는 강력한 스킵 모드를 활성화할 수 있는 옵션이 제공됩니다. 이 모드는 입력 파라미터의 안정성에 관계없이 항상 스킵 로직을 생성합니다. 즉, 불안정한 클래스를 사용하는 Composable도 스킵될 수 있습니다. 강력한 스킵 모드에 대한 자세한 내용과 활성화 방법은 문서나 Ben Trengrove의 블로그 포스에서 확인할 수 있습빈다.

우선 강력한 스킵 모드는 입력 파라미터가 이전 구성과 비교해 변경되었는지를 두 가지 방법으로 판단합니다.

  1. 클래스가 안정적인 경우: 구조적 동등성 ( ’.equals()’ )를 사용합니다.
  2. 클래스가 불안정한 경우: 참조 동등성 ( ‘===’ )을 사용합니다.

프로젝트에서 강력한 스킵 모드를 활성화한 후에는, 불안정한 Subscription 클래스를 사용하는 Composable 함수는 해당 인스턴스가 이전 구성과 동일한 경우 재구성되지 않고 건너뛰게 됩니다.

예를 들어 SubscriptionComposable이 다른 Composable인 Screen 내에서 사용되며, Screen은 inputText라는 파라미터를 받는다고 가정해보겠습니다. 만약 InputText 파라미터가 변경되고 subscription 파라미터가 변경되지 않는다면, SubscriptionComposable은 재구성되지 않고 건너뛰게 됩니다. 이로 인해 불필요한 재구성을 방지하고, 성능을 최적화할 수 있습니다.

@Composable
fun Screen(inputText: String, subscription: Subscription) {
    Text(inputText)

    // It's skipped when subscription parameter didn't change
    SubscriptionComposable(subscription)
}

하지만 renewSubscription이라는 함수가 있다고 가정해봅시다. 이 함수는 상태 변수를 현재 날짜로 업데이트하여 마지막 변경이 발생한 날짜를 추적합니다.

fun renewSubscription() {
   state = state.copy(renewalOn = LocalDate.now().plusDays(30))
}

copy 함수는 클래스의 새로운 인스턴스를 생성하며, 같은 날 발생한다면 동일한 구조적 속성을 가진 인스턴스를 만듭니다. 하지만 강력한 스킵핑 모드에서는 불안정한 클래스들간의 비교에 참조 동등성을 사용하기 때문에 copy함수가 새로운 인스턴스를 생성할 때마다 SubscriptionComposable이 다시 리컴포지션됩니다. 날짜가 동일하더라도 참조 동등성을 사용하기 때문에, SubscriptionComposable은 여전히 리컴포지션이 일어납니다.

즉, 클래스의 인스턴스를 복사할 때마다 새로운 객체가 생성되기 때문에, Kotlin 컴포저블 함수에서 참조 동등성을 기준으로 비교할 때 매번 다른 객체로 인식되어 재컴포지션이 발생한다는 뜻입니다.

구조적 데이터가 변경되지 않았을 때 (즉 equals() 메서드가 동일한 결과를 반환할 때) SubscriptionComposable이 리컴포지션되지 않도록 하려면, Subscription 클래스를 수동으로 안정성 표시해야한다는 것입니다. 이 경우, 해당 클래스는 변경할 수 없는 상태이므로 @Immutable 어노테이션을 클래스에 추가하는 간단한 방법으로 문제를 해결할 수 있습니다.

즉 클래스가 변경 불가능한 경우에는 @Immutable 어노테이션을 사용하여 해당 클래스를 안정적으로 표시함으로써, 불필요한 리컴포지션을 방지할 수 있다는 설명입니다.

+@Immutable           
-data class Subscription(       // unstable
+data class Subscription(       // stable
    val id: Int,                // stable
    val planName: String,       // stable
    val renewalOn: LocalDate    // unstable
)

이 예제에서 renewSubscription이 호출되면 이제 SubscriptionComposable이 다시 스킵 됩니다. 그 이유는 이제 참조 동등성 대신 equals 함수를 사용하기 때문입니다. equals 함수는 이전 상태와 비교했을 때 true를 반환하게 됩니다. 즉, equals() 함수는 객체의 값이 같은지 비교하므로, 이전 상태와 현재 상태가 동일한 경우 true를 반환하고, 이로 인해 SubscriptionComposable이 리컴포지션되지 않고 건너뛰어진다는 설명입니다.

이런 상황이 언제 발생할까요?

현실적인 예로, 데이터베이스 엔터티, API 엔터티, Firestore 변경 사항 등 시스템의 주변부에서 오는 엔터티를 사용할 때, 여전히 클래스에 @Immutable 애노테이션을 추가해야 할 필요가 있습니다.

이런 엔터티들은 기본 데이터에서 매번 파싱되기 때문에, 매번 새로운 인스턴스를 생성하게 됩니다. 따라서 애노테이션 없이 사용하면 이들이 리컴포지션될 것입니다.

참고: 리컴포지션은 모든 매개변수에 대해 equals()를 호출하는 것보다 더 빠를 수 있습니다. 안정성을 최적화할 때는 변경의 효과를 항상 측정해야합니다.

안정성을 제어하는 설정 파일을 통해 안전성을 제어하기

당신의 코드베이스에 포함되지 않은 클래스에 대해서는, 이전에는 해당 클래스를 안정화하는 유일한 방법이 해당 클래스를 당신의 코드베이스의 일부인 클래스로 래핑하고, 그 클래스를 @Immutable로 애노테이션하는 것이었습니다.

예를 들어, java.time.localDate 매개변수를 직접 받는 컴포저블을 생각해보세요

@Composable
fun LatestChangeOn(updated: LocalDate) {
  // present the day parameter on screen
}

최신 변경 사항을 업데이트하기 위해 renewSubscription 함수를 호출하면, 이전과 비슷한 상황에 처하게 됩니다. LatesChangeOn 컴포저블이 같은 날인지 여부와 상관없이 계속해서 리컴포지션됩니다. 그러나 이 경우에는 해당 클래스를 애노테이션할 수 있는 방법이 없습니다. 왜냐하면 그 클래스가 표준 라이브러리의 일부이기 때문입니다.

이문제를 해결하려면 안정성 설정 파일을 활성화할 수 있습니다. 이 파일에는 Compose 컴파일러가 안정적이라고 간주할 클래스나 클래스 패턴을 포함할 수 있습니다.

이를 활성화하려면 ComposeComplier 설정에 stabilityConfigurationFile을 추가해야합니다.

composeCompiler {
  ...

  // Set path of the config file
  stabilityConfigurationFile = rootProject.file("stability_config.conf")
}

프로젝트의 루트 폴더에 stability_config_conf 파일을 생성하여, 그 파일에 LocalDate 클래스를 추가해줍니다.

// add the immutable classes outside of your codebase
java.time.LocalDate

// alternatively you can stabilize all java.time classes with *
java.time.*

도메인 모델 클래스의 안정성 확보

안정성 설정 파일은 모든 데이터 또는 도메인 모델 클래스의 안정성을 확보하는 데도 유용할 수 있습니다. (이들이 변경 불가능하다는 전체 하에서). 이렇게 하면 도메인 모듈이 Java Gradle 모듈이 될 수 있으며, Compose 컴파일러에 대한 의존성이 필요 없게 됩니다.

// stabilize all classes in model package
com.example.app.domain.model.*

규칙을 어기는 것에 주의하세요

변경 가능한 클래스에 @Immutable 애노테이션을 추가하거나 안정성 설정 파일에 해당 클래스를 추가하는 것은 코드베이스에 버그를 일으킬 수 있으므로 주의해야합니다. Compose 컴파일러는 이 계약을 검증할 수 없기 때문에, 리컴포지션이 필요하다고 생각하는 상황에서 리컴포지션이 발생하지 않는 문제가 발생할 수 있습니다.

람다를 remember()할 필요는 없다

강력한 스킵핑의 또 다른 이점은 컴포지션에서 사용된 모든 람다를, 불안정한 캡처가 있는 경우에도 ‘기억’ 한다는 것입니다. 이전에는 예를 들어 ViewModel과 같은 불안정한 클래스를 사용하는 람다가 리컴포지션의 원인이 될 수 있었습니다. 일반적인 해결책 중 하나는 람다 함수를 remember 로 감싸는 것 입니다.

따라서 코드베이스에 remember로 랩핑된 람다 함수가 있다면, 이제는 remember 호출을 안전하게 제거할 수 있습니다. 왜냐하면 Compose 컴파일러가 이를 자동으로 처리해주기 때문입니다.

Screen(
-removeItem = remember(viewModel){ { id -> viewModel.removeItem(id) } }
+removeItem = { id -> viewModel.removeItem(id) }
)

불변 컬렉션이 여전히 필요할까요?

과거에는 kotlinx.collections.immutable 컬렉션, 예를 들어 ImmutableList를 사용하여 항목의 리스트를 안정적으로 만들어 컴포저블이 리컴포지션되지 않도록 할 수 있었습니다. 만약 리스트 파라미터를 가진 컴포저블의 리컴포지션을 방지하기 위해 코드베이스에 이러한 불변 컬렉션을 사용하고 있다면, 이를 일반적인 List로 리팩토링하고 java.util.List를 안정성 설정 파일에 추가하는 것을 고려할 수 있습니다.

하지만

만약 그렇게 한다면 리스트 파라미터가 불안정했을 때보다 컴포저블이 느려질 수 있습니다. List를 안정성 설정 파일에 추가하면, 리스트 파라미터는 equals 호출로 비교되는데, 이는 결국 리스트의 모든 항목에 대해 equals를 호출하게 됩니다. Lazy 리스트의 경우, 동일한 equals 검사가 아이템 컴포저블의 관정에서 다시 호출되며, 이는 많은 가시적인 항목에 대해 equals() 호출이 두 번 계산되는 결과를 초래하고, 보이지 않는 모든 항목에 대해 불필요하게 계산될 수 있습니다.

리스트 파라미터를 포함하는 컴포저블에 다른 UI 구성 요소가 많지 않다면, 리컴포지션이 equals() 검사를 계산하는 것보다 더 빠를 수 있습니다. 그러나 여기에 일괄적인 해결책은 없음으로, 벤치마크를 통해 확인하는 것이 좋습니다.

개념을 적용해본 경험

해당 글을 읽은 뒤 저는 @Immutable 어노테이션을 사용하면 되는 것이 아닌가? 하고 실제로 적용해본 경험은 없었습니다.

하지만 위의 개념은 멀티-모듈을 사용할 때 큰 도움이 되었습니다.

대체로 data, presentation 모듈의 경우에는 안드로이드 타입의 모듈을 만들게되고 domain 모듈의 경우에는 Kotlin/Java 네이티브 모듈로 만들게 됩니다.

이때 Strong Skipping Mode는 매우 유용하게 사용할 수 있습니다.

data 레이어에서 가져온 값이 domain 모듈로 넘어와 mapping이 되어 presentation으로 넘어올 때 data 클래스에는 Compose의 어노테이션인 @Immutable을 추가할 수가 없습니다.

해당 방법을 떠올리기 전에는 불변 컬렉션을 시도해보고, presentation에서의 한번 더 mapping 하는 방법을 고안했지만 위의 방법을 통해 recomposition을 추가적으로 발생시키지 않았습니다!

Refrences

profile
Android 개발 잘하고 싶어요!!!

0개의 댓글