Jetpack Compose의 리컴포지션 이해하기

Jetpack Compose를 배우기 시작한 지 얼마 안 된 사람 입장에서는 기존에 사용하던 뷰 시스템에서 모든 게 바뀌었다 보니 구글에서 제공하는 공식 문서를 봐도 사용할 때 어떻게 배워야 하는지 감이 안 잡힌 것 같은 느낌이 들 때가 있었다. 안 그런 사람이 많을지도 모르겠지만 아무튼 나는 그랬다. 나와 비슷한 처지에 있는 사람들과 혹시라도 이걸 까먹을 미래의 나에게 도움을 주기 위해서 이 글을 써보고자 한다. 더 읽어보면 좋을 만한 글들을 맨 아래에 써뒀으니 관심 있으신 분들은 읽어보면 좋을 것 같다.

스냅샷 시스템

일반적인 UI를 그리는 목적으로 Compose를 사용한다면 보통 알 필요가 없지만 좀 더 이해를 깊게 하기 위해서는 짚고 넘어가야 하는 개념이기 때문에 한 번 간단하게 정리해보고자 한다.

Jetpack Compose는 사실 안드로이드만을 위한 UI 툴킷이 아니라서 androidx 패키지명을 가지고 있음에도 불구하고 compose-jb와 같이 데스크탑이나 웹에서도 사용이 가능하고 심지어 터미널 환경에서도 사용이 가능하다. 이런 게 가능한 이유는 순수한 코틀린 모듈인 compose runtime의 존재 덕분이다. 이 모듈은 트리(e.g. UI 레이아웃 트리)를 관리하고 상태 관리를 해주는 역할을 한다. 여기서 상태 관리에 사용되는 요소 중에 스냅샷이 있다. 스냅샷은 mutableStateOf() 등을 통해서 생성된 상태 객체들이 Composable 함수 안에서 올바른 값이 “보이도록” 도와준다.

아래 코드를 보자. 참고로 이 코드는 보면 알 수 있듯 안드로이드 코드도 아니고, @Composable 어노테이션을 사용하지도 않았다. (다시 말해서 컴파일러의 도움을 받지 않았다)

import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.mutableStateOf

fun main() {
	val state = mutableStateOf("Hello World") // Composable 함수가 아니기 때문에 remember를 사용하지 않는다
	val snapshot = Snapshot.takeMutableSnapshot()
	snapshot.enter {
		state.value = "Foo"
		println("One: ${state.value}")
	}

	println("Two: ${state.value}")
	snapshot.apply()
	println("Three: ${state.value}")
}

위 코드의 실행 결과는 다음과 같다:

One: Foo
Two: Hello World
Three: Foo

실행 순서를 유심히 살펴보자. One이 Two보다 먼저 실행된 걸 봤을 때 state의 값은 Foo로 변경되었음에도 불구하고 Two의 값은 Hello World가 나온다. 보다시피 enter 안의 코드는 밖의 상태가 어떻게 되든 takeMutableSnapshot()을 시점의 상태를 유지한다.

여기까지 스냅샷에 대한 소개는 마치고, takeMutableSnapshot()에는 이번 포스트에서 중요하게 다루는 한 가지 기능이 있다. 바로 enter 블록 안에서 State<T> 객체가 읽히는 것을 알 수 있다는 것이다.

바로 위 코드에서 takeMutableSnapshot()readObserver 값을 넣어보겠다.

// ...
val snapshot = Snapshot.takeMutableSnapshot(
	readObserver = { println(it) }
)
// ...

이제 다시 실행해보면 아래와 같은 결과가 나온다:

MutableState(value=Foo)@1650967483
One: Foo
Two: Hello World
Three: Foo

보면 알 수 있듯 enter 블럭 안에서 MutableState를 읽었다는 걸 알 수 있다.

리컴포지션

Jetpack Compose는 컴포지션을 수행할 때 위에서 본 코드와 유사하게 enter 블록 안에서 Composable 함수를 실행한다. 즉 Composable 안에서 읽는 상태 객체들을 모두 추적할 수 있다는 의미다.

다시 말해서 우리는 코드를 작성할 때 “상태 객체가 읽히는 곳”만을 생각하면 자신이 짠 코드가 어떻게 동작할지 쉽게 이해할 수 있다. 특히 성능면에서 말이다.

한 가지만 기억하면 된다. Composable 함수의 안에서 읽히는 상태 객체는 이후에 값이 바뀌면 함수가 다시 실행된다고 생각하면 된다. 여기서 좀 더 집중해서 봐야 할 것은 Composable 함수 안이라는 조건이다.

아래 코드를 확인해보자.

@Composable
fun Sample() {
	var text by remember { mutableStateOf("Hello, World!") }

	Button(
		onClick = {
			println("text: $text")
			text = "Hello"
		}
	) {
		Text(text) // <- 여기서 읽음
	}
}

여기서 우리가 중요하게 봐야 할 곳은 Text를 호출하는 부분이다. text 상태 값을 읽고 있다. onClick 콜백이 호출이 돼서 text에 값이 대입되면 Jetpack Compose는 text의 값이 바뀐 것을 알고 Text가 있는 블럭을 재호출한다. 하지만 onClick 람다 안에서도 text 상태를 읽고 있지만 여기서는 값을 읽어도 onClick 람다가 재호출 되지는 않는다. 왜냐하면 위에서 말한 Composable 함수 안에서 호출되는 게 아니기 때문이다. 즉, Snapshot.enter 블럭 안에서 호출되지 않았기 때문에 상태 값이 읽혔다는 것을 Compose가 추적할 수 없다 (물론 추적한다 해도 다시 실행할 수도 없기 때문에 추적할 필요도 없다.)

참고로 콜스택이 아무리 내려가도 Compose는 어디서 상태를 읽었는지 추적할 수 있다. 가장 흔한 예시는 커스텀 UI 상태 클래스를 사용하는 경우다.

@Stable
class SampleState {
    var count by mutableStateOf(0)

    fun perform() {
        count++
    }

    fun text() = "Count: $count"
}

@Composable
fun Sample() {
    val state = remember { SampleState() }

    Button(onClick = { state.perform() }) {
        Text(state.text())
    }
}

위 코드는 Composable 함수에서 직접적으로 count 상태를 읽는 곳이 없다. 그에 비해서 perform() 함수는 count 상태를 1씩 올린다. 언뜻 보기에는 perform()을 호출하도 리컴포지션이 되지 않을 것 같지만 놀랍게도 텍스트가 업데이트 되는 것을 볼 수 있다. 그 이유는 스냅샷 시스템은 콜스택이 내려가도 상태가 읽히는 것을 추적할 수 있기 때문이다. 참고로 여기서 유의해야 할 점은, count의 값이 바뀌었을 때 text()만 다시 실행되는 게 아니라 가장 가까운 리컴포지션 스코프인 Button 전체가 다시 실행된다는 점이다. Button이 "재시작"할 수 있는 가장 작은 범위기 때문이다.

참고로 거의 쓸 일은 없겠지만, 상태 읽는 걸 Compose가 알면 안 되는 경우에는 Snapshot.withoutReadObservation을 사용할 수 있다:

fun text() = Snapshot.withoutReadObservation {
	"Count: $count"
}

이렇게 바꾸면 버튼을 눌러도 텍스트가 업데이트 되지 않는다.