본문 바로가기

Android/Tech

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

Unsplash, Courtney Cook.

동기

Jetpack Compose 를 활용하여 개발 중인 앱 <쿡셔너리> 에는 드래그 앤 드랍과 같은 유저 인터랙션이 존재합니다. 다만 문제가 좀 있었습니다. 드래그 앤 드랍 시 화면이 버벅거린다는 점이었고, 이는 매우 치명적인 문제였습니다. 이를 해결하기 위해 다양한 솔루션들을 시도해보았고, 끝내 효과적인 방법 두 가지를 알게 되었습니다.
 
Jetpack Compose 로 UI 를 구현하였지만 앱의 퍼포먼스 저하를 경험하고 계신 분들께서는 지금부터 기술할 방법들을 적용해보시면 좋을 것 같습니다.


권장사항

 

권장사항 준수  |  Jetpack Compose  |  Android Developers

권장사항 준수 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 발생할 수 있는 일반적인 Compose 실수가 몇 가지 있습니다. 이러한 실수로 인해 코드가 잘 실행

developer.android.com

 
안드로이드 개발자 공식 문서에는 불필요한 Recomposition 을 최소화하기 위한 여러 가지 솔루션들을 제안하며, 이는 권장사항이므로 응당 따르는 것이 좋습니다. 해당 문서에서는 Jetpack Compose 를 활용한 UI 구현에 있어서 개발자들이 지켜야 할 사항들에 대해 이야기 합니다.

 

다만, 해당 권장사항들을 모두 준수하여도 모든 문제가 해결되는 것은 아닙니다.


문제 파악하기

문제 해결의 첫걸음은 문제 진단입니다. 어떤 문제가 존재하고, 그 요인을 찾는 것이 가장 중요합니다. 이 단계에서 문제가 발생하면, 거대한 리소스를 소모하고도 문제는 해결되지 않는 상황이 발생할 수 있기 때문입니다.

 

앱 <쿡셔너리> 에서 발생한 문제는 당연하게도 UI 렌더링 문제였습니다. 다만, 이 문제가 실제 디바이스에서 유독 심하게 발생하는 상황이었기 때문에, 소스 코드의 문제인지 아니면 디바이스의 문제인지를 먼저 파악하고자 했습니다. 이를 위해, 우리는 Systrace 와 LayoutInspector 를 사용할 수 있습니다.

Systrace

Systrace 는 스튜디오의 Profiler 를 통해 활성화할 수 있습니다. 

 

 

System Trace 에 체크하고 Record 버튼을 눌러 기록을 시작합니다. 이후 앱을 조작하고, 기록된 슬라이스 내의 디바이스 리소스 사용 정보에 대한 문제 인식이 가능합니다.

 

 

Janky frames 의 Janky 는 '신뢰할 수 없는', '품질이 낮은' 의 의미를 갖습니다. 즉, 문제가 발생하고 있는 프레임을 말합니다. 프레임은 한 순간의 화면이고, 프레임은 일정한 제한 시간을 갖습니다. 30fps 라면 1초에 30 장의 프레임을 렌더링하여야 하므로, 한 프레임 당 약 0.033.. 초의 제한 시간을 갖게 되는 겁니다.

 

제한 시간 내에 해당 프레임에 이루어져야 하는 UI 렌더링 작업이 모두 마무리되어야 하는데, UI 렌더링 관련 연산이 과하게 요구되어 UI 렌더링에 지연이 발생하는 겁니다.

 

Systrace 를 통해 문제가 있다는 사실을 알았으니, LayoutInspector 를 활용하여 불필요한 Recomposition 이 존재하는 부분을 포착한 뒤 이를 해결하면 되겠습니다.

 

 

LayoutInspector 는 Jetpack Compose 환경에서 사용할 경우, 어떠한 Composable 에 Recomposition 이 몇 번 요청되었는지, 또 그 중 렌더링이 실제로 요구된 것은 몇 번이었는지 등에 대한 데이터를 제공합니다.

 

 

참 부끄러운 일이지만, 불필요한 Recomposition 이 너무나 많이 발생하고 있었습니다. 약간의 유추를 통해 어떠한 부분이 문제인지 파악할 수 있었는데요. 일단 조작하지도 않은 LazyRow 에 유효 Recompositon 이 발생하고 있었습니다. 그리고 해당 Composable 의 파라미터는 LazyRow 의 항목들에 해당하는 List<Item> StateHoisting 을 위한 람다가 전부였습니다.

 

