Composable함수의 Stability안정성을 위해 알아야할 것

SSY·2024년 6월 21일
1

Compose

목록 보기
7/15
post-thumbnail

1. 시작하며

선언형 UI인 Compose는 상태값의 변경 및 해당 상태값이 주입 된 copmosable함수만 다시 그릴 수 있는데, 이를 Recomposition이라고 한다. 하지만 보통 UI의 구조는 1개의 composable만 있는 게 아닌 여러개의 UI가 트리형태로 존재하는데, 이때, Compose Runtime은 어떤 UI를 건너뛸 수 있을지를 판단할 수 있어야 한다.

만약 개발자가 안정적이지 못한 composable 함수를 만들게 된다면? Compose Runtime은 건너뛰어야 할 UI를 건너뛰지 못하고 불필요한 UI 재호출을 지속할 수 있다. 따라서 개발자는 안정적인 Composable 함수를 만들 수 있어야 하며, 이는 Stability즉, 안정성을 올바르게 설계할 수 있는 능력과 직결된다.

다만, SmartRecomposition을 위해 알아야할 출발 지식이 있다.

  1. compose compiler를 통해 composable함수의 파라미터 타입이 stable & unstable이 된다는 것
  2. 모든 파라미터 타입이 stable할 경우, composable함수는 skippable해진다는 것.(Compose matrix에 기재됨)
  3. 단 하나의 파라미터라도 unstable할 경우, composable함수는 skippable하지 않으며 Recomposition대상에 무조건적으로 포함된다는 것.

2. 컴포저블 함수의 Stable & Unstable타입이란?

개발자가 Composable 함수를 작성하고 빌드하면 Compose Compiler는 Compose Runtime이 알아들을 수 있는 코드로 변경하게 되고, Compose Runtime은 composable함수를 건너뛸 수 있는지? 아닌지? 를 판단하게 된다. 이때, 이러한 부분을 결정짓는 중요한 요소 중 하나가 composable함수의 파라미터 타입이다. (참고) 이에 따라 compose compiler는 코드를 아래와 같이 변환한다.

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SnackCollection(
  stable snackCollection: SnackCollection
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
  stable index: Int = @static 0
  stable highlight: Boolean = @static true
)

위 코드를 보면 알다시피, 각 파라미터들 앞에 stable이 붙어있다. 그리고 더 나아가, 모든 파라미터들이 stable하다면, 해당 composable 함수들에는 skippable표시가 붙게된다. skippable표시가 붙은 composable 함수는, Compose Runtime이 시의 적절하게 Recomposition대상에서 제외할 수가 있다. 즉, 주입된 composable 상태값이 변경되지 않았다면, 해당 composable함수를 Recomposition하지 않는다는 뜻이다.

그럼, composable함수를 skippable하게 만들기 위한 첫 걸음인, 파라미터를 stable하게 만들기 위한 composable 함수의 파라미터 타입은 무엇일까?

  1. 원시 타입(eg., String, Int, Float...)
  2. 원시 타입을 파라미터로 정의하고 반환하는 함수 타입(eg., (String) -> Boolean)

이러한 타입 지정을 통해 composable함수의 각 파라미터 타입은 stable해진다.

restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

stable한 타입이 있는 것처럼, 그 반대인 Unstable 타입도 있다. 이는 composable함수가 주입 된 composable 상태값의 변경을 추적할 수 없다는 뜻이고, 이러한 unstable이 1개라도 포함될 경우, 해당 composable함수는 SmartRecomposition대상에서 제외하게 된다. 즉, 해당 composable함수가 포함된 상위 composable함수의 호출은 무조건적으로 해당 composable 함수를 호출한다는 뜻이며 이를 통해 UI의 불필요한 버벅거림을 경험할 확률이 높아지게 된다.

위 코드를 보면 알다시피, List<Snack>타입이 unstable하다고 나와있다. 이는 Compose Compiler가 Abstract Class or Interface 타입을 unstable로 지정하기 때문이다. 왜냐하면 인터페이스, 추상 클래스 타입은 내부 구현체가 변경될 가능성이 있을 뿐만 아니라, val로 선언되어 '불변' 처럼 보이는 List라 하더라고 MutableList처럼 내부 값이 변경 가능성을 배제할 수 없기 때문이다. 즉, unstable한 타입이 단 1개라도 포함될 경우, Compose Compiler는 해당 composable 함수 타입에 unstable을 표시하고 이는 skippable표시 또한 제거되는 것을 확인할 수 있다.

그렇다면 composable함수를 정의할 때, 참조 타입이나 인터페이스 타입은 항상 unstable하게만 둬야만 하는걸까? 그렇지 않다. compose에서는 참조 타입 또한 stable한 타입으로 만들어주기 위한 여럿 방법을 제공하고 있다.

  1. @Stable, @Immutable어너테이션 사용
  2. Jetbrains 또는 Google사의 ImmutableCollection 사용

위 2가지를 사용해준다면 각 파라미터 타입들이 stable하게 바뀐다는걸 확인할 수 있다.

3. @Stable, @Immutable의 사용

@Stable/@Immutable을 적용한 타입은 Compose Matrix상에서 stable하게 표시된다는 공통점이 있다. 즉, 기존에 unstable하던 Abstract Class/Class/Interface타입을 stable하게 만들어줄 수 있다는 것이다. 하지만 사용 및 용도에 있어 차이가 존재한다.

