Compose 기본2026년 03월 05일· 12 min read

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

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

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

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

Compose를 처음 쓰면 버튼을 눌러도 숫자가 안 올라가거나, 올라가긴 하는데 화면이 엉뚱하게 다시 그려지는 일을 겪는다. 로컬 변수로 카운트를 올렸는데 UI가 그대로거나, 반대로 리스트 스크롤이 튀면서 전체가 리컴포지션되는 경우도 흔하다. 이 문제는 문법이 아니라 “값이 어디에 저장되고, 누가 그 값을 읽었는지”를 Runtime이 어떻게 추적하는지에서 시작한다. 이 글은 remember와 MutableState가 내부에서 어떤 계약을 만들고, 그 계약이 깨질 때 어떤 현상이 생기는지에 초점을 둔다.

핵심 개념

Compose에서 UI는 함수 호출의 결과물이다. 함수가 다시 호출되면 UI도 다시 계산된다. 문제는 “언제 다시 호출할지”를 개발자가 직접 트리거하지 않는다는 점이다. Runtime이 상태 읽기(read)와 쓰기(write)를 추적해, 필요한 함수만 다시 호출한다. 이 추적의 단위가 State이며, 그 중 가장 흔한 구현이 MutableState이다.

remember는 상태를 ‘저장’하는 키워드가 아니라, 특정 컴포지션 위치에 객체를 ‘고정(pinning)’하는 장치에 가깝다. 같은 Composable이 리컴포지션으로 여러 번 호출되어도, remember 블록이 만든 객체는 Slot Table의 같은 슬롯에 재사용된다. remember가 없으면 매 호출마다 새 객체가 만들어지고, 이전 호출에서 만들어진 객체와 연결된 관찰 정보(누가 읽었는지)가 끊긴다.

MutableState는 값 컨테이너이면서 관찰 포인트다. value를 읽는 순간 Runtime은 현재 실행 중인 RecomposeScope(대략 ‘이 Composable 호출 구간’)를 구독자로 등록한다. 이후 value가 바뀌면 Snapshot 시스템이 변경을 기록하고, 다음 프레임에서 해당 스코프만 무효화(invalidate)한다. 이 구조가 “전체 UI가 아니라 필요한 부분만 다시 호출”을 가능하게 만든다.

용어를 맥락으로 정의한다. Composition은 Composable 호출 트리를 만들고 Slot Table에 결과를 기록하는 단계다. Recomposition은 이미 존재하는 Slot Table을 기반으로 일부 구간만 다시 실행하는 단계다. Snapshot은 상태 변경을 트랜잭션처럼 모아 일관된 읽기/쓰기 규칙을 제공하는 시스템이다. Stability(@Stable/@Immutable)는 ‘이 값이 바뀌었는지’를 Runtime이 더 공격적으로 생략(skip)할 수 있도록 힌트를 주는 계약이다.

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.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14fun CounterBasic() {
15    var count by remember { mutableIntStateOf(0) }
16
17    Column {
18        Text(text = "count=$count")
19        Button(onClick = { count++ }) {
20            Text("+1")
21        }
22    }
23}
24
25@Preview(showBackground = true)
26@Composable
27private fun CounterBasicPreview() {
28    CounterBasic()
29}

이 코드를 실행하면 버튼을 누를 때마다 Text만 바뀌는 듯 보인다. 내부에서는 Button의 onClick에서 count가 바뀌는 순간 Snapshot이 write를 기록하고, count를 읽었던 Text가 속한 스코프가 invalidate된다. 다음 프레임에서 Runtime은 그 스코프만 재호출한다. 여기서 remember가 빠지면 count 컨테이너 자체가 매번 새로 만들어져 invalidate 연결이 끊기거나, 클릭 직후 값이 다시 0으로 돌아가 UI가 고정된 것처럼 보인다.

컴포넌트 해부

State/MutableState를 “컴포넌트”처럼 해부하는 이유는, 실제로는 UI가 아니라 Runtime 계약의 조합이기 때문이다. remember는 Slot Table의 특정 인덱스에 값을 저장하는 API이고, mutableStateOf는 Snapshot 관찰이 가능한 State 객체를 만든다. 둘을 붙이면 “특정 위치에 고정된 관찰 가능한 값”이 된다.

  • remember { ... }: 컴포지션 위치 기반으로 객체를 재사용한다. 호출 순서가 바뀌면 다른 슬롯을 가리킨다.
  • remember(key1, key2) { ... }: 키가 바뀌면 해당 슬롯의 값을 폐기하고 새로 만든다. 캐시 무효화 규칙이다.
  • mutableStateOf(initial): 일반 타입용 State 컨테이너를 만든다. equals 정책과 stability 영향을 받는다.
  • mutableIntStateOf(initial): 박싱을 줄인 특화 State다. Int 변경 시 객체 할당이 줄어든다.
  • var x by state: getValue/setValue 연산자로 value 접근을 축약한다. 읽기/쓰기 자체는 동일하다.
  • state.value: 명시적 접근. 어디서 읽었는지 추적이 더 눈에 보인다.
  • rememberSaveable: 프로세스 죽음/구성 변경까지 저장하려는 의도다. 저장 가능 타입 제약이 있다.
  • derivedStateOf: 다른 State로부터 계산된 State를 만든다. 읽기 추적을 ‘계산 결과’로 합친다.
  • snapshotFlow: State 읽기를 Flow로 변환한다. 스냅샷 읽기 규칙을 지킨다.
  • @Stable/@Immutable: 파라미터 변경 감지에서 skip 가능성을 높이는 힌트다. 잘못 붙이면 버그가 난다.
  • state hoisting: State를 상위로 올려 단방향 데이터 흐름을 만든다. 테스트와 재사용성이 달라진다.
  • key(): 컴포지션 위치를 인위적으로 분기한다. 리스트/조건 분기에서 슬롯 정렬을 맞춘다.

