아티클 목록으로 가기
컴포즈 Strong Skipping Mode에 대한 진실과 오해

컴포즈 Strong Skipping Mode에 대한 진실과 오해

skydovesJaewoong Eum (skydoves)||9분 소요

컴포즈 Strong Skipping Mode에 대한 진실과 오해

Strong Skipping Mode는 Jetpack Compose에서 가장 오해가 많은 기능 중 하나입니다. 흔히 Strong Skipping Mode를 활성화하면 모든 타입이 안정(stable) 상태가 되어 안정성(stability)에 대해 더 이상 고민할 필요가 없다고 여겨지기도 하는데, 이는 사실과 다릅니다. Strong Skipping Mode는 어떤 타입의 안정성도 변경하지 않습니다. 불안정한(unstable) 타입은 여전히 불안정한 상태로 남아 있습니다. 실제로 달라지는 것은 런타임이 스킵 검사(skip check) 과정에서 불안정한 파라미터를 처리하는 방식입니다.

기존에는 불안정한 파라미터가 하나라도 있으면 컴포저블을 항상 재실행했지만, Strong Skipping Mode에서는 참조 동등성(referential equality, ===)을 사용하여 불안정한 값을 비교하고, 이전과 동일한 인스턴스가 전달되면 스킵합니다. 이는 분명 의미 있는 최적화이지만, 안정성에 대한 이해를 완전히 하지 않아도 된다는 것은 아니며 일반적인 패턴에서 불필요한 리컴포지션(Recomposition)을 완전히 방지하지도 못합니다.

이번 아티클에서는 Strong Skipping Mode가 컴파일러와 런타임 수준에서 실제로 무엇을 변경하는지, 안정 파라미터와 불안정 파라미터에 대해 생성되는 코드가 어떻게 다른지 살펴봅니다. 또한 리컴포지션마다 새 인스턴스가 생성되는 경우 참조 동등성 비교가 왜 도움이 되지 않는지, 람다 메모이제이션(memoization)이 Strong Skipping에서 어떻게 달라지는지, 그리고 Strong Skipping이 활성화되어 있어도 안정성이 여전히 중요한 실질적인 사례들을 함께 살펴보겠습니다.

흔한 오해

Strong Skipping Mode에 대한 다양한 오해들이 있습니다. "Strong Skipping Mode를 사용하면 모든 파라메터가 Stable 상태가 된다." "@Stable이나 @Immutable은 더 이상 필요 없다." "안정성 문제는 이제 해결됐다." 이러한 견해는 모두 같은 오해에서 비롯됩니다. 스킵할 수 있는 능력과 실제 안정성 처리하는 과정을 혼동하고 있는 것입니다.

안정성(stability)은 타입이 가지는 속성입니다. 컴파일러가 해당 값의 관찰 가능한 상태(observable state)가 Compose에 통지하지 않고는 변경되지 않을 것임을 보장할 수 있다는 뜻입니다. Int, String, @Immutable로 표시된 data class는 안정적입니다. 반면 List<T>(내부적으로 가변일 수 있는 인터페이스), var 프로퍼티를 가진 클래스, Compose 컴파일러가 처리하지 않는 외부 모듈의 타입은 불안정합니다. Strong Skipping Mode는 이러한 분류를 전혀 직접적으로 변경하지 않습니다. Strong Skipping을 활성화하더라도 List<Item>은 여전히 불안정합니다. 컴파일러가 각 클래스에 대해 생성하는 $stable 비트마스크(bitmask)는 Strong Skipping의 활성화 여부와 관계없이 동일합니다.

변경되는 것은 스킵 판단(skip decision) 로직입니다. Strong Skipping 없이는 불안정한 파라미터가 하나라도 있는 컴포저블은 "절대" 스킵할 수 없습니다. Strong Skipping을 사용하면 불안정한 파라미터가 이전 컴포지션(Composition)과 정확히 동일한 인스턴스일 때 스킵할 수 있게 됩니다.

