본문 바로가기

Android/Tech

[Jetpack Compose] Strong Skipping Mode 는 어떻게 렌더링 효율을 개선하는가

Hennie Stander, Unsplash.

동기

코틀린 2.0.20 버전 이상부터 Jetpack Compose 프로젝트 생성 시 StrongSkippingMode 가 기본으로 적용되어 렌더링 이슈를 겪는 일이 크게 줄었습니다. Jetpack Compose 를 초창기부터 사용해오셨던 분들은 과거에 비해 확실히 신경쓸 것이 줄어 개발하기 편해지셨으리라 봅니다. 저도 정말 요긴하게 사용하고 있는데, 어떤 방식으로 변수가 기억되고 람다가 기억되는지 명확하게 알고 있지는 못해서, 이번 기회에 알아보고 정리해두려 합니다.

 

강력 건너뛰기 모드  |  Jetpack Compose  |  Android Developers

강력한 건너뛰기는 불안정한 매개변수가 있는 컴포저블을 건너뛸 수 있도록 하고 불안정한 캡처가 있는 람다의 자동 메모이제이션을 사용 설정하여 성능을 향상하는 Compose 컴파일러 모드입니

developer.android.com

이번 포스트의 주된 내용은 사실 해당 페이지에 모두 잘 작성되어 있습니다. 꼭 필요한 내용들만 골라서요. 


Recomposition 과 Stability

 

[Jetpack Compose] 불필요한 Recomposition 을 줄여 앱 퍼포먼스 개선하기

동기 Jetpack Compose 를 활용하여 개발 중인 앱 에는 드래그 앤 드랍과 같은 유저 인터랙션이 존재합니다. 다만 문제가 좀 있었습니다. 드래그 앤 드랍 시 화면이 버벅거린다는 점이었고, 이는 매우

blothhundr.tistory.com

위 포스트에서는 다양한 방식으로 불필요한 Recomposition 을 건너뛰는 방법에 대해 자세히 기술해두었습니다. 다만, 해당 포스트는 코틀린 2.0.20 이 릴리즈 되기 전인 2024년 초에 작성되어 StrongSkippingMode 에 대한 설명은 없습니다. 물론 그 때도 StrongSkippingMode 를 사용할 수 있었지만, 잘 알려진 기능은 아니었습니다.

 

간단하게 이야기하자면, 화면을 이루는 컴포넌트인 Composable 은 상태 변경 시 Recomposition 이 발생하게 되는데, 불필요한 Recomposition 까지 모두 수행하게 되면 심각한 렌더링 성능 저하를 야기할 수 있다는 것입니다.

 

다만, Compose Compiler 는 건너뛸 수 있는 Recomposition 은 건너뛰도록 설계가 되었기 때문에 Compose Compiler 에게 상태 변화에 따라 어떤 Composable 의 Recomposition 을 건너뛸 수 있는지 사전에 잘 마킹을 해두는게 렌더링 성능을 유지하는 열쇠가 될 수 있습니다.

 

이를 위해 사용되었던 개념이 Stability 이며, StableMarker 나 원시 타입 파라미터, Wrapper Class 등으로 Stability 를 확보할 수 있습니다. 이를 적절히 활용하면 Compose Compiler 가 불안정하다고 판단하는 타입이 Composable 파라미터에 포함되더라도 Recomposition 을 건너뛰어 렌더링 성능을 유지할 수 있습니다.


Compose Compiler 는 어떻게 Recomposition 을 건너뛰는가?

Compose Compiler 가 불필요한 Recomposition 을 건너뛰어 렌더링 효율을 개선하는 방식에 대한 이해를 위해서는 Compose Compiler 가 어떤 원리로 Recomposition 을 수행하는지에 대한 지식이 필요합니다.

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name")
}

 

개발자가 @Composable 어노테이션을 작성하면, Compose Compiler 는 Kotlin IR(Intermediate Representation) 단계에서 메서드의 시그니처를 변경합니다.

fun Greeting(
    name: String, 
    $composer: Composer, // UI 트리를 관리하는 객체 추가
    $changed: Int        // 파라미터 변경 여부를 나타내는 비트마스크 추가
) {
    // ...
}

 

