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

18. LazyColumn items/item/key로 리스트 상태가 흔들리는 이유와 고치는 법

LazyColumn의 item/items와 key가 Slot Table에서 상태를 어떻게 붙잡는지, 재정렬·삽입 시 왜 상태가 섞이는지 내부 동작과 함께 설명한다. 실습 코드 포함. (Compose)​ 154자 내외)​ 154자 내외)​ 154자 내외

18. LazyColumn items/item/key로 리스트 상태가 흔들리는 이유와 고치는 법

LazyColumn items/item/key로 리스트 상태가 흔들리는 이유와 고치는 법

스크롤 가능한 리스트를 LazyColumn으로 만들고 TextField나 체크박스를 넣는 순간부터 문제가 시작된다. 스크롤을 조금만 하거나 아이템을 정렬하면 입력한 글이 다른 행으로 순간이동하고, 체크 상태가 엉뚱한 항목에 붙는다. 로그를 찍어도 클릭은 정상인데 UI만 뒤섞인다. 원인은 대부분 item/items 자체가 아니라 key를 안 주거나 잘못 준 데 있다. key는 ‘식별자’가 아니라 Slot Table에서 상태가 붙을 위치를 고정하는 장치다. 이 차이를 이해하면 리스트 UI의 80%가 안정된다.

핵심 개념

LazyColumn은 ‘보이는 것만 Compose한다’는 아이디어로 만들어진다. View의 RecyclerView처럼 재사용 풀을 굴리는 구조가 아니라, Compose Runtime이 Slot Table에 기록한 그룹(Composable 호출 트리의 단위)을 기준으로 필요한 부분만 다시 호출한다. 그래서 리스트가 길어져도 초기 Composition 비용이 줄어들고, 스크롤 시에도 전체를 다시 그리지 않는다.

items와 item의 차이는 API 표면만 보면 ‘여러 개 vs 한 개’다. 내부 관점에서는 LazyListScope에 ‘아이템 팩토리’를 등록하는 방식이 다르다. items는 인덱스 기반 반복을 런타임에 맡기고, item은 단일 그룹을 추가한다. 중요한 포인트는 둘 다 결국 ‘그룹의 순서’와 ‘키 매핑’을 통해 Slot Table에 자리를 잡는다는 점이다.

key는 화면에서 동일한 데이터가 ‘어느 슬롯(그룹)에 붙는가’를 결정한다. key가 없으면 기본 키는 인덱스에 강하게 의존한다. 중간 삽입/삭제/정렬이 발생하면 인덱스가 밀리고, remember로 만든 상태(예: TextField 입력값)가 다른 데이터에 붙는다. 이 현상은 버그처럼 보이지만, 인덱스 기반 그룹 재사용이라는 규칙에 충실한 결과다.

Slot Table은 Compose Runtime이 Composition 결과를 저장하는 테이블 구조다. 각 Composable 호출은 ‘그룹’으로 기록되고, remember 값은 그 그룹의 슬롯에 저장된다. Recomposition 때 Runtime은 이전 테이블과 현재 호출 흐름을 비교해 가능한 한 같은 그룹을 재사용한다. 여기서 key는 ‘이 그룹은 이전의 어떤 그룹과 같은가’를 알려주는 힌트다.

@Stable/@Immutable은 key와 직접적으로 연결되지는 않지만, 리스트 성능과 재구성 범위에 영향을 준다. 예를 들어 items(list)에서 list 자체가 매 프레임 새로 만들어지면(새 인스턴스) LazyColumn은 아이템 변경을 더 자주 의심한다. 데이터 클래스 + 불변 리스트를 유지하면 불필요한 recomposition과 측정/배치가 줄어든다. key는 ‘상태가 붙는 위치’를, 안정성 어노테이션은 ‘변경 감지의 정확도’를 담당한다.

BasicLazyColumn.kt
1import androidx.compose.foundation.layout.padding
2import androidx.compose.foundation.lazy.LazyColumn
3import androidx.compose.foundation.lazy.items
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.ui.Modifier
7import androidx.compose.ui.unit.dp
8
9data class UserRow(val id: Long, val name: String)
10
11@Composable
12fun BasicLazyColumn(users: List<UserRow>) {
13    LazyColumn {
14        item {
15            Text("Header", modifier = Modifier.padding(16.dp))
16        }
17        items(
18            items = users,
19            key = { it.id }
20        ) { user ->
21            Text(user.name, modifier = Modifier.padding(16.dp))
22        }
23    }
24}

이 코드에서 key = { it.id } 한 줄이 ‘정렬/삽입/삭제에도 Text가 같은 사용자에 붙는다’는 보장을 만든다. 실행 후 users를 id 기준으로 정렬해도 각 행의 텍스트는 같은 id의 그룹을 재사용한다. key가 없으면 인덱스가 키 역할을 하고, 중간 삽입 시 이후 모든 행의 그룹이 한 칸씩 밀리면서 remember 상태가 연쇄적으로 이동한다.

컴포넌트 해부

LazyColumn은 Column처럼 보이지만, 내부 계층은 ‘레이아웃(측정/배치) + 아이템 생성 정책 + 스크롤 상태’로 분리된다. Surface 같은 배경을 기본으로 제공하지 않고, 스크롤 컨테이너로서 필요한 최소한의 책임만 가진다. 그래서 배경/구분선/클릭 처리는 아이템 콘텐츠 쪽에서 Modifier로 붙이는 형태가 자연스럽다.

