Compose Stability

이승우·2024년 10월 19일

Understanding Stability

Compose 컴파일러는 parameter를 stable, unstable 2가지로 분류하며, 이는 compose 런타임이 composable 함수의 재구성을 필요로 하는지 결정하는데 사용된다.

Stable vs. Unstable

  • String을 포함하여 Primitive type은 stable 하다.
  • 람다와 같은 함수 유형은 stable 로 간주된다. ((Int) -> String)
  • class 특히, immutable 하고 stable 한 프로퍼티로 이루어진 data class나 @Stable, @Immutable과 같은 어노테이션이 표시된 클래스는 stable 로 간주된다.
  • interface (ex. List, Map) 등은 컴파일 시점에 구현을 예측할 수 없는 Any와 같은 추상 클래스는 unstable 한 것으로 간주된다.
// stable
data class User(
  val id: Int,
  val name: String,
)

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

컴파일러는 모든 속성의 안정성을 집합적으로 평가하여 결정되기 때문에 발생하며, 하나의 가변 속성만 있어도 class 전체가 unstable 하게 간주될 수 있다.

Stability Annotations

@Immutable

  • 클래스의 모든 공개 속성과 필드가 처음에 생성된 이후 절대 변경되지 않음을 보장한다.
  • 이는 언어 차원에서 val 키워드가 제공하는 것보다 더 엄격한 보장을 의미한다.
  • val은 속성이 setter를 통해 재할당될 수 없음을 보장하지만, 여전히 MutableList로 초기화된 List와 같은 가변 데이터 구조로 생성될 가능성은 허용한다.
val myList: List<String> = listOf("A", "B", "C")
(myList as MutableList).add("D")

myList는 한번만 할당할 수 있지만, 내부적으로는 가변적이기 때문에 요소를 변경할 수 있다. val은 재할당될 수 없음을 보장하지만, 가변 데이터 구조를 통해 변경 가능성이 여전히 존재한다.

이럴 때, @Immutable을 사용하면 이러한 불변성을 더 강력하게 보장할 수 있다.

@Immutable을 통해 효과적으로 안정성을 챙기려면 아래 내용을 준수하면 된다.

  1. 모든 공개 속성에 대해 val 키워드를 사용하여 불변성을 보장한다.
  2. 사용자 정의 setter를 피하고 공개 속성이 가변성을 지원하지 않도록 한다.
  3. 모든 공개 속성의 유형이 본질적으로 불변이거나 stable 어노테이션으로 명시되어 있는지 확인한다. ex) 인터페이스는 unstable 한 것으로 간주되므로, 속성으로 사용되는 인터페이스 유형은 stable 어노테이션을 추가해야 한다.
  4. 속성이 collection인 경우, 안정성을 유지하기 위해 kotlinx.collections.immutable 에서 제공하는 불변 collection을 선택한다.

@Immutable 어노테이션은 위와 같이 불변성 규칙을 준수하는 클래스에 효과적이며, 불필요한 재구성을 건너뛰고 성능 개선에 중요한 역할을 한다.

하지만, @Immutable 어노테이션을 신중하게 적용하는게 중요하다. 부적절하게 사용하면 재구성이 의도치 않게 건너뛰어져 Compose 레이아웃이 예상대로 업데이트 되지 않을 수 있다. 따라서 클래스의 실제 불변성을 고려하고 불변성의 기준을 충족하는지 확인하는게 중요하다.

@Stable

@Stable은 @Immutable에 비해 Compose 컴파일러에 대한 강력하지만, 약간 덜 엄격한 약속을 나타낸다.

함수나 속성에 적용될 때, @Stable은 해당 유형이 가변적일 수 있음을 의미한다. 역설적으로 보일 수 있지만, 여기서 "Stable"이라는 용어는 같은 입력에 대해 함수가 일관되게 동일한 결과를 반환한다는 것을 의미하며, 잠재적인 가변성에도 불구하고 예측 가능한 동작을 보장한다.

즉, @Stable을 사용하면 속성이 가변적일 수 있지만, 그 속성의 값을 사용하는 방식이나 그 값이 제공하는 결과는 항상 일관되게 유지된다는 의미이다. 이러한 방식으로 Compose는 안정성을 보장하면서도 유연성을 제공할 수 있다.

따라서, @Stable 어노테이션은 공개 속성이 불변이지만 클래스 자체가 안정성 기준을 충족하지 않을 수 있는 클래스에 가장 적합하다. ex) Compose의 State 인터페이스는 value라는 불변 속성만을 노출한다. 그러나 기본값은 일반적으로 MutableState를 생성하여 setValue 함수를 통해 여전히 수정될 수 있다.

MutableState에 의해 생성된 State의 인스턴스는 getValue 함수(즉, value 속성의 getter)에서 항상 동일한 값을 가져오며, 이는 setValue 함수에 동일한 입력에 대해 동일한 결과를 생성한다.

@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
}

State는 불변성을 보장하면서도 MutableState를 통해 가변성을 허용할 수 있는 유연성을 제공한다.

[결론]

@Stable 어노테이션을 클래스에 추가함으로써 해당 클래스가 가진 속성이 안정적이라는 것을 보장할 수 있다. 이는 같은 입력에 대해 같은 결과를 제공하는 일관성을 의미한다.

