Compose 기본2026년 03월 01일· 10 min read

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부

mutableStateOf와 remember로 UI가 갱신되는 이유를 Compose Runtime 관점에서 설명한다. Slot Table, recomposition 추적, 안정성(@Stable)까지 연결한다. 초보도 내부 흐름을 잡는다.','primaryKeywords':['Jetpack Compose State','m

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부

Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부

Compose를 처음 쓰면 버튼을 눌렀는데 값은 바뀌는데 화면이 안 바뀌거나, 반대로 화면이 바뀌긴 하는데 매번 초기화되는 상황을 겪는다. 특히 var count = 0 같은 평범한 변수를 쓰면 클릭 로그는 증가하는데 Text가 그대로인 경우가 흔하다. 이 문제는 ‘상태를 어디에 두었는지’가 아니라, Runtime이 어떤 값을 ‘관찰 가능한 상태’로 추적하는지에 달려 있다. 이 글은 그 추적이 Slot Table과 Snapshot을 통해 어떻게 이어지는지까지 연결한다.

핵심 개념

View 시스템에서는 invalidate()/requestLayout()을 직접 혹은 간접적으로 호출해 ‘다시 그려야 한다’는 신호를 올렸다. Compose는 그 신호를 함수 호출로 바꾼다. 다시 그려야 하는 범위를 ‘함수 단위’로 쪼개고, 그 함수가 읽은 State가 바뀌면 그 함수만 다시 호출한다. 그래서 mutableStateOf는 단순한 값 저장소가 아니라 ‘읽기 추적(read observation)’과 ‘쓰기 알림(write notification)’을 가진 관찰 지점이다.

remember는 상태를 ‘어디에 저장하느냐’의 문제를 해결한다. Composable은 재호출(recomposition)될 수 있고, 재호출은 일반 함수 재호출과 동일하게 로컬 변수를 다시 초기화한다. remember는 Slot Table(정확히는 Composition의 슬롯 구조)에 값을 저장해, 같은 위치에서 다시 호출될 때 이전 값을 되돌려준다. 그래서 remember 없이 mutableStateOf를 만들면, 상태 객체 자체가 매번 새로 생성되어 이전 연결(관찰/구독)이 끊긴다.

용어를 실행 맥락으로 정의한다. Composition은 ‘Composable 호출 트리’를 만들고 Slot Table에 호출 위치별 데이터를 저장한다. Recomposition은 ‘이미 만들어진 트리’에서 일부 호출만 다시 실행하는 과정이다. Snapshot은 State 읽기/쓰기의 일관성을 보장하고, 어떤 State를 어떤 RecomposeScope가 읽었는지 매핑한다. Stable/Immutable은 ‘변경 감지 비용’을 줄이기 위한 계약이며, Runtime이 파라미터 비교를 더 공격적으로 생략할 근거가 된다.

처음에 나도 remember가 왜 필요한지 감이 없어서, count를 var로 두고 onClick에서 증가시키면 Text가 바뀔 거라 믿었다. 로그는 "count=1,2,3" 찍히는데 화면은 멈춰 있었다. Layout Inspector의 Recomposition Count도 0이었다. 그때 깨달은 포인트는 ‘값이 바뀌는 것’과 ‘Compose가 그 변화를 관찰하는 것’이 별개라는 사실이다.

CounterBasic.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.mutableStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14fun CounterBasic() {
15    var count by remember { mutableStateOf(0) }
16
17    Column {
18        Text(text = "count=$count")
19        Button(onClick = { count++ }) {
20            Text("+1")
21        }
22    }
23}
24
25@Preview
26@Composable
27private fun PreviewCounterBasic() {
28    CounterBasic()
29}

이 코드를 실행하면 버튼을 누를 때마다 Text가 즉시 바뀐다. 중요한 관찰 포인트는 두 가지다. (1) Text는 count를 읽는다. 이 읽기가 Runtime에 기록된다. (2) onClick에서 count를 쓰면 Snapshot이 변경을 감지하고, count를 읽었던 RecomposeScope에 invalidation을 걸어 다음 프레임에 CounterBasic의 해당 범위를 다시 호출한다. UI 업데이트는 ‘setText’가 아니라 ‘함수 재호출 + 변경된 입력 반영’으로 일어난다.

한 문단 요약: remember는 상태 객체를 Slot Table의 ‘호출 위치’에 고정하고, mutableStateOf는 읽기/쓰기 추적을 통해 ‘이 상태를 읽은 Composable만’ 다시 호출되게 만든다. UI 갱신은 invalidate가 아니라 recomposition 스케줄링이다.

컴포넌트 해부