처음에 나도 key를 ‘성능 옵션’ 정도로 착각했다. 실제로는 상태 일관성 옵션에 가깝다. 예전에 TextField가 있는 리스트에서 정렬 버튼을 누르면 입력값이 다른 행으로 옮겨 가는 현상을 3시간 디버깅했다. Layout Inspector로 recomposition count를 봐도 그럴듯했고, 로그도 정상이라 더 헷갈렸다. 원인은 items에 key를 안 준 상태에서 리스트를 정렬한 것이었다.

  • LazyColumn(modifier): 스크롤 컨테이너 자체의 크기/패딩/배경/클립을 결정한다. 아이템과 독립적으로 적용된다.
  • LazyColumn(state: LazyListState): 스크롤 위치를 저장하고 복원한다. rememberLazyListState로 만들지 않으면 recomposition마다 새 상태가 되어 스크롤이 튄다.
  • LazyColumn(contentPadding): 리스트 전체의 안쪽 여백이다. 아이템 padding과 다르게 첫/마지막 아이템에도 동일하게 적용된다.
  • LazyColumn(verticalArrangement): 아이템 간 간격과 정렬 규칙이다. Divider를 넣는 방식과 비용이 다르다(추가 아이템 그룹이 생김).
  • LazyColumn(horizontalAlignment): 아이템 콘텐츠의 가로 정렬 힌트다. Row/Box 내부 정렬과 혼동하기 쉽다.
  • LazyColumn(reverseLayout): 데이터 순서와 스크롤 방향 관계를 바꾼다. 채팅 UI에서 자주 쓰며, key/animateItemPlacement와 상호작용이 크다.
  • LazyColumn(userScrollEnabled): 스크롤 제스처를 막는다. nestedScroll과 함께 쓰면 이벤트 전달 순서가 중요해진다.
  • LazyColumn(flingBehavior): 플링 감속 곡선을 바꾼다. 스크롤 이벤트 빈도가 달라져 derivedStateOf 최적화가 체감된다.
  • LazyColumn(beyondBoundsItemCount / prefetch): 화면 밖 아이템을 얼마나 미리 Compose할지에 영향을 준다(버전에 따라 API가 다름).
  • LazyListScope.item(key, contentType): 단일 아이템 그룹을 추가한다. 헤더/푸터/광고 슬롯에 적합하다.
  • LazyListScope.items(items, key, contentType): 컬렉션을 아이템 그룹들로 확장한다. key는 상태 고정, contentType은 재사용 힌트 성격이다.
  • key: Slot Table 그룹의 안정적 식별자다. 인덱스가 아니라 데이터의 영속적 id를 써야 한다.
  • contentType: 서로 다른 레이아웃 타입(예: 광고 vs 일반 행)을 구분해 측정/재사용 경로를 안정화한다. 잘못 주면 오히려 재측정이 늘 수 있다.

Surface 계층 관점에서 LazyColumn은 ‘아무것도 칠하지 않는 컨테이너’에 가깝다. 배경색을 깔고 싶으면 LazyColumn에 Modifier.background를 주거나, 더 안전하게는 상위에 Surface를 두고 내부에 LazyColumn을 둔다. 이유는 클릭 리플/클립/스크롤바 같은 시각 요소가 어디에 붙는지 예측 가능해야 하기 때문이다.

Content 계층은 LazyListScope DSL로 정의된다. item/items 호출은 즉시 UI를 그리지 않고, ‘이런 아이템을 언제든 만들 수 있다’는 람다를 등록한다. 스크롤 위치가 바뀌면 Lazy 레이아웃이 필요한 인덱스 범위를 계산하고, 그 범위의 람다만 호출해 Composition을 만든다. 그래서 아이템 람다 안에서 무거운 계산을 하면 스크롤 중 프레임 드랍이 바로 난다.

items의 key는 아이템 콘텐츠 람다의 remember와 강하게 결합된다. remember는 ‘현재 그룹의 슬롯에 저장’되기 때문에, 그룹이 다른 데이터로 재사용되면 상태도 같이 따라간다. key를 주면 그룹이 데이터 id에 붙어서 재사용되고, 인덱스 이동이 있어도 상태가 이동하지 않는다.

EducationalLazyListScope.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.padding
3import androidx.compose.foundation.lazy.LazyListScope
4import androidx.compose.material3.Divider
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.ui.Modifier
8import androidx.compose.ui.unit.dp
9
10data class RowModel(val id: Long, val title: String)
11
12fun LazyListScope.educationalList(
13    rows: List<RowModel>,
14    showHeader: Boolean,
15    headerKey: Any = "header"
16) {
17    if (showHeader) {
18        item(key = headerKey, contentType = "header") {
19            Text("Header", modifier = Modifier.padding(16.dp))
20            Divider()
21        }
22    }
23    items(
24        count = rows.size,
25        key = { index -> rows[index].id },
26        contentType = { "row" }
27    ) { index ->
28        Column(Modifier.padding(16.dp)) {
29            Text(rows[index].title)
30            Divider()
31        }
32    }
33}

이 재구성 코드는 LazyListScope가 ‘아이템 생성기 목록’을 들고 있다는 감각을 주기 위한 스케치다. items(count=...) 형태를 쓰면 key가 결국 index -> id 매핑이라는 점이 더 노골적으로 드러난다. rows가 정렬되면 같은 index가 다른 id를 가리키므로, key를 id로 잡지 않으면 그룹-상태 매핑이 깨진다.

LazyColumnWithSurface.kt
1import androidx.compose.foundation.background
2import androidx.compose.foundation.layout.fillMaxSize
3import androidx.compose.foundation.layout.padding
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Surface
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.graphics.Color
12import androidx.compose.ui.unit.dp
13
14data class ChatItem(val id: String, val text: String, val isSystem: Boolean)
15
16@Composable
17fun LazyColumnWithSurface(items: List<ChatItem>) {
18    Surface(color = MaterialTheme.colorScheme.background) {
19        LazyColumn(
20            modifier = Modifier.fillMaxSize().background(Color.Transparent),
21            contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp)
22        ) {
23            items(
24                items = items,
25                key = { it.id },
26                contentType = { if (it.isSystem) "system" else "user" }
27            ) { row ->
28                Text(
29                    text = row.text,
30                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
31                )
32            }
33        }
34    }
35}