컴파일러가 실제로 생성하는 코드

이러한 차이를 이해하는 가장 좋은 방법은 Compose 컴파일러가 생성하는 코드를 직접 살펴보는 것입니다. 불안정한 파라미터를 받는 컴포저블을 예로 들어 보겠습니다.

@Composable
fun Test(x: Foo) {
    A(x)
}

여기서 Foo는 불안정한 클래스입니다. 예를 들어 var 프로퍼티를 갖고 있거나 외부 모듈에 속한 타입이라면 불안정한 것으로 간주됩니다.

Strong Skipping Mode 적용 시 (Compose 1.11 컴파일러 기준)

컴파일러는 changedInstance()를 사용하는 스킵 검사 코드를 생성합니다.

@Composable
fun Test(x: Foo, %composer: Composer?, %changed: Int) {
    %composer = %composer.startRestartGroup(<>)
    val %dirty = %changed
    if (%changed and 0b0110 == 0) {
        %dirty = %dirty or if (%composer.changedInstance(x)) 0b0100 else 0b0010
    }
    if (%dirty and 0b0011 != 0b0010) {
        A(x, %composer, 0b1110 and %dirty)
    } else {
        %composer.skipToGroupEnd()
    }
    %composer.endRestartGroup()
}

핵심은 changedInstance(x) 호출입니다. 이 메서드는 x의 현재 값을 이전 값과 !==(참조 동등성)를 사용하여 비교합니다. 동일한 인스턴스가 전달되면 컴포저블은 스킵하고, 구조적으로 동일한 값이더라도 다른 인스턴스가 전달되면 재실행됩니다. 이 점이 매우 중요한데, 내용이 같더라도 새로운 객체라면 스킵되지 않기 때문입니다.

Strong Skipping Mode 미적용 시

Strong Skipping 없이는 불안정한 필수 파라미터가 있는 컴포저블에 대해 컴파일러가 mightSkip = false로 설정합니다.

if (
    !FeatureFlag.StrongSkipping.enabled &&
    isUsed && isUnstable && isRequired
) {
    mightSkip = false
}

mightSkipfalse이면 컴파일러는 스킵 검사 코드 자체를 생성하지 않습니다. 파라미터 값이 변경되었든 아니든 컴포저블은 항상 재실행됩니다. 즉, 최적화 기회 자체가 사라지는 셈입니다.

안정 파라미터는 두 모드 모두에서 구조적 동등성을 사용합니다

비교를 위해 Int처럼 안정적인 파라미터를 받는 컴포저블을 살펴보겠습니다. 안정 파라미터에서는 항상 구조적 동등성(structural equality)을 사용하는 changed() 메서드가 적용됩니다.

@Composable
fun Test(x: Int, %composer: Composer?, %changed: Int) {
    // ...
    if (%changed and 0b0110 == 0) {
        %dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
    }
    // ...
}

changed(x)!=(구조적 동등성)을 사용합니다. 두 개의 Int 값이 모두 42라면 서로 다른 객체이더라도 동일한 것으로 간주합니다. 값 타입에 대해 기대하는 올바른 동작 방식입니다.

changed()changedInstance()의 런타임 차이

Composer 인터페이스의 두 메서드를 살펴보면 핵심 차이를 명확히 이해할 수 있습니다.

override fun changed(value: Any?): Boolean {
    return if (nextSlot() != value) {  // 구조적 동등성
        updateValue(value)
        true
    } else {
        false
    }
}

override fun changedInstance(value: Any?): Boolean {
    return if (nextSlot() !== value) {  // 참조 동등성
        updateValue(value)
        true
    } else {
        false
    }
}

유일한 차이는 !=!==입니다. 구조적 동등성(!=)은 equals()를 호출하고, 참조 동등성(!==)은 두 참조가 메모리에서 정확히 같은 객체를 가리키는지 확인합니다.

