본문 바로가기

Jetpack Compose

[Jetpack Compose] Compose 성능 최적화

서론

Compose로 만들어진 앱은 recomposition을 통해 다시 Composable이 그려지면서 UI를 변하게 할 수 있습니다. 구현에 급급하다보니 일단 문제가 없어보이면 불필요한 recomposition이 있는지 확인하거나 이를 줄이기 위한 작업이 없이 넘어갔습니다. 따라서 Compose의 recomposition이 언제 일어나는지, Compose의 성능을 최적화하기 위해서는 어떻게 해야 하는지에 대해 알아보려고 합니다.

recomposition

먼저 Compose의 recomposition부터 짚고 넘어가야 합니다. recompositionComposable 내부의 원소가 변경이 되었을 때 Compose가 다시 계산되어 UI가 화면에 다시 그려지는 것을 의미합니다. Compose가 화면에 그려지기까지는 3단계를 거치게 됩니다.

 

1. Composition : Composable tree를 생성하고 어떠한 UI들을 화면에 표시할 것인지 결정하는 과정입니다. - What to show

2. Layout : UI를 배치할 위치를 결정합니다. 이 과정은 측정과 배치라는 두 단계로 구성되며 레아이웃 요소는 Composable tree에 있는 각 노드의 레이아웃 요소 및 모든 하위 요소를 2D 좌표로 측정하고 배치합니다. - Where to show

3. Draw : UI를 화면에 렌더링하는 과정입니다. - Draw to the screen

 

만약 Composable 내부에 데이터가 변경되어 recomposition이 일어나는데 Composition 단계에서 기존의 Composition과 비교했을 때 아무런 변화가 없다면 Compose는 recomposition 건너뛰게 되고 이는 성능 향상으로 이어집니다.

 

저는 여기서 말하는 Composable의 내부 데이터가 정확히 어떤 것인지가 궁금했습니다. 어떤 글에서는 그냥 원소라고 표현하기도 하고 어떤 글에서는 Compose의 State라고 표현하기 합니다. 그래서 Layout Inspector를 통해 recomposition 횟수를 디버깅해보면서 직접 알아보았습니다. 

 

State가 변하면 recomposition이 일어난다는 것은 공식문서에도 나와 있기 때문에 일반 변수의 경우 변수의 값이 변하면 어떻게 될지 궁금했습니다. Composable 내부에 일반 변수를 선언하고 Button 클릭 시 변수의 값을 변하게 했을 때는 recomposition이 일어날까요?

 

@Composable
fun RecompositionTestScreen() {
    var normalValue = 0

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Button(
            onClick = {
                normalValue ++
            }
        ) {
            Text(text = "Click me!")
        }
    }
}

 

다음과 같이 버튼을 누를 때마다 recomposition이 일어나지만 UI가 업데이트되지는 않음을 확인할 수 있었습니다. 밑에서 설명하겠지만 이는 normalValue 프로퍼티가 var로 선언되어 unstable하기 때문입니다.

 

recomposition 횟수를 나타내는 Layout Inspector

 

불필요한 recomposition

compose에서 발생하는 불필요한 recomposition을 줄이는 것이 compose의 성능 개선에 큰 도움이 됩니다. 여기서 불필요하다는 것은 recomposition이 일어나지만 값이 그대로이고 UI가 변하지 않는 것과 같이 무의미한 recomposition을 의미합니다. 어떤 부분에서 불필요한 recomposition이 발생하는지 파악하기 위해서는 compose의 stablility를 먼저 알아야 합니다.

 

Compose의 Stability

Compose는 2가지의 타입으로 구분될 수 있습니다. 바로 stable한 것이냐, unstable한 것이냐로 말이죠. stable의 뜻을 통해 알 수 있듯이 stable한 것은 안정적인 상태인 것입니다. 만약 Compose가 immutable하거나 recomposition이 일어났을 때 내부의 어떤 값이 변했는지 파악할 수 있는 경우에 stable하다고 표현합니다. 반면 recomposition이 일어났을 때 내부의 어떤 값이 변했는지 파악할 수 없다면 unstable하다고 표현합니다.

 

이렇게 stable이냐, unstable에 따라 Compose의 동작이 크게 달라지게 됩니다. 바로 recomposition을 할 것이냐를 결정하는 요인이기 때문입니다. 만약 어떤 Compose 컴포넌트가 stable하다면 내부의 값이 같을 때 Compose는 recomposition을 하지 않고 넘어갑니다. 그러나 unstable하다면 내부의 값이 동일해도 Compose는 해당 부모 컴포넌트가 recomposition 될 때 해당 컴포넌트도 같이 recomposition을 하게 됩니다.

 