내부 동작 원리

Compose의 렌더링 파이프라인은 Composition → Layout → Drawing 순서다. LazyColumn 맥락에서 Composition은 ‘현재 스크롤 위치에서 필요한 아이템 콘텐츠 람다를 호출해 UI 트리를 만든다’로 읽힌다. Layout은 Lazy 레이아웃이 각 아이템을 측정하고, 보이는 위치에 배치한다. Drawing은 배치된 노드들이 Canvas/RenderNode로 그려지는 단계다.

Composition 단계에서 Compose Compiler가 생성한 코드는 각 Composable 호출을 그룹으로 감싼다. 런타임은 이 그룹을 Slot Table에 기록한다. remember는 그룹 내부 슬롯에 값을 저장하고, 다음 recomposition에서 같은 그룹을 다시 만나면 저장된 값을 돌려준다. 핵심은 ‘같은 그룹을 다시 만나는가’이며, LazyColumn에서는 그 기준이 key 매핑으로 강화된다.

Recomposition 트리거는 State 읽기/쓰기 관계로 결정된다. 아이템 람다 안에서 state.value를 읽으면, 해당 그룹은 그 State의 구독자가 된다. 이후 state.value가 바뀌면 런타임은 그 그룹을 invalidation 대상으로 표시하고, 다음 프레임에 그 그룹부터 다시 호출한다. 전체 LazyColumn이 아니라 ‘그 state를 읽은 아이템’만 다시 호출되는 경우가 많다.

하지만 key가 없거나 불안정하면, 런타임이 invalidation 대상으로 표시한 그룹이 다음 프레임에 ‘다른 데이터’로 재사용될 수 있다. 그러면 바뀐 state가 엉뚱한 행을 다시 그리게 된다. 사용자는 ‘체크박스가 다른 행에서 켜졌다’로 인지하고, 개발자는 ‘State는 맞는데 UI가 틀렸다’로 혼란을 겪는다.

Modifier 체인은 레이아웃/그리기/입력/시맨틱스 노드를 순서대로 감싸는 구조다. LazyColumn의 아이템마다 Modifier.clickable, padding, semantics를 붙이면 아이템 노드가 그만큼 깊어진다. 이 체인은 ‘순서가 의미’다. padding 후 clickable과 clickable 후 padding은 터치 영역과 ripple clip이 달라진다. 리스트에서 이 차이는 스크롤 중 터치 충돌(클릭이 씹힘)로 나타난다.

contentType은 종종 무시되지만, 내부적으로는 아이템을 재사용/측정할 때 ‘같은 타입끼리 묶을 수 있는가’에 영향을 준다. 예를 들어 시스템 메시지(한 줄)와 사용자 메시지(여러 줄)가 섞인 채팅에서 contentType을 분리하면, 측정 캐시가 덜 깨지고 스크롤 중 layout pass가 줄어드는 경우가 있다. 다만 타입을 너무 잘게 쪼개면 캐시 히트가 줄어 역효과가 난다.

KeyBugRepro.kt
1import androidx.compose.foundation.layout.Arrangement
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material3.Button
9import androidx.compose.material3.Checkbox
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.unit.dp
18
19data class Todo(val id: Long, val title: String)
20
21@Composable
22fun KeyBugRepro() {
23    var todos by remember {
24        mutableStateOf(
25            listOf(
26                Todo(1, "Write"),
27                Todo(2, "Ship"),
28                Todo(3, "Sleep")
29            )
30        )
31    }
32
33    Column {
34        Row(
35            modifier = Modifier.fillMaxWidth().padding(12.dp),
36            horizontalArrangement = Arrangement.spacedBy(12.dp)
37        ) {
38            Button(onClick = { todos = todos.shuffled() }) { Text("Shuffle") }
39            Button(onClick = { todos = listOf(Todo(System.nanoTime(), "New")) + todos }) { Text("Insert") }
40        }
41
42        LazyColumn {
43            items(todos) { todo ->
44                var checked by remember { mutableStateOf(false) }
45                Row(Modifier.fillMaxWidth().padding(16.dp)) {
46                    Checkbox(checked = checked, onCheckedChange = { checked = it })
47                    Text(todo.title, modifier = Modifier.padding(start = 12.dp))
48                }
49            }
50        }
51    }
52}

이 코드를 실행하고 체크박스를 몇 개 켠 뒤 Shuffle을 누르면, 체크가 다른 제목으로 이동하는 장면이 보인다. Insert를 누르면 더 극적으로 밀린다. checked는 각 아이템 람다 안에서 remember로 저장되는데, key가 없으니 그룹이 인덱스 기반으로 재사용되면서 checked 슬롯도 인덱스에 붙는다. 데이터는 섞였는데 슬롯은 그대로라서 UI가 뒤집힌다.