Surface 계층 관점에서 보면 remember는 ‘렌더링 표면’이 아니라 ‘컴포지션 저장소’에 값을 둔다. View 시스템의 setTag나 onSaveInstanceState에 가까워 보이지만 결정적으로 다르다. remember는 View 트리 외부에 존재하는 Slot Table에 저장되고, 그 Slot Table은 컴포지션 위치(호출 순서)에 의해 주소가 결정된다. 그래서 조건문으로 Composable 호출 순서를 바꾸면 저장 위치도 바뀐다.

Surface 계층에서 생기는 실무 문제는 두 가지다. 첫째, remember를 남발하면 화면 전환 시 해제되지 않는 값(사실은 composition이 살아있는 동안 유지되는 값)이 쌓인다. 둘째, remember의 키를 잘못 주면 캐시가 과도하게 무효화되어 매 프레임 객체가 재생성된다. 메모리 할당과 GC가 눈에 띄게 늘어나는 지점이다.

Content 계층 관점에서 MutableState는 ‘값’이 아니라 ‘관찰 지점’이다. Text가 count를 읽는 순간, Runtime은 “Text가 속한 스코프가 count에 의존한다”는 엣지를 그래프처럼 저장한다. 이후 count 변경은 해당 스코프만 invalidate한다. 이 그래프가 없으면 개발자가 notifyDataSetChanged 같은 호출을 직접 해야 한다.

Content 계층에서 중요한 설계 의도는 “읽은 곳만 다시 호출”이다. View 시스템은 보통 invalidate가 View 단위로 올라가며, 어댑터 기반 UI는 변경 범위를 직접 계산해야 했다. Compose는 State 읽기 추적을 통해 변경 범위를 자동 산출한다. 대신 개발자가 지켜야 하는 규칙이 생긴다. 상태는 Composable 호출 사이에 안정적으로 유지되어야 하며(remember), 상태 변경은 Snapshot 규칙 안에서 일어나야 한다(mutableStateOf 계열).

RememberSketch.kt
1package com.example.state
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.remember
6
7// 교육용 스케치: 실제 Runtime 코드가 아니라 구조를 설명하기 위한 형태다.
8private class SlotTableSketch {
9    private val slots = ArrayList<Any?>()
10    fun <T> remember(index: Int, factory: () -> T): T {
11        if (index >= slots.size) slots.add(factory())
12        @Suppress("UNCHECKED_CAST")
13        return slots[index] as T
14    }
15}
16
17@Composable
18fun RememberSketch(externalIndex: Int = 0) {
19    // 실제로는 index를 컴파일러가 호출 위치로 계산한다.
20    val table = remember { SlotTableSketch() }
21    val state = table.remember(index = externalIndex) { mutableStateOf(0) }
22    // state.value를 읽는 순간, Runtime은 현재 스코프를 state에 구독시킨다.
23    // 이 스케치에서는 그 부분을 생략했다.
24}

이 스케치를 읽으면 remember가 왜 ‘위치 기반’인지 감이 온다. 실제 Compose Compiler는 각 remember 호출 지점에 그룹 키와 슬롯 인덱스를 부여하고, Runtime은 그 인덱스로 Slot Table을 탐색한다. 외부Index 같은 것을 개발자가 넘기지 않아도 되는 이유가 컴파일러 변환 덕분이다. 대신 호출 순서가 바뀌면 인덱스가 달라져 다른 값이 튀어나온다.

GreetingCard.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.padding
4import androidx.compose.material3.Card
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun GreetingCard(name: String, modifier: Modifier = Modifier) {
17    var taps by remember(name) { mutableIntStateOf(0) }
18
19    Card(modifier = modifier.padding(16.dp), onClick = { taps++ }) {
20        Text(
21            text = "Hello $name (taps=$taps)",
22            style = MaterialTheme.typography.titleMedium,
23            modifier = Modifier.padding(16.dp)
24        )
25    }
26}

remember(name)을 넣으면 name이 바뀌는 순간 taps가 초기화된다. 이 동작은 의도적으로 “캐시 무효화”를 표현한다. name이 바뀌면 사실상 다른 카드로 취급하고 싶다는 의미다. 키를 빼면 name이 바뀌어도 taps는 유지된다. 어떤 쪽이 맞는지는 UI 요구사항이 결정하지만, 내부적으로는 “키 변경 → 슬롯 폐기 → 새 객체 생성”이라는 규칙으로만 동작한다.

내부 동작 원리

Compose 파이프라인은 Composition → Layout → Drawing 순서로 진행된다. Composition은 Composable 함수를 실행해 UI 트리를 기술하고 Slot Table에 그룹과 슬롯을 기록한다. Layout은 측정/배치(Measure/Place) 단계로, Modifier와 Layout 노드가 여기서 영향을 준다. Drawing은 Canvas에 실제 픽셀을 그리는 단계다. State 변경은 보통 Drawing을 바로 건드리지 않고, 먼저 Recomposition을 예약해 Composition을 부분 재실행한다.