이 두 문제를 해결하기 위해서는, Jetpack Compose 의 Stability 에 관해 알아야만 했습니다.


Stability

Jetpack Compose 에는 Stability 라는 너무나도 중요한 개념이 존재합니다. 이 Stability 라는 지표는

Compose Compiler 가 임의의 Composable 에 대해 Recompositon 을 수행해야 하는지, 또는 그렇지 않아도 되는지에 대한 여부를 나타내게 됩니다. 즉, 입력된 파라미터가 Stable 하고 변경되지 않았다면 Recomposition 을 생략할 수 있습니다.

 

StableMarker(@Stable, @Immutable) 에 관한 내용은 다음 공식 문서에 작성되어 있습니다. 

 

 

컴포저블 수명 주기  |  Jetpack Compose  |  Android Developers

컴포저블 수명 주기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 컴포저블의 수명 주기에 관해 알아보며 Compose에서 컴포저블에 재구성

developer.android.com

 

@Stable 과 @Immutable 의 역할은 불필요한 Recomposition 의 발생을 억제한다는 점에서 같지만, 약간의 차이가 있습니다. @Stable 은 파라미터가 되는 타입의 필드 중 var 로 선언된 Stable 한 필드가 존재하는 경우에 사용하고, @Immutable 은 @Stable 보다 조금 더 강한 표현을 위해 사용하는데, 타입의 어느 필드도 절대 변경되지 않고, 변경을 위해서는 새로운 인스턴스가 생성되어 교체되어야만 함을 표시하기 위해 사용됩니다.

 

StableMarker 는 같은 방식으로 동작하며, 이러한 StableMarker 가 없고 Stable 의 조건을 충족하지 못하는 경우에는 상위 Composable 의 Recomposition 이 요청되면 해당 Composable 도 Recomposition 되게 됩니다.

 

즉, StableMarker 를 통해 불안정한 타입을 안정적인 상태로 선언하여 Compose Compiler 가 효율적으로 동작하도록 명령할 수 있는 것이 주요 골자입니다.

 

UiState 의 경우, List<T> 타입의 필드를 포함하거나 개발자가 구현한 커스텀 데이터 타입의 필드를 포함하는 경우가 많습니다. 그러므로, 불필요한 Recomposition 의 발생을 LayoutInspector 를 통해 인지한 경우에 StableMarker 사용을 고려할 수 있습니다.

 

중요한 것은, UiState 의 필드에 대해 StableMarker 가 필요한 항목들에 대해 모두 처리하고, UiState 타입까지 StableMarker 를 사용해주어야 한다는 점입니다. 결국 UI 가 업데이트 되는 건 UiState 객체의 대체에 있으니까요.


List<T> Wrapper

인터페이스는 기본적으로 Unstable 합니다. 이유는 간단한데요, 서브 클래스가 안정적인 타입이 아닐 수 있기 때문입니다. 예로, List<T> 타입의 서브 클래스인 MutableList<T>의 경우 데이터는 변경될 수 있지만 val 과 함께 선언 될 수 있습니다. 이와 같은 경우, 우리는 List<T> 타입을 래핑하는 클래스를 생성하고, 이에 StableMarker 를 추가하는 방식으로 List<T> 타입을 안정적인 타입의 필드로 사용할 수 있습니다.

 

data class Ingredient(
    val name: String,
    val imageUrl: String
) : BaseModel

 

이와 같은 간단한 데이터 타입이 존재합니다. 모든 공개 필드가 Stable 하며(Primitive), 

data class 의 특성으로 두 인스턴스의 데이터가 같다면 equals() 메서드의 수행 결과가 항상 true 이고,

유형의 공개 속성이 변경되면 컴포지션에 알림이 전송되어야 하지만, val 로 선언하였기 때문에 변경 될 수 없으므로 관계가 없게 되니, Ingredient 는 Stable 한 상태라 볼 수 있습니다.

 

그러나, 앞서 기술하였듯이 List<T> 타입은 Unstable 하므로, 이를 래핑하는 클래스를 생성하여 사용할 수 있습니다. 앱 <쿡셔너리> 에서는 Nullable List<T?> 와 NotNull List<T> 를 모두 사용하므로, 각각 따로 래퍼 클래스를 생성할 수 있습니다.

 

@Immutable
data class NullableIngredientList(val values: List<Ingredient?>)
@Immutable
data class NotNullIngredientList(val values: List<Ingredient>)

 