KeyFixRepro.kt
1import androidx.compose.foundation.layout.Arrangement
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material3.Button
9import androidx.compose.material3.Checkbox
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun KeyFixRepro() {
21    var todos by remember {
22        mutableStateOf(
23            listOf(
24                Todo(1, "Write"),
25                Todo(2, "Ship"),
26                Todo(3, "Sleep")
27            )
28        )
29    }
30
31    Column {
32        Row(
33            modifier = Modifier.fillMaxWidth().padding(12.dp),
34            horizontalArrangement = Arrangement.spacedBy(12.dp)
35        ) {
36            Button(onClick = { todos = todos.shuffled() }) { Text("Shuffle") }
37            Button(onClick = { todos = listOf(Todo(System.nanoTime(), "New")) + todos }) { Text("Insert") }
38        }
39
40        LazyColumn {
41            items(
42                items = todos,
43                key = { it.id }
44            ) { todo ->
45                var checked by remember(todo.id) { mutableStateOf(false) }
46                Row(Modifier.fillMaxWidth().padding(16.dp)) {
47                    Checkbox(checked = checked, onCheckedChange = { checked = it })
48                    Text(todo.title, modifier = Modifier.padding(start = 12.dp))
49                }
50            }
51        }
52    }
53}

실습하기

실습 목표는 세 가지다. (1) item과 items를 섞어 헤더/리스트를 구성한다. (2) key 유무에 따라 상태가 이동하는 장면을 눈으로 확인한다. (3) LazyListState와 derivedStateOf로 스크롤 기반 UI를 만들고, recomposition 범위를 좁힌다. 실행 중 Logcat과 Layout Inspector의 Recomposition Count를 같이 보면 ‘어디가 다시 호출되는지’가 보인다.

build.gradle.kts (module)
1android {
2  buildFeatures { compose true }
3  composeOptions {
4    kotlinCompilerExtensionVersion = "1.5.14"
5  }
6}
7
8dependencies {
9  implementation(platform("androidx.compose:compose-bom:2024.06.00"))
10  implementation("androidx.compose.material3:material3")
11  implementation("androidx.activity:activity-compose:1.9.0")
12  implementation("androidx.compose.ui:ui-tooling-preview")
13  debugImplementation("androidx.compose.ui:ui-tooling")
14}

버전은 예시다. 핵심은 BOM으로 Compose 버전 축을 고정하고, tooling을 debug에만 넣는 구성이다. tooling을 release에 넣으면 메서드 수와 리소스가 늘고, 실기기에서 초기화 비용이 커진다. 실습 단계에서는 Layout Inspector를 쓰기 위해 debugImplementation이 필요하다.

1단계: item + items로 헤더/푸터 붙이기

화면 상단에 헤더(고정 문구), 중간에 사용자 목록, 하단에 푸터(총 개수)를 둔다. 스크롤하면 헤더/푸터도 같이 스크롤된다. 헤더/푸터에 key를 주면, 리스트 내용이 바뀌어도 헤더/푸터의 remember 상태가 다른 아이템으로 흘러가지 않는다.

실행하면 첫 줄에 Header가 보이고, 마지막에 Footer가 보인다. 아직 상태를 넣지 않아서 key의 가치는 체감이 약하다. 다음 단계에서 TextField를 넣으면 헤더/푸터의 그룹 안정성도 같이 중요해진다.

Step1.kt
1import android.os.Bundle
2import androidx.activity.ComponentActivity
3import androidx.activity.compose.setContent
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.tooling.preview.Preview
13import androidx.compose.ui.unit.dp
14
15data class Person(val id: Long, val name: String)
16
17class MainActivity : ComponentActivity() {
18    override fun onCreate(savedInstanceState: Bundle?) {
19        super.onCreate(savedInstanceState)
20        setContent {
21            Surface(color = MaterialTheme.colorScheme.background) {
22                Step1Screen(
23                    people = listOf(
24                        Person(10, "Ada"),
25                        Person(20, "Linus"),
26                        Person(30, "Grace")
27                    )
28                )
29            }
30        }
31    }
32}
33
34@Composable
35fun Step1Screen(people: List<Person>) {
36    LazyColumn {
37        item(key = "header", contentType = "header") {
38            Text("Header", modifier = Modifier.padding(16.dp))
39        }
40        items(items = people, key = { it.id }, contentType = { "person" }) { p ->
41            Text(p.name, modifier = Modifier.padding(16.dp))
42        }
43        item(key = "footer", contentType = "footer") {
44            Text("Footer: ${people.size}", modifier = Modifier.padding(16.dp))
45        }
46    }
47}
48
49@Preview(showBackground = true)
50@Composable
51fun Step1Preview() {
52    Step1Screen(listOf(Person(1, "A"), Person(2, "B")))
53}

여기서 item은 헤더/푸터 같은 ‘개별 슬롯’을 표현하기 좋다. items는 데이터 컬렉션을 확장한다. contentType은 지금은 큰 차이를 못 느끼지만, 실제 앱에서 헤더/광고/일반행이 섞이면 측정 경로가 달라져 스크롤 중 레이아웃 스파이크가 생길 수 있다. 타입을 세 덩어리 정도로만 나눠도 Inspector에서 Layout pass가 줄어드는 경우가 있다.

2단계: TextField 상태가 섞이는 장면 만들기

각 행에 TextField를 넣고, 정렬/삽입 버튼으로 리스트를 흔든다. key가 없으면 입력 텍스트가 다른 사람으로 이동한다. key를 주면 이동하지 않는다. 이 차이는 ‘State가 데이터에 붙는가, 인덱스에 붙는가’의 차이다.

실행하면 각 행에 입력창이 보이고, 아무 행에나 텍스트를 입력한 뒤 Shuffle을 누른다. key를 끈 버전에서는 입력이 다른 행으로 이동한다. key를 켠 버전에서는 같은 id의 행에 입력이 남는다. 이 장면을 직접 봐야 key의 의미가 ‘식별자’가 아니라 ‘그룹 매핑’이라는 감각으로 바뀐다.