State 변경부터 화면 반영까지의 타임라인을 따라가면 디버깅이 쉬워진다. onClick에서 count++가 실행되면 MutableState의 setValue가 Snapshot write로 기록된다. Runtime은 이 State를 읽었던 RecomposeScope 목록을 알고 있다. 그 스코프들을 invalidate하고, Choreographer 프레임에 맞춰 recomposition을 수행한다. recomposition은 기존 Slot Table을 기반으로 변경된 그룹만 다시 호출하고, 변경된 레이아웃/드로잉만 하위로 전파한다.

Slot Table은 ‘이전 호출의 흔적’이다. 각 Composable 호출은 그룹을 만들고, remember는 그 그룹의 슬롯에 값을 저장한다. 리컴포지션 때 Runtime은 그룹 키와 호출 순서를 맞춰가며 기존 슬롯을 재사용한다. 그래서 “같은 코드, 같은 분기, 같은 호출 순서”라는 조건이 사실상 상태 보존의 전제 조건이 된다.

Compose Compiler는 Composable을 그대로 호출하지 않는다. 실제로는 추가 파라미터(Composer, changed flags)를 가진 함수로 변환되고, 각 구간에 startGroup/endGroup, skip 판단 코드가 삽입된다. 개발자가 보는 소스에는 없지만, Runtime 입장에서는 ‘그룹 경계’가 명확해야 Slot Table을 탐색하고 부분 스킵을 할 수 있다.

비교(equality)도 오해가 많다. MutableState는 값이 바뀌었는지 판단할 때 기본적으로 equals를 쓴다(정확한 정책은 정책 객체에 따라 달라진다). 값이 같다고 판단되면 invalidate가 발생하지 않는다. 반대로 값이 달라졌다고 판단되면 invalidate가 걸린다. @Stable/@Immutable은 파라미터 변경 감지에서 ‘깊은 비교’를 피하고 스킵을 늘리기 위한 계약이다. 잘못 표시하면 값이 바뀌었는데도 스킵되어 UI가 갱신되지 않는 형태로 터진다.

Modifier 체이닝은 Layout/Draw/PointerInput 같은 동작을 ‘순서가 있는 리스트’로 만들기 위한 설계다. 체이닝 순서가 곧 적용 순서다. padding().background()와 background().padding()은 측정 결과와 그려지는 영역이 달라진다. State와 직접 연결되는 지점은 clickable/semantics/interactionSource 같은 Modifier들이 내부에서 State를 읽거나 이벤트를 만들어내는 방식이다.

RecomposeTraceDemo.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.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.tooling.preview.Preview
14
15@Composable
16fun RecomposeTraceDemo() {
17    var count by remember { mutableIntStateOf(0) }
18
19    Log.d("Trace", "Composable body executed: count=$count")
20    SideEffect { Log.d("Trace", "SideEffect after successful composition: count=$count") }
21
22    Column {
23        Text(text = "count=$count")
24        Button(onClick = { count++ }) { Text("Increment") }
25    }
26}
27
28@Preview(showBackground = true)
29@Composable
30private fun RecomposeTraceDemoPreview() {
31    RecomposeTraceDemo()
32}

이 코드를 실행하면 Logcat에 두 종류의 로그가 찍힌다. 버튼을 누를 때마다 Composable body 로그가 먼저 늘고, 그 다음 SideEffect 로그가 따라온다. body 로그는 recomposition 때마다 실행된다. SideEffect는 composition이 성공적으로 적용된 뒤에만 실행된다. 처음에 나도 이 로그 순서를 반대로 예상해서 30분 정도 헤맸다. 클릭 직후에 SideEffect가 바로 찍힐 줄 알았는데, 실제로는 recomposition이 프레임에 맞춰 처리되면서 한 박자 늦게 보였다.

ModifierStateLinkDemo.kt
1package com.example.state
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.selection.toggleable
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.semantics.Role
16import androidx.compose.ui.semantics.contentDescription
17import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.unit.dp
19import androidx.compose.ui.tooling.preview.Preview
20
21@Composable
22fun ModifierStateLinkDemo() {
23    var checked by remember { mutableStateOf(false) }
24    val interaction = remember { MutableInteractionSource() }
25
26    Column(
27        modifier = Modifier
28            .padding(16.dp)
29            .semantics { contentDescription = "wifi-toggle" }
30            .toggleable(
31                value = checked,
32                interactionSource = interaction,
33                indication = null,
34                role = Role.Switch,
35                onValueChange = { checked = it }
36            )
37    ) {
38        Text(
39            text = if (checked) "Wi‑Fi: ON" else "Wi‑Fi: OFF",
40            style = MaterialTheme.typography.titleLarge
41        )
42        Text(text = "tap anywhere in this column")
43    }
44}
45
46@Preview(showBackground = true)
47@Composable
48private fun ModifierStateLinkDemoPreview() {
49    ModifierStateLinkDemo()
50}

toggleable은 내부적으로 포인터 입력을 받아 onValueChange를 호출한다. checked를 읽는 지점은 toggleable의 value 파라미터와 Text의 if 분기 두 군데다. checked가 바뀌면 두 군데가 속한 스코프가 invalidate된다. semantics는 접근성 트리에도 영향을 주는데, contentDescription 같은 값이 상태에 따라 바뀌면 접근성 노드도 다시 계산된다. 화면만 바뀌는 게 아니라 “보조 기술이 읽는 정보”도 State에 종속된다는 점이 실무에서 자주 놓친다.

한 문단 요약: remember는 Slot Table의 ‘같은 위치’에 객체를 고정하고, MutableState는 읽은 스코프를 구독자로 등록한다. value 변경은 Snapshot에 기록되고, 구독 중인 스코프만 invalidate되어 다음 프레임에 부분 리컴포지션이 일어난다.