이러한 안정성 덕분에 클래스 내부에서 속성이 가변적일 수 있음에도 불구하고 외부에서는 이 속성을 예측 가능하고 안정적으로 사용할 수 있게 된다. 즉, 내부적으로 값이 변경될 수 있지만 외부 사용자에게는 변하지 않는 것처럼 보이기 때문에 안정적인 인터페이스를 제공할 수 있다.

이로 인해 Compose에서 재구성을 수행할 때 불필요한 업데이트를 피하고 성능을 유지하는데 도움을 주며, 성능 개선에 도움이 된다.

Immutable vs Stable

@Immutable과 @Stable의 차이점 및 어떤 것을 사용할지 결정하는 것은 혼란스러울 수 있다.

앞서 언급했듯이,

  • @Immutable은 클래스의 모든 공개 속성이 불변임을 나타내며, 즉 클래스가 생성된 이후에는 상태가 변경될 수 없다는 의미이다.
    - domain model에 가장 자주 적용된다. (특히 kotlin data class)
@Immutable
data class User(
    val id: String,
    val name: String,
    val email: String
)

[불변성 보장]

User data class는 @Immutable 로 표시되어 있으며, 모든 속성이 val로 선언되어 있어 불변성을 보장한다. 따라서 이 클래스의 인스턴스는 생성된 이후에 상태가 변경될 수 없다. 이를 통해 Compose에서 안정적이고 예측 가능한 방식으로 사용될 수 있다.

[data class 특성]
data class는 기본적으로 equals(), hashCode(), toString()과 같은 메소드를 자동으로 생성하며, @Immutable 이 추가되면 이러한 특성이 더 강조된다. 인스턴스가 불변이기 때문에 데이터 비교 및 사용이 일관되고 안정적으로 이루어진다.

[재구성 최적화]
Compose에서는 불변 객체를 활용하여 불필요한 재구성을 피할 수 있다. User 클래스의 인스턴스가 불변이므로, 동일한 User 객체를 참조하는 UI 컴포넌트는 상태 변화가 없을 경우 재구성을 건너뛰고 성능을 최적화할 수 있다.

예를 들어, 2개의 User 객체가 동일한 id, name, email 값을 가지면 Compose는 이를 동일한 객체로 간주하고, 같은 객체를 참조한다면(즉, 동일한 메모리 주소라면) 재구성을 하지 않는다.(건너뛴다.)

[스레드 안정성]
불변성 덕분에 User 클래스의 인스턴스는 여러 스레드에서 안전하게 공유될 수 있다. 데이터가 변경되지 않기 때문에 동시성 문제를 걱정할 필요가 없어, 멀티 스레드 환경에서도 안정적으로 작동한다.

[간단한 데이터 모델링]
@Immutable을 사용함으로써 User 클래스는 간단한 데이터 모델링을 가능하게 한다. 즉, 상태가 변경되지 않는 도메인 모델을 설계할 수 있으며, 이로 인해 코드의 유지보수성과 가독성이 향상된다.

  • @Stable은 가변 객체에 적용할 수 있으며, 동일한 입력에 대해 일관된 결과를 생성해야 한다는 조건이 있다.
    • 여러 구현 가능성을 제공하고 내부 가변 상태를 가질 수 있는 인터페이스에 일반적으로 사용된다.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?
  
    val hasSuccess: Boolean
        get() = exception == null
}

[안정성 보장]
@Stable 어노테이션을 통해 UiState 인터페이스를 구현할 때, 구현체가 동일한 입력에 대해 일관된 결과를 제공해야 함을 나타낸다. 이는 UI 컴포넌트가 이 인터페이스를 사용하여 상태를 관리할 때, 안정적으로 동작하도록 보장한다.

[가변성과 일관성]
UiState 인터페이스는 내부적으로 value와 exception 2개의 속성을 가지며, 이 속성들은 각각 성공적인 결과와 오류 상태를 나타낸다. 이들 속성은 가변적일 수 있지만, @Stable이 적용되었기 때문에 외부에서는 항상 예측 가능한 방식으로 사용해야 한다.

예를 들어, value가 변경되거나 exception이 발생하더라도 hasSuccess 속성은 항상 그 상태를 기반으로 올바른 결과를 반환해야 한다.

[효율적인 재구성]
Compose는 @Stable이 붙은 인터페이스를 활용하여 불필요한 재구성을 피할 수 있다. UiState가 안정적인 상태를 유지한다면, UI는 상태가 변경되지 않았을 경우 재구성을 건너뛰고 이전 결과를 재사용할 수 있다.

예를 들어, UI가 UiState의 hasSuccess 속성을 사용하여 조건부 렌더링을 수행할 경우, exception이 null인 상태가 변경되지 않았다면 해당 UI 컴포넌트는 재구성 없이 이전 상태를 그대로 유지할 수 있다.

[유연한 데이터 관리]
@Stable을 사용하면 가변성을 허용하면서도 일관성을 유지할 수 있어, 다양한 상태를 표현할 수 있다. 즉, UiState는 성공적인 결과(value)와 오류(exception)를 동시에 관리할 수 있으며, 이 모든 것이 안정적이고 예측 가능한 방식으로 이루어진다.

profile
Android Developer

0개의 댓글