Step2KeyToggle.kt
1import androidx.compose.foundation.layout.Arrangement
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material3.Button
9import androidx.compose.material3.OutlinedTextField
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun Step2KeyToggleScreen() {
22    var useKey by remember { mutableStateOf(true) }
23    var people by remember {
24        mutableStateOf(
25            listOf(Person(10, "Ada"), Person(20, "Linus"), Person(30, "Grace"))
26        )
27    }
28
29    Column {
30        Row(
31            modifier = Modifier.fillMaxWidth().padding(12.dp),
32            horizontalArrangement = Arrangement.spacedBy(12.dp)
33        ) {
34            Button(onClick = { useKey = !useKey }) { Text("key: $useKey") }
35            Button(onClick = { people = people.shuffled() }) { Text("Shuffle") }
36            Button(onClick = { people = listOf(Person(System.nanoTime(), "New")) + people }) { Text("Insert") }
37        }
38
39        LazyColumn {
40            val block: (Person) -> Unit = { p ->
41                var text by remember { mutableStateOf("") }
42                Row(Modifier.fillMaxWidth().padding(16.dp)) {
43                    Text(p.name, modifier = Modifier.padding(end = 12.dp))
44                    OutlinedTextField(
45                        value = text,
46                        onValueChange = { text = it },
47                        modifier = Modifier.fillMaxWidth(),
48                        singleLine = true
49                    )
50                }
51            }
52
53            if (useKey) {
54                items(items = people, key = { it.id }) { p -> block(p) }
55            } else {
56                items(items = people) { p -> block(p) }
57            }
58        }
59    }
60}
61
62@Preview(showBackground = true)
63@Composable
64fun Step2Preview() {
65    Step2KeyToggleScreen()
66}

여기서 일부러 remember에 key를 넣지 않았다. 이유는 key가 LazyColumn의 그룹 매핑을 고정하면, remember는 그 그룹의 슬롯에 붙어서 자연스럽게 데이터 id에 따라 움직이기 때문이다. 반대로 LazyColumn key가 없으면, remember는 인덱스 슬롯에 붙고 데이터 재정렬 시 이동한다. remember(p.id)를 추가하면 ‘그룹이 흔들려도 remember만 다시 초기화’되는 또 다른 결과가 나오는데, 그건 문제를 가리는 방식이라 실무에선 조심스럽다.

3단계: 스크롤 기반 UI와 recomposition 범위 줄이기

상단에 ‘현재 첫 번째로 보이는 아이템 id’를 표시하고, 스크롤할 때만 텍스트가 바뀌게 만든다. LazyListState를 remember로 보관하지 않으면 스크롤 위치가 튄다. derivedStateOf를 쓰지 않으면 스크롤 중 매 프레임 불필요한 recomposition이 늘어난다.

실행하면 상단 텍스트가 스크롤에 따라 바뀐다. Layout Inspector에서 Step3Screen의 recomposition count는 스크롤 중에도 비교적 안정적이어야 한다. derivedStateOf를 제거하면 상단 텍스트가 같은 값이어도 recomposition이 더 자주 발생하는 경향이 보인다(기기/버전에 따라 차이가 있다).

Step3.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.fillMaxWidth
3import androidx.compose.foundation.layout.padding
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.foundation.lazy.rememberLazyListState
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.derivedStateOf
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.remember
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun Step3Screen(people: List<Person>) {
19    val state = rememberLazyListState()
20
21    val firstVisibleId by remember {
22        derivedStateOf {
23            val index = state.firstVisibleItemIndex
24            people.getOrNull(index)?.id
25        }
26    }
27
28    Column {
29        Text(
30            text = "First visible id: ${firstVisibleId ?: "-"}",
31            modifier = Modifier.fillMaxWidth().padding(12.dp),
32            style = MaterialTheme.typography.titleMedium
33        )
34
35        LazyColumn(state = state) {
36            items(items = people, key = { it.id }) { p ->
37                Text("${p.id}: ${p.name}", modifier = Modifier.padding(16.dp))
38            }
39        }
40    }
41}
42
43@Preview(showBackground = true)
44@Composable
45fun Step3Preview() {
46    Step3Screen((1L..50L).map { Person(it, "Person $it") })
47}

한 문단 요약: LazyColumn의 key는 ‘아이템의 재사용’이 아니라 ‘Slot Table 그룹과 remember 상태가 어떤 데이터에 붙는지’를 고정한다. 중간 삽입·삭제·정렬이 있는 리스트에서 key가 없으면 상태가 인덱스에 붙어 이동한다.

심화: Advanced 버전 만들기

실무 리스트는 단순 텍스트 나열이 아니라 상호작용과 비동기 상태가 섞인다. 로딩 중에는 스켈레톤/프로그레스가 나오고, 빠른 연타는 디바운스가 필요하며, 아이콘+텍스트 조합과 롱프레스가 들어간다. 접근성 라벨도 필요하다. 이런 요구사항은 결국 ‘상태를 어디에 두고, 키를 무엇으로 잡을 것인가’로 귀결된다.

사례 1: 리스트 아이템 액션 버튼(loading + debounce + long press)

버튼을 리스트 행마다 두면, 클릭 상태(loading)가 행에 종속된다. 이때 key가 불안정하면 로딩 스피너가 다른 행으로 이동하는 장면이 나온다. 특히 네트워크 응답이 늦을 때 더 잘 보인다. 로딩 상태는 보통 ViewModel에 두거나, 최소한 id 기반 맵으로 관리해야 한다.