mutableStateOf와 remember는 UI 컴포넌트처럼 보이지 않지만, Compose에서 UI 업데이트를 만드는 ‘핵심 부품’이라서 해부 대상이 된다. remember는 사실상 Runtime의 현재 Composer에 접근해, 현재 호출 위치에 값을 저장/로드한다. mutableStateOf는 SnapshotState<T>를 만들고, get은 ‘읽기 관찰’을, set은 ‘쓰기 알림’을 수행한다.

  • remember(calculation): 왜 람다 형태인가? 재호출 시 불필요한 객체 생성을 막기 위해서다. 계산은 최초 composition에서만 실행되고 이후에는 Slot Table에서 복원된다.
  • remember(key1, key2, ...): 왜 키가 있나? 호출 위치는 같지만 입력이 바뀌면 상태를 버리고 새로 만들기 위해서다. 예: userId가 바뀌면 이전 사용자의 편집 상태를 폐기.
  • mutableStateOf(value, policy): 왜 policy가 있나? 변경 여부 비교(equivalence)를 커스터마이즈해 invalidation 빈도를 제어한다. 기본은 structural equality다.
  • by 위임(getValue/setValue): 왜 필요한가? State.value 접근을 간결하게 하고, 읽기/쓰기 지점을 코드에서 명확히 드러내 관찰 추적을 안정적으로 만든다.
  • State<T> vs MutableState<T>: 읽기 전용/쓰기 가능을 분리해 상태 소유권(hoisting) 설계를 강제한다.
  • rememberSaveable: 왜 remember와 다르나? 프로세스 재시작/구성 변경에서 복원 가능한 형태로 저장하기 위해서다. Slot Table만으로는 Activity 재생성에 살아남지 못한다.
  • derivedStateOf: 왜 필요하나? 여러 State에서 계산된 값을 캐시하고, 입력이 바뀔 때만 재계산해 불필요한 recomposition을 줄인다.
  • @Stable/@Immutable: 왜 존재하나? 파라미터 비교 비용을 줄이고, ‘이 객체의 변경은 이런 규칙을 따른다’는 계약을 Compiler/Runtime에 제공한다.
  • RecomposeScope: 왜 범위 단위인가? 전체 트리를 다시 호출하면 비용이 커서, 읽기 추적을 통해 최소 범위를 무효화하기 위해서다.
  • Slot Table: 왜 테이블 구조인가? 호출 순서 기반으로 값을 저장하면 트리 구조를 별도 객체로 만들지 않고도 빠르게 재호출 위치를 찾을 수 있다.

Surface 계층 관점에서 보면, 상태는 시각적 표현(색, shape, elevation)과 직접 연결될 때가 많다. 예를 들어 enabled가 false면 Surface의 tonalElevation이나 contentColor가 바뀐다. 이때 상태가 Surface 바깥에 있으면 재사용이 쉬워지고, Surface 내부에 있으면 컴포넌트가 자기 상태를 숨긴다. Compose는 상태를 외부로 끌어올리는 패턴(state hoisting)을 선호한다.

Content 계층은 slot API로 구성된다. Button { Text(...) }에서 중괄호 블록은 ‘자식 컴포저블을 나중에 호출할 수 있는 함수 값’이다. Slot Table에는 부모 호출과 자식 호출이 순서대로 기록되고, 부모가 recomposition될 때 동일한 순서로 자식을 다시 호출해 슬롯을 맞춘다. 그래서 조건문으로 자식 호출 순서를 바꾸면 슬롯 불일치 위험이 생긴다.

파라미터를 안 쓰면 어떤 일이 생기는지로 이해가 빨라진다. remember를 빼면 상태 객체가 재호출마다 새로 만들어져 관찰 그래프가 끊긴다. mutableStateOf를 빼고 일반 var를 쓰면 Runtime은 읽기 추적을 할 수 없어서 invalidation 대상이 없다. 둘 중 하나만 빠져도 UI는 ‘우연히’ 바뀌지 않는다.

CounterLikeStructure.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Row
4import androidx.compose.material3.Surface
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.mutableStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.Color
13
14@Composable
15fun CounterLikeStructure(modifier: Modifier = Modifier) {
16    var count by remember { mutableStateOf(0) }
17
18    Surface(color = if (count % 2 == 0) Color(0xFFE3F2FD) else Color(0xFFFFF3E0)) {
19        Row(modifier = modifier) {
20            Text(text = "count=$count")
21            Text(text = "  (tap area elsewhere in real Button)")
22        }
23    }
24}

이 코드는 ‘Surface(외피) + Row(레이아웃) + Text(콘텐츠)’의 층을 드러낸다. count가 바뀌면 Surface의 color 분기와 Text 문자열이 함께 바뀐다. Runtime은 count를 읽은 지점을 모두 기록하므로, 동일한 상태 하나가 여러 레이어를 동시에 무효화할 수 있다. 이때 중요한 건 ‘읽기 위치’이며, 상태가 어디서 생성됐는지는 remember로 고정된다.

CounterStyled.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Surface
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.unit.dp
16import androidx.compose.ui.tooling.preview.Preview
17
18@Composable
19fun CounterStyled() {
20    var count by remember { mutableStateOf(0) }
21
22    Surface(color = MaterialTheme.colorScheme.background) {
23        Column(Modifier.padding(16.dp)) {
24            Text(text = "count=$count", style = MaterialTheme.typography.headlineSmall)
25            Button(
26                modifier = Modifier.padding(top = 12.dp),
27                onClick = { count++ }
28            ) {
29                Text("Increase")
30            }
31        }
32    }
33}
34
35@Preview
36@Composable
37private fun PreviewCounterStyled() {
38    CounterStyled()
39}

실행하면 headline 텍스트가 증가하고 버튼은 기본 Material 스타일로 렌더링된다. 여기서 Modifier 체이닝은 ‘데코레이터 리스트’처럼 쌓인다. padding을 두 번 주면 두 개의 PaddingModifier가 순서대로 적용된다. 상태 변경은 Modifier와 무관하게 recomposition을 만들지만, recomposition 이후 Layout 단계에서 padding이 다시 계산되므로 Modifier 구성도 성능에 관여한다.