실습하기

실습은 “상태가 안 바뀌는 것처럼 보이는” 상황을 일부러 만들고 고친다. 실행 결과를 눈으로 확인해야 Slot Table과 구독 그래프가 감각적으로 연결된다. Android Studio에서 Layout Inspector의 Recomposition Count를 같이 켜면 더 좋다.

app/build.gradle.kts
1plugins {
2    id("com.android.application")
3    id("org.jetbrains.kotlin.android")
4}
5
6android {
7    compileSdk = 35
8    defaultConfig { minSdk = 24 }
9    buildFeatures { compose = true }
10    composeOptions { kotlinCompilerExtensionVersion = "1.5.15" }
11}
12
13dependencies {
14    implementation(platform("androidx.compose:compose-bom:2024.10.00"))
15    implementation("androidx.activity:activity-compose:1.9.3")
16    implementation("androidx.compose.material3:material3")
17    implementation("androidx.compose.ui:ui-tooling-preview")
18    debugImplementation("androidx.compose.ui:ui-tooling")
19}

버전은 프로젝트 상황에 맞춰 조정하면 된다. 핵심은 BOM으로 Compose 라이브러리 버전을 맞추고, activity-compose와 material3, tooling을 넣는 구성이다. tooling이 있어야 Preview와 Layout Inspector에서 정보가 제대로 나온다.

1단계: remember 없이 실패를 재현한다

첫 화면에서 버튼을 눌러도 숫자가 0에서 안 움직이는 버전을 만든다. 이 상태가 초보가 가장 먼저 만나는 벽이다. 실행하면 클릭은 되는데 Text가 그대로라서 “onClick이 안 타나?”부터 의심하게 된다.

Logcat으로 onClick은 타는 걸 확인하고도 UI가 안 바뀌는 이유는, 로컬 변수가 recomposition 사이에 유지되지 않기 때문이다. 그리고 로컬 변수가 바뀌어도 Runtime이 그 변화를 관찰하지 못한다. 관찰 가능한 컨테이너(State)가 없기 때문이다.

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.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13
14class MainActivity : ComponentActivity() {
15    override fun onCreate(savedInstanceState: Bundle?) {
16        super.onCreate(savedInstanceState)
17        setContent {
18            MaterialTheme {
19                Surface { BrokenCounter() }
20            }
21        }
22    }
23}
24
25@Composable
26fun BrokenCounter() {
27    var count = 0
28
29    Column {
30        Text(text = "count=$count")
31        Button(onClick = {
32            count++
33            Log.d("BrokenCounter", "clicked, count=$count")
34        }) {
35            Text("+1")
36        }
37    }
38}

실행하면 Logcat에는 count가 1,2,3으로 증가하는 로그가 찍힌다. 화면의 count는 0에서 멈춘다. count 변경이 Runtime에 의해 관찰되지 않으니 recomposition이 예약되지 않는다. 또한 recomposition이 어떤 이유로든 발생하면(예: 상위 상태 변경) count는 다시 0으로 초기화된다. 이 두 현상이 remember와 State의 필요성을 강하게 만든다.

2단계: remember + MutableState로 자동 업데이트를 만든다

같은 UI를 유지한 채 count를 MutableState로 바꾼다. 버튼을 누르면 Text가 즉시 바뀐다. Layout Inspector의 Recomposition Count를 켜면 BrokenCounter와 달리 특정 노드만 카운트가 증가하는 모습이 보인다.

이 단계에서 확인할 포인트는 두 가지다. 첫째, remember가 없으면 state 객체가 매번 새로 만들어져 클릭 후 값이 튀는 현상이 난다. 둘째, state.value를 읽은 곳만 invalidate된다는 점이다. count를 읽지 않는 다른 Composable은 호출되지 않을 수 있다.

WorkingCounter.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.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14fun WorkingCounter() {
15    var count by remember { mutableIntStateOf(0) }
16
17    Column {
18        Text(text = "count=$count")
19        Button(onClick = { count++ }) {
20            Text("+1")
21        }
22    }
23}
24
25@Preview(showBackground = true)
26@Composable
27private fun WorkingCounterPreview() {
28    WorkingCounter()
29}

버튼 클릭 시 Text가 바뀌는 이유는 count를 읽은 Text가 속한 스코프가 count의 구독자가 되었기 때문이다. count++는 Snapshot write를 만들고, Runtime은 그 구독자 스코프만 invalidate한다. invalidate된 스코프가 다시 호출되면서 Text가 새 값을 출력한다. View 시스템의 notifyDataSetChanged가 사라진 자리에 “읽기 추적 기반 무효화”가 들어온 형태다.

3단계: 상태 끌어올리기와 커스터마이징으로 설계 의도를 확인한다

컴포넌트를 재사용하려면 내부에서 상태를 숨기기보다 외부에서 주입하는 형태가 필요하다. 같은 Counter를 여러 화면에서 쓰고, 테스트에서 상태를 제어하려면 state hoisting이 사실상 필수다. 실행하면 버튼을 눌러도 내부 로직이 아니라 외부에서 상태가 바뀌는 구조가 된다.

Modifier 커스터마이징은 “어디까지가 recomposition이고 어디부터가 layout/draw인가”를 체감하게 만든다. padding, shape, colors는 값이 바뀌면 layout/draw 단계까지 영향을 준다. 반면 onClick 람다만 바뀌는 경우는 스킵이 가능하다(안정성 조건이 맞는 경우).

