아티클 목록으로 가기

Jetpack Compose Mechanisms, Part 2: 런타임은 어떻게 값을 기억하고, UI는 어떻게 픽셀이 되는가

skydovesJaewoong Eum (skydoves)||20분 소요

Jetpack Compose Mechanisms, Part 2: 런타임은 어떻게 값을 기억하고, UI는 어떻게 픽셀이 되는가

Part 1에서는 컴파일러가 평범한 함수를 스스로 건너뛰기도 하고 알아서 다시 실행되기도 하는 함수로 바꾸는 과정을 살펴봤습니다. 이 변환으로 컴포저블(composable)이 어떻게 재시작하는지는 설명되지만, 언제 재시작하는지, 실행과 실행 사이에 필요한 값이 실제로 어디에 저장되는지는 여전히 알 수 없습니다. 여러분도 매일 이 질문들의 답에 기대어 코드를 작성하고 계십니다. 값을 remember로 감싸고 mutableStateOf를 꺼내 쓰면 화면이 갱신됩니다. 그런데 remember만 썼더니 화면이 전혀 갱신되지 않거나, remember 없이 쓴 mutableStateOf가 자꾸 초기화된다면, 그 이유는 런타임에서 찾아야 합니다. 런타임이 무엇이 바뀌었는지 알아낸 뒤에도 할 일은 남아 있습니다. Compose UI가 그 기록을 안드로이드가 그릴 수 있는 형태로 바꿔야 합니다.

이번 글에서는 Compose 런타임과 UI 계층을 깊이 파고듭니다. 값을 보관하는 네 가지 방법에서 출발해 픽셀에 이르기까지, 슬롯 테이블(SlotTable)과 그 안의 갭 버퍼(gap buffer), 위치 기반 메모이제이션(positional memoization), 구독(subscription)처럼 동작하는 상태(state) 읽기, 프레임에 맞춰 일어나는 리컴포지션(Recomposition), Applier, Layout 기본 요소(primitive), AndroidComposeView를 차례로 살펴봅니다.

이 글은 DroidKaigi 2026 & Droidcon Berlin 2026에서 발표한 Jetpack Compose Mechanisms 세션을 바탕으로 한 3부작 시리즈의 두 번째 글입니다. 발표 자료를 함께 보셔도 좋습니다. 이 시리즈의 다이어그램도 모두 발표 자료에서 가져왔습니다. Part 1에서는 컴파일러를 다뤘고, Part 3에서는 전체 그림을 성능 개선 작업에 적용해 봅니다. 이 글에서 인용하는 소스 코드는 Compose 1.13.0-beta01 기준입니다.

두 가지 트리거: 입력 변경과 상태 읽기

Part 1은 호출될 때마다 본문을 실행해야 할지 스스로 판단하는 함수에서 끝났습니다. 컴포저블이 다시 실행되는 경로는 두 가지인데, 이 판단은 그중 하나에 해당합니다.

  1. 입력 변경: 호출한 쪽(caller)이 다시 실행되면, 생성된 코드는 각 인자를 지난번 실행 때 저장해 둔 값과 비교하고, 하나라도 다르면 본문을 실행합니다. Part 1에서 보신 $composer.changed(name) 검사가 이 역할을 합니다.
  2. 상태 읽기: State를 읽은 컴포저블은 그 상태에 의존한다고 기록됩니다. 그 상태가 바뀌면 런타임은 읽기를 수행한 가장 가까운 재시작 가능한 스코프(restartable scope)가 다시 실행되도록 예약합니다. 호출한 쪽이 다시 실행되지 않더라도 마찬가지입니다.

둘 중 하나만 일어나도 본문은 다시 실행됩니다. 첫 번째 경로는 인자만 넘기시면 되고, 따로 하실 일이 없습니다. 두 번째 경로는 mutableStateOf로 직접 마련하셔야 합니다. 이때 거의 항상 remember로 감싸서 쓰는데, 혼란은 대부분 이 조합에서 시작됩니다.