내부 동작 원리

Compose 파이프라인은 Composition → Layout → Drawing 순서다. Composition은 Composable 호출을 실행해 UI 트리를 ‘의미적으로’ 만든다. Layout은 measure/layout로 크기와 위치를 정한다. Drawing은 실제 캔버스에 그린다. State 변경은 Composition 단계만 다시 실행하는 게 아니라, 변경된 결과가 레이아웃/드로잉에 영향을 주면 그 단계도 연쇄적으로 다시 수행된다.

Compose Compiler는 @Composable 함수를 일반 함수로 두지 않고, composer와 changed 플래그를 받는 형태로 변환한다. 실제 시그니처는 버전에 따라 다르지만 핵심은 동일하다. 호출 위치마다 그룹(group)을 열고 닫으며 Slot Table에 데이터를 저장한다. remember는 ‘현재 그룹의 슬롯 인덱스’에 값을 저장하고, 다음 recomposition에서 같은 인덱스를 읽는다.

Slot Table은 호출 순서 기반 데이터 구조다. if/when으로 Composable 호출 순서가 바뀌면, 같은 호출 위치라고 생각했던 remember 슬롯이 다른 값과 매칭될 수 있다. 그래서 키가 있는 remember가 필요해진다. 키가 바뀌면 Runtime은 해당 슬롯을 폐기하고 새로 계산해 저장한다.

mutableStateOf는 SnapshotState를 만든다. 읽기 시점에 현재 실행 중인 RecomposeScope(정확히는 Composer가 관리하는 scope)가 ‘이 State를 읽었다’고 기록된다. 쓰기 시점에는 Snapshot이 변경을 기록하고, 해당 State를 읽었던 scope들을 invalidation 큐에 넣는다. 다음 프레임에서 choreographer와 연동된 recomposer가 큐를 비우며 필요한 scope만 재실행한다.

비교는 두 층에서 일어난다. 첫째, State 자체의 변경 여부 비교다. 기본 정책은 structural equality라서 set할 때 새 값이 equals로 같으면 invalidation이 발생하지 않을 수 있다. 둘째, Composable 파라미터의 changed 비교다. Compiler가 생성한 changed 마스크를 통해 ‘이 파라미터는 이전과 동일하다’면 해당 그룹의 본문 실행을 스킵할 수 있다. @Stable/@Immutable은 이 비교를 더 안전하게 스킵하기 위한 힌트다.

처음에 Slot Table을 제대로 이해 못 해서 3시간 삽질한 적이 있다. 리스트 아이템마다 remember { mutableStateOf(false) }로 선택 상태를 넣었는데, 스크롤만 해도 선택이 다른 아이템으로 튀었다. Logcat에는 에러가 없고, UI만 이상했다. 원인은 아이템의 호출 위치가 스크롤/재사용으로 바뀌는데 key를 주지 않아 remember 슬롯이 다른 아이템과 재매칭된 것이었다. LazyColumn에서 key를 주고, 상태는 아이템 id로 map에 보관하니 증상이 사라졌다.

RecompositionTrace.kt
1package com.example.state
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.SideEffect
9import androidx.compose.runtime.mutableStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.tooling.preview.Preview
14
15@Composable
16fun RecompositionTrace() {
17    var count by remember { mutableStateOf(0) }
18
19    SideEffect {
20        Log.d("Recompose", "Recomposition happened, count=$count")
21    }
22
23    Column {
24        Text("count=$count")
25        Button(onClick = { count++ }) { Text("Click") }
26    }
27}
28
29@Preview
30@Composable
31private fun PreviewRecompositionTrace() {
32    RecompositionTrace()
33}

이 코드를 실행하고 버튼을 누르면 Logcat에 "Recomposition happened"가 클릭마다 한 번씩 찍힌다. SideEffect는 성공적으로 composition이 적용된 뒤에 실행되므로, ‘재호출만 되고 스킵된 경우’가 아니라 ‘실제로 반영된 프레임’만 관찰할 수 있다. count를 읽는 Text와 SideEffect가 같은 scope 안에 있으니 둘 다 함께 다시 실행된다.

ToggleableLabel.kt
1package com.example.state
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.selection.toggleable
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.remember
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.semantics.Role
11import androidx.compose.ui.semantics.contentDescription
12import androidx.compose.ui.semantics.semantics
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun ToggleableLabel(
17    checked: Boolean,
18    onCheckedChange: (Boolean) -> Unit,
19    label: String
20) {
21    val interaction = remember { MutableInteractionSource() }
22
23    Text(
24        text = if (checked) "[x] $label" else "[ ] $label",
25        modifier = Modifier
26            .semantics { contentDescription = "toggle-$label" }
27            .toggleable(
28                value = checked,
29                interactionSource = interaction,
30                indication = null,
31                role = Role.Checkbox,
32                onValueChange = onCheckedChange
33            )
34            .padding(12.dp)
35    )
36}

Modifier 체인은 순서가 의미를 가진다. semantics가 먼저 붙으면 접근성 노드에 설명이 추가되고, 그 다음 toggleable이 입력 이벤트를 가로채 상태 변경을 만든다. interactionSource를 remember로 고정하지 않으면 recomposition마다 새 인스턴스가 생겨 press/ripple 상태가 끊긴다. UI가 ‘깜빡이는’ 느낌이 나는 경우가 여기서 자주 나온다.

