1편에서 "Composable은 순수 함수여야 한다"고 했습니다.
4편에서 "@Stable은 성능 도구가 아니라 컴파일러와의 계약이다"고 했습니다.
5편에서 "순수할 수 없는 부수 효과는 Effect API로 격리한다"고 했습니다.
6편에서 "Snapshot System은 사실 DB의 MVCC다"고 했습니다.
그런데 4·5·6편을 다시 펼쳐 보면, 우리는 같은 단어를 계속 쓰고 있었습니다. "Recomposition(재구성)".
4편에서는 "Recomposition을 Skip한다"고 했고, 6편에서는 "상태가 바뀌면 Recomposition된다"고 했고, 5편에서는 "이펙트가 Recomposition을 건너 살아남는다"고 했습니다. 세 편이 전부 이 단어 위에 서 있는데, 정작 한 번도 정확히 묻지 않았습니다.
Recomposition이 일어날 때, 무엇이 다시 실행되는 거지?
"컴포저블 함수가 다시 호출되는 것"이라고 답하기 쉽습니다. 저도 4·5·6편을 쓰면서 그렇게 말했습니다. 하지만 엄밀히 말하면, 다시 실행되는 단위는 함수가 아니라 재구성 범위(Recompose Scope)입니다. 이 단위를 알고 나면 4·5·6편이 사실 한 가지를 세 각도에서 본 글이었다는 게 보입니다.
Compose 글을 읽다 보면, 그리고 제가 4·5·6편에서 그랬듯이, 이런 문장을 자주 만납니다.
"Recomposition은 컴포저블 함수가 다시 호출되는 것이다."
"상태가 바뀌면 그 함수가 다시 실행된다."
틀린 말은 아닙니다. 결과만 보면 함수 본문이 다시 도는 게 맞습니다.
그런데 이 프레이밍엔 문제가 있습니다. 함수를 단위로 생각하면 설명되지 않는 것들이 생깁니다.
derivedStateOf나 람다로 상태 읽기를 미루는 "성능 팁"은 대체 무엇을 좁히는 건가?이 질문들은 "함수가 다시 호출된다"로는 답이 안 나옵니다. 단위가 함수가 아니기 때문입니다. 정확한 단위는 재구성 범위이고, 위 질문들은 전부 "어느 재구성 범위가 다시 도느냐"의 문제입니다.
시리즈 1~6편을 읽은 독자
Recomposition을 "함수 단위"로 이해하는 개발자
derivedStateOf·람다 deferring을 "성능 팁"으로만 외운 개발자
먼저 재구성 범위의 정체부터 봅니다. 4편에서 한 번 봤던 코드를 다시 가져오겠습니다. 우리가 작성한 컴포저블을 컴파일러가 변환한 모습입니다.
// 우리가 작성하는 코드
@Composable
fun A(x: Int) {
f(x)
}
// Compose Compiler가 변환한 코드 (단순화)
@Composable
fun A(x: Int, $composer: Composer, $changed: Int) {
$composer.startRestartGroup()
// ...
f(x)
$composer.endRestartGroup()?.updateScope { next ->
A(x, next, $changed or 0b1)
}
}
4편에서는 이 코드를 "Skip 로직이 들어간다"는 관점으로만 봤습니다. 이번엔 양 끝의 startRestartGroup() / endRestartGroup()을 봅니다.
startRestartGroup()이 여는 것이 바로 재시작 가능한 그룹(Restartable group)입니다. Jetpack Compose Internals(이하 'Internals')에 따르면, 이 그룹이 하나 생길 때마다 Composer는 그에 대응하는 RecomposeScope 객체를 하나씩 만듭니다.[^1]
이 RecomposeScope — 우리말로 재구성 범위 — 가 무엇이냐면, composition 전체가 아니라 그 안에서 자기만 따로, 독립적으로 다시 그려질 수 있는 한 영역입니다. 핵심은 "독립적으로"입니다. Recomposition이 다시 돌리는 진짜 단위가 바로 이 영역입니다.
그리고 끝에 붙은 endRestartGroup()?.updateScope { ... } — 여기서 updateScope에 넘기는 람다가, 1편부터 우리가 막연히 "재시작 가능"이라고 불러온 그 재시작의 물리적 실체입니다. 이 람다가 곧 "이 재구성 범위를 다시 실행하는 법"이고, 나중에 무효화가 일어나면 런타임은 이 람다를 다시 호출합니다.
여기서 "함수 ≠ 스코프"의 첫 번째 근거가 나옵니다. 컴파일러가 컴포저블 본문에 심는 그룹은 한 종류가 아닙니다. Internals는 세 종류를 듭니다.[^2]
| 그룹 종류 | 언제 생성되나 | 자기 재구성 범위를 갖나 |
|---|---|---|
| 재시작 가능 그룹 | 재시작 가능한 컴포저블 함수 | O — 이것만 가짐 |
| (Restartable group) | (Unit 반환, 비인라인) | |
| 교체 가능 그룹 | 조건부 블록(if/when), 컴포저블 람다, | X |
| (Replaceable group) | @NonRestartableComposable | |
| 이동 가능 그룹 | key() 호출 본문 | X |
| (Movable group) |
RecomposeScope를 갖는 건 재시작 가능한 그룹뿐입니다. 인라인 컴포저블, @NonRestartableComposable이 붙은 함수, 값을 반환하는 컴포저블은 startRestartGroup 대신 startReplaceableGroup이 들어갑니다. 즉 자기만의 재구성 범위가 없고, 독립적으로 다시 그려지지 못합니다. 부모 재구성 범위가 다시 돌 때 같이 끌려갈 뿐입니다.
"컴포저블 함수 = 재구성 단위"가 이미 여기서 깨집니다. 어떤 함수는 단위가 되고, 어떤 함수는 안 됩니다.
두 번째 근거가 더 결정적입니다. 재시작 가능한 그룹이 있어서 RecomposeScope가 만들어졌다 해도, 그게 곧바로 "다시 그려질 수 있는 단위"가 되는 건 아닙니다. Internals에 따르면 RecomposeScope는 항상 켜져 있지 않습니다. 컴포저블 본문이 상태(State) 스냅샷을 한 번이라도 읽었을 때만, Composer가 그 범위를 used로 표시합니다.[^1]
상태를 하나도 안 읽으면 어떻게 될까요? 그때는 endRestartGroup()이 null을 반환합니다.[^2] 그러면 코드를 다시 보세요.
$composer.endRestartGroup()?.updateScope { next -> ... }
// ^ null이면 ?. 때문에 updateScope가 아예 호출 안 됨
?.에서 끊깁니다. 재시작 람다 자체가 만들어지지 않습니다. 상태를 안 읽는 컴포저블은 "다시 실행하는 법"이 등록되지 않고, 따라서 독립적으로 무효화될 수 없습니다.
정리하면, 재구성의 진짜 단위는 이렇습니다.
함수가 아니라, 재시작 가능한 그룹이며, 그중에서도 상태를 읽어
used로 켜진 재구성 범위.
이게 이 글의 후크입니다. 그리고 지금부터, 4·5·6편이 각각 다룬 이야기가 전부 이 단위 위에서 일어난 일이었다는 걸 하나씩 맞춰봅니다.
4편 주제는 "@Stable은 컴파일러와의 계약"이었습니다. 거기서 Skip 로직을 봤습니다. 다시 봅시다.
@Composable
fun Header(text: String, $composer: Composer, $changed: Int) {
var $dirty = $changed
if ($changed and 0b0110 === 0) {
$dirty = $dirty or if ($composer.changed(text)) 0b0010 else 0b0100
}
if ($dirty and 0b1011 xor 0b1010 !== 0 || !$composer.skipping) {
f(text) // 본문 실행 = 재구성
} else {
$composer.skipToGroupEnd() // 본문 통째로 건너뜀
}
}
4편에서는 $changed 비트마스크와 안정성(Stability)에 집중했습니다. 이번엔 무엇이 건너뛰어지는가를 봅시다. skipToGroupEnd() — 이름 그대로 "그룹의 끝까지 건너뛰기"입니다. Skip의 대상은 함수가 아니라 그룹, 정확히는 이 함수의 재시작 가능한 그룹 = 재구성 범위입니다.
4편의 안정성은 "이 입력을 비교해도 되는가"라는 판단 재료였습니다. 재구성 범위는 그 판단의 결과로 건너뛰거나 다시 도는 대상입니다. 안정성은 Skip 판정의 '입력'이고, 재구성 범위는 그 판정의 '대상'입니다 — 같은 Skip 메커니즘의 양 끝인 셈입니다.
@Stable이면 $changed 비트로 "안 바뀌었다"고 확신할 수 있다 → Skip 가능"@Stable을 붙이면 Skip이 잘 된다"는 말은, 정확히는 "그 재구성 범위를 건너뛸 수 있게 된다"는 말이었습니다.
6편에서는 Snapshot System 와 MVCC 을 비유한 것이었습니다. 거기서 "상태가 바뀌면 그 상태를 읽은 곳이 다시 그려진다"고 했습니다. 그런데 "읽은 곳"이 정확히 어디일까요?
재구성 범위는 앞에서 상태 스냅샷을 읽었을 때 비로소 used로 켜진다고 했습니다. 스냅샷이 추적하는 "상태를 읽은 주체"가 바로 재구성 범위입니다. Text("Hello $name")에서 name을 읽으면, 그 Text를 감싼 재구성 범위가 name의 구독자로 등록됩니다.
그래서 상태가 바뀔 때 일어나는 일을 단위까지 정확히 쓰면 이렇게 됩니다.[^3]
state.value 변경
→ 스냅샷이 변경을 무효화 목록에 등록
→ 그 상태를 읽은 재구성 범위(들)을 '무효(invalid)'로 표시
→ Composer가 무효화된 재구성 범위들을 스택에 쌓음
→ 다음 프레임(withFrameNanos)에서
→ 슬롯 테이블 포인터를 그 그룹의 시작 위치로 이동
→ updateScope에 등록해둔 재시작 람다를 호출 → 그 범위만 다시 실행
withFrameNanos는 Compose 런타임의 함수로 "다음 화면 프레임이 올 때까지 기다렸다가, 프레임이 오면 콜백을 실행"하는 suspend 함수이다.
6편에서 "MVCC는 누가 어떤 버전을 읽는지 추적한다"고 했던 그 추적의 키가 재구성 범위입니다. 6편은 "상태가 어떻게 버전 관리되는가"를 봤고, 7편은 "그 상태를 읽은 누가 다시 도는가"를 봅니다. 둘이 만나야 "상태 변경 → 화면 갱신"의 사이클이 완성됩니다.
5편에서는 "부수 효과를 Effect API로 격리"하는 내용을 다루었습니다. 거기서 "이펙트의 수명은 Composition이 소유한다"고 했습니다. 그 "수명"이 어디에 묶일까요?
LaunchedEffect는 슬롯 테이블에 기록되기 때문에 SideEffect와 달리 composition의 수명 주기를 인지합니다.[^4] LaunchedEffect, DisposableEffect, remember는 모두 슬롯 테이블 위에, 그 컴포저블의 그룹이 차지하는 영역 안에 기록됩니다. 그래서 그룹이 트리에서 사라지면(그 재구성 범위가 컴포지션을 떠나면) 안에 들어 있던 이펙트도 같이 정리됩니다. 5편에서 "Composition이 이탈하면 코루틴이 자동 취소된다"고 한 그 "이탈"의 경계선이, 바로 재시작 가능한 그룹이 차지하는 영역입니다.
대비되는 게 SideEffect입니다. SideEffect는 슬롯 테이블에 저장되지 않습니다.[^5] 그래서 컴포지션이 실패하면 그냥 버려지고, "매 성공 커밋마다 실행"이라는 다른 성격을 갖습니다.
한 가지만 짚어둡니다. 슬롯 테이블, 그룹,
RecomposeScope는 셋 다 슬롯 테이블 위에 살지만 같은 객체가 아닙니다. "이펙트 수명이 그룹(재구성 범위)이 차지하는 영역에 묶인다"는 큰 그림은 정확하지만, "RecomposeScope객체가 이펙트를 직접 들고 있다"는 식으로 뭉뚱그리면 안 됩니다. 정확한 구조는 Internals 3장 "슬롯 테이블" 절을 참고하세요.
여기까지 오면, 머리말에서 던진 질문에 답할 수 있습니다. "왜 상태를 더 깊은 곳에서 읽으면 다시 그리는 범위가 좁아지는가?"
규칙은 하나입니다. Compose는 상태가 바뀌면, 그 상태를 읽은 가장 가까운 재구성 범위만 다시 돌립니다. 최상단에서 읽으면 최상단 범위 전체가 돌고, 깊은 곳에서 읽으면 그 깊은 범위만 돕니다.
개발자는 재구성 범위를 직접 만들 수 없습니다. 컴파일러가 만듭니다. 하지만 상태를 어디서 읽을지는 정할 수 있고, 그게 곧 "어느 재구성 범위가 구독자가 될지"를 정하는 일입니다. 흔히 "성능 팁"으로 외우는 패턴들이 사실 전부 이 한 가지입니다.
// 전: scroll.value 를 SnackDetail 범위에서 읽음 → SnackDetail 전체가 다시 돔
@Composable
fun SnackDetail() {
val scroll = rememberScrollState(0)
Title(snack, scroll.value)
}
// 후: 람다로 감싸 읽는 지점을 Title 안쪽으로 내림
@Composable
fun SnackDetail() {
val scroll = rememberScrollState(0)
Title(snack) { scroll.value } // 람다는 코드 — 호출될 때 비로소 읽음
}
람다는 값이 아니라 코드입니다. scroll.value를 람다 안에 넣으면, 그 읽기는 람다가 호출되는 곳에서 일어납니다. 구독자가 SnackDetail의 재구성 범위에서 Title의 재구성 범위로 옮겨갑니다.
Modifier.offset { IntOffset(0, scrollProvider()) } // 레이아웃 단계에서 읽음
Modifier.drawBehind { drawRect(color) } // 그리기 단계에서 읽음
읽기를 아예 컴포지션 단계 밖으로(레이아웃이나 그리기 단계로) 내리면, 그 상태가 바뀌어도 컴포지션(재구성) 자체를 건너뛰고 해당 단계만 다시 실행됩니다.
derivedStateOf로 무효화 횟수를 줄이기val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
이건 읽는 위치가 아니라 무효화되는 빈도를 다룹니다. 스크롤 픽셀이 바뀔 때마다가 아니라, "첫 항목 인덱스가 0을 넘느냐"라는 파생 결과가 실제로 바뀔 때만 구독 범위를 무효화합니다.
세 가지 모두, 결국 같은 문장으로 모입니다.
어디서 상태를 읽느냐가, 곧 어디가 다시 그려지느냐다.
이제 시리즈 전체를 이 단위로 다시 읽어봅니다.
@Stable은 "이 입력을 비교해도 된다"는 계약이다. → 무엇을 건너뛸지 판단하는 재료.4·5·6편은 사실 한 객체를 세 각도에서 본 글이었습니다. 4편은 재구성 범위가 건너뛰어지는 순간을, 6편은 재구성 범위가 무효화되는 순간을, 5편은 재구성 범위가 사라지며 이펙트를 정리하는 순간을 봤습니다. 셋을 한자리에 놓으면 재구성 범위의 정체가 또렷해집니다.
재구성 범위는 정체성(1편 위치 기억법 — 호출 위치로 자기를 식별)과 상태 구독(6편 — 스냅샷 읽기로 구독자가 됨)과 재시작 람다(
updateScope— 자기를 다시 실행하는 법)를 한데 묶은 단위다.
이 셋이 한 단위 안에 있기 때문에, Compose는 "누가 무엇을 읽었고, 바뀌면 어떻게 다시 그릴지"를 알 수 있습니다.
Recomposition은 컴포저블 함수가 다시 호출되는 것이다. — 큰 틀에서는 맞는 말입니다. 4·5·6편을 쓰면서 저도 이렇게 말했고요. 하지만 엄밀히 말하면 이렇습니다.
Recomposition은 함수의 재호출이 아니라, 상태를 읽어 활성화된 재구성 범위의 재실행이다.
이 단위를 손에 쥐면 흩어져 보이던 것들이 한 줄에 꿰입니다. 4편의 Skip은 재구성 범위를 건너뛰는 일이고, 6편의 무효화는 재구성 범위를 깨우는 일이고, 5편의 이펙트 정리는 재구성 범위가 사라질 때 따라오는 일입니다. 그리고 람다 deferring이나 derivedStateOf 같은 "성능 팁"은, 사실 "어느 재구성 범위가 구독자가 될지"를 설계하는 일이었습니다.
"컴포저블 함수가 다시 돈다"고만 알아도 Compose는 쓸 수 있습니다. 하지만 "왜 이 함수만 도는가", "왜 이렇게 옮기면 좁아지는가"를 설명하려는 순간, 단위를 함수에서 재구성 범위로 바꿔야 합니다. 그 전까지 우리는 4·5·6편을 따로 떨어진 세 편으로 읽고 있었던 셈입니다.
이 글은 Compose의 Recomposition 구조를 Jetpack Compose Internals로 공부하면서 정리한 내용입니다. 부정확한 부분이 있을 수 있습니다. 틀린 내용이나 보완할 점이 있다면 댓글로 알려주시면 감사하겠습니다.
[^1]: 『Jetpack Compose Internals』 한국어판, 3장 "컴포즈 런타임"
[^2]: 같은 책, 2장 "Compose 컴파일러" — "컨트롤 플로우 그룹 생성" 절
[^3]: 같은 책, 3장 "Recomposition 프로세스" 절
[^4]: 같은 책, 3장 (책 119쪽).
[^5]: 같은 책, 3장 "Composer와 사이드 이펙트" 절