[@Immutable]
Compose Compiler에게 클래스의 public한 내부 property들이 일절 변경되지 않음을 약속시키는 어너테이션이다. 해당 어너테이션이 선언되었을 때, Recomposition은 어떻게 동작할까? @Immutable이 붙은 타입이 composable 함수에 상태 변수로 주입된다 할 때, 새로운 객체가 들어와야만 Recomposition이 발생한다.

헷갈릴 수 있는 점이, data classequals(), hashCode()를 결과값을 내부 프로퍼티 값을 기반으로 한다는 점에서, 객체 동등성 비교를 hashCode()로 하는것 아닌가? 할 수 있지만 그렇지 않다. 순수 객체 참조 비교(===)를 통해 이뤄진다.

따라서 해당 어너테이션 사용 시, 지켜야할 3가지가 있다.

  1. 내부 public한 프로퍼티들 정의에 var를 사용하지 말 것. 모두 val로 정의할 것.
  2. custom한 setter를 사용하지 말 것
  3. 내부 public한 프로퍼티 중, MutableCollection등의 방법으로 내부 콜렉션 상태를 변경할 일이 없도록 할 것

만약, 위 3가지를 지키지 않겠다는 말은 객체 내부 상태값을 변경하겠다는 의미이고, 이는 @Immutable사용의 의미에 반하게 된다. 만약 위 3가지에 대한 확신이 서지 않는 상태로 @Immutable을 사용한다면 건너뛰지 말아야 할 Recomposition을 건너뛰게되어 UI 갱신이 이뤄지지 않을 수 있다.

[@Stable]
Compose Compiler에게 '참조 타입 객체의 내부 public한 property' 또는 '하위 타입'이 안전하게 변경될 수 있음을 알려주는 어너테이션이다. 즉, var로 선언 된 public한 property의 변경 또는 val로 선언된 인터페이스 하위 구현객체의 변경을 안정적으로 추적하여 SmartRecomposition을 진행해 준다는 것을 의미한다. 이에 따라 @Stable을 사용하는 경우는 크게 2가지로 나뉠 수 있다.

  1. 클래스에 적용 : var로 선언 된 public한 내부 프로퍼티들의 안전한 변경을 compose compiler에게 약속하고자 하는 경우
  2. 인터페이스에 적용 : 하위 구현체 타입의 안전한 변경을 compose compiler에게 약속하고자 하는 경우

4. @Stable vs @Immutable 어떻게 골라쓸까?

우선, 일반적인 UiModel을 사용하고자 하는 경우, @Immutable을 사용하면 좋다.

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

위와 같이 @Immutable을 적용해줌으로써 위, UserUiModel객체 내부의 프로퍼티들은 일절 변경되지 않음을 compose compiler에게 알린다. 따라서 내부 상태가 변경된다 할지라도(ex. profileImagesMutableList객체 형태를 통한 변경) Recomposition 대상에 포함시켜주지 않는다. Recomposition을 시켜주고 싶거든 UserUiModel객체를 다시 만들어주면 된다. (eg., data class의 copy메서드 사용)

@Stable
public data class UserUiModel(
  public var id: String,
  public var nickname: String,
)

다만, UiModel을 만들 때, 내부 public한 property들을 var로 선언할 수 있다는 점이다. 그럴 경우, @Stable어너테이션을 사용하여, 내부 public한 property들의 변경마다 Recomposition 수행할 것임을 compose compiler에게 알려 성능을 향상시킬 수 있다.

또한 @Stable어너테이션은 하위 타입이 변경될 수 있는 interface/Abstract Class에도 사용될 수 있다. 여기서 타입이 변경될 수 있다는 말은 해당 타입이 '제네릭'으로 선언되었음을 의미한다. 이에 가자 유용하게 사용될 수 있는 경우가 바로 UiState이다.

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

5. Jetbrains 또는 Google사의 ImmutableCollection사용

@Stable, @Immutable어너테이션 사용을 위해 안드로이드 공식홈페이지를 둘어보다보면 아래와 같은 문구를 발견할 수 있다.

@Stable, @Immutable어너테이션은 여러 public한 property들 전체가 포함되어 있는 클래스를 통으로 stable하게 만들어버린다. 따라서 해당 어너테이션을 사용하는 것보다 하나의 프로퍼티 상태를 stable하게 만듦으로써 data class 자체를 stable하게 만드는 것을 우선적으로 고려하는 것이 바람직하다. 그에 대한 좋은 솔루션으로 ImmutableCollection을 사용하는 것이다.

public data class UserUiModel(
  public val id: String,
  public val nickname: String,
  ... 그 외, 20개의 public한 프로퍼티들 ...
  public val userProfiles: List<Profile>
)

기존 List는 순수한 인터페이스이다. 또한 val로 선언된 List는 더더욱 불변처럼 보이지만, 내부 구현체로 MutableList를 둘 수 있으며 이를 통해 내부 요소들에 변할 수 있다는 것을 알아야 한다. (이로 인해 Compose Runtime은 해당 타입을 unstable하게 간주함)

반면 ImmutableCollection의 경우, 내부 구현체들이 변경될 때, 깊은 복사를 적용해 새로운 객체를 만드는 방식을 적용한다. 즉, 단 1개의 요소를 추가/삭제 한다 하더라도 새로운 객체를 생성하기에 참조 동등성 비교(===)에서 다른 결과를 만들어 낸다는 것이다.
참고 : List/MutableList vs PersistentList/ImmutableList의 차이점

해당 라이브러리를 사용하기 위해선 JetBrains의 공식 깃허브 또는 Guava의 공식 깃허브를 사용할 수 있다.

6. Lambda 타입을 통한 Recomposition 방지

참고 : Composable함수 상태값 전달에서, Field vs Lambda 주입의 차이

[참고]

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글