실습하기

실습의 목표는 세 단계다. 1단계에서는 ‘상태가 없으면 UI가 안 바뀐다’를 눈으로 확인한다. 2단계에서는 remember + mutableStateOf로 UI가 바뀌는 것을 확인하고, Logcat으로 recomposition을 관찰한다. 3단계에서는 상태를 컴포넌트 밖으로 올려서(state hoisting) 재사용 가능한 구조를 만든다.

app/build.gradle.kts
1plugins {
2    id("com.android.application")
3    id("org.jetbrains.kotlin.android")
4}
5
6android {
7    namespace = "com.example.state"
8    compileSdk = 34
9
10    defaultConfig {
11        minSdk = 24
12        targetSdk = 34
13    }
14
15    buildFeatures { compose = true }
16    composeOptions {
17        kotlinCompilerExtensionVersion = "1.5.14"
18    }
19}
20
21dependencies {
22    implementation(platform("androidx.compose:compose-bom:2024.06.00"))
23    implementation("androidx.activity:activity-compose:1.9.0")
24    implementation("androidx.compose.material3:material3")
25    debugImplementation("androidx.compose.ui:ui-tooling")
26}

버전은 프로젝트에 맞춰 조정하면 된다. 중요한 건 Compose BOM으로 런타임/UI 라이브러리 버전을 맞추는 점과, debugImplementation에 ui-tooling을 넣어 Preview/Inspector 기능을 쓰는 점이다. recomposition 관찰은 tooling이 있으면 훨씬 빠르다.

1단계: 상태 없이 값만 바꾸기

이 단계는 일부러 실패하는 예제다. 버튼을 누르면 로그에는 숫자가 올라가지만, 화면의 Text는 바뀌지 않는다. 실행 결과가 이상해야 정상이다. 이 차이를 눈으로 확인해야 ‘Compose는 아무 값이나 관찰하지 않는다’는 감각이 생긴다.

실행하면 화면에는 count=0이 보인다. 버튼을 여러 번 눌러도 화면은 그대로다. Logcat에는 count가 증가하는 로그만 찍힌다. Layout Inspector에서 Recomposition Count를 켜면 증가하지 않는다.

MainActivity.kt
1package com.example.state
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11
12class MainActivity : ComponentActivity() {
13    override fun onCreate(savedInstanceState: Bundle?) {
14        super.onCreate(savedInstanceState)
15        setContent { NoStateCounter() }
16    }
17}
18
19@Composable
20fun NoStateCounter() {
21    var count = 0
22    Column {
23        Text("count=$count")
24        Button(onClick = {
25            count++
26            Log.d("NoStateCounter", "count=$count")
27        }) {
28            Text("Click")
29        }
30    }
31}

여기서 count는 일반 로컬 변수라서, Compose Runtime의 관찰 대상이 아니다. Text가 count를 읽어도 ‘State 읽기’가 아니므로 읽기 추적이 남지 않는다. onClick에서 count를 바꿔도 invalidation이 걸리지 않는다. 화면이 바뀌려면 recomposition이 스케줄돼야 하는데, 스케줄링을 유발하는 트리거가 없다.

2단계: remember + mutableStateOf로 UI 갱신 만들기

2단계는 같은 UI를 유지하면서 상태 저장소만 바꾼다. 실행하면 버튼 클릭마다 Text가 바뀌고, Logcat에 recomposition 로그가 찍힌다. 이 로그가 ‘함수 재호출 기반 렌더링’이라는 증거가 된다.

실행 후 버튼을 3번 누르면 화면에 count=3이 보인다. Logcat에는 "Recomposition happened"가 3번 찍힌다. 여기서 중요한 건 Activity나 View를 직접 건드린 적이 없다는 사실이다.

MainActivity2.kt
1package com.example.state
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.SideEffect
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.setValue
16
17class MainActivity2 : ComponentActivity() {
18    override fun onCreate(savedInstanceState: Bundle?) {
19        super.onCreate(savedInstanceState)
20        setContent { StatefulCounter() }
21    }
22}
23
24@Composable
25fun StatefulCounter() {
26    var count by remember { mutableStateOf(0) }
27
28    SideEffect { Log.d("StatefulCounter", "recomposed count=$count") }
29
30    Column {
31        Text("count=$count")
32        Button(onClick = { count++ }) { Text("Click") }
33    }
34}

remember가 없으면 mutableStateOf(0) 자체가 recomposition마다 새로 만들어지고, 이전 State를 읽던 scope와 새 State의 연결이 끊긴다. 증상은 ‘클릭하면 1로 올라갔다가 바로 0으로 돌아감’처럼 보이기도 한다. 실제로는 0으로 돌아가는 게 아니라, 새로 생성된 State(초기값 0)를 화면이 읽는 상황이다.

3단계: 상태 끌어올리기(state hoisting)로 재사용 구조 만들기

3단계는 컴포넌트를 두 개로 나눈다. 하나는 상태를 소유하는 부모, 다른 하나는 상태를 입력으로 받는 순수 UI다. 실행하면 동작은 같지만, UI 컴포넌트는 테스트/재사용이 쉬워진다. 실무에서는 이 구조가 없으면 화면이 커질수록 상태가 뒤엉킨다.