CounterHoisted.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.ButtonDefaults
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.MutableIntState
11import androidx.compose.runtime.mutableIntStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.graphics.Color
15import androidx.compose.ui.unit.dp
16import androidx.compose.ui.tooling.preview.Preview
17
18@Composable
19fun CounterHoisted(
20    countState: MutableIntState,
21    modifier: Modifier = Modifier,
22    onIncrement: () -> Unit
23) {
24    Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
25        Text(text = "count=${countState.intValue}")
26        Button(
27            onClick = onIncrement,
28            colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2D2A55))
29        ) { Text("+1") }
30    }
31}
32
33@Preview(showBackground = true)
34@Composable
35private fun CounterHoistedPreview() {
36    val s = remember { mutableIntStateOf(7) }
37    CounterHoisted(
38        countState = s,
39        modifier = Modifier.padding(16.dp),
40        onIncrement = { s.intValue++ }
41    )
42}

이 구조에서 CounterHoisted는 상태를 소유하지 않는다. 상태 소유자가 바뀌면 recomposition의 범위도 바뀐다. 예를 들어 상위에서 여러 상태를 한 번에 바꾸면 CounterHoisted도 같이 invalidate될 수 있다. 반대로 상태를 잘게 쪼개면 더 작은 범위만 invalidate된다. 실무에서 “왜 어떤 화면은 버튼 한 번에 전체가 다시 그려지지?”라는 질문의 답이 여기 있다.

심화: Advanced 버전 만들기

실무 버튼은 단순 클릭만 처리하지 않는다. 로딩 중 비활성화, 연타 방지, 아이콘+텍스트, 롱프레스, 접근성 라벨까지 요구사항이 붙는다. 이 요구사항을 State와 연결할 때 recomposition 폭발이 쉽게 발생한다. 이유는 ‘상태를 어디서 읽느냐’가 곧 무효화 범위를 결정하기 때문이다.

사례 1: 로딩 + 연타 방지(debounce) + 접근성 라벨

로딩 상태를 버튼 내부에서 remember로 만들면 화면을 벗어났다가 돌아올 때 로딩이 이상하게 유지되는 일이 생긴다. 로딩은 서버 요청과 같은 외부 이벤트에 종속되므로 상위에서 주입하는 편이 자연스럽다. debounce는 UI 이벤트 품질 문제다. 클릭 이벤트가 초당 10번 들어오면 네트워크가 10번 나가고, 그 결과로 상태 변경이 연쇄적으로 발생해 recomposition이 폭증한다.

AdvancedButton.kt
1package com.example.state
2
3import android.os.SystemClock
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.Button
8import androidx.compose.material3.CircularProgressIndicator
9import androidx.compose.material3.Icon
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableLongStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.semantics.contentDescription
18import androidx.compose.ui.semantics.semantics
19import androidx.compose.ui.unit.dp
20
21@Composable
22fun AdvancedButton(
23    text: String,
24    loading: Boolean,
25    enabled: Boolean,
26    icon: (@Composable () -> Unit)? = null,
27    debounceMs: Long = 600L,
28    a11yLabel: String = text,
29    onClick: () -> Unit
30) {
31    var lastClickAt by remember { mutableLongStateOf(0L) }
32
33    Button(
34        onClick = {
35            val now = SystemClock.elapsedRealtime()
36            if (now - lastClickAt < debounceMs) return@Button
37            lastClickAt = now
38            onClick()
39        },
40        enabled = enabled && !loading,
41        modifier = Modifier.semantics { contentDescription = a11yLabel }
42    ) {
43        if (loading) {
44            CircularProgressIndicator(strokeWidth = 2.dp)
45            Spacer(Modifier.width(8.dp))
46        }
47        if (icon != null) {
48            icon()
49            Spacer(Modifier.width(8.dp))
50        }
51        Text(text)
52    }
53}

여기서 remember는 lastClickAt을 Slot Table에 고정한다. 이 값은 UI 상태라기보다 ‘입력 품질 제어’에 가깝고, 화면 생명주기 동안 유지되는 것이 맞다. loading을 내부 remember로 만들지 않은 이유는 외부 상태(네트워크 결과)와 동기화가 필요하기 때문이다. a11yLabel은 semantics 트리에 들어가므로, 상태에 따라 바뀌면 접근성 노드도 invalidate된다.

사례 2: 롱프레스 + 상태 읽기 위치 최적화

롱프레스는 pointerInput으로 처리하는 경우가 많다. 이때 상태를 pointerInput 블록 안에서 직접 읽으면, 작은 상태 변경에도 입력 처리 코루틴이 재시작되며 프레임 드랍이 생길 수 있다. 읽기 위치를 분리해 recomposition과 입력 처리의 결합도를 낮추는 편이 낫다.

LongPressBox.kt
1package com.example.state
2
3import androidx.compose.foundation.gestures.detectTapGestures
4import androidx.compose.foundation.layout.Box
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.rememberUpdatedState
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.input.pointer.pointerInput
10
11@Composable
12fun LongPressBox(
13    label: String,
14    onClick: () -> Unit,
15    onLongPress: () -> Unit
16) {
17    val clickLatest = rememberUpdatedState(onClick)
18    val longPressLatest = rememberUpdatedState(onLongPress)
19
20    Box(
21        modifier = Modifier.pointerInput(Unit) {
22            detectTapGestures(
23                onTap = { clickLatest.value.invoke() },
24                onLongPress = { longPressLatest.value.invoke() }
25            )
26        }
27    ) {
28        Text(label)
29    }
30}

