상태가 바뀌면 그 상태를 읽은 컴포저블만 다시 실행하는 것.
선언형 — UI는 상태의 함수
- 명령형 (View) — findViewById → setText → setVisibility → notifyDataSetChanged
- "어떻게 바꿀지" 를 단계마다 지시한다
- 상태와 화면이 어긋날 여지가 매 단계 생긴다
선언형 (Compose) UI = f(상태)
- "이 상태면 이렇게 보인다" 만 적는다
- 갱신은 런타임이 한다
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text("클릭 $count 회") // count 를 읽는다 → 여기가 다시 실행된다
Button(onClick = { count++ }) { Text("증가") } // 읽지 않는다 → 건너뛴다
}
}
읽은 곳만 다시 실행된다
mutableStateOf 는 스냅샷 시스템에 등록된 관찰 가능한 상태다 Composable 이 그 값을 읽으면 '이 스코프가 이 상태를 구독한다' 가 기록된다
- count++ → 구독한 스코프(Text 를 감싼 람다) 만 무효화 → 그 범위만 재실행
- Column 전체도, Button 도 다시 그려지지 않는다
컴포저블은 순수해야 한다
- 같은 입력이면 같은 출력 (부수효과 없음)
- 순서에 의존하지 않는다 (병렬로 실행될 수 있다)
- 여러 번 실행돼도 안전해야 한다 (한 프레임에 여러 번 불릴 수 있다)
- 건너뛸 수 있어야 한다
// ❌ 컴포지션 중에 부수효과를 낸다 — 리컴포지션마다 반복된다
@Composable
fun Screen(vm: MyViewModel) {
vm.loadData() // 스크롤할 때마다 API 를 친다
analytics.log("screen_view") // 로그가 수십 번 찍힌다
}
// ✅ 효과는 전용 API 안에서
LaunchedEffect(Unit) { vm.loadData() }
얼마나 자주 도나
애니메이션 중에는 초당 60~120회 돈다
- 컴포저블 안에서 정렬 · 필터링 · 객체 생성을 하면 그만큼 반복된다
- 무거운 계산은 remember 로 감싸거나 ViewModel 로 올린다
면접 함정
- ❌ "상태가 바뀌면 화면 전체를 다시 그린다" → 읽은 스코프만이다. 이게 성능의 핵심이다.
- ❌ "var count = 0으로도 된다" →
remember가 없으면 리컴포지션마다 초기화되고,mutableStateOf가 없으면 변경이 관측되지 않는다.
세 단계
-
컴포지션(Composition) — 무엇을 그릴지 결정한다
-
레이아웃(Layout) — 크기를 재고 위치를 정한다
-
드로잉(Drawing) — 실제로 그린다
-
상태를 어느 단계에서 읽느냐가 비용을 정한다
- 컴포지션에서 읽으면 → 세 단계 전부 다시
- 레이아웃에서 읽으면 → 레이아웃 + 드로잉
- 드로잉에서 읽으면 → 드로잉만
위치 기억(positional memoization)
@Composable
fun List(items: List<String>) {
items.forEach { item ->
Text(item) // 호출 '위치' 로 슬롯을 식별한다
}
}
Compose 는 컴포저블 호출의 '소스 위치' 로 컴포지션 슬롯을 식별한다
→ 같은 위치의 호출은 같은 remember 슬롯을 쓴다
→ 목록에서 항목 순서가 바뀌면 상태가 엉뚱한 항목으로 간다
→ key(item.id) { } 또는 items(key = ...) 로 정체를 명시한다
리컴포지션 횟수를 직접 본다
@Composable
fun RecompositionCounter(tag: String) {
val count = remember { mutableIntStateOf(0) }
SideEffect { count.intValue++ } // 리컴포지션이 성공할 때마다
Text("$tag: ${count.intValue}")
}
Android Studio 의 'Layout Inspector → Recomposition counts' 로도 볼 수 있다 스크롤 한 번에 수천 번이 찍히면 그 컴포저블이 범인이다