실행하면 카운터 UI는 동일하다. 차이는 CounterStateless가 count와 onIncrement만 받아서, Preview에서 임의 값으로도 렌더링 가능하다는 점이다. 또한 부모가 count를 다른 데이터 소스(ViewModel, repository)로 바꾸기 쉬워진다.

CounterHoisting.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.mutableStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14fun CounterHost() {
15    var count by remember { mutableStateOf(0) }
16    CounterStateless(
17        count = count,
18        onIncrement = { count++ }
19    )
20}
21
22@Composable
23fun CounterStateless(
24    count: Int,
25    onIncrement: () -> Unit
26) {
27    Column {
28        Text("count=$count")
29        Button(onClick = onIncrement) { Text("Click") }
30    }
31}
32
33@Preview
34@Composable
35private fun PreviewCounterStateless() {
36    CounterStateless(count = 42, onIncrement = {})
37}

이 구조는 Compiler의 파라미터 changed 비교에도 유리하다. CounterStateless는 입력이 count와 onIncrement뿐이라, count가 안 바뀌면 Text도 다시 계산할 필요가 없다. 반대로 onIncrement가 매 recomposition마다 새 람다로 생성되면 changed로 판단돼 불필요한 재호출이 생길 수 있다. 람다 안정성은 심화 섹션에서 다룬다.

심화: Advanced 버전 만들기

실무에서 버튼 하나가 요구사항을 빨아들이기 시작하면 상태 설계가 흔들린다. loading 동안 클릭 막기, 연속 클릭 debounce, 아이콘+텍스트, long press, 접근성 라벨까지 들어오면 ‘상태가 어디에 있어야 하는지’가 성능과 버그를 동시에 좌우한다. 여기서는 상태를 외부 입력으로 받고, 내부에는 UI 동작을 위한 최소 상태만 둔다.

사례 1: debounce는 상태가 아니라 ‘시간’ 문제다

debounce를 count 같은 State로 처리하면 recomposition이 과도해질 수 있다. 클릭 시각은 UI에 표시할 필요가 없으니, Compose 상태로 만들 이유가 없다. remember로 lastClickTime을 저장하되, mutableStateOf가 아니라 일반 var를 remember에 넣은 holder로 두면 된다. UI가 시간값을 읽지 않으니 recomposition 트리거가 되지 않는다.

내가 예전에 debounce를 mutableStateOf로 만들고, 클릭마다 lastClickTime을 set했더니 버튼 주변의 recomposition count가 폭증했다. 화면에는 아무 변화도 없는데, 클릭할 때마다 3~5개의 scope가 다시 호출됐다. Layout Inspector에서 원인을 못 찾다가, SideEffect 로그로 ‘시간 상태를 읽는 곳이 없는데도 왜 recomposition이 생기지?’를 추적했고, 결국 내가 다른 곳에서 lastClickTime을 Text로 찍어둔 디버그 코드가 남아 있었다. 디버그 한 줄이 성능을 바꾼다.

사례 2: loading은 UI 상태지만, 소유자는 화면이다

loading은 UI가 바뀌어야 하므로 State가 맞다. 다만 소유권은 보통 화면(ViewModel/Host)에 있다. 버튼 내부에서 네트워크를 시작하면 취소/재시도/화면 회전에서 상태가 꼬인다. 버튼은 loading을 입력으로 받아서 enabled와 content를 바꾸는 역할만 한다.

AdvancedButton.kt
1package com.example.state
2
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Icon
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.remember
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun AdvancedButton(
20    text: String,
21    loading: Boolean,
22    enabled: Boolean,
23    accessibilityLabel: String,
24    onClick: () -> Unit,
25    onLongClick: (() -> Unit)? = null,
26    leadingIcon: (@Composable (() -> Unit))? = null,
27    debounceMillis: Long = 500L,
28    modifier: Modifier = Modifier
29) {
30    val gate = remember { ClickGate(debounceMillis) }
31
32    Row(
33        modifier = modifier
34            .semantics { contentDescription = accessibilityLabel }
35            .combinedClickable(
36                enabled = enabled && !loading,
37                onClick = {
38                    if (gate.tryPass()) onClick()
39                },
40                onLongClick = onLongClick
41            )
42    ) {
43        if (loading) {
44            CircularProgressIndicator(
45                strokeWidth = 2.dp,
46                color = MaterialTheme.colorScheme.primary
47            )
48            Spacer(Modifier.width(8.dp))
49        } else if (leadingIcon != null) {
50            leadingIcon()
51            Spacer(Modifier.width(8.dp))
52        }
53        Text(text)
54    }
55}
56
57private class ClickGate(private val debounceMillis: Long) {
58    private var lastClickUptime: Long = 0L
59
60    fun tryPass(now: Long = android.os.SystemClock.uptimeMillis()): Boolean {
61        val ok = now - lastClickUptime >= debounceMillis
62        if (ok) lastClickUptime = now
63        return ok
64    }
65}

여기서 gate는 remember로 보존되지만 Compose State가 아니다. UI가 gate를 읽어 렌더링을 바꿀 필요가 없기 때문이다. 반면 loading은 UI를 바꾸므로 입력 파라미터로 받고, loading이 true면 progress indicator를 보여준다. combinedClickable을 쓰면 click과 long click이 같은 입력 노드에서 처리된다. semantics contentDescription은 스크린리더에서 읽히는 텍스트로, 버튼 텍스트와 별개로 지정할 수 있다.

