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

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

skydovesJaewoong Eum (skydoves)||12분 소요

컴포즈 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이 안정성을 100% 대체하지 못하는 것처럼, 안정성 역시 항상 이점을 주는 것은 아닙니다. 타입을 안정적으로 만들어도 실질적인 이점 없이 오버헤드만 추가되는 경우가 존재합니다.

StateFlow는 이미 방출을 중복 제거합니다

StateFlow는 내부적으로 구조적 동등성 비교를 수행합니다. emit(newValue) 또는 .value를 업데이트하면 StateFlownewValue == currentValue를 확인하고, 동등한 경우 방출을 억제합니다. 따라서 collectAsStateWithLifecycle()에 도달하는 값은 Compose가 이를 확인하기 전에 이미 동등성 검사를 통과한 상태입니다.

UI 상태가 StateFlow를 통해 흐르고 해당 타입이 equals()를 올바르게 구현하고 있다면, Compose의 안정성 검사는 이미 다르다고 확인된 값에 대해 두 번째 비교를 수행하는 셈입니다. 이 시나리오에서 타입을 안정적으로 만들면 Compose 계층에서 불필요한 equals() 호출이 추가됩니다. StateFlow는 진짜 새로운 값만 방출하므로 컴포저블은 어차피 재실행됩니다.

이것이 StateFlow에서 안정성이 무용하다는 의미는 아닙니다. StateFlow 방출과 무관한 이유(가령 형제 상태가 변경된 경우)로 부모 컴포저블이 재실행될 때, 안정적인 자식 파라미터는 부모가 실행되더라도 equals()를 통해 스킵할 수 있기 때문입니다. 다만 StateFlow의 직접적인 소비자는 중복 제거가 이미 완료된 상태라는 점을 인지하는 것이 중요합니다.

구조적 동등성 비교에는 비용이 따릅니다

안정성은 equals() 비교를 수행하는데, equals()는 단순 가벼운 동작이 아닙니다. 수백 개의 요소를 포함하는 List<Item>을 가진 data class의 경우, 구조적 동등성 비교는 리스트의 모든 요소를 순회합니다. 데이터가 실제로 변경되어 컴포저블이 어차피 재실행될 상황이라면, equals() 비교는 의미없는 작업입니다.

이 비용은 크고 깊게 중첩된 데이터 구조에서 문제가 됩니다. 여러 리스트, 맵, 중첩 객체를 포함하는 UiState는 컴포저블 본문을 다시 실행하는 것보다 equals() 동작이 더 비쌀 수 있습니다. 이런 경우에는 안정성에 의존하기보다 대상을 좁힌 키와 함께 remember를 사용하는 것이 더 효율적인 경우가 많습니다.

val processedItems = remember(items.size, filterKey) {
    items.filter { it.matchesFilter(filterKey) }
}

이와 같은 방식은 경량 키로 파생 값을 메모이제이션하여 구조적 동등성 비교 비용과 리컴포지션 비용을 동시에 회피합니다.

파생 값은 안정성 없이도 메모이제이션할 수 있습니다

컴포저블이 데이터를 변환하거나 필터링하는 경우, 입력 타입의 안정성과 관계없이 remember를 사용하여 변환 결과를 메모이제이션할 수 있습니다.

@Composable
fun FilteredList(items: List<Item>, query: String) {
    val filtered = remember(items, query) {
        items.filter { it.name.contains(query) }
    }
    LazyColumn {
        items(filtered) { ItemRow(it) }
    }
}

remember 호출은 필터링된 리스트를 캐싱하고, items 또는 query가 변경될 때만(키에 대한 참조 동등성 비교) 재계산합니다. List<Item>이 불안정하더라도 동작합니다. 메모이제이션이 작동하기 위해 리스트를 안정적으로 만들 필요는 없습니다. remember의 키 비교는 Strong Skipping이 사용하는 것과 동일한 참조 동등성을 활용하므로, 두 접근 방식이 서로 보완적으로 작동합니다.

실무적 균형

올바른 질문은 "이 타입을 안정적으로 만들어야 하는가?"가 아니라 "이 맥락에서 이 파라미터에 대해 안정성이 실리적 이득을 더하는가?"입니다. 값이 StateFlow를 통해 흐르고 컴포저블이 직접 소비자라면, 안정성은 불필요한 검사를 추가합니다. 타입의 equals()가 비용이 크고 자주 변경된다면, 안정성은 오버헤드를 더합니다. 경량 키를 사용하여 remember로 값을 메모이제이션할 수 있다면, 전체 객체에 대한 구조적 동등성보다 효율적일 수 있습니다.

안정성이 가장 큰 가치를 발휘하는 경우는 컴포지션 트리의 서로 다른 지점에서 동등한 인스턴스가 생성될 때, 부모 리컴포지션이 변경되지 않은 값을 자식 컴포저블에 전달할 때, 그리고 equals() 비용이 컴포저블 본문 비용에 비해 낮을 때입니다. 이러한 트레이드오프를 이해하면 안정성이 도움이 되는 곳에 적용하고, 도움이 되지 않는 곳에서는 피할 수 있습니다.

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

안정성과 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, 인라인으로 생성된 객체, 불안정한 값을 캡처하는 람다에도 동일한 원칙이 적용됩니다.

실무적 결론은 보다 섬세합니다. 안정성이 항상 필요한 것은 아닙니다. StateFlow의 중복 제거, remember 메모이제이션, 복잡한 타입에 대한 equals() 비용은 특정 맥락에서 안정성 어노테이션을 생략해도 되는 충분한 근거가 됩니다. 그러나 안정성이 구식이 된 것도 아닙니다. 구조적으로 동등하지만 참조적으로 다른 값을 Compose가 감지할 수 있게 하는 유일한 메커니즘이며, 부모 리컴포지션이 변경되지 않은 데이터를 자식 컴포저블에 전파할 때 여전히 중요합니다. Strong Skipping은 컴포저블이 절대 스킵하지 못하는 최악의 시나리오를 방지하는 안전망이지, 각 도구가 언제 어디서 가치를 제공하는지에 대한 이해를 대체할 수는 없습니다. 코드베이스에서 안정성이 중요한 지점을 파악하려면 Compose Stability Analyzer를 사용하여 불안정한 파라미터를 감지하고 컴포저블 트리 전반의 리컴포지션 패턴을 추적해 보시길 권장합니다.

아티클 목록으로 가기