지난 글에서 remember가 있어야 리컴포지션이 일어나도 값이 유지된다는 것까지 확인했다. 그런데 리컴포지션이라는 말 자체를 좀 더 정확히 알아야 할 것 같아서, 이번엔 리컴포지션의 동작 원리와 그걸 가능하게 하는 내부 구조까지 정리해봤다.
State 값이 바뀌면 Composable 함수가 다시 실행되면서 화면이 갱신된다. 이렇게 상태 변화에 따라 Composable 함수를 다시 실행해서 화면을 다시 그리는 과정을 리컴포지션이라고 부른다. 카운터 앱에서 버튼을 눌러 count가 1 늘어나면, count를 보여주던 Text 부분이 리컴포지션을 거쳐 새 값으로 다시 그려지는 식이다.
문제는 화면 하나에 Composable이 수십, 수백 개 있을 수 있다는 점이다. 값 하나가 바뀔 때마다 화면 전체를 처음부터 다시 그리면 느려지고 배터리도 많이 먹는다. 그래서 Compose는 바뀐 부분만 골라서 다시 그리려고 하는데, 이때 쓰는 기법이 메모이제이션(Memoization)이다.
메모이제이션은 한 번 계산한 결과를 저장해두었다가 같은 입력이 다시 들어오면 계산을 반복하지 않고 저장해둔 결과를 재사용하는 최적화 기법이다. 피보나치수를 재귀로 구할 때 이미 구한 값을 저장해두면 같은 계산을 건너뛸 수 있는 것과 같은 원리다.
Compose는 값 자체가 아니라 "이 Composable이 코드 안에서 어느 위치에서, 어떤 순서로 호출됐는지"를 기준으로 이전 결과를 재사용할지 판단한다. 그래서 위치 기반 메모이제이션(Positional Memoization)이라고 부른다. 위치가 같고 입력값도 그대로면 다시 계산하지 않고 이전 결과를 그대로 화면에 쓴다.
이 위치 기반 메모이제이션을 실제로 가능하게 해주는 내부 자료구조가 Slot Table이다.
Slot Table은 Compose가 화면을 그릴 때마다 "어떤 Composable 함수가 어떤 순서로 호출됐고, 그때 어떤 값을 가지고 있었는지"를 기록해두는 자료구조다. 공연장 좌석표를 떠올리면 이해하기 쉬웠다. 좌석표에 몇 번 자리에 누가 앉았는지 적어두면, 다음번에 자리를 다시 확인할 때 처음부터 모든 좌석을 돌아보지 않고 표만 보고도 바뀐 자리를 바로 찾을 수 있다. Slot Table도 마찬가지로, 이전 리컴포지션 때 기록해둔 정보를 보고 실제로 값이 바뀐 Composable만 골라낸다.
이 Slot Table은 갭 버퍼(Gap Buffer)라는 자료구조를 기반으로 만들어져 있다. Gap Buffer는 원래 텍스트 에디터에서 문자를 삽입/삭제할 때 자주 쓰이는 자료구조다. 배열 중간에 비어 있는 공간(gap)을 미리 마련해두고, 데이터를 추가/삭제할 때 이 gap 근처에서만 처리하기 때문에 배열 전체를 앞뒤로 밀어내지 않고도 빠르게 삽입/삭제할 수 있다. Compose 화면에서도 리스트 항목이 추가/삭제되는 것처럼 Composable이 새로 생기거나 없어지는 상황이 자주 발생하는데, Slot Table이 Gap Buffer 구조를 쓰는 이유도 이런 삽입/삭제를 효율적으로 처리하기 위해서다.
개념은 이해했는데, "화면 전체가 아니라 바뀐 부분만 다시 그린다"는 말을 실제로 눈으로 보고 싶어서 독립된 카운터 두 개를 만들어봤다.
@Composable
fun ScopedCounters(modifier: Modifier = Modifier) {
Column(modifier) {
CounterA()
CounterB()
}
}
@Composable
private fun CounterA(modifier: Modifier = Modifier) {
var count by remember { mutableStateOf(0) }
Log.d(TAG, "CounterA 재구성됨 count=$count")
Column(modifier) {
Text("A: $count")
Button(onClick = { count++ }) { Text("+1") }
}
}
@Composable
private fun CounterB(modifier: Modifier = Modifier) {
var count by remember { mutableStateOf(0) }
Log.d(TAG, "CounterB 재구성됨 count=$count")
Column(modifier) {
Text("B: $count")
Button(onClick = { count++ }) { Text("+1") }
}
}
CounterA와 CounterB는 각각 자기 자신의 remember 상태를 들고 있는 별개의 Composable이다. Logcat을 "Recomposition" 태그로 필터링해두고 A의 +1 버튼만 눌러봤다.
찍히는 로그는 "CounterA 재구성됨 count=1" 한 줄뿐이었다. B는 화면에 그대로 떠 있는데도 로그가 전혀 찍히지 않았다. B의 +1을 눌렀을 때도 마찬가지로 B 쪽 로그만 찍혔다. 두 카운터가 하나의 화면(같은 Column) 안에 같이 있는데도, 값이 바뀐 쪽만 리컴포지션이 일어나고 나머지는 건드려지지 않는다는 걸 로그로 직접 확인할 수 있었다.
글로 읽을 때는 "Compose는 바뀐 부분만 다시 그린다"는 문장이 다소 추상적으로 느껴졌는데, 두 개의 독립된 Composable을 만들어서 Logcat으로 확인해보니 훨씬 구체적으로 와닿았다. 각 Composable이 자기 범위(scope) 안의 State만 구독하고 있고, 그 State가 바뀔 때만 해당 범위가 다시 실행된다는 게 핵심이었다. Slot Table이 각 Composable의 호출 위치와 값을 따로 기록해두기 때문에 가능한 구조라는 것도 이번에 코드로 확인하면서 제대로 이해가 됐다.