여기서 추가된 Composer 객체가 바로 Recomposition 을 관장하는 핵심 객체입니다. 해당 객체는 Composable 이 호출될 때마다 UI 트리의 현재 위치를 추적하고 데이터를 메모리에 저장합니다.

 

Jetpack Compose 는 메모리에 Slot Table 이라는 배열을 보유하고 있습니다. Composable 이 처음 실행될 때, 즉 Initial Composition 시 컴파일러가 주입한 코드는 Composable 이 호출된 코드 상의 위치를 키로 삼아 입력된 파라미터와 내부 상태를 Slot Table 에 저장합니다.

 

이후 Recomposition 이 발생하면, 저장된 이전 파라미터와 새롭게 들어온 파라미터를 동등성 비교(==, equals())하여, 변경된 경우에만 UI 를 업데이트 합니다. 이를 도식화하면 아래와 같습니다.

 

 

이는 원론적인 내용이며, Recomposition Skip 작동 방식에 대한 이해를 위해서는 Group 에 대한 지식도 빠질 수 없습니다. Jetpack Compose 내부 컨셉에 대한 내용이라 공식 문서에는 이에 대한 내용이 따로 없으니, 여기에서 어떤 컨셉인지 파악만 해보시는 것을 추천드립니다.

 

쉽게 말하면, '독립적으로 건너뛰거나 재실행할 수 있는 Compose UI 최소 실행 단위' 정도로 보시면 됩니다. Composable 메서드 자체가 Group 이 될 수 있고(RestartGroup; 참조하고 있는 상태가 변화하면 다시 시작), 조건문에 의해 Group 이 될 수도 있으며,(ReplaceableGroup; 조건이 맞지 않으면 해당 그룹의 존재 여부가 변경) 반복문에 의해 Group 이 될 수도 있습니다.(MovableGroup; 순서가 바뀌면 그룹 전체 위치만 이동, 보통 key 를 기반으로 조율)

 

아무튼요, Group Recomposition Skip 의 작동 방식을 이해하기 위해 꼭 필요한 개념인 이유는 Composable 메서드 코드를 바이트코드로 변환한 코드에서 파악할 수 있습니다.

public static final void TestComposable(@NotNull List<String> list, @Nullable Composer $composer, int $changed) {
    block3: {
        Intrinsics.checkNotNullParameter(list, (String)"list");
        $composer = $composer.startRestartGroup(2102415117);
        ComposerKt.sourceInformation((Composer)$composer, (String)"C(TestComposable)N(list)*8@211L8:TestFunction.kt#vje5q6");
        
        if (ComposerKt.isTraceInProgress()) {
            ComposerKt.traceEventStart((int)2102415117, (int)$changed, (int)-1, (String)"com.example.strongskippingmodecodetest.TestComposable (TestFunction.kt:6)");
        }
        
        Iterable iterable = list;
        boolean bl = false;
        for (Object t : iterable) {
            String string = (String)t;
            boolean bl2 = false;
            TextKt.Text-Nvy7gAk((String)string, null, (long)0L, null, (long)0L, null, null, null, (long)0L, null, null, (long)0L, (int)0, (boolean)false, (int)0, (int)0, null, null, (Composer)$composer, (int)0, (int)0, (int)262142);
        }
        
        if (ComposerKt.isTraceInProgress()) {
            ComposerKt.traceEventEnd();
        }
        
        ScopeUpdateScope scopeUpdateScope = $composer.endRestartGroup();
        if (scopeUpdateScope == null) break block3;
        scopeUpdateScope.updateScope((arg_0, arg_1) -> TestFunctionKt.TestComposable$lambda$1(list, $changed, arg_0, arg_1));
    }
}

 

네 번째 줄, 그리고 끝에서 다섯 번째 줄을 보면 startRestartGroup(), endRestartGroup() 이라는 메서드를 호출하고 있습니다. 그 사이에는 실질적으로 Composable 을 그려내는 코드가 있고요. 즉, 화면에 보여지는 Composable 이 그려지기 위해서는 Group 의 시작과 끝 사이의 코드가 실행되어야 함을 알 수 있습니다. 

 

그렇다면 Compose Compiler 가 Recomposition 을 건너뛰는 원리는 매우 간단합니다. Group 의 시작과 끝 사이의 코드를 실행하지 않으면 됩니다.


StrongSkippingMode