AdvancedRowAction.kt
1import android.os.SystemClock
2import androidx.compose.foundation.combinedClickable
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.width
6import androidx.compose.material3.CircularProgressIndicator
7import androidx.compose.material3.Icon
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableLongStateOf
13import androidx.compose.runtime.mutableStateOf
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
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.filled.Send
22
23@Composable
24fun AdvancedRowAction(
25    title: String,
26    loading: Boolean,
27    onClick: () -> Unit,
28    onLongPress: () -> Unit,
29    accessibilityLabel: String,
30    debounceMs: Long = 500L
31) {
32    var lastClickAt by remember { mutableLongStateOf(0L) }
33
34    Row(
35        modifier = Modifier
36            .semantics { contentDescription = accessibilityLabel }
37            .combinedClickable(
38                onClick = {
39                    val now = SystemClock.elapsedRealtime()
40                    if (now - lastClickAt >= debounceMs && !loading) {
41                        lastClickAt = now
42                        onClick()
43                    }
44                },
45                onLongClick = { if (!loading) onLongPress() }
46            )
47    ) {
48        Icon(imageVector = Icons.Default.Send, contentDescription = null)
49        Spacer(Modifier.width(8.dp))
50        Text(title, style = MaterialTheme.typography.bodyLarge)
51        Spacer(Modifier.width(12.dp))
52        if (loading) {
53            CircularProgressIndicator(strokeWidth = 2.dp)
54        }
55    }
56}

이 코드가 필요한 이유는 ‘리스트 행 내부에서 연타/롱프레스/접근성/로딩’을 동시에 해결해야 하기 때문이다. combinedClickable은 클릭과 롱클릭을 한 노드에서 처리해 제스처 경쟁을 줄인다. 디바운스는 SystemClock 기반으로 처리해 프레임 시간과 무관하게 동작한다. 실행하면 로딩 중에는 클릭이 막히고, 연타해도 onClick이 500ms에 한 번만 호출되는 것을 로그로 확인할 수 있다.

사례 2: 리스트 전체에서 로딩 상태를 id로 고정하기

행 내부 remember로 loading을 들고 있으면 key 문제에 취약하고, 프로세스 재생성에도 약하다. id→loading 맵을 상위에서 관리하면, 정렬/삽입에도 로딩 표시가 같은 id에 붙는다. 이 구조는 Slot Table의 그룹 매핑과 별개로 ‘도메인 상태’를 안정화한다.

AdvancedListScreen.kt
1import androidx.compose.foundation.layout.Arrangement
2import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.tooling.preview.Preview
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun AdvancedListScreen() {
21    var people by remember { mutableStateOf((1L..20L).map { Person(it, "Person $it") }) }
22    var loadingById by remember { mutableStateOf<Map<Long, Boolean>>(emptyMap()) }
23
24    Column {
25        Row(
26            modifier = Modifier.fillMaxWidth().padding(12.dp),
27            horizontalArrangement = Arrangement.spacedBy(12.dp)
28        ) {
29            Button(onClick = { people = people.shuffled() }) { Text("Shuffle") }
30            Button(onClick = { people = listOf(Person(System.nanoTime(), "New")) + people }) { Text("Insert") }
31        }
32
33        LazyColumn {
34            items(items = people, key = { it.id }, contentType = { "person" }) { p ->
35                val loading = loadingById[p.id] == true
36                AdvancedRowAction(
37                    title = "Send to ${p.name}",
38                    loading = loading,
39                    onClick = {
40                        loadingById = loadingById + (p.id to true)
41                    },
42                    onLongPress = {
43                        loadingById = loadingById - p.id
44                    },
45                    accessibilityLabel = "Send action for ${p.name}"
46                )
47            }
48        }
49    }
50}
51
52@Preview(showBackground = true)
53@Composable
54fun AdvancedListPreview() {
55    AdvancedListScreen()
56}

이 화면에서 특정 행을 눌러 로딩을 켠 뒤 Shuffle/Insert를 반복하면, 로딩 표시가 같은 id에 붙어 유지된다. key가 id로 고정돼서 그룹 매핑도 안정적이고, loading 상태도 id 맵이라 도메인적으로도 안정적이다. 둘 중 하나만 해도 개선되지만, 둘 다 맞춰야 ‘네트워크 지연 + 정렬’ 같은 현실 조건에서 흔들리지 않는다.

내 흑역사 1: key를 index로 줬다. 코드 리뷰에서 ‘key를 줬으니 됐다’는 말이 나왔고, QA에서도 한동안 안 걸렸다. 운영에서 ‘필터 변경 후 체크가 다른 항목으로 바뀐다’ 제보가 들어왔다. 원인은 필터가 바뀌면 리스트가 재정렬되는데 index key는 그대로라서, 그룹은 안정적이라고 착각했지만 실제로는 인덱스 안정성만 강화한 셈이었다. 고친 뒤에는 key를 서버 id로 바꾸고, 필터/정렬 케이스를 테스트에 추가했다.

내 흑역사 2: key를 UUID.randomUUID()로 줬다. 상태 섞임은 사라졌지만 스크롤이 끊기고, Layout Inspector에서 recomposition이 폭증했다. 매 recomposition마다 key가 바뀌니 런타임은 이전 그룹을 재사용할 수 없고, 아이템 그룹이 전부 새로 만들어졌다. Logcat에 찍히던 커스텀 측정 로그가 스크롤 한 번에 수백 줄씩 나오면서 원인을 확신했다. key는 ‘안정적이고 반복 가능’해야 한다.

자주 하는 실수

1) key를 안 주고 아이템 내부에 remember 상태를 둠

증상: 체크박스/텍스트 입력/확장 상태가 정렬·삽입·삭제 후 다른 행으로 이동한다. 특히 중간 삽입에서 이후 모든 행이 연쇄적으로 흔들린다.

원인: key가 없으면 기본 키가 인덱스에 묶인다. remember는 ‘현재 그룹 슬롯’에 저장되므로 인덱스 기반 그룹 재사용이 발생하면 상태도 인덱스에 붙는다.