AdvancedButtonDemo.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Icon
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.graphics.vector.ImageVector
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun AdvancedButtonDemo() {
19    var loading by remember { mutableStateOf(false) }
20
21    Column(Modifier.padding(16.dp)) {
22        AdvancedButton(
23            text = if (loading) "Loading..." else "Submit",
24            loading = loading,
25            enabled = true,
26            accessibilityLabel = "submit-button",
27            onClick = { loading = true },
28            onLongClick = { loading = false },
29            leadingIcon = {
30                val fakeIcon: ImageVector? = null
31                if (fakeIcon == null) {
32                    Text("[icon]")
33                } else {
34                    Icon(fakeIcon, contentDescription = null)
35                }
36            }
37        )
38        Text("Long press to stop loading")
39    }
40}
41
42@Preview
43@Composable
44private fun PreviewAdvancedButtonDemo() {
45    AdvancedButtonDemo()
46}

실행하면 Submit 버튼이 있고, 클릭하면 로딩 인디케이터가 나타난다. 길게 누르면 로딩이 해제된다. 여기서 recomposition 범위는 AdvancedButtonDemo의 loading을 읽는 부분과 AdvancedButton 내부의 분기 부분이다. gate는 클릭 때만 갱신되고 UI가 읽지 않으므로 recomposition을 유발하지 않는다. 이런 분리가 ‘상태는 UI에 필요한 만큼만’이라는 실무 감각이다.

흑역사 하나 더 있다. loading을 버튼 내부에서 remember { mutableStateOf(false) }로 두고, onClick에서 네트워크 시작과 함께 loading=true로 바꿨다. 화면 회전 후 loading이 false로 돌아가서 사용자가 연타했고, 서버에 동일 요청이 4번 들어갔다. 로그에는 200 OK가 4개 찍혔고, QA가 재현 영상을 보내왔다. 해결은 loading을 화면 상태로 끌어올리고, 요청 식별자를 기준으로 중복 실행을 막는 구조로 바꾸는 것이었다.

자주 하는 실수

1) remember 없이 mutableStateOf를 만든다

증상: 버튼을 누르면 값이 1로 올라갔다가 다시 0으로 돌아가거나, 화면이 ‘항상 초기값’처럼 보인다. 어떤 경우에는 클릭할 때마다 ripple만 나오고 텍스트는 그대로다.

원인: mutableStateOf 객체가 recomposition마다 새로 생성된다. 이전 State를 읽던 scope는 이전 객체에 연결돼 있고, 화면은 새 객체의 초기값을 읽는다. 관찰 그래프가 끊어진다.

해결: remember { mutableStateOf(...) }로 State 객체를 슬롯에 고정한다. 입력이 바뀔 때만 초기화가 필요하면 remember(key) 형태로 키를 준다.

2) 일반 var를 바꾸면 UI가 바뀐다고 믿는다

증상: Logcat에는 값이 증가하는데 Text는 변하지 않는다. Layout Inspector에서 recomposition count가 증가하지 않는다.

원인: Compose Runtime은 일반 변수의 변경을 관찰하지 못한다. invalidation을 걸 대상 scope를 찾을 수 없다. View의 invalidate 같은 호출도 없다.

해결: UI에 반영돼야 하는 값은 SnapshotState(mutableStateOf), StateFlow collectAsState, LiveData observeAsState 같은 관찰 가능한 형태로 만든다.

3) remember를 조건문 안에서 호출한다

증상: 특정 조건에서만 화면이 깨지거나, 상태가 엉뚱한 곳으로 이동한다. 운이 나쁘면 런타임 예외가 나고, 운이 좋으면 ‘가끔’만 이상해 디버깅이 더 어렵다.

원인: Slot Table은 호출 순서에 의존한다. 조건에 따라 remember 호출 횟수가 바뀌면 슬롯 인덱스가 밀려 다른 remember 값과 매칭된다.

해결: remember 호출은 항상 동일한 호출 경로에서 일어나게 둔다. 조건에 따른 초기화가 필요하면 remember(key)로 키를 바꾸거나, 조건 분기 바깥에서 상태를 만들고 내부에서만 사용한다.

4) LazyColumn에서 key 없이 아이템 내부 remember를 쓴다

증상: 스크롤하면 체크 상태/입력값이 다른 아이템으로 튄다. 특히 검색 필터로 리스트가 바뀔 때 재현이 잘 된다.

원인: 아이템의 ‘호출 위치’가 리스트 변화로 바뀌면 remember 슬롯이 다른 데이터와 결합된다. Compose는 위치 기반으로 슬롯을 맞추기 때문에 안정적인 키가 없으면 상태가 떠돈다.

해결: LazyColumn items(items, key = { it.id })처럼 안정적인 key를 준다. 상태는 id 기반으로 외부에 저장하고 아이템은 입력만 받게 만든다.

5) 파라미터에 매번 새 객체/람다를 만들어 불필요한 recomposition을 만든다

증상: 화면은 잘 동작하지만, Recomposition Count가 계속 증가하고 스크롤/입력에서 잔렉이 생긴다. 프로파일러에서 할당이 늘어난다.

