질문
아래 코드에서 modifier에 color를 없애면 버튼이 눌리는 대로 count가 증가하는데, 왜 color가 있으면 리컴포지션이 일어나지 않을까?
@Suppress("UnrememberedMutableState")
@Composable
fun ButtonTest2() {
var count by mutableStateOf(0)
// var color by mutableStateOf(if (count % 2 == 0) Color.Blue else Color.Red)
Button(
onClick = { count++ },
// modifier = Modifier.background(color),
) {
Text(text = count.toString())
}
}
위 코드는 count가 바뀔 때 버튼 색을 바꾸려는 의도였지만, 실제로는 매 composition마다 if/else가 새 state의 초기값으로 한 번씩 평가될 뿐이라, 색은 항상 첫 번째 분기 결과(Blue)로 고정된다.
결론 먼저
color가 있을 때도 리컴포지션은 일어난다.- 다만 매번 state가 새로 생성되어 0으로 리셋되기 때문에 화면이 변하지 않아서 리컴포지션이 일어나지 않는 것처럼 보일 뿐이다.
- 핵심은
@Suppress("UnrememberedMutableState")로 가린remember누락과, state를 read하는 위치가 어느 스코프인지다.
핵심 개념
- Compose는 함수 단위가 아니라 Restartable Group(재시작 가능한 그룹) 단위로 invalidation을 추적한다.
State를 read한 가장 가까운 restartable 스코프만 다시 실행된다. Button { ... }의 content 람다는 별도의 restartable 스코프를 만든다. 즉 람다 안에서 state를 읽으면 그 람다만 재실행되고, 람다를 호출한 부모 함수는 재실행되지 않는다.- 즉
color가 없을 때 가장 가까운 Restartable Group은 다음과 같다. { Text(text = count.toString()) }
- 즉
remember없는mutableStateOf는 자신을 호출하는 스코프가 재실행될 때마다 매번 새 state 객체를 만들어버린다. 그래서 "어떤 스코프가 재실행되느냐"가 곧 "state가 살아남느냐"를 결정한다.
1. color가 없을 때
Button(
onClick = { count++ },
) {
Text(text = count.toString()) // ← count를 여기서 read
}
count는 Button content 람다 안에서 read된다. count 상태와 가장 가까운 이 람다는 그 자체로 하나의 restartable scope다.
- 첫 composition:
ButtonTest2본문이 실행됨 →state₁ = mutableStateOf(0)생성 → onClick 람다는state₁을 캡처 → Text가state₁을 읽음. - 버튼 클릭:
state₁.value = 1. state₁을 read한 가장 가까운 restartable scope는 Button의 content 람다. 그래서 그 람다만 다시 실행된다.- 핵심:
ButtonTest2함수 본문은 재실행되지 않음 →mutableStateOf(0)이 다시 호출되지 않음 →state₁이 그대로 살아 있음 → count가 정상적으로 1, 2, 3 ... 증가한다.
2. color가 있을 때
var count by mutableStateOf(0)
var color by mutableStateOf(if (count % 2 == 0) Color.Blue else Color.Red)
Button(
onClick = { count++ },
modifier = Modifier.background(color),
)
이번에는 count를 read하는 위치가 ButtonTest2 함수 본문(if (count % 2 == 0)) 안이다. 가장 가까운 restartable scope는 ButtonTest2 자신이다.
- 첫 composition:
state_count₁ = 0,state_color₁ = Blue. onClick은state_count₁을 캡처. - 버튼 클릭:
state_count₁.value = 1. state_count₁을 읽은 스코프는ButtonTest2본문 → 함수 전체가 재실행됨.- color의 mutableStateOf가 count를 read하기 때문에
ButtonTest2전체가 범위
- color의 mutableStateOf가 count를 read하기 때문에
- 재실행되는 순간
var count by mutableStateOf(0)이 다시 호출되어state_count₂ = 0이 새로 생성된다. 이전 state는 버려진다. - 화면에는 항상 0이 그려지고, 색깔도 항상 Blue다. 리컴포지션은 매번 일어나지만, 결과가 변하지 않으니 멈춰 있는 것처럼 보인다.
리컴포지션이 안 일어나는 게 아니라, "매번 0으로 리셋되는 리컴포지션이 무한히 반복되는 것"이다.
3. 그래서 remember가 필요한 이유
remember 없이 mutableStateOf 를 사용하면 경고를 표시한다.

var count by remember { mutableStateOf(0) }
remember는 Compose의 슬롯 테이블(slot table)에 결과를 저장한다. 슬롯은 호출 위치(call-site)로 식별되기 때문에, 같은 위치가 다음 composition에서 다시 실행되더라도 람다는 더 이상 실행되지 않고 슬롯에 저장된 객체를 그대로 꺼내 돌려준다.
4. 검증
위 분석이 맞다면, count를 ButtonTest2 본문에서 read하지 않게 만들면 다시 리컴포지션되야 한다. 실제로 그렇다.
var count by mutableStateOf(0)
var color by mutableStateOf(Color.Blue) // count를 더 이상 read하지 않음
Button(
onClick = { count++ },
modifier = Modifier.background(color),
) {
Text(text = count.toString()) // count는 이 람다 안에서만 read
}
이 코드는 클릭할 때마다 0, 1, 2, ...로 정상 증가한다. 이유는 단순하다.
count의 read 지점이 다시 Button content 람다 한 군데로 줄어들었기 때문이다. ButtonTest2 본문은 더 이상 dirty로 마킹되지 않고, mutableStateOf(0)도 단 한 번만 호출된다. 즉 remember가 없어도 동작한다, 우연히.
마무리
mutableStateOf는 반드시remember와 짝이여야 한다. 그렇지 않으면 state의 수명은 “그걸 만든 가장 가까운 restartable scope가 얼마나 자주 재실행되느냐”에 종속된다.- Compose의 invalidation 단위는 함수가 아니라 'state를 read한 가장 가까운 restartable scope'다. 같은 state라도 어디서 읽느냐에 따라 재실행되는 범위가 달라진다.
추가
invalidation이 헷갈리지 않는가? "뭔가 잘못됐다", "에러가 났다" 같은 부정적인 뉘앙스로 읽히는 것만 같다. 하지만, 컴포즈에서는 “캐시가 더 이상 신뢰할 수 없게 됐다”라는 의미이다.
즉, “count++하여 발생시킨 상태는 더 이상 신뢰할 수 없으니 다음 프레임에 다시 실행해서 UI를 새로 만들라”라는 의미와 같다.
'안드로이드' 카테고리의 다른 글
| 안드로이드 원정대 - 왜 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 |
| 리컴포지션 원정대 - Restart Scope, inline Composable (0) | 2026.04.19 |
| 안드로이드 컴포즈 dp, sp 단위 (1) | 2026.03.30 |