rememberUpdatedState는 “람다 레퍼런스가 바뀌어도 pointerInput 블록을 재시작하지 않게” 만드는 장치다. 처음에 나도 이걸 모르고 pointerInput(key1 = onClick) 같은 식으로 키에 람다를 넣었다가, 상태가 바뀔 때마다 입력 코루틴이 재시작되며 롱프레스가 씹혔다. 당시 로그에는 별 게 없고, 체감만 ‘가끔 안 눌림’이라 3시간 삽질했다. 원인은 recomposition이 람다를 새로 만들고, 그게 key 변경으로 인식되어 pointerInput이 취소/재시작되던 것이었다.

내가 만든 흑역사도 하나 더 있다. 로딩 상태를 버튼 내부 remember로 만들고, 네트워크 응답에서 로딩을 false로 바꿔야 하는데 외부에서 접근이 안 되니 이벤트를 억지로 흘려보냈다. 그 과정에서 화면 회전 후 로딩이 영원히 true로 남는 버그가 났고, 사용자는 ‘앱이 멈춤’으로 인식했다. 원인은 상태 소유권을 UI 내부로 숨긴 설계였다. 로딩은 화면 상태(ViewModel)로 올리고, 버튼은 loading 파라미터만 받아 그리게 바꾸니 버그가 사라졌다.

AdvancedButtonUsageDemo.kt
1package com.example.state
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material.icons.Icons
6import androidx.compose.material.icons.filled.Refresh
7import androidx.compose.material3.Icon
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
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 AdvancedButtonUsageDemo() {
20    var loading by remember { mutableStateOf(false) }
21
22    Column(Modifier.padding(16.dp)) {
23        AdvancedButton(
24            text = if (loading) "Loading" else "Refresh",
25            loading = loading,
26            enabled = true,
27            icon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
28            a11yLabel = "refresh-button",
29            onClick = {
30                loading = true
31                // 실제 앱에서는 여기서 비동기 작업 후 loading=false로 되돌린다.
32            }
33        )
34        Text("loading=$loading")
35    }
36}
37
38@Preview(showBackground = true)
39@Composable
40private fun AdvancedButtonUsageDemoPreview() {
41    AdvancedButtonUsageDemo()
42}

이 데모는 클릭하면 버튼이 비활성화되고 로딩 인디케이터가 나타난다. 여기서 loading을 버튼 내부가 아니라 상위에서 소유하니, 화면 생명주기/비동기 작업/테스트가 단순해진다. recomposition 관점에서도 loading을 읽는 지점이 버튼과 텍스트로 명확히 제한된다.

자주 하는 실수

1) remember 없이 로컬 변수로 상태를 관리한다

증상: 버튼 클릭 로그는 증가하는데 화면 텍스트는 바뀌지 않는다. 또는 어떤 계기로 recomposition이 한 번이라도 일어나면 값이 초기화되며 ‘가끔만 동작’처럼 보인다.

원인: 로컬 변수는 관찰 대상이 아니고, recomposition 사이에 유지되지 않는다. Runtime은 로컬 변수 변경을 알 수 없으니 invalidate를 걸 수 없다.

해결: UI에 반영되어야 하는 값은 MutableState로 만들고 remember로 고정한다. 화면 상태라면 상위로 끌어올려 ViewModel 등에서 소유한다.

2) 조건문으로 remember 호출 순서를 바꾼다

증상: 토글을 켰다 껐다 하면 상태가 서로 섞이거나, A 화면에서 입력한 값이 B 화면에서 튀어나온다. 드물게는 ‘Index out of bounds’ 같은 내부 예외가 난다.

원인: remember는 호출 위치(슬롯 인덱스)에 저장된다. 조건문으로 remember 호출이 생략되거나 순서가 바뀌면 슬롯 정렬이 깨진다.

해결: remember 호출은 가능한 한 동일한 실행 경로에서 같은 순서로 유지한다. 분기가 필요하면 분기 바깥에서 remember를 하고, 분기 안에서는 값만 사용한다. 리스트에서는 key()로 그룹 키를 고정한다.

3) MutableState에 큰 객체를 넣고 자주 교체한다

증상: 스크롤이 끊기고 GC가 자주 발생한다. Layout Inspector에서 Recomposition Count가 빠르게 증가하며, 프로파일러에서 allocation이 튄다.

원인: data class 리스트 같은 큰 객체를 통째로 새로 만들어 state.value에 넣으면, equals 비교/할당/하위 recomposition이 연쇄적으로 발생한다. 특히 리스트를 매 타이핑마다 새로 만들면 프레임당 수천 개 객체가 생긴다.

해결: 상태를 더 작은 단위로 쪼갠다. derivedStateOf로 계산 결과를 캐시하고, 불변 컬렉션/아이템 단위 상태를 사용한다. 필요하면 SnapshotStateList 같은 관찰 가능한 컬렉션을 고려한다.

4) @Stable/@Immutable을 근거 없이 붙인다

증상: 값이 바뀌었는데 UI가 갱신되지 않는다. 재현이 어렵고, 특정 기기/특정 경로에서만 보인다.

원인: Stability 계약은 Runtime의 스킵 판단에 영향을 준다. 실제로 내부 필드가 바뀌는데도 ‘안 바뀐다’고 힌트를 주면, 변경이 전파되지 않고 스킵되어 버그가 된다.

해결: 안정성 애너테이션은 라이브러리/공유 모델에서만 신중히 사용한다. 먼저 데이터 구조를 불변으로 만들고, 변경은 새 인스턴스 생성으로 표현한다. 프로파일링으로 스킵 효과가 필요한지 확인한 뒤 적용한다.