원인: Compiler의 changed 비교에서 ‘다른 객체’로 판단돼 스킵이 깨진다. 특히 onClick = { ... }가 부모 recomposition마다 새 인스턴스가 되면 자식이 계속 다시 호출된다.

해결: 상태 소유자를 정리하고, 필요하면 remember로 람다를 안정화한다. derivedStateOf로 계산값을 캐시하고, 데이터 클래스에는 @Immutable을 붙이는 설계를 고려한다.

LambdaStabilize.kt
1package com.example.state
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5
6@Composable
7fun LambdaStabilize(
8    onEvent: (Int) -> Unit,
9    id: Int
10): () -> Unit {
11    val stableClick = remember(onEvent, id) {
12        { onEvent(id) }
13    }
14    return stableClick
15}

이 코드는 ‘부모가 자주 recomposition되는 화면’에서 자식에게 넘기는 onClick을 안정화할 때 쓴다. remember 키에 onEvent와 id를 넣어, 둘 중 하나가 바뀔 때만 새 람다를 만든다. 실행 결과로는 UI가 같지만, Layout Inspector의 recomposition count가 눈에 띄게 줄어드는 경우가 많다.

성능 최적화 체크리스트

  • UI에 반영돼야 하는 값이 일반 var인지 SnapshotState인지 구분한다(로그만 바뀌고 UI가 멈추면 1순위 점검).
  • State 객체 생성이 remember 블록 안에 있는지 확인한다(remember 없이 mutableStateOf 생성 금지).
  • remember 호출이 조건문/반복문에 의해 호출 횟수가 달라지지 않는지 확인한다(슬롯 인덱스 밀림 방지).
  • LazyColumn/LazyRow 아이템에 안정적인 key를 제공한다(상태 튐 현상 방지).
  • 상태 소유권을 화면(Host/ViewModel)과 UI(Stateless)로 분리한다(state hoisting).
  • UI가 읽지 않는 값(시간 게이트, 캐시 핸들)은 Compose State로 만들지 않는다(불필요 invalidation 방지).
  • 자식에 넘기는 람다/객체가 매 recomposition마다 새로 생성되는지 확인한다(필요 시 remember로 안정화).
  • 계산 비용 큰 파생 값은 derivedStateOf로 캐시한다(입력 State가 바뀔 때만 재계산).
  • 데이터 모델이 불변이면 @Immutable, 내부 변경이 제한적이면 @Stable 계약을 검토한다(파라미터 비교 최적화).
  • SideEffect/LaunchedEffect를 ‘관찰/디버그’와 ‘비즈니스 로직’ 용도로 혼용하지 않는다(재실행 타이밍 오해 방지).
  • Recomposition Count, Layout Inspector, Profileable build를 사용해 ‘어느 scope가 다시 도는지’부터 찾는다.
  • 할당 추적(Allocation Tracking)으로 클릭/스크롤 시 객체 생성이 폭증하는 지점을 찾는다(Modifier/람다/리스트 아이템).

자주 묻는 질문

mutableStateOf를 쓰면 왜 UI가 자동으로 바뀌나? invalidate를 호출한 적이 없는데도 갱신된다.

mutableStateOf가 만드는 SnapshotState는 ‘값 저장소’가 아니라 관찰 가능한 노드다. Composable이 state.value를 읽는 순간, Runtime은 현재 실행 중인 RecomposeScope를 잡고 “이 scope가 이 State를 읽었다”를 기록한다. 이후 state.value에 쓰기가 발생하면 Snapshot 시스템이 변경을 기록하고, 해당 State를 읽었던 scope들을 invalidation 큐에 넣는다. Recomposer는 다음 프레임에서 큐를 처리하며 필요한 scope만 다시 호출한다. 이 재호출이 Composition 단계의 재실행이고, 그 결과가 바뀌면 Layout/Draw가 연쇄적으로 갱신된다. 학습 키워드는 Snapshot, RecomposeScope, invalidation, Recomposer다.

remember가 없으면 왜 값이 초기화되나? 코드상으로는 같은 줄에서 mutableStateOf를 만들었는데도 유지되지 않는다.

Composable은 언제든 다시 호출될 수 있고, 다시 호출은 일반 함수 호출처럼 로컬 변수를 새로 만든다. remember는 “현재 호출 위치(슬롯 인덱스)에 값을 저장”하는 장치다. remember가 없으면 mutableStateOf(0) 자체가 매번 새 객체가 된다. 화면은 새 객체의 초기값 0을 읽는다. 더 나쁜 케이스는 이전 State를 읽던 scope와 새 State가 분리되어, 클릭해도 기대한 scope가 무효화되지 않는 상황이다. remember(key)를 쓰면 호출 위치는 같아도 키 변화 시 슬롯을 폐기하고 새로 만들 수 있다. 학습 키워드는 Slot Table, call position, remember keys다.

State를 어디에 두는 게 맞나? 컴포넌트 내부 remember로 두면 편한데 실무에서는 왜 hoisting을 강하게 말하나?