따라서 값이 변하지 않는 경우인데 unstable하면 불필요한 recomposition이 일어날 수 있고, 이것이 반복되거나 심해지면 성능의 저하로 이어질 수도 있습니다. 다음 예시를 통해 실제로 stable한 경우와 unstable한 경우에 recomposition이 어떻게 일어나는지 알아보도록 하겠습니다.

 

먼저 stable한 객체를 생성하기 위해 immutable한 data class를 생성해보겠습니다. name과 number 모두 val로 선언되어 있기 때문에 한 번 생성된 Contact 인스턴스는 immutable 이기에 Contact 인스턴스는 stable 합니다.

data class Contact(
    val name: String, 
    val number: String
)

 

다음은 Screen에 Button을 배치해서 Button을 클릭할 때마다 selected 프로퍼티가 바뀌도록 해서 Screen의 어느 부분이 recomposition 되는지 확인해보겠습니다.

 

@Composable
fun RecompositionTestScreen() {
    Surface(
        modifier = Modifier.fillMaxSize()
    ) {
        ContactRow(Contact("Peter", "010-111-1111"))
    }
}

@Composable
fun ContactRow(
    contact: Contact,
) {
    var selected by remember { mutableStateOf(false) }

    Row(
        modifier = Modifier.fillMaxSize(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center
    ) {
        ContactDetails(contact)
        Button(
            onClick = { selected = !selected }
        ) {
            Text("Click Button!")
        }
    }
}

@Composable
fun ContactDetails(contact: Contact) {
	/* to do something */
}

data class Contact(val name: String, val number: String)

 

화면에 있는 Button을 누를 때마다 다음과 같이 recomposition 이 일어남을 확인할 수 있었습니다. 오른쪽에 있는 숫자가 recomposition 횟수인데, selected의 영향을 받은 Button Composable만 recomposition이 일어났음을 확인할 수 있습니다. immutable한 Contact 객체를 사용하는 ContactDetails는 Compose가 skip하고 recomposition이 일어나지 않았음을 알 수 있습니다.

 

Layout Inspector를 이용한 recomposition debugging

 

만약 Contact data class의 프로퍼티들을 var로 변경하면 어떻게 될까요? 먼저 Contact의 내부 프로퍼티가 변경되어도 Compose는 이를 인지하지는 못합니다. 왜냐하면 Compose는 Compose State의 변화만 인지하기 때문입니다. 변경의 여지는 있지만 변화를 인지하지 못하기 때문에 Compose는 이러한 컴포넌트를 unstable로 인지합니다. 언제 변화할지 모르기 때문에 selected가 변할 때마다 Contact를 사용하는 ContactDetails Composable도 recomposition이 일어나게 됩니다.

 

그렇다면 Contact의 인자를 MutableState<String> 타입으로 변경하면 어떻게 될까요? 그렇게 되면 Contact를 선언하는 곳에서 mutableStateOf<String>을 통해 값을 넣어주어야 하는데 remember를 사용하라는 오류를 내뱉습니다. 개발하면서 종종 보았던 에러인데 이제야 이유를 알 것 같습니다. remember를 통해 값을 기억하고 있지 않으면 값이 매번 달라지는 것으로 판단하고 unstable한 객체인 Contact의 recomposition이 불필요하게 일어날 수 있기 때문에 remember를 강제하는 듯 합니다.

 

mutableStateOf를 remeber와 함께 사용하지 않으면 오류를 내뱉는 모습

 

Compose에서의 구현

Compose 컴파일러는 코드를 읽으면서 각 컴포넌트에 태그를 표시하는데, 해당 태그를 보고 recomposition을 수행할지 여부를 정합니다. 태그들을 한 번 알아봅시다.

 

다음은 Composable 함수에 표시하는 태그입니다. 둘 중 하나를 표시할 수도 있고 하나만 표시할 수도 있고 아무 것도 표시하지 않는 경우도 있습니다.

  • Skippable : 해당 태그가 있는 컴포넌트는 Compose가 recomposition을 일으키지 않고 스킵합니다. 해당 컴포넌트의 모든 인자 값들이 이전의 값과 새롭게 바뀐 값이 동일한 경우에 해당 태그가 달립니다.
  • Restartable : 해당 컴포넌트를 기준으로 스코프를 만들어 recomposition이 일어납니다. 즉 state가 변할 수 있고 recomposition의 시작점입니다. 

Parameter는 다음과 같이 구분합니다.

  • Immutable : Compose에서는 모든 프로퍼티들이 immutable이고 메서드들이 *참조 투명하면 Immutable 태그를 붙입니다. 모든 primitive 타입(String, Int, Float 등)은 immutable로 표시됩니다. 대표적으로 모든 프로퍼티가 primitive 타입이고 val 형태인 data class가 있습니다. 
  • Stable : 생성 후 프로퍼티가 변경될 수 있는 유형으로, 즉 값이 바뀔 수는 있지만 런타임 중 프로퍼티가 변경된다면 Compose가 변경 사항을 추적할 수 있는 컴포넌트입니다. 대표적으로 State<T>가 있습니다.
  • Unstable : Immutable과 Stable에 속하지 않는 유형으로 recomposition을 유발합니다.

불필요한 recomposition 줄이기

1. Compose는 List, Set, Map과 같은 collection을 값이 바뀔 수 있는 여지가 있다고 생각해서 항상 unstable한 객체라고 판단합니다. 즉, 우리가 흔히 사용하는 data class 안에 List 타입의 프로퍼티가 있다면 val로 선언되어 있어도 해당 data class는 unstable한 객체가 됩니다.

 

따라서 만약 불변한 collection을 쓰고 있어서 stable하다고 compose에게 알려주기 위해서 @Immutable 이나 @Stable 어노테이션을 붙이거나 ImmutableList, ImmutableSet, ImmutableMap 과 같은 immutable collection 을 사용하면 됩니다. 단, 개발자가 임의로 @Immutable, @Stable 붙여도 컴파일러가 unstable한 객체로 판단하면 recomposition이 일어나기 때문에 사용에 주의해야 합니다.

 

immutable collection을 사용하기 위해서는 다음과 같이 implementation을 해야 하고 변경되는 버전이나 최신 사항에 대해서는 다음 깃허브 링크를 통해 확인할 수 있습니다.

implementation ("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6")

 

간단하게 다음과 같이 사용할 수 있으며, persistentListOf()는 empty한 ImmutableList를 반환합니다.

val examples: ImmutableList<Example> = persistentListOf()

 

2. 이미지 뷰를 그릴 때 쓰는 Painter 클래스는 unstable 합니다. 따라서 Image의 painter 인자에 Painter 클래스를 활용하면 이미지가 변하지 않는 상황에서도 불필요한 recomposition이 일어날 수 있습니다. Painter 클래스 대신 paintResource를 이용해 stable한 Int 값인 resourceId를 넘겨주면 불필요한 recomposition을 막을 수 있습니다.

 

Image(
    painter = painterResource(id = R.drawable.example_img),
    contentDescription = "Example Image"
)

 

만약 svg 파일을 사용하는 Image 컴포넌트라면 ImageVector를 이용할 수 있습니다. ImageVector class는 내부적으로 다음과 같이 @Immutable 어노테이션을 가지고 있기 때문에 stable합니다.

 

Image Vector 내부 구현

 

3.  반복문(for문, forEach문)이나 Lazy Composable에는 key를 씁니다. 많은 분들이 Lazy Column이나 Row와 같은 Composable에서는 key를 활용하지만 for문이나 forEach문으로 Composable을 활용하는 경우에는 key를 사용하지 않습니다. 다음은 안드로이드 공식 문서에서 나와 있는 for문을 사용했을 때 recomposition에 관한 코드입니다.

 

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            MovieOverview(movie)
        }
    }
}

 