5) pointerInput/LaunchedEffect 키에 자주 바뀌는 값을 넣는다

증상: 제스처가 가끔 씹히고, 롱프레스가 중간에 끊긴다. LaunchedEffect가 반복 실행되며 네트워크가 중복 호출된다.

원인: 키가 바뀌면 effect/pointerInput 코루틴이 취소되고 재시작된다. recomposition으로 람다 레퍼런스가 바뀌는 것만으로도 키 변경이 될 수 있다.

해결: 키는 진짜로 재시작이 필요한 값만 넣는다. 콜백은 rememberUpdatedState로 최신 참조만 전달한다. effect 내부에서 state를 읽을 때는 snapshotFlow로 읽기 규칙을 지킨다.

EffectKeyFix.kt
1package com.example.state
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.rememberUpdatedState
6
7@Composable
8fun EffectKeyFix(query: String, onSearch: (String) -> Unit) {
9    val onSearchLatest = rememberUpdatedState(onSearch)
10
11    LaunchedEffect(query) {
12        // query가 바뀔 때만 재시작된다.
13        onSearchLatest.value.invoke(query)
14    }
15}

이 패턴은 ‘재시작 조건’과 ‘최신 콜백 참조’를 분리한다. query는 재시작 조건이고, onSearch는 최신 참조만 필요하다. 둘을 같은 키로 묶으면 recomposition마다 effect가 취소/재시작될 수 있다.

성능 최적화 체크리스트

  • UI에 반영돼야 하는 값이 로컬 변수로 남아 있지 않은가(remember/hoist 필요)?
  • remember 호출이 조건문/반복문에서 실행 경로에 따라 누락되거나 순서가 바뀌지 않는가?
  • remember 키가 ‘캐시 무효화 의도’와 일치하는가(너무 자주 바뀌는 키는 아닌가)?
  • MutableState에 넣는 값이 과도하게 큰 객체(대형 리스트/맵)이고, 매번 통째로 교체하고 있지 않은가?
  • Int/Long/Boolean 같은 기본 타입은 mutableIntStateOf 등 특화 State로 박싱/할당을 줄였는가?
  • derivedStateOf로 계산 비용이 큰 값을 캐시하고, 입력 State가 바뀔 때만 재계산되게 했는가?
  • LazyColumn 아이템에 안정적인 key를 제공해 슬롯 재사용이 뒤틀리지 않게 했는가?
  • 람다를 자주 새로 만들며 하위로 전달하고 있지 않은가(필요 시 remember/rememberUpdatedState 사용)?
  • @Stable/@Immutable을 붙인 타입이 실제로 불변/안정 계약을 지키는가(가변 필드/백도어 변경이 없는가)?
  • SideEffect/LaunchedEffect/DisposableEffect의 키가 의도한 재시작 조건인지, recomposition 잡음에 흔들리지 않는지 확인했는가?
  • Layout Inspector에서 Recomposition Count를 켜고, 클릭/스크롤 시 증가하는 노드가 예상 범위인지 확인했는가?
  • Android Studio Profiler에서 allocation을 보며 상태 변경 시 객체 할당 급증 지점이 없는지 확인했는가?

자주 묻는 질문

remember가 없으면 왜 UI가 업데이트되지 않나? mutableStateOf만 있으면 되는 것 아닌가?

mutableStateOf는 ‘관찰 가능한 컨테이너’를 만들 뿐이고, 그 컨테이너 인스턴스가 recomposition 사이에 유지된다는 보장은 없다. Composable은 상태 변경으로 다시 호출될 수 있는데, remember 없이 mutableStateOf를 호출하면 매 호출마다 새 State 인스턴스를 만든다. 그러면 이전 프레임에서 구독 관계(어떤 스코프가 이 State를 읽었는지)가 붙어 있던 객체와, 지금 화면이 읽는 객체가 달라진다. 클릭에서 바꾸는 객체와 화면이 읽는 객체가 어긋나면 UI가 멈춘 것처럼 보인다. 해결은 State 인스턴스를 remember로 Slot Table에 고정하거나, 더 큰 단위에서는 ViewModel 같은 상위 소유자가 State를 들고 내려주는 형태로 만든다. 학습 키워드는 Slot Table, remember 위치 기반 저장, 구독 그래프(invalidate 대상)이다.

remember는 어디에 저장되나? Activity 필드나 View tag처럼 메모리에 그냥 남는 건가?

remember 값은 Composable 호출 트리의 구조를 저장하는 Slot Table에 들어간다. 이 Slot Table은 Composition이 살아있는 동안 유지되며, 화면을 벗어나 composition이 dispose되면 같이 정리된다. Activity 필드처럼 프로세스 전체에서 살아남는 저장소가 아니고, View tag처럼 View 노드에 붙는 것도 아니다. 그래서 navigation으로 다른 화면으로 이동해 composition이 폐기되면 remember 값도 사라진다. 반대로 같은 화면에서 recomposition이 수백 번 일어나도 Slot Table의 같은 슬롯을 재사용하므로 객체가 유지된다. 구성 변경/프로세스 죽음까지 보존이 필요하면 rememberSaveable 또는 상위 상태 저장소가 필요하다. 학습 키워드는 Composition lifecycle, dispose, rememberSaveable의 저장 가능 타입 제약이다.

State를 읽은 곳만 리컴포지션된다고 했는데, 왜 내 화면은 버튼 하나 눌러도 전체가 다시 그려지나?