이 차이가 Strong Skipping의 장점과 한계를 동시에 설명합니다. 안정적인 data class User(val name: String, val age: Int)의 경우 이름과 나이가 같은 두 인스턴스는 구조적으로 동등하므로 changed()false를 반환하여 컴포저블이 스킵합니다.

반면 Strong Skipping 하에서 불안정한 타입은 changedInstance()가 정확히 같은 객체가 전달된 경우에만 false를 반환합니다. 같은 값을 가진 복사본이나 새 인스턴스를 전달하면 스킵하지 않습니다.

Strong Skipping이 도움이 되지 않는 경우

참조 동등성 검사의 동작 원리를 이해하면, Strong Skipping이 실질적인 이점을 제공하지 못하는 사례를 파악할 수 있습니다.

리컴포지션마다 새 인스턴스가 생성되는 경우

@Composable
fun Screen() {
    val items = listOf(Item("A"), Item("B"), Item("C"))
    ItemList(items = items)
}

Screen이 리컴포지션될 때마다 listOf()가 새로운 List 인스턴스를 생성합니다. 내용이 완전히 동일하더라도 새로운 객체이므로 items !== previousItems가 됩니다. changedInstance()true를 반환하고, ItemList는 재실행됩니다. 인스턴스 재사용이 이루어지지 않기 때문에 Strong Skipping이 도움이 되지 않는 대표적인 경우입니다.

해결 방법은 Strong Skipping이 없을 때와 동일합니다. remember를 사용하여 인스턴스를 보존하거나, 안정적인 불변 컬렉션(immutable collection) 타입을 사용하면 됩니다.

data classcopy() 패턴

@Composable
fun UserCard(user: User) { /* ... */ }

@Composable
fun Screen(viewModel: MyViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    UserCard(user = state.user)
}

UiState가 매 상태 방출(state emission) 시마다 재생성되는 경우(MVI 패턴에서 copy()를 사용할 때 흔히 발생), state.user는 실제 사용자 데이터가 변경되지 않았더라도 새 인스턴스일 수 있습니다. changedInstance()는 다른 참조를 감지하고 리컴포지션을 발생시킵니다. 만약 User가 안정적인 타입이었다면 changed()equals()를 사용하여 올바르게 스킵했을 것입니다. 이처럼 copy()로 인해 새 인스턴스가 만들어지는 패턴에서는 안정성 표시가 매우 중요합니다.

람다 캡처가 새 인스턴스를 생성하는 경우

@Composable
fun Screen(items: List<Item>) {
    ItemList(
        items = items,
        onClick = { item -> handleClick(item) }
    )
}

{ item -> handleClick(item) } 람다는 불안정한 값을 캡처하지 않으므로 컴파일러가 메모이제이션하여 동일한 인스턴스를 재사용할 수 있습니다. 하지만 람다가 불안정한 값을 캡처하는 경우에는 상황이 달라집니다.

@Composable
fun Screen(items: List<Item>, filter: Filter) {
    ItemList(
        items = items,
        onClick = { item -> applyFilter(item, filter) }
    )
}

이 람다는 불안정한 filter를 캡처합니다. 컴파일러는 changedInstance(filter)를 사용하여 새 람다 인스턴스를 생성할지 결정합니다.

// 컴파일러가 생성하는 코드
val onClick = %composer.cache(%composer.changedInstance(filter)) {
    { item -> applyFilter(item, filter) }
}

filter가 리컴포지션마다 새 인스턴스라면, 람다도 매번 새로 생성됩니다. Strong Skipping은 캡처된 값에 대해 참조 동등성으로 람다를 메모이제이션하지만, 캡처된 값이 동일한 인스턴스일 때만 효과가 있습니다.

안정 캡처와 불안정 캡처가 혼합된 경우

Compose 컴파일러의 골든 테스트 스위트(golden test suite)를 보면 혼합 캡처가 정확히 어떻게 처리되는지 확인할 수 있습니다. 람다가 안정 타입(Bar)과 불안정 타입(Foo)을 모두 캡처하는 경우, 컴파일러는 각각에 대해 서로 다른 비교 호출을 생성합니다.