이 방식에 문제가 있는 것은 아닌데, 조금 불편한 부분이 있습니다. 명칭과 List<T>T 만 다른 클래스들이 우후죽순 생겨 날 수 있습니다. 다만, 앱 <쿡셔너리> 에서는 그다지 많은 타입을 사용하고 있지 않아서 큰 문제는 되지 않아 이대로 사용하기로 결정합니다. 

 

만약 수많은 타입에 대한 적용이 요구된다면, 다음 링크의 라이브러리를 사용하면 간편할 것 같습니다.

 

GitHub - Kotlin/kotlinx.collections.immutable: Immutable persistent collections for Kotlin

Immutable persistent collections for Kotlin. Contribute to Kotlin/kotlinx.collections.immutable development by creating an account on GitHub.

github.com


람다 인스턴스

앞서 Stability 에 관해 이야기할 때 첨부했던 안드로이드 공식 문서에는 분명히 람다 역시 안정적인 것으로 간주된다고 표기되어 있습니다.

 

 

@Composable
private fun TopSection(
    uiEvent: (UiEvent) -> Unit
) {
    ...
}

 

그렇다면 이 Composable 메서드는 수 없이 많이 발생하는 Recomposition 에 모두 안전할까요? 그렇지 않습니다. 경우에 따라 안정적일 수도, 그렇지 않을 수도 있습니다. 또한, 공식 문서에서 안정적인 것으로 간주한다는 것은 타입에서의 이야기이지, Composable 메서드의 이야기는 아닙니다. 

 

람다 파라미터 uiEvent 가 안전하지 않음을 확인하기 위해, 외부의 값을 캡쳐하지 않는 또 다른 람다

onTest: () -> Unit 을 함께 넘겨 해시 코드를 확인해 봅니다.

 

UI_EVENT 156942950
ON_TEST 63179175
UI_EVENT 105700738
ON_TEST 63179175
UI_EVENT 249384831
ON_TEST 63179175
UI_EVENT 83593306
ON_TEST 63179175
UI_EVENT 31051814
ON_TEST 63179175
...

 

Recomposition 될 때마다 uiEvent 람다 인스턴스가 새롭게 생성되어 적용되고 있습니다. 이유는 간단한데, 외부에서 캡처하는 값이 Stable 하지 않기 때문입니다. 제 경우엔 viewModel 을 간접적으로 참조하여 이벤트를 넘겨주고 있는데, ViewModel 의 메서드를 호출하는 것도 똑같이, ViewModelStable 하지 못한 값이기 때문에 람다 인스턴스가 재생성됩니다. 즉, Stable 하지 못한 객체를 외부에서 캡처하는 람다는 Recomposition 에서 자유로울 수 없습니다.

 

그러므로, 근본적인 해결 방법은 람다가 캡처하는 외부의 값을 Stable 하게 만들어주면 됩니다만, ViewModel 은 상태를 갖고 있기 때문에 Stable 할 수 없습니다. 그러므로, 이벤트 처리 람다를 Stable 하게 만들어주면 됩니다.

 

val onEvent = remember { { uiEvent: UiEvent -> uiEvent(uiEvent) } }

 

간단합니다. 이벤트 객체를 처리하는 람다를 remember { } 로 감싸줍니다. 이를 통해 생성되는 객체는 파라미터가 변한다고 해서 다른 값으로 변경되지 않습니다.

 

ON_EVENT 156942950
ON_EVENT 156942950
ON_EVENT 156942950

 

해당 Composable 의 람다 파라미터가 아닌 정수 타입 파라미터 값이 변경되도록 하여 의도적인 Recompositon 이 발생하도록 하였습니다. 해당 Composable 이 Recomposition 되지만, 이벤트 람다의 해시 코드 값은 그대로입니다. 이와 같은 방식으로 외부의 값을 캡처하는 람다로 인해 발생하는 Skippable Recompositon 을 생략할 수 있습니다.

 

 

600번 이상의 Recomposition 이 요청되었으나, Stability 에 대한 설정을 수행하니 생략 가능한 Recomposition 은 모두 생략되었습니다.

 

Systrace 의 Janky frames 도 확인해봤습니다. 충분히 좋은 개선이 있었으며, 몇 개의 Janky frame 이 남아 있지만 실제 사용 시에는 버벅거림이 없을 정도로 개선됩니다.


Jetpack Compose 는 데이터 기반의 UI 를 구현하고자 할 때 사용하기 정말 좋은 도구입니다. 큰 수고로움 없이 데이터를 UI 와 바인딩할 수 있으니까요. 그러나, 편리한 만큼 또 다른 곳에서 신경 써야 할 것들이 많은 것 같습니다.