해결: items에 key = { it.id }를 준다. id는 서버/DB의 영속 식별자를 쓰고, 없으면 생성 시점에 고정된 로컬 id를 만든다(리스트 생성마다 새로 만들면 안 된다).

2) key를 index로 줘서 더 단단하게 망가뜨림

증상: ‘key를 줬는데도’ 상태가 섞인다. 필터/정렬이 들어가면 재현율이 100%에 가깝다.

원인: index key는 데이터 이동을 허용하지 않는다. 데이터가 재정렬되면 ‘이전 index의 그룹’을 그대로 재사용하므로, 상태가 오히려 더 강하게 잘못 붙는다.

해결: key는 데이터의 정체성을 나타내야 한다. 정렬/필터/페이지네이션이 있는 리스트라면 index key는 금지에 가깝다.

3) key를 매번 바뀌는 값으로 줌(시간/랜덤/해시 오남용)

증상: 스크롤이 끊기고, 아이템 애니메이션이 불안정하며, 입력 포커스가 자주 풀린다. Inspector에서 recomposition과 layout pass가 과도하게 증가한다.

원인: 런타임이 이전 그룹을 재사용할 단서를 잃는다. Slot Table에서 그룹 매칭이 실패해 아이템이 매번 새로 compose되고, remember도 매번 초기화된다.

해결: key는 동일 데이터에 대해 항상 같은 값을 반환해야 한다. random/now/hashCode(객체 인스턴스 기반)는 피하고, 영속 id 또는 안정적 복합키를 쓴다.

4) 리스트 상태를 remember하지 않아 스크롤이 튐

증상: 버튼 클릭이나 상태 변경 후 스크롤이 맨 위로 튄다. 화면 회전/다크모드 전환에서 위치가 사라진다.

원인: LazyListState를 매 recomposition마다 새로 만들면, 스크롤 오프셋이 유지될 수 없다. 상태 객체가 바뀌면 Lazy 레이아웃은 초기 위치로 간주한다.

해결: rememberLazyListState를 쓴다. 프로세스 재생성까지 복원해야 하면 rememberSaveable + LazyListState.Saver를 고려한다(버전에 따라 제공 형태가 다름).

5) 아이템 람다에서 무거운 계산/할당을 수행함

증상: 스크롤 중 GC가 잦고, 프레임 드랍이 생긴다. 특히 빠른 플링에서 끊김이 커진다.

원인: LazyColumn은 스크롤 중 필요한 아이템을 자주 compose한다. 아이템 람다에서 정규식/날짜 포맷/이미지 디코딩 같은 작업을 하면 프레임 예산(16ms)을 넘기기 쉽다.

해결: 계산 결과를 상위에서 준비하거나 remember/derivedStateOf로 캐시한다. 포맷터 같은 객체는 remember로 한 번만 만들고 재사용한다.

RememberFormatter.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.remember
3import java.text.SimpleDateFormat
4import java.util.Locale
5
6@Composable
7fun rememberDateFormatter(): SimpleDateFormat {
8    return remember {
9        SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
10    }
11}

성능 최적화 체크리스트

  • items에 key를 준다. key는 영속 id(서버/DB PK) 또는 생성 시 고정된 로컬 id다.
  • 정렬/필터/중간 삽입이 있는 리스트에서 index key를 쓰지 않는다.
  • key에 System.nanoTime(), currentTimeMillis, random 값을 쓰지 않는다. 그룹 재사용이 깨진다.
  • 아이템 내부 remember 상태가 도메인 상태인지(UI 상태인지) 구분한다. 도메인 상태는 상위로 끌어올린다.
  • LazyListState는 rememberLazyListState로 보관한다. recomposition마다 새 인스턴스를 만들지 않는다.
  • 스크롤 기반 파생 값은 derivedStateOf로 감싼다. 스크롤 이벤트마다 불필요한 recomposition을 줄인다.
  • 헤더/푸터/광고 슬롯은 item(key=...)로 고정 키를 준다. 리스트 변화에 휩쓸리지 않게 한다.
  • 서로 다른 레이아웃 타입이 섞이면 contentType을 2~4개 수준으로 분리한다(과도한 분리는 금지).
  • 아이템 람다에서 날짜 포맷/정렬/필터 같은 무거운 계산을 하지 않는다. 상위에서 준비하거나 remember로 캐시한다.
  • TextField/Focus가 있는 리스트는 key 안정성을 최우선으로 확인한다. 포커스 이동은 대부분 key 문제다.
  • Layout Inspector에서 Recomposition Count와 Layout passes를 같이 본다. key 변경 전후를 비교한다.
  • Trace(Perfetto)에서 Choreographer 프레임 드랍 구간을 확인하고, 해당 시점에 어떤 아이템이 compose/measure되는지 연결한다.

자주 묻는 질문

items와 itemsIndexed 중 무엇을 써야 하나?

itemsIndexed는 인덱스가 필요할 때만 쓴다. 인덱스를 UI에 표시하거나(예: 순번), 인접 아이템과의 관계(구분선, 그룹 헤더)를 계산할 때 유용하다. 하지만 인덱스는 데이터 정체성이 아니라 ‘현재 순서’이므로 key를 index로 쓰는 유혹이 생긴다. itemsIndexed를 쓰더라도 key는 id 기반으로 둔다. 학습 키워드는 LazyListScope.items, key parameter, state hoisting, stable identity다. 인덱스가 필요하면 contentType이나 별도 계산을 통해 해결하고, 상태는 id에 붙인다.

key를 줬는데도 상태가 초기화되는 경우가 있다. 왜 그런가?