대부분은 ‘상위에서 상태를 읽고 있기 때문’이다. 예를 들어 Screen() 최상단에서 count를 읽어 문자열을 만들고, 그 문자열을 여러 하위에 전달하면 Screen 스코프가 count에 구독된다. count 변경은 Screen 전체를 invalidate하고, 그 아래가 연쇄적으로 다시 호출된다. 또 다른 원인은 불안정(unstable)한 파라미터 전달이다. 매 recomposition마다 새 List/Map/람다를 만들어 하위로 내려주면, 값이 같아도 변경으로 판단되어 스킵이 줄어든다. 해결은 상태 읽기 위치를 좁히는 것(필요한 하위에서만 읽기), 파라미터를 안정화(remember로 캐시, 불변 데이터 사용), derivedStateOf로 계산 결과를 캐시하는 것이다. Layout Inspector의 Recomposition Count로 어떤 노드가 증가하는지 먼저 확인하고, 그 노드가 어떤 State를 읽는지 역추적하면 원인이 드러난다.

mutableStateOf는 값 비교를 어떻게 하나? 같은 값을 다시 넣으면 리컴포지션이 안 되나?

기본 정책에서는 새 값과 기존 값을 equals로 비교해 동일하다고 판단되면 변경으로 취급하지 않는 경우가 많다. 그래서 같은 값을 다시 넣으면 invalidate가 발생하지 않을 수 있다. 이 동작은 ‘불필요한 recomposition을 줄이기 위한 설계’다. 다만 equals가 비용이 큰 타입(대형 컬렉션)에서는 비교 자체가 부담이 될 수 있고, 반대로 equals가 항상 false가 되도록 구현된 타입이면 작은 변경에도 계속 invalidate가 걸린다. 실무 처방은 값 타입을 작게 유지하고, 큰 구조는 분해하거나 아이템 단위로 상태를 두는 것이다. 또, 상태 변경이 반드시 UI 갱신을 의미해야 한다면 모델링을 바꾸는 편이 낫다(예: 이벤트는 State가 아니라 Channel/Flow로). 학습 키워드는 SnapshotMutationPolicy, equals 비용, 상태와 이벤트 분리다.

rememberSaveable과 remember의 차이는 무엇이고, 언제 어떤 걸 써야 하나?

remember는 composition이 살아있는 동안만 유지된다. 화면 회전 같은 구성 변경이나 프로세스 재시작에서는 값이 사라질 수 있다. rememberSaveable은 SavedStateRegistry를 통해 Bundle에 저장 가능한 형태로 값을 직렬화해, 구성 변경/프로세스 죽음 이후에도 복원하려는 목적이다. 그래서 저장 가능한 타입(primitive, String, Parcelable 등) 제약이 있고, 커스텀 타입이면 Saver를 제공해야 한다. 언제 쓰는지가 중요하다. 입력 폼의 텍스트, 탭 선택 같은 ‘사용자가 입력한 UI 상태’는 rememberSaveable이 자연스럽다. 반면 네트워크 로딩 상태, 서버 응답 데이터는 저장 복원보다 상위 상태 소유자(ViewModel)에서 재로딩/캐싱 전략으로 다루는 경우가 많다. 학습 키워드는 SavedStateRegistry, Saver, UI state vs domain state 구분이다.

@Stable/@Immutable은 성능을 위해 붙이면 좋은가? 왜 존재하나?

이 애너테이션들은 Runtime이 파라미터 변경 감지에서 더 많은 스킵을 할 수 있도록 돕는 힌트다. Compose는 파라미터가 바뀌었는지 판단해 그룹을 스킵할지 결정한다. 타입이 안정적이라고 알려지면, 내부 필드 변경 가능성이 낮다고 가정하고 더 공격적으로 생략할 수 있다. 하지만 ‘성능을 위해 아무 데나 붙이는 것’은 위험하다. 실제로 내부가 가변인데 @Immutable을 붙이면, 값이 바뀌었는데도 스킵되어 UI가 갱신되지 않는 버그가 된다. 실무 처방은 먼저 데이터 구조를 진짜 불변으로 만들고, 변경은 새 인스턴스로 표현하는 것이다. 그 다음에 프로파일링으로 스킵이 필요한 병목 구간에서만 적용한다. 학습 키워드는 stability inference, skip, 잘못된 안정성 힌트로 인한 stale UI다.

Modifier 체이닝과 State는 어떤 관계가 있나? Modifier 순서가 리컴포지션에도 영향을 주나?

Modifier는 레이아웃/드로잉/입력/시맨틱스 동작을 순서 있는 체인으로 만든다. 순서는 측정 결과와 그려지는 영역, 클릭 영역, 접근성 노드에 직접 영향을 준다. State와의 관계는 두 층위다. 첫째, Modifier 파라미터로 State에서 계산된 값을 넣으면 그 Modifier를 가진 노드가 invalidate될 수 있다(예: padding이 상태에 따라 변하면 layout 단계까지 영향). 둘째, clickable/toggleable/semantics 같은 Modifier는 내부에서 이벤트를 받아 상태 변경을 트리거하거나, 접근성 정보를 상태로부터 만들어낸다. 순서가 바뀌면 클릭 영역이 달라져 이벤트 발생 빈도가 달라지고, 그 결과 상태 변경 횟수와 recomposition 빈도도 달라질 수 있다. 실무 처방은 의도한 영역(배경/패딩/클릭/시맨틱스)을 먼저 정의하고, 상태 기반 Modifier는 최소 범위에만 적용하는 것이다. 학습 키워드는 layout modifier order, semantics tree, interactionSource와 recomposition 연결이다.

관련 글

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

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

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

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

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

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

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

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

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