StrongSkippingMode 는 앞서 언급한 까다로운 Stability(안정성) 기준을 획기적으로 완화하여, 개발자가 별도의 처리 없이도 손쉽게 Recomposition 을 건너뛸 수 있게 해주는 강력한 기능입니다. 기존에는 Composable 의 파라미터 중 단 하나라도 불안정하다고 판단되는 타입이 포함되어 있으면 Recomposition 을 건너뛸 수 없었는데요, StrongSkippingMode 가 적용되면 불안정한 타입의 파라미터가 있더라도 이전 상태와 비교했을 때 객체의 인스턴스 참조가 동일하다면 이를 건너뜁니다.

 

이에 더해, 내부에서 정의된 람다들 역시 불안정한 변수를 캡처하더라도 자동으로 Memoization 처리되므로, 개발자가 렌더링 성능 최적화를 위해 불필요한 Wrapper Class 를 만들거나 remember {} 처리에 쏟아야 했던 수고를 크게 덜어줍니다.

 

Kotlin 2.0.20 버전부터는 StrongSkippingMode 가 기본으로 활성화되어 있습니다. 이전 버전에서는 다음처럼 모듈 단위로 활성화할 수 있고요.

composeCompiler {
    enableStrongSkippingMode = true
}

 

StrongSkippingMode 는 크게 두 가지의 최적화를 진행합니다. 첫 째로 Unstable 한 파라미터가 포함된 Composable 의 동일성 비교를 통한 Recomposition 최적화를, 둘 째로 Unstable 한 값을 캡처하는 람다의 Memoization 입니다.

 

앞서 설명했던 Compose Compiler 가 Recomposition 을 건너뛰는 원리를 통해 StrongSkippingMode 가 적용되지 않은, 그리고 적용된 코드를 살펴봅니다. 먼저, Unstable 한 파라미터가 포함된 Composable 의 경우입니다.

data class User(
    val id: Long, 
    val nicknames: List<String>
)

@Composable
fun UserCard(user: User) {
    val nicknamesText = user.nicknames.joinToString(", ")
    Text(text = "User ${user.id} (Nicknames: $nicknamesText)")
}

 

User 에는 List<String> 프로퍼티가 있어 불안정한 data class 입니다. List<T> 는 Interface 이기 때문에, 실제 구현체가 매우 불안정할 수 있습니다. 해당 코드를 바이트 코드로 변환하면 다음과 같습니다.

@Composable
public static final void UserCard(final User user, Composer $composer, final int $changed) {
    $composer = $composer.startRestartGroup(-12345678);
    
    String nicknamesText = CollectionsKt.joinToString$default(
        user.getNicknames(), ", ", null, null, 0, null, null, 62, null
    );
    TextKt.Text("User " + user.getId() + " (Nicknames: " + nicknamesText + ")", ...);
    
    ScopeUpdateScope $dirtyScope = $composer.endRestartGroup();
    if ($dirtyScope != null) {
        $dirtyScope.updateScope((Function2)(new Function2<Composer, Integer, Unit>() {
            public final void invoke(Composer composer, int $force) {
                UserCard(user, composer, $changed | 1);
            }
        }));
    }
}

 

$dirtyScope 는 $composer.endRestartGroup() 메서드에 의해 생성되는데요. 상태 변화가 아예 없는 경우에는 해당 값이 null 이라, UserCard Composable 을 호출할 필요가 없게 됩니다. 하지만 이 경우, 전달되는 파라미터 User 가 Unstable 하기 때문에 $dirtyScope 가 null 이 아니게 되고, 이에 따라 UserCard Composable 이 재호출됩니다. 즉, StrongSkippingMode 를 적용하지 않았기 때문에 상위 Composable 의 Recomposition 요청이 들어오면 반드시 유효 Recomposition 을 수행합니다.

@Composable
public static final void UserCard(final User user, Composer $composer, final int $changed) {
    $composer = $composer.startRestartGroup(-12345678);
    int $dirty = $changed;
    
    if (($changed & 14) == 0) {
        $dirty |= $composer.changed(user) ? 4 : 2;
    }
    
    if (($dirty & 11) == 2 && $composer.getSkipping()) {
        $composer.skipToGroupEnd(); 
    } else {
        String nicknamesText = CollectionsKt.joinToString$default(
            user.getNicknames(), ", ", null, null, 0, null, null, 62, null
        );
        TextKt.Text("User " + user.getId() + " (Nicknames: " + nicknamesText + ")", ...);
    }
    
    ScopeUpdateScope $dirtyScope = $composer.endRestartGroup();
    if ($dirtyScope != null) {
        $dirtyScope.updateScope((Function2)(new Function2<Composer, Integer, Unit>() {
            public final void invoke(Composer composer, int $force) {
                UserCard(user, composer, $changed | 1);
            }
        }));
    }
}

 