key는 그룹 매핑을 안정화하지만, 상태가 초기화되는 다른 원인이 있다. 대표적으로 (1) key가 사실상 변하고 있다(복합키에 포함된 값이 바뀜), (2) Composable 호출 경로가 바뀌어 그룹 구조가 달라졌다(if 분기 위치 변경), (3) remember가 조건문 안에 있어 실행 흐름이 바뀐다. Layout Inspector에서 해당 아이템이 recomposition이 아니라 ‘recompose 후 dispose/recreate’되는지 관찰하면 단서가 나온다. 학습 키워드는 group structure, conditional composition, remember keys, stability다. 해결은 key를 데이터 id만으로 단순화하고, remember 호출 위치를 고정하는 쪽으로 설계를 바꾼다.

리스트 아이템에 TextField를 넣으면 왜 더 자주 문제가 터지나?

TextField는 내부적으로 많은 상태(입력 버퍼, selection, composition region, focus)를 가진다. 이 상태는 remember/rememberSaveable과 Slot Table에 기대서 유지된다. key가 불안정하면 포커스가 다른 행으로 이동하거나, 입력 중 커서가 튀는 현상이 난다. 특히 IME 조합 중에는 상태가 더 민감해서 재현이 쉽다. 처방은 (1) items에 안정적 key 제공, (2) TextField value를 상위 상태로 끌어올려 id 기반 맵으로 관리, (3) 필요하면 rememberSaveable을 id 키로 묶는 방식이다. 학습 키워드는 TextField state, focus, rememberSaveable, LazyColumn key다.

contentType은 언제 주는 게 맞나? 안 주면 성능이 나빠지나?

contentType은 ‘같은 타입의 아이템을 같은 측정/배치 경로로 다룰 수 있다’는 힌트를 준다. 안 준다고 항상 성능이 나빠지지는 않는다. 하지만 서로 다른 레이아웃 구조가 섞인 리스트(광고 카드, 섹션 헤더, 일반 행, 로딩 행)가 많으면, 타입 구분이 없을 때 측정 캐시가 자주 깨지고 스크롤 중 layout pass가 늘 수 있다. 반대로 타입을 너무 세분하면 캐시 히트가 줄어들고 관리 비용이 오른다. 실전에서는 2~4개 정도 큰 덩어리로 나누고, Inspector/Trace로 전후를 비교한다. 학습 키워드는 contentType, measurement caching, LazyLayout, profiling이다.

LazyColumn에서 item을 여러 개 넣는 것과 items로 합치는 것의 차이는 뭔가?

표면적으로는 코드 스타일 차이처럼 보이지만, 내부적으로는 그룹 구조가 달라진다. item은 단일 그룹을 추가하므로 헤더/푸터/광고 같은 ‘고정 슬롯’을 만들 때 안정적이다. items는 반복되는 그룹들의 시퀀스를 만든다. 중간에 item을 끼우면 그룹 경계가 생겨서 key 매핑이 더 명확해지는 장점도 있다. 다만 item을 과도하게 쪼개 Divider를 전부 item으로 만들면 그룹 수가 늘고 composition/measure 비용이 증가할 수 있다. 학습 키워드는 LazyListScope DSL, group boundaries, slot table, composition cost다.

왜 remember만으로는 리스트 상태 문제가 해결되지 않나?

remember는 ‘현재 그룹’에 저장한다. 리스트에서 그룹이 어떤 데이터에 대응되는지는 LazyColumn이 결정한다. key가 없으면 그룹은 인덱스에 대응되고, 데이터가 이동하면 그룹-데이터 관계가 바뀐다. remember는 그 규칙을 따를 뿐이라, 상태 이동을 막지 못한다. 그래서 해결 순서는 key로 그룹-데이터 매핑을 고정하고, 그 다음에 remember/hoisting으로 상태의 소유권을 정한다. 학습 키워드는 remember semantics, slot table, group identity, key mapping이다. 실전 처방은 id key + 상태 hoisting(예: Map<Id, State>) 조합이 가장 안전하다.

성능을 확인할 때 무엇을 측정해야 하나? recomposition만 보면 되나?

recomposition count만 보면 절반만 보게 된다. 리스트는 Layout(측정/배치) 비용이 더 큰 경우가 많다. Inspector에서 Recomposition Count와 함께 Layout passes를 확인하고, 스크롤 중 측정이 과도한 아이템이 있는지 찾는다. 더 깊게는 Perfetto/Trace에서 Choreographer 프레임 드랍 구간을 잡고, 같은 타임라인에 GC, measure/layout, draw가 어떻게 겹치는지 본다. 학습 키워드는 Layout Inspector, Perfetto, tracing, measure/layout/draw pipeline이다. 처방은 key 안정화, contentType 적절 분리, 아이템 람다 할당 최소화, derivedStateOf로 스크롤 파생값 제한 순서로 진행한다.

관련 글

19. LazyColumn items와 item으로 목록을 만드는 이유와 내부 동작
Compose 기본2026.03.03

19. LazyColumn items와 item으로 목록을 만드는 이유와 내부 동작

LazyColumn에서 items와 item이 왜 분리됐는지, Compose Runtime·Slot Table·Recomposition 관점에서 목록이 어떻게 유지·갱신되는지 실습으로 설명한다. 키 안정성까지 다룬다. (Compose) 153자 내외)

23. remember·mutableStateOf로 Compose 상태를 시작하는 내부 원리
Compose 기본2026.03.05

23. remember·mutableStateOf로 Compose 상태를 시작하는 내부 원리

remember와 mutableStateOf가 왜 필요한지, Compose Runtime이 상태 읽기/쓰기와 Slot Table, 리컴포지션 범위를 어떻게 결정하는지 내부 동작으로 설명한다. 초보가 겪는 버그를 재현한다. 140~160자 내외 문장 구성.

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 기준 실무 팁 포함.