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

SSY·2024년 6월 21일
0

Compose

목록 보기
7/10
post-thumbnail

시작하며

Compose는 선언형 UI로써 함수 호출을 통해 UI를 그리게 된다. 이를 Recomposition이라고 하는데, 최 상위 UI가 호출됨에 따라 하위 UI함수가 무분별하게 재호출될 수 있다. 하지만 Compose는 State로 지정 된 상태값이 변경되고, 그 상태값이 주입 된 composable 함수만 갱신하게 되는데 이를 SmartRecomposition이라고 한다.

하지만 문제가 있다. Compose Compiler는 파라미터 타입에 따라 아규먼트가 변경되었는지? 아닌지?를 100%판단할 수 없다는 것이다. 만약 판단을 못하는 파라미터 타입이 정의되고, 이 곳에 동일한 아규먼트 값이 계속 들어온다고 가정했을 시, Compose Runtime은 해당 Composable함수를 무조건적으로 Recomposition대상에 포함시켜버린다는 점이다.

결국 Stability안정성을 구현한다는 것은 SmartRecomposition을 올바르게 잘 의도하는 능력이라고 볼 수도 있다.

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

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

Stable & Unstable타입

개발자가 컴포즈 코드를 작성하고 빌드하면 compose compiler는 해당 코드를 변경하게 된다. 이를 통해 Compose Runtime이 composable함수가 skippable한지? 아닌지?에 따라 실행시키게 되는데, 이를 결정짓는 요소가 바로 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이 붙은걸 볼 수 있다. 이는 composable함수가 아규먼트 변경을 추적할 수 있다는 의미이며, 이를 통해 SmartRecomposition 대상에 포함될 수 있단 뜻이다. 이를 통해 UI의 불필요한 갱신이 사라지게 되며 성능 향상을 기대할 수 있다. 그리고 이러한 사실은 compose compiler에 의해 변환된 composable함수의 선언부, skippable을 통해 알 수 있다.

그럼 어떤 타입을 composable함수의 파라미터로 지정해야 할까?

  1. 원시 타입
  2. 원시 타입을 파라미터로 정의하고 반환하는 함수 타입

이러한 타입 지정을 통해 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
)

하지만 Unstable한 타입도 있다. 이는 composable함수가 아규먼트의 변경을 추적할 수 없다는 뜻이고, 이를 통해 SmartRecomposition대상에서 제외하게 된다. 해당 composable함수가 포함된 상위 composable함수가 호출될 경우, 무조건적으로 해당 UI가 호출된다는 뜻이며 이를 통해 UI의 불필요한 버벅거림을 경험할 확률이 높아지게 된다. 위 코드를 보면 알다시피, List<Snack>타입이 unstable하다고 나와있다. 이는 Compose Compiler가 추상 클래스, 클래스, 인터페이스로 만들어진 참조 타입을 unstable로 지정하기 때문이다. 이처럼 unstable한 타입이 단 1개라도 포함될 경우, 해당 composable함수엔 skippable이 붙지 않은걸 확인할 수 있다.

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

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

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

@Stable, @Immutable 어너테이션 사용

해당 어너테이션들은 Compose Compiler가 변형 한 Composable 함수의 stability 타입이 Stable해짐을 약속한다는 공통점이 있다. 하지만 사용함에 있어 미세한 차이들이 존재한다.

[@Immutable]
Compose Compiler에게 클래스의 public한 내부 상태값이 일절 변경될 일이 없음을 약속시켜주는 어너테이션이다. 해당 어너테이션이 선언되었다는 것은 해당 타입의 아규먼트를 변경하고자 할 시, 새로 생성된 객체가 아규먼트로 들어와야만 상태가 변경됨을 알리는 어너테이션이다.

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

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

위 3가지를 지키지 않겠다는 말은 객체 내부 상태값을 변경하겠다는 의미이다. 이는 @Immutable어너테이션 사용성 의미에 반하게 된다. 만약 위 3가지에 대한 확신이 서지 않는 상태로 @Immutable어너테이션을 사용한다면 Recomposition이 원하는대로 발생하지 않을 수 있다. 즉, SmartRecomposition을 노려 성능 개선을 원했던 일이 Recomposition을 못하게 만들어 UI갱신이 안될 수도 있단 의미이다.

[@Stable]
Compose Compiler에게 내부 상태가 안전하게 변경될 수 있음을 알려주는 어너테이션이다. 이해를 위해 Stable이란 의미를 짚고 넘어가면 좋은데, 이는 동일한 입력에 동일한 결과를 반환한다는 의미이다. 즉, var로 선언 된 public한 프로퍼티들의 변경을 추적하거나, val로 선언 된 인터페이스 하위 구현체들의 실질 타입을 추적하여 SmartRecomposition을 진행해 준다는 것을 의미한다. 이에 따라 @Stable을 사용하는 경우는 크게 2가지로 나뉠 수 있다고 본다.

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

@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객체를 다시 만들어주면 된다. (ex. copy메서드 사용 등..)

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

다만, 주의할 점이 있다. UiModel을 만들 때는 여러가지 방법이 있는데, 내부 public 프로퍼티 정의 시, var를 사용할 수도 있다는 점이다. 그럴 경우, @Stable어너테이션을 사용함으로써, 내부 public한 프로퍼티들의 변경시마다 Recomposition 수행할 것임을 compose compiler에게 알려야 한다.

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

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

Jetbrains 또는 Google사의 ImmutableCollection사용

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

@Stable, @Immutable어너테이션은 여러 public한 프로퍼티들 전체가 포함되어 있는 클래스를 통으로 stable하게 만들어버린다. 따라서 해당 어너테이션을 사용하는 것보다 하나의 프로퍼티 상태를 'stable'하게 만듦으로써 data class 자체를 stable하게 만드는 것도 방법이다.

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

만약 위와 같은 data class가 있다면 어떻게 할까? 크게 2가지 방법이 떠오른다.

  1. @Immutable어너테이션 적용 및 UserUiModel을 stable하게 만들기
  2. userProfilesList타입을 ImmutableList타입으로 바꾸기

1번의 방법은 어쩌면 닭잡는 데 소잡는 칼을 쓰는 느낌도 없지않아 있다. 따라서 위와 같은 경우는 ImmutableList를 쓰는 것도 1가지 방법일 수도 있다.

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

참고

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

0개의 댓글