근본적인 문제: 값을 보관하는 네 가지 방법

같은 컴포저블을 네 가지 버전으로 작성해 보았습니다. 모두 Dove 하나를 보관하며, remember를 쓰는지, mutableStateOf를 쓰는지, 아니면 둘 다 쓰는지만 다릅니다.

@Composable
fun BirdProfile() {
    var bird = Dove()
}

@Composable
fun BirdProfile() {
    var bird = remember { Dove() }
}

@Composable
fun BirdProfile() {
    var bird = mutableStateOf(Dove())
}

@Composable
fun BirdProfile() {
    var bird = remember { mutableStateOf(Dove()) }
}

remember와 mutableStateOf를 함께 써야 한다는 사실은 아마 알고 계실 겁니다. 궁금한 것은 각각이 혼자서 무엇을 해 주느냐인데, 답은 런타임이 맡은 두 가지 역할로 나뉩니다. 하나는 실행과 실행 사이에 값을 유지하는 일이고, 다른 하나는 변경을 알리는 일입니다.

remember 없이: 실행할 때마다 새로운 값

먼저 가장 단순한 버전부터 보겠습니다. 컴포저블은 여러 번 실행될 수 있고 그때마다 본문을 처음부터 다시 거치므로, 평범한 Dove() 호출은 매번 새 인스턴스를 만듭니다.

Without remember, every recomposition creates a new Dove instance

리컴포지션이 두 번 일어나면 실행은 모두 세 번이고, 그때마다 서로 다른 Dove 인스턴스가 만들어집니다. 지역 변수는 함수가 한 번 실행되는 동안만 살아 있으므로, 한 실행의 bird와 다음 실행의 bird를 이어 주는 것은 아무것도 없습니다.

remember: 슬롯 테이블에 값 저장하기

호출을 remember로 감싸면 실행할 때마다 같은 인스턴스를 돌려받습니다.

With remember, every recomposition gets the same Dove instance back

실행과 실행 사이에 값이 머물 곳이 어딘가에 있어야 합니다. 컴포지션(composition)이 기록하는 모든 것은 슬롯 테이블이라는 구조에 담깁니다. Part 1에서 보신 그룹(group)과 그 안에 기록된 값이 여기에 들어가며, 값은 각각 슬롯(slot) 하나를 차지합니다. remember는 값을 슬롯에 써 두었다가 다음 실행에서 다시 읽어 옵니다. remember 없이 만든 지역 값은 이 테이블에 기록되지 않으므로, 다음 실행에서 읽어 올 것이 없습니다. 인자는 사정이 다릅니다. Part 1에서 보셨듯이 changed()는 비교하는 인자를 하나하나 슬롯에 보관하며, 아래 다이어그램에 "Count: 0" 같은 값이 들어 있는 것도 그래서입니다.

remember의 구현을 살펴보겠습니다.

@Composable
public inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

remember는 Composer.cache에 일을 넘깁니다.

public inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

구조를 단계별로 나눠 보면 다음과 같습니다.

  1. 읽기: rememberedValue()는 지난번 실행에서 컴포지션의 같은 위치에 저장해 둔 값을 반환합니다. 아직 아무것도 저장되지 않았거나 콘텐츠를 재사용하는 중이라면 Composer.Empty를 반환합니다.
  2. 한 번만 계산: 첫 컴포지션이거나 invalid가 true이면 람다가 실행되고, updateRememberedValue가 그 결과를 저장합니다.
  3. 재사용: 그 이후의 실행에서는 저장된 값이 그대로 돌아오고, 람다는 아예 실행되지 않습니다.