이번에는 StrongSkippingMode 를 적용한 경우인데요. $changed 값을 $dirty 라는 지역 변수에 할당합니다. 우리가 주목해야 할 부분은 ($dirty & 11) == 2 && $composer.getSkipping() 조건문입니다. $changed 의 값을 비트마스크로 활용하여 파라미터의 변화 여부나 상태를 확인하는 데에 사용됩니다. 즉, $changed 값이 Recomposition 을 건너뛸 수 있고, composer 역시 건너뛸 수 있는 상태라면 $composer.skipToGroupEnd() 메서드를 호출하여 쿨하게 건너뜁니다.

 

이후에는 아래로 내려가 ScopeUpdateScope 를 생성합니다. new Function2() 는 람다 생성입니다. 이후에 해당 Composable 을 다시 그릴 수 있도록 하기 위해 람다 객체를 생성해서 시스템에 등록해두는 과정입니다.


다음은 메서드 본문 내 람다 Memoization 입니다. Unstable 한 값을 캡처하는 람다를 Memoization 처리하여, 람다로 인한 불필요한 Recomposition 이 발생하지 않도록 합니다. 먼저, Composable 본문입니다.

@Composable
fun StrongSkippingModeTest() {
    val value: Int = 0
    var variable: Int = 0 

    Button(
        onClick = { variable++ }
    ) {
        Text("$value $variable")
    }
}

 

메서드 본문 내에 variable 이라는 변수가 있습니다. Button Composable 의 onClick {} 람다에서 해당 변수를 캡처하게 하여 매우 불안정한 Composable 메서드를 작성했습니다. StrongSkippingMode 를 적용하게 되면 해당 람다를 Memoization 하게 됩니다. 변화를 구별하기 위해 먼저 StrongSkippingMode 를 적용하지 않고 바이트 코드로 변환합니다.

@Composable
public static final void StrongSkippingModeTest(Composer $composer, final int $changed) {
    $composer = $composer.startRestartGroup(98765432);
    
    if (($changed & 1) == 0 && $composer.getSkipping()) {
        $composer.skipToGroupEnd();
    } else {
        final int value = 0;
        final Ref.IntRef variable = new Ref.IntRef();
        variable.element = 0;
        
        Function0 onClick = new Function0<Unit>() {
            @Override
            public Unit invoke() {
                variable.element++;
                return Unit.INSTANCE;
            }
        };
        
        ButtonKt.Button(onClick, ...);
    }
    $composer.endRestartGroup();
}

 

앞선 경우와는 다르게 기본적으로 건너뛰는 코드가 있습니다. 조건에 맞지 않으면 건너뛰지 않고 다시 그리도록 되어 있습니다. $changed 가 1이면 강제 렌더링이기 때문에 다시 그립니다. 아무튼, 이상할 것 없는 코드입니다. 매우 불안정한 Composable 이기 때문에 까다로운 조건 없이 다시 그리도록 변환됩니다.

 

주목할만 한 건 val 과 var 의 처리인데요. val 로 선언된 것은 단순하게 final int, var 로 선언된 쪽은 값이 변할 수 있기에 람다 안에서 값을 조작할 수 있도록 자바의 객체 참조형인 Ref.IntRef 라는 홀더 객체에 담습니다. 이는 두 가지 이유가 있는데요.

 

첫 째로, 메서드는 호출 이후 바로 끝나지만 람다는 언제 실행될지 모르기에, 람다에서 캡처하는 변수는 스택 영역이 아닌 힙 영역에 두어 참조 유실을 피하기 위함입니다. 그리고 둘 째로, 애초에 자바는 람다 내에서 외부 스코프 변수를 캡처했을 때 해당 변수를 변경할 수 없기 때문입니다.

 