// 소스 코드
@Composable
fun Test() {
    val foo = Foo(0)  // unstable
    val bar = Bar(1)  // stable
    val lambda = { foo; bar }
}

생성된 코드는 불안정한 캡처에 changedInstance를, 안정적인 캡처에 changed를 사용합니다.

// 컴파일러 생성 코드: 캡처의 안정성에 따라 다른 비교 메서드 적용
val lambda = %composer.cache(
    %composer.changedInstance(foo) or %composer.changed(bar)
) {
    { foo; bar }
}

이 코드에서 알 수 있듯이, Strong Skipping은 모든 캡처를 동일하게 취급하지 않습니다. 안정적인 캡처는 여전히 구조적 동등성 비교의 이점을 누립니다. 불안정한 캡처만 참조 동등성으로 폴백(fallback)합니다. foo가 새 인스턴스이면서 이전 값과 구조적으로 동일하더라도 람다가 재생성됩니다. 반면 bar가 새 인스턴스이더라도 equals()로 비교하여 구조적으로 동일하다면 해당 캡처 때문에 캐시가 무효화되지는 않습니다.

Strong Skipping이 실질적으로 도움이 되는 경우

Strong Skipping이 성능을 실질적으로 개선하는 시나리오는 크게 두 가지입니다.

싱글톤 및 object 참조

파라미터가 리컴포지션 전반에 걸쳐 동일한 객체 인스턴스를 유지하면, 참조 동등성 검사로 이를 감지합니다.

val config = AppConfig.getInstance() // 싱글톤이므로 항상 같은 인스턴스
ConfigDisplay(config = config) // changedInstance가 false를 반환하여 스킵

Strong Skipping 없이는 AppConfig가 불안정한 외부 타입이므로 이 컴포저블은 절대 스킵하지 못했습니다. Strong Skipping에서는 동일한 인스턴스가 전달되므로 스킵합니다. 실무에서 싱글톤 설정 객체나 DI 컨테이너에서 주입된 객체 등은 이 패턴의 혜택을 받을 수 있습니다.

재생성되지 않는 상태 객체

@Composable
fun Screen() {
    val scrollState = rememberScrollState() // 리컴포지션 전반에서 같은 인스턴스 유지
    ScrollableContent(state = scrollState)
}

ScrollState는 클래스 정의에 @Stable 어노테이션이 없지만, remember를 통해 동일한 인스턴스가 재사용됩니다. Strong Skipping은 같은 참조를 감지하여 스킵합니다. rememberScrollState(), rememberLazyListState()remember 기반의 상태 API를 사용하는 패턴이 여기에 해당합니다.

안정성이 제공하는 것 vs Strong Skipping이 제공할 수 없는 것

안정성이 있으면 구조적 동등성 비교를 수행할 수 있습니다. 동일한 값을 가지지만 서로 다른 인스턴스인 경우에 이 차이가 결정적입니다.

  • copy\(\)로 재생성된 data class: 안정적인 data classequals()로 비교하지만, Strong Skipping은 ===로만 비교합니다. 값이 같아도 인스턴스가 다르면 Strong Skipping은 스킵하지 못합니다.
  • 동일한 데이터로 재구성된 컬렉션: 같은 요소를 가진 ImmutableList는 구조적으로 동등하지만, 같은 요소를 가진 새 List는 참조적으로 서로 다릅니다.
  • StateFlow를 통해 흐르는 값: 각 방출(emission)마다 새 값 인스턴스가 생성되므로, 안정성이 있어야 런타임이 실제로 내용이 변경되지 않았음을 감지할 수 있습니다.

핵심을 정리하면 이렇습니다. Strong Skipping은 동일한 인스턴스가 우연히 재사용되는 경우를 잡아내는 안전망(safety net)입니다. 안정성은 인스턴스 정체성(instance identity)과 관계없이 동등한 값이 같은 것으로 인식되도록 보장하는 메커니즘입니다.