키를 받는 remember가 바꾸는 것은 invalid 플래그뿐입니다. remember(key1) { ... }는 cache(currentComposer.changed(key1), calculation)을 호출하는데, 여기서 쓰는 changed는 Part 1에서 보신 그 함수입니다. 그래서 키가 지난번에 저장된 값과 더 이상 같지 않을 때에만 값을 다시 계산합니다. 컴파일된 코드에서는 컴파일러가 이 로직을 인라인으로 펼쳐 키를 직접 비교합니다. 키가 안정적인(stable) 매개변수라면 Part 1의 $dirty 비트를 재사용할 수 있고, 상수 키는 아예 비교하지도 저장하지도 않습니다. 불안정(unstable)하다고 알려진 매개변수는 언제나 별도의 changed() 호출을 거칩니다. 반면 안정성(stability)이 런타임에야 결정되는 매개변수라면, 호출한 쪽이 그 인자를 불안정하다고 표시했을 때에만 changed()를 호출합니다. 코틀린 2.4부터는 List가 여기에 해당합니다. Part 3에서는 키를 받는 remember로 리스트가 다시 만들어지는 것을 막아 봅니다.

슬롯 테이블의 내부: 그룹, 슬롯, 갭 버퍼

테이블이 어떻게 구성되는지 보려면, 작은 카운터 하나가 두 번의 컴포지션을 거치는 과정을 따라가 보는 것이 좋습니다.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    if (count > 0) {
        Icon(Icons.Default.Star, contentDescription = null)
    }
    Text("Count: $count")
}

컴포저(composer)는 코드가 실행되는 순서 그대로 슬롯 테이블을 훑어 나가는데, 문서 위를 움직이는 커서와 비슷합니다. 아래 다이어그램은 알아보기 쉽도록 테이블을 세로 한 줄로 펼쳐 그렸지만, 실제 테이블은 그룹과 슬롯을 서로 다른 배열 두 개에 나눠 보관합니다. 다이어그램은 상태의 현재 값도 코드 안에 적어 두었습니다. 그래서 뒤쪽 다이어그램에 나오는 mutableStateOf(1)은 소스가 바뀌었다는 뜻이 아니라, 상태가 지금 1을 담고 있다는 뜻입니다.

첫 컴포지션에서는 리컴포즈 스코프(recompose scope), 기억된 상태, Text 호출이 차례로 기록됩니다. count가 0이므로 if 블록은 실행되지 않고, Icon 자리에는 분기를 표시하는 빈 그룹 하나만 남습니다. 기록된 항목들 뒤로 테이블 끝까지 이어지는 빈 항목들이 갭(gap)입니다.

On the first composition, Icon is skipped and the empty entries at the end form the gap

이제 count가 1로 바뀌고, 리컴포지션이 같은 코드를 다시 훑습니다. 이번에는 if 블록이 실행되므로, 테이블 한가운데, 상태와 Text 호출 사이에 Icon이 들어갈 자리가 필요합니다.

여기서 갭 버퍼가 등장합니다. 갭 버퍼는 많은 텍스트 에디터가 입력 중인 문서를 담을 때 쓰는 자료 구조입니다. 텍스트는 배열 하나에 들어 있고, 커서 위치에는 갭이라고 부르는 빈 공간 덩어리가 놓입니다. 글자를 입력하면 다른 내용은 건드리지 않고 갭을 채우며, 커서를 옮기면 갭도 함께 옮겨 갑니다. 슬롯 테이블도 같은 방식으로 동작하는데, 다이어그램에서 압축해 표현한 세부 사항이 하나 있습니다. 작성하신 코드가 실행되는 동안 컴포저는 기존 테이블을 읽기만 합니다. Icon 같은 새 콘텐츠는 별도의 삽입 테이블(insert table)에 컴포즈하고, 필요한 편집은 변경 목록(change list)에 기록해 둡니다. 이 변경 사항을 적용할 때 테이블에 쓰기를 담당하는 writer가 갭을 삽입 지점으로 옮깁니다. 갭이 옮겨 가면서 그 사이에 있던 항목들이 갭 반대편으로 복사되는데, 이 예제에서는 Text 호출의 항목 두 개입니다. 그다음 비워진 자리에 Icon의 항목이 기록됩니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기