이를 Clousre 라 하는데, Closure 에 관한 내용은 아래 포스트에 기술해두었습니다. 오래된 글이라 썩 깔끔하진 않지만, 필요하신 분들은 참고하셔도 좋겠습니다.

 

Kotlin Closure

Closure Closure (Close over) 기능을 최초로 제공한 언어는 1958년, John McCarthy 에 의해 개발된 언어 Lisp 입니다. Closure 를 포함한 수많은 현대적 프로그래밍 개념과 아이디어를 선보인 언어죠. Lisp 에서는

blothhundr.tistory.com

@Composable
public static final void StrongSkippingModeTest(Composer $composer, final int $changed) {
    $composer = $composer.startRestartGroup(98765432);
    
    if (($changed & 1) == 0 && $composer.getSkipping()) {
        $composer.skipToGroupEnd();
    } else {
        final int value = 0;
        final Ref.IntRef variable = new Ref.IntRef();
        variable.element = 0;
        
        $composer.startReplaceableGroup(112233);
        
        boolean $changedKeys = $composer.changed(variable);
        Object rememberedValue = $composer.rememberedValue();
        
        if ($changedKeys || rememberedValue == Composer.Companion.getEmpty()) {
            rememberedValue = new Function0<Unit>() {
                @Override
                public Unit invoke() {
                    variable.element++;
                    return Unit.INSTANCE;
                }
            };
            $composer.updateRememberedValue(rememberedValue);
        }
        $composer.endReplaceableGroup();
        
        ButtonKt.Button((Function0)rememberedValue, ...);
    }
    $composer.endRestartGroup();
}

 


아무튼요, 이번엔 StrongSkippingMode 가 적용된 버전입니다. 처음 마주하는 조건문은 이전과 같이 쿨하게 건너뛸 수 있으면 건너뜁니다. 이후 val, var 에 대한 처리도 같고요. 진짜 중요한 건 그 다음부터인데요. new Function0<Unit>(람다) 를 rememberedValue 라는 Object 객체에 할당합니다. 즉, 람다 자체를 remember {} 처리하여 매번 새로 생성되는 람다 객체를 Composer 가 기억하도록 처리합니다.

 

이렇게 되면 우리는, 다음과 같은 코드를 작성할 수 있게 됩니다.

Button(onClick = { viewModel.onClickButton() }) { 
    Text(text = "버튼)
}

 


후기 (feat.기본의 중요성)

원래는 엣지 케이스라던지, 마이그레이션에 대한 비용이라던지, StableMarker 의 존속 여부라던지에 대해 다양하게 기술하고자 했는데요. 관련 내용들을 쭉 살펴보며 하나 하나 체크할 때마다 들었던 생각이, 'StableMarker 잘 붙여놨고(유무에 따라 동일성/동등성 비교 여부가 달라지기 때문에) UDF 만 잘 지켜왔으면 신경 쓸 문제가 아닌데?' 였습니다.(물론 람다 자동 Memoization 은 압도적인 편리함을 줍니다.) 그래서 그냥 써놨던 내용들을 모두 지우고 바로 후기를 작성하게 됐습니다.

 

결국, 기본을 잘 지켜오시던 분들은 별도의 마이그레이션 비용 없이 람다 자동 Memoization 과 도메인 모듈의 순수성 보장(도메인 모듈 내 data class 에 StableMarker 를 안 붙이셔도 되니까요) 정도의 이점을 누리실 수 있게 됐습니다.

 

Jetpack Compose 가 세상의 빛을 본 지 어느새 7년이라는 시간이 흘렀습니다. 그리고 2026년 5월, 구글은 Compose First 를 공식 선언했습니다. 이제 기존의 Android View System 은 유지보수 모드로 전환되었고요. 확실히 Jetpack Compose 이라는 기술이 꽤나 많이 성숙해져왔음을 실감하고 있습니다.

 

불과 몇 년 전만 하더라도, 구글 검색창에 Jetpack Compose 를 검색하면 'Jetpack Compose 단점', 'Jetpack Compose 배워야 하나요' 와 같은 주제의 글들을 보곤 했던 것 같은데, 이제는 모두가 Jetpack Compose 를 사용하는 시대가 되었으니 정말 격세지감입니다.

 

앞으로는 또 어떤 기술들이 안드로이드 개발을 편리하게 바꿔줄까요?

긴 글 읽어주셔서 감사합니다.