컴포넌트 내부 remember는 ‘그 컴포넌트의 생명주기’에 묶인다. 화면 회전, 내비게이션 back stack, 리스트 재사용 같은 상황에서 상태의 소유권이 흐려지면 버그가 난다. 예를 들어 로딩 상태를 버튼 내부에 두면 화면이 재생성될 때 로딩이 풀려 중복 요청이 생긴다. hoisting은 상태를 화면(또는 ViewModel)이 소유하고, UI는 입력을 받아 렌더링만 하게 만든다. 이렇게 하면 테스트가 쉬워지고, 동일 UI를 다른 상태 소스(StateFlow, repository)로 연결하기도 쉽다. 학습 키워드는 state hoisting, unidirectional data flow(UDF)다.

recomposition은 전체 화면을 다시 그리는 건가? 성능이 걱정된다.

recomposition은 ‘전체 화면 재그리기’가 아니라 ‘무효화된 scope의 Composable 재호출’이다. State 읽기 추적 때문에, 어떤 State를 읽은 범위만 invalidation 대상이 된다. 다만 scope를 크게 잡으면(큰 Composable 안에서 여러 상태를 읽으면) 작은 변경에도 큰 범위가 다시 호출된다. 또 recomposition이 일어나도 Layout/Draw가 항상 전체로 퍼지는 건 아니다. 변경이 레이아웃에 영향이 없으면 measure/layout이 줄어들 수 있다. 실제 확인은 Layout Inspector의 recomposition count, 그리고 프로파일러에서 measure/layout 호출 빈도를 같이 보는 방식이 정확하다. 학습 키워드는 recomposition scope, skipping, layout passes다.

왜 @Stable/@Immutable이 필요한가? 안 붙여도 동작은 하던데 의미가 있나?

동작은 한다. 문제는 ‘파라미터 비교 비용’과 ‘스킵 가능성’이다. Compiler는 changed 마스크로 파라미터가 바뀌었는지 판단하고, 바뀌지 않았으면 그룹 본문을 스킵할 수 있다. 그런데 객체가 내부적으로 변할 수 있으면, equals가 같아도 실제 내용이 바뀌는 상황이 생긴다. @Immutable은 “내부가 절대 변하지 않는다”는 강한 계약이고, @Stable은 “변하더라도 Compose가 추적 가능한 방식으로 변한다(예: State로 노출)”는 계약에 가깝다. 이 계약이 있으면 Runtime이 더 공격적으로 스킵하거나, 안정성 판단을 쉽게 한다. 학습 키워드는 stability inference, skipping, changed flags다.

Modifier 체이닝 순서가 왜 중요한가? padding 먼저/나중에 차이가 실제로 있나?

Modifier는 리스트처럼 누적되고, 각 노드는 측정/배치/그리기/입력/시맨틱 중 일부를 가로챈다. 예를 들어 clickable 뒤에 padding을 붙이면 ‘클릭 영역’이 padding 포함 여부에 따라 달라진다. semantics를 먼저 붙이면 접근성 노드에 설명이 먼저 들어가고, 이후 입력 Modifier가 추가되더라도 설명은 유지된다. 반대로 semantics를 조건부로 붙였다 떼면 접근성 트리가 흔들릴 수 있다. Layout Inspector에서 노드의 Modifier 체인을 보면 순서가 그대로 표현되고, 실제 hit test 영역도 그 순서의 영향을 받는다. 학습 키워드는 modifier node, hit testing, semantics tree다.

왜 derivedStateOf가 필요한가? 그냥 val computed = a + b 하면 되지 않나?

computed가 비싼 계산이거나, recomposition이 자주 일어나는 scope 안에 있을 때 문제가 된다. 그냥 val computed = ...는 recomposition마다 다시 계산된다. derivedStateOf는 입력 State를 읽는 동안 계산을 캐시하고, 입력이 바뀔 때만 값을 갱신한다. 또한 derivedStateOf의 결과를 읽는 쪽은 결과가 바뀔 때만 invalidation을 받으므로, a나 b가 바뀌어도 computed가 동일하면 downstream recomposition을 막을 수 있다(정책에 따라). 실무에서는 스크롤 위치 기반 헤더, 필터링된 리스트 카운트, 복잡한 포맷팅에서 효과가 크다. 학습 키워드는 derivedStateOf, snapshot reads, structural equality policy다.

관련 글

13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유
Compose 기본2026.03.01

13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유

Jetpack Compose에서 State/MutableState가 왜 필요한지, remember·Slot Table·recomposition 추적이 어떻게 연결되는지 내부 동작 관점에서 설명한다. 실습 코드 포함. 154자 내외로 맞춘 설명 문장이다.

25. Compose State와 MutableState: remember가 UI를 자동 갱신하는 이유
Compose 기본2026.03.05

25. Compose State와 MutableState: remember가 UI를 자동 갱신하는 이유

Jetpack Compose의 State/MutableState, remember가 필요한 이유, Slot Table 저장 방식과 리컴포지션 범위를 내부 동작 관점에서 설명한다. 초보가 흔히 겪는 버그까지 다룬다. 2026-03 기준 실무 팁 포함.

30. derivedStateOf로 불필요한 Recomposition 줄이는 실전 패턴
Compose 기본2026.03.07

30. derivedStateOf로 불필요한 Recomposition 줄이는 실전 패턴

Jetpack Compose에서 derivedStateOf가 왜 필요한지, Slot Table·스냅샷·재구성 비교까지 따라가며 불필요한 recomposition을 줄이는 패턴을 구현한다. 초보도 동작 이유를 이해한다. ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