위와 같이 for문을 이용해 movies 배열을 그린 뒤, movies에 새로운 원소를 추가하면 어떻게 될까요? 모든 MovieOverview에 대해서 recomposition이 일어날까요? 

 

답은 '새로운 원소가 추가되는 위치에 따라 달라진다' 입니다. 만약 새로운 원소가 movies 배열의 마지막에 추가되면 기존에 있던 movies의 원소들은 위치가 그대로이기 때문에 MovieOverview 인스턴스를 재활용할 수 있어 recomposition이 일어나지 않습니다. 

 

반면 원소가 맨 앞에 추가되거나 정렬이 바뀌거나 중간에 원소가 추가되어 위치가 바뀌게 되는 원소들은 내부의 값이 movie의 값이 동일해도 MovieOverview가 recomposition이 일어나게 됩니다. 따라서 내부의 값이 동일하면 skippable하게 만드려면 key를 사용해야 합니다.

 

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { 
                MovieOverview(movie)
            }
        }
    }
}

 

위와 같이 movie를 식별할 수 있는 값인 id를 key로 설정하면 각각의 MovieOverview가 movie의 id와 매핑되어 recomposition되는 대신 컴포지션 트리 내에서 인스턴스를 재활용해 재정렬하게 되면서 불필요한 recomposition을 줄일 수 있습니다.

 

주석

* 참조 투명성 : 함수나 메서드가 주어진 입력에 대해 항상 동일한 출력을 생성하며, 부작용(side effects)가 없는 성질을 의미

 

예시 ) 참조 투명한 함수

fun add(a: Int, b: Int): Int {
    return a + b
}

 

예시 ) 참조 투명하지 않은 함수

var total = 0

fun addWithSideEffect(a: Int): Int {
    total += a
    return total
}