
내 블로그 글을 보고 코니가 퀴즈를 내줬다… 퀴즈를 풀다가 몇가자 의문점이 생겼는데, 의문점을 해소하는 과정에서 배운점과 통찰을 공유하고자 한다. 이야기가 길어져서 이 글에서는 Restart Scope와 inline Composable만 설명한다.
버튼을 누를때마다 println 으로 무엇이 나올 것 같은가? 혹은 아무것도 안나올 것 같은가?
1. 문제의 코드
@Composable
fun RecompositionQuiz() {
val count: MutableState<Int> = remember { mutableStateOf(0) }
println(":red_circle: RecompositionQuiz")
QuizContent(
countState = count,
onIncrement = { count.value++ }
)
}
@Composable
fun QuizContent(
countState: MutableState<Int>, // <- 원래는 State<Int>인데 이러면 컴파일 에러가 난다
onIncrement: () -> Unit,
) {
println(":large_orange_circle: QuizContent")
Scaffold(
topBar = {
println(":large_yellow_circle: topBar")
CenterAlignedTopAppBar(
title = { Text("Count: ${countState.value}") }
)
}
) { padding ->
println(":large_green_circle: content")
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
println(":large_blue_circle: Box")
Button(onClick = onIncrement) {
println(":large_purple_circle: Button")
Text("+ 증가")
}
}
}
}
주석에 달린 "원래는 State<Int>인데 이러면 컴파일 에러가 난다" 라는 미끼는 2편에서 풀어본다. 1편에서는 이 주석이 왜 달려 있는지만 기억해두면 된다.
코드를 하나하나씩 뜯어보니 바로 정답이 나오진 않았다. 하지만 생각을 해보니 두 가지 결과가 예상되었는데 둘 중 하나가 정답일 것 같았다.
- 버튼을 눌러도 아무것도 나오지 않는다
- Text("Count: ${countState.value}") 만 리컴포지션이 일어날거라고 생각했다.
- QuizContent에 있는 모든 println()이 실행된다.
- QuizContent가 countState 를 가지고 있으니 QuizContent 가 countState 를 read한다고 생각헀다. 하지만, remember로 감싸져 있으니 countState 가 격리되는 건 아닐까 생각이 들었다.
실제로 코드를 실행해보니 정답은 1 이었다. 그렇다면 왜 1번일까?
이것에 대해서 통찰을 얻은 과정과 깨닳은 바를 공유하려한다.
실험, 해당 코드에서 다시 버튼을 누르면?
CenterAlignedTopAppBar(
title = {
println("recomposition 1")
Box {
// Column, Row, Box는 대표적인 인라인 함수, 컴포넌트로 한번 감쌌다고 생각되지만
// 인라인 컴포넌트의 스코프는 무시해야 한다.
// 그래서 recomposition 1까지 출력되는 것,
println("recomposition 2")
Text("Count: ${countState.value}")
}
},
)
출력 결과
recomposition 1
recomposition 2
2. 핵심 개념: Restart Scope
Compose의 재구성 단위는 함수 단위가 아니라 restart scope 단위다. 규칙은 이렇게 요약할 수 있다.
- Compose 컴파일러는 non-inline @Composable 함수/람다마다 startRestartGroup / endRestartGroup을 감싸서 restart scope를 만든다.
- inline과 non-inline의 차이는 아래에서 바로 설명한다.
- 상태가 변경되면, 그 상태를 읽은 코드 위치를 감싸는 가장 가까운 restart scope가 invalidate되고, 다음 프레임에 해당 scope 전체가 재실행된다.
- "어떤 composable을 호출했느냐"가 기준이 아니라 **"어디에서 상태를 읽었느냐"**가 기준이다.
의사 코드로 보면 composable 람다는 대략 이렇게 변환된다.
title = composableLambda(...) { composer, _ ->
composer.startRestartGroup(key)
// ...
composer.endRestartGroup()?.updateScope { next, _ -> /* 재시작 */ }
}
3. 왜 실험에서 출력이 두번 됐을까?
Box가 inline 함수이기 때문이다. 직관으로는 Box가 가지고 있는 스코프 범위가 Restart Scope라고 생각되서 "recomposition 2"만 출력될 것 같다. 그런데 "recomposition 1"은 왜 출력되는걸까? Box의 실제 시그니처는 아래와 같다.
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit,
)
inline이 붙어 있기 때문에 Box{}는 컴파일 시 호출 지점에 코드가 그대로 펼쳐진다. 즉, "Box의 content 람다"라는 별개의 람다 객체가 런타임에 존재하지 않고, 그 코드는 title 람다 안으로 녹아 들어간다.
결과적으로 실제 restart scope 계층은 이렇게 된다.
title 람다 ← restart scope (count 읽기가 이 안에 inlined됨)
└─ (Box는 inline → scope X, )
└─ Text ← 자체 restart scope는 있지만, 파라미터 계산은 바깥에서 일어남
Text("Count: ${countState.value}")에서 상태를 읽는 건 Text 내부가 아니라 문자열 처리가 계산되는 호출 지점이다. 그 호출 지점은 inline된 Box 블록 안이고, inline이라 경계가 없으니 가장 가까운 restart scope는 title 람다가 된다.
따라서 count가 바뀌면:
- title 람다의 restart group이 invalidate된다.
- 다음 프레임에 title 람다 전체가 재실행된다.
- 안에 inlined돼 있던 println("recomposition 2")와 Text()가 함께 재실행된다.
실험: Box를 non-inline으로 감싸기
내부를 별도의 non-inline @Composable 함수로 빼면 경계가 새로 생긴다.
@Composable
fun InlineBox(countState: MutableState<Int>) {
Box {
println("recomposition 2")
Text("Count: ${countState.value}")
}
}
title = {
println("recomposition 1")
InlineBox(countState)
},
InlineBox가 non-inline @Composable 함수이므로 자체 restart scope를 갖고, count 읽기는 이 scope 안에서 일어난다. count가 바뀌면 InlineBox만 invalidate되고 title 람다의 println("recomposition 1")은 더 이상 재실행되지 않는다.
4. 범위를 좁게 잡은 경우: title에 Text만 남기면?
title = {
Text("Count: ${countState.value}")
}
이때 “재시작 가능한 그룹”은 title 람다까지다. Text보다 더 안으로는 들어갈 수 없다.
Text는 분명히 @Composable fun이고 자체 restart group을 가진다. 하지만 그 restart group은 Text 함수 본문 안에서 일어나는 state 읽기를 위한 것이지, 호출자가 파라미터를 계산하면서 읽은 상태까지 잡아주진 않는다. Text 입장에서는 이미 완성된 String만 받을 뿐, countState의 존재 자체를 모른다.
반대로, 만약 Text가 State<Int>를 직접 받아서 내부에서 .value로 읽는 시그니처였다면 어떻게 될까? 상태 읽기가 Text 본문 안에서 일어나므로 Text의 restart group이 구독자가 된다. 이 경우 count 변경 시 title 람다는 건드려지지 않고 Text만 재실행된다. 즉 "상태를 어디에서 읽느냐"가 재구성 범위를 결정한다는 원칙이 그대로 확인된다.
그래서 count 변경 시 실제로 일어나는 일은 이렇다.
- countState가 변경을 알림.
- 구독자 목록에서 title 람다의 restart group을 invalidate.
- 다음 프레임에 title 람다를 재시작.
- "Count: ${countState.value}"가 새 값으로 재계산됨.
- Text에 새 문자열이 들어오고, 파라미터가 바뀌었으니 Text도 재구성.
즉 invalidate는 title 람다 1개, 결과적으로 재구성되는 건 title 람다 + Text 2개가 된다. "원인(invalidate)"과 "결과(재구성)"를 구분해서 보는 것이 중요하다.
5. 한 줄 요약 + 다음 글 예고
리컴포지션은 "어떤 composable을 호출했느냐"가 아니라 "상태를 어디에서 읽었느냐"로 결정된다.
상태 읽기가 어느 group 안에서 일어나느냐 = 그 group의 scope가 구독자가 된다 = 그 group이 재실행 단위가 된다.
Restart group? Restart scope?
둘은 자주 혼용되어 사용되나 엄밀하게는 다르다.
- Restart group: 컴파일러가 @Composable 함수/람다 본문을 감싸서 만드는 재실행 가능한 코드 구역.
- Restart scope (RecomposeScope): 그 구역과 1:1로 붙어서 상태를 구독하고 invalidate 시 재실행을 트리거하는 런타임 객체.
group이 "코드 상의 경계"라면 scope는 그 경계에 붙어서 실제로 일을 하는 "담당자"다. 일상적으로는 혼용해도 대화가 통하지만, 내부 동작을 설명할 때는 이 구분이 있어야 "group이 invalidate된다"는 말이 실제로는 "group에 붙은 scope 객체가 invalidate 신호를 받아서 자기가 담당하는 group을 다시 실행시킨다"는 뜻임이 선명해진다.
by 와 State 에 대해서도 설명하려 했지만 글의 호흡이 너무 길어져서 다음 글에서 다루겠다~
'안드로이드' 카테고리의 다른 글
| 안드로이드 원정대 - 왜 Intent가 필요할까?, Activity, IPC, Bundle (0) | 2026.05.06 |
|---|---|
| Q2. inline + non-local return vs labeled return (0) | 2026.04.26 |
| Q1. by lazy + 순환 초기화 (0) | 2026.04.22 |
| 리컴포지션 원정대 - Compose 리컴포지션 스코프와 remember의 관계 (0) | 2026.04.13 |
| 안드로이드 컴포즈 dp, sp 단위 (1) | 2026.03.30 |