두 메커니즘에 대한 멘탈 모델

안정성과 Strong Skipping을 의사결정 트리(decision tree)의 두 계층(layer)으로 생각하면 이해하기 쉽습니다.

런타임이 던지는 첫 번째 질문은 "이 파라미터가 이전과 동일한 인스턴스인가?"입니다. 동일하다면 스킵합니다. 이것이 Strong Skipping이 불안정한 타입에 대해 활성화하는 changedInstance() 검사입니다.

두 번째 질문은 안정적인 타입에 대해서만 적용되며, "이 파라미터가 이전 값과 구조적으로 동등한가?"입니다. 동등하다면 스킵합니다. 이것이 안정성이 활성화하는 changed() 검사입니다.

Strong Skipping 없이는 불안정한 파라미터가 어떤 질문에도 도달하지 못합니다. 컴포저블은 항상 재실행됩니다. Strong Skipping이 있으면 불안정한 파라미터도 첫 번째 질문을 받을 수 있지만, 두 번째 질문은 받을 수 없습니다. 안정적인 파라미터만이 두 질문 모두를 받을 수 있으며, 이것이 안정성이 더 강력한 보장을 제공하는 이유입니다.

이 두 계층 모델을 사용하면 각 메커니즘이 언제 효과적인지 명확해집니다. Strong Skipping은 쉬운 경우, 즉 동일한 객체가 다시 전달되는 상황을 처리합니다. 안정성은 어려운 경우, 즉 내용은 같지만 다른 객체인 상황을 처리합니다. 완전히 최적화된 UI를 구현하려면 두 메커니즘에 대해 모두 이해가 필요합니다.

결론

이번 아티클에서는 Strong Skipping Mode가 컴파일러와 런타임 수준에서 실제로 변경하는 내용을 살펴보았습니다. 컴파일러는 불안정한 파라미터에 대해 스킵을 완전히 비활성화하는 대신 changedInstance()(참조 동등성)를 생성하며, 안정적인 파라미터에 대해서는 기존과 동일하게 changed()(구조적 동등성)를 사용합니다. 람다 캡처도 같은 패턴을 따릅니다. 불안정한 캡처에는 changedInstance()를, 안정적인 캡처에는 changed()를, 혼합 캡처에는 두 메서드를 함께 사용합니다. 타입 자체는 변경되지 않으며, Strong Skipping으로 인해 안정성 분류가 달라지는 경우는 없습니다.

이 차이를 이해하면 실무에서 다양한 상황에 적절히 대응할 수 있습니다. StateFlow에서 방출될 때마다 새로 구성되는 List<Item>을 컴포저블이 받는 경우, 각 방출이 새 리스트 인스턴스를 생성하므로 Strong Skipping으로는 리컴포지션을 방지할 수 없습니다. 타입을 안정적으로 만들면(ImmutableList 사용 또는 @Stable 어노테이션 추가) 런타임이 내용 기반으로 비교하여 항목이 변경되지 않았을 때 스킵할 수 있습니다. copy()로 생성된 data class, 인라인으로 생성된 객체, 불안정한 값을 캡처하는 람다에도 동일한 원칙이 적용됩니다.

실무적인 권장 사항을 정리하면 다음과 같습니다. 컬렉션 파라미터에는 불변 컬렉션을 사용하고, data class의 프로퍼티는 val로 유지하며, 컴파일러가 안정성을 추론하지 못하는 타입에는 @Stable 또는 @Immutable 어노테이션을 추가하세요. 리컴포지션 전반에서 인스턴스를 보존하려면 remember를 활용하시고, 컴포저블 파라미터의 불안정성을 감지하려면 Compose Stability Analyzer를 사용해 보시길 권장합니다. Strong Skipping은 컴포저블이 절대 스킵하지 못하는 최악의 시나리오를 방지하는 유용한 안전망이지만, 안정성 설계를 100% 대체할 수는 없습니다.

아티클 목록으로 가기