19. LazyColumn items와 item으로 목록을 만드는 이유와 내부 동작
LazyColumn에서 items와 item이 왜 분리됐는지, Compose Runtime·Slot Table·Recomposition 관점에서 목록이 어떻게 유지·갱신되는지 실습으로 설명한다. 키 안정성까지 다룬다. (Compose) 153자 내외)
LazyColumn items와 item으로 목록을 만드는 이유와 내부 동작
LazyColumn을 처음 쓰면 items { } 안에서 상태를 바꿨는데 스크롤을 조금만 해도 체크가 풀리거나, 항목이 갑자기 다른 데이터로 바뀌는 경험을 한다. 로그를 찍으면 클릭한 건 하나인데 여러 줄이 다시 그려진다. 원인은 대개 key 부재, item과 items의 역할 오해, 그리고 런타임이 슬롯을 어떤 기준으로 재사용하는지 모르는 데 있다. 이 글은 목록이 왜 ‘게으르게’ 동작하는지와 그 설계가 리컴포지션 범위에 어떤 영향을 주는지에 초점을 둔다. 처음에 나도 LazyColumn에서 체크박스 상태가 스크롤할 때마다 섞이는 현상을 3시간 붙잡고 있었다. Layout Inspector에서는 항목이 재사용되는 것처럼 보이는데, 실제로는 슬롯이 “위치 기반”으로 다시 매핑되고 있었다. 로그에는 "Re-
핵심 개념
LazyColumn은 ‘모든 자식을 한 번에 Composition + Layout + Drawing’ 하지 않는다. 스크롤로 화면에 들어온 범위(대략 viewport 주변)만 Compose 단계들을 통과시킨다. View 시스템의 RecyclerView가 ViewHolder를 재사용하듯, LazyColumn은 ‘컴포저블 호출 결과(슬롯)’를 재사용한다. 차이는 재사용 단위가 View 인스턴스가 아니라 Slot Table의 그룹과 노드라는 점이다.
items와 item의 분리는 API 취향 문제가 아니다. item은 “단일 항목을 한 번” 추가하는 선언이고, items는 “여러 개 항목을 인덱스/키 규칙에 따라 반복 추가”하는 선언이다. 이 구분 덕분에 헤더/푸터(단일) + 본문 리스트(다중) 조합이 자연스럽고, 런타임은 각 블록을 서로 다른 그룹으로 추적한다.
키(key)는 ‘상태가 누구의 것인지’를 슬롯에 붙이는 라벨이다. 키가 없으면 기본은 위치(인덱스) 기반이다. 데이터가 삽입/삭제/정렬되면 같은 위치에 다른 데이터가 오게 되고, remember로 만든 상태가 엉뚱한 항목으로 이동한다. 초보가 겪는 “체크가 다른 줄로 옮겨감”은 거의 100% 이 케이스다.
contentType은 ‘이 항목이 어떤 형태의 UI인지’ 힌트다. RecyclerView의 viewType과 비슷하지만, Compose에서는 슬롯 재사용과 측정/배치 캐시가 contentType 경계에서 더 안전해진다. 서로 다른 형태(예: 광고 카드 vs 일반 행)를 섞을 때 contentType이 없으면 불필요한 재측정이 늘어난다.
Slot Table은 컴포지션 결과를 트리 형태로 저장하는 런타임 자료구조다. 각 Composable 호출은 그룹을 만들고, 그 안에 remember 값, 노드(UI 노드), 키 정보가 연결된다. LazyColumn은 ‘필요한 그룹만’ 활성화(activate)해 노드를 만들고, 필요 없으면 비활성화(deactivate)한다. 이 구조 때문에 스크롤이 곧바로 전체 리컴포지션으로 이어지지 않는다.
1import androidx.compose.foundation.layout.PaddingValues
2import androidx.compose.foundation.layout.fillMaxSize
3import androidx.compose.foundation.lazy.LazyColumn
4import androidx.compose.foundation.lazy.items
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.unit.dp
10
11@Composable
12fun BasicLazyColumn(names: List<String>) {
13 LazyColumn(
14 modifier = Modifier.fillMaxSize(),
15 contentPadding = PaddingValues(vertical = 12.dp)
16 ) {
17 item {
18 Text("Header", style = MaterialTheme.typography.titleMedium)
19 }
20 items(names) { name ->
21 Text(name)
22 }
23 item {
24 Text("Footer")
25 }
26 }
27}이 코드를 실행하면 헤더 1개, 본문 N개, 푸터 1개가 서로 다른 그룹으로 기록된다. 헤더는 item 블록 하나로 고정되고, 본문은 items가 생성하는 반복 그룹들의 집합이다. 화면에 보이지 않는 본문 항목은 노드 생성이 지연되므로, 초기 프레임에서 생성되는 노드 수가 ‘전체 데이터 개수’와 비례하지 않는다.
컴포넌트 해부
LazyColumn의 핵심은 ‘리스트를 그린다’가 아니라 ‘리스트의 구성 함수를 저장해두고, 필요할 때만 호출한다’이다. 그래서 파라미터들은 대부분 “언제/어떻게 호출할지”를 제어하는 쪽으로 설계돼 있다. View 시스템에서 LayoutManager/Adapter/ItemDecoration/Recycler를 조합하던 역할이 LazyColumn 한 곳으로 모인다.
- modifier: 레이아웃/터치/그리기 동작을 체인으로 누적한다. Lazy 내부 노드에도 순서대로 적용된다.
- state: LazyListState. 스크롤 위치와 레이아웃 정보를 외부로 노출한다. remember 없이 매 프레임 새로 만들면 스크롤이 튄다.
- contentPadding: 리스트 전체의 안쪽 여백. 첫/마지막 항목의 배치에 직접 영향을 준다.
- reverseLayout: 배치 방향을 뒤집는다. 채팅 UI에서 자주 쓰인다.
- verticalArrangement: 항목 간 간격과 정렬. 단순 spacing이 아니라 배치 알고리즘 힌트다.
- horizontalAlignment: 자식의 가로 정렬 기준.
- flingBehavior: 스크롤 물리. 기본은 플랫폼 물리와 유사하지만 커스텀 가능하다.
- userScrollEnabled: 스크롤 입력 차단. 모달/온보딩에서 유용하다.
- beyondBoundsItemCount(버전에 따라 제공): viewport 밖 프리패치 개수. 스크롤 시 jank와 메모리 사이 트레이드오프다.
- content: LazyListScope. item/items/stickyHeader 같은 DSL을 모으는 스코프다.
- items(count): 인덱스 기반 반복. 데이터 없이도 셀 수만으로 구성한다.
- items(list): 리스트 기반 반복. key/contentType을 함께 주는 게 일반적이다.
- item: 단일 항목. 헤더/푸터/구분선/로딩 인디케이터 같은 ‘하나짜리’를 표현한다.
- key: 슬롯 재사용의 정체성. 데이터 이동/삽입/삭제에서 상태를 보호한다.
- contentType: 항목 형태 분류. 서로 다른 레이아웃 섞을 때 측정 캐시를 보호한다.
Surface 계층 관점에서 LazyColumn은 ‘스크롤 가능한 컨테이너’다. 내부적으로는 스크롤 입력(드래그/플링)을 받아 오프셋을 계산하고, 그 오프셋을 기준으로 어떤 항목을 compose/measure/place 할지 결정한다. 즉, Surface는 스크롤/클리핑/오버스크롤 같은 환경을 제공하고, Content는 항목을 생성하는 람다들이다.
Content 계층은 LazyListScope DSL로 분리돼 있다. item과 items는 이 스코프에 “항목 공급자(item provider)”를 등록한다. 등록만 해두고 즉시 실행하지 않는다는 점이 포인트다. 스크롤이 바뀌거나, 데이터가 바뀌거나, 레이아웃 제약이 바뀌면 그때 필요한 공급자만 호출된다.
item을 안 쓰고 모든 것을 items로만 만들 수도 있다. 하지만 헤더/푸터 같은 단일 요소를 items(listOf(...))로 억지로 넣으면 key 설계가 복잡해지고 contentType이 섞이기 쉬워진다. item은 “이 항목은 리스트의 일부지만 반복 규칙이 없다”는 의도를 런타임에 명확히 전달한다.
1import androidx.compose.foundation.layout.Row
2import androidx.compose.foundation.layout.fillMaxWidth
3import androidx.compose.foundation.layout.padding
4import androidx.compose.foundation.lazy.LazyListScope
5import androidx.compose.material3.Divider
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.unit.dp
10
11// 교육용 스케치: 실제 LazyListScope 내부 구현이 아니라, 구조를 이해하기 위한 재구성 코드
12fun LazyListScope.simpleHeaderAndRows(
13 header: String,
14 rows: List<String>
15) {
16 item(key = "header", contentType = "header") {
17 Row(Modifier.fillMaxWidth().padding(16.dp)) {
18 Text(header)
19 }
20 Divider()
21 }
22
23 items(
24 count = rows.size,
25 key = { index -> "row:${rows[index]}" },
26 contentType = { "row" }
27 ) { index ->
28 Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
29 Text(rows[index])
30 }
31 Divider()
32 }
33}이 스케치는 “등록 단계”가 어떻게 생겼는지 보여준다. item/items는 결국 key/contentType/콘텐츠 람다를 묶어 스코프에 추가한다. 실제 런타임에서는 이 묶음이 LazyLayoutItemProvider로 변환되고, 현재 스크롤 위치에 필요한 인덱스만 골라 콘텐츠 람다를 호출한다.
1import androidx.compose.foundation.layout.fillMaxSize
2import androidx.compose.foundation.lazy.LazyColumn
3import androidx.compose.foundation.lazy.rememberLazyListState
4import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.Surface
6import androidx.compose.runtime.Composable
7import androidx.compose.ui.Modifier
8
9@Composable
10fun HeaderBodyFooterSample() {
11 val state = rememberLazyListState()
12 val rows = List(50) { "Row #$it" }
13
14 Surface(color = MaterialTheme.colorScheme.background) {
15 LazyColumn(
16 modifier = Modifier.fillMaxSize(),
17 state = state
18 ) {
19 simpleHeaderAndRows(header = "Contacts", rows = rows)
20 item(key = "footer", contentType = "footer") {
21 androidx.compose.material3.Text("End")
22 }
23 }
24 }
25}여기서 state를 remember로 고정하지 않으면 스크롤 위치가 매 recomposition마다 초기화되는 증상이 나온다. 실제로 한 번 겪었던 에러는 ‘상단으로 튐’이었고, 원인은 화면 상단의 필터 토글 상태가 바뀔 때 LazyListState가 새로 생성되던 것이었다. state는 UI 상태가 아니라 스크롤 상태이지만, 런타임 관점에서는 그냥 객체 참조다. 참조가 바뀌면 LazyColumn은 새 상태로 간주한다.
내부 동작 원리
Compose의 프레임 파이프라인은 Composition → Layout → Drawing 순서다. LazyColumn은 이 3단계를 ‘전체 자식’이 아니라 ‘필요한 자식’에만 적용한다. Composition 단계에서 items 람다가 전부 호출되는 게 아니라, 현재 viewport에 필요한 인덱스만 호출된다. 그래서 items 안에서 비싼 계산을 하면 “스크롤할 때만” 갑자기 프레임이 떨어지는 형태로 나타난다.
Compose Compiler는 @Composable 함수를 변환해 composer 파라미터와 changed 플래그를 추가한 형태로 만든다. LazyColumn의 content 람다도 같은 변환을 거친다. 런타임은 이 changed 플래그와 안정성(stability) 정보를 이용해 ‘같은 호출 위치에서 같은 입력이면 스킵’할 수 있다. Lazy에서는 호출 위치가 ‘인덱스 + key’로 확장된다고 이해하는 편이 직관적이다.
Slot Table 저장 관점에서, LazyColumn의 content DSL은 큰 그룹 하나를 만들고 그 안에 item/items가 등록한 항목 그룹들이 논리적으로 매핑된다. key가 없으면 항목 그룹은 (리스트 내 위치)로 식별된다. 데이터 삽입으로 인덱스가 밀리면, 런타임은 “기존 그룹을 새 인덱스에 재배치”할 근거가 없어서 그냥 순서대로 재사용한다. 그 결과 remember 상태가 다른 데이터에 붙는다.
Recomposition은 ‘State 읽기’를 기준으로 구독이 생긴다. items 블록 안에서 mutableStateOf 값을 읽으면, 그 읽기가 일어난 항목의 그룹이 그 State를 구독한다. 값이 바뀌면 해당 그룹만 재실행된다. 다만 key가 불안정하면 “어떤 그룹이 그 State를 읽었는지” 매핑이 흔들려서, 의도한 항목이 아니라 다른 항목이 재실행되는 것처럼 보인다.
Modifier 체인은 노드에 순서대로 감기는 데코레이터다. LazyColumn 자체의 modifier는 스크롤 컨테이너 노드에 적용되고, 각 아이템의 modifier는 아이템 노드에 적용된다. 순서가 바뀌면 hit-test 영역과 padding 적용 위치가 바뀐다. 예를 들어 padding 후 clickable과 clickable 후 padding은 터치 영역이 달라진다. Lazy에서 이 차이는 스크롤 중 터치 충돌(클릭이 안 먹거나 스크롤이 끊김)로 체감된다.
interactionSource/semantics는 ‘동작’과 ‘접근성’이 런타임 트리에 어떻게 붙는지 보여주는 좋은 창이다. 항목마다 새로운 MutableInteractionSource를 만들면 스크롤 중 객체 할당이 늘고, ripple/pressed 상태가 아이템 재사용 시 꼬일 수 있다. remember로 항목 단위에 고정하면 Slot Table의 remember 슬롯에 저장돼 재사용된다.
1import android.util.Log
2import androidx.compose.foundation.clickable
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.Checkbox
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.mutableStateMapOf
12import androidx.compose.runtime.remember
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.unit.dp
15
16private const val TAG = "LazyRecompose"
17
18data class Person(val id: Long, val name: String)
19
20@Composable
21fun RecomposeTraceList(people: List<Person>) {
22 val checked = remember { mutableStateMapOf<Long, Boolean>() }
23
24 LazyColumn {
25 items(
26 items = people,
27 key = { it.id },
28 contentType = { "personRow" }
29 ) { person ->
30 Log.d(TAG, "compose row id=${person.id}")
31 val isChecked = checked[person.id] ?: false
32
33 Row(
34 modifier = Modifier
35 .fillMaxWidth()
36 .clickable { checked[person.id] = !isChecked }
37 .padding(16.dp)
38 ) {
39 Checkbox(checked = isChecked, onCheckedChange = { checked[person.id] = it })
40 Text("${person.name} (id=${person.id})", modifier = Modifier.padding(start = 12.dp))
41 }
42 }
43 }
44}이 코드를 실행하고 한 줄을 클릭하면 Logcat에 클릭한 줄 근처의 로그만 다시 찍히는 패턴이 보인다. 체크 상태는 mutableStateMapOf에 저장되고, 각 행은 자신의 id로만 상태를 읽는다. key를 id로 줬기 때문에 삽입/삭제가 생겨도 ‘id 그룹’이 유지된다. key를 제거하고 앞쪽에 사람을 한 명 추가하면, 체크가 다른 사람에게 옮겨 가는 장면이 재현된다.
1import androidx.compose.foundation.interaction.MutableInteractionSource
2import androidx.compose.foundation.layout.Row
3import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.selection.toggleable
6import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material3.Switch
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.remember
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.semantics.Role
14import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun SemanticsAndInteractionList(
20 items: List<Person>,
21 enabledIds: Set<Long>,
22 onToggle: (Long) -> Unit
23) {
24 LazyColumn {
25 items(items = items, key = { it.id }, contentType = { "toggleRow" }) { person ->
26 val interaction = remember(person.id) { MutableInteractionSource() }
27 val enabled = enabledIds.contains(person.id)
28
29 Row(
30 modifier = Modifier
31 .fillMaxWidth()
32 .semantics { contentDescription = "toggle:${person.name}" }
33 .toggleable(
34 value = enabled,
35 enabled = true,
36 role = Role.Switch,
37 interactionSource = interaction,
38 indication = null,
39 onValueChange = { onToggle(person.id) }
40 )
41 .padding(16.dp)
42 ) {
43 Text(person.name, modifier = Modifier.weight(1f))
44 Switch(checked = enabled, onCheckedChange = null)
45 }
46 }
47 }
48}remember(person.id)로 interactionSource를 고정하면 pressed/dragged 상태가 행의 정체성과 함께 유지된다. remember 없이 MutableInteractionSource()를 직접 생성하면 스크롤 중 재컴포지션이 발생할 때마다 새 객체가 만들어지고, 특정 기기에서는 ripple이 끊기는 듯한 체감이 생긴다. Layout Inspector에서 Semantics 노드를 켜면 contentDescription이 각 행에 붙는 것도 확인된다.
실습하기
1단계: item + items로 헤더/목록/푸터 만들기
목표는 ‘헤더는 고정 1개, 본문은 100개, 푸터는 고정 1개’ 구성이다. 실행하면 상단에 Header 텍스트, 중간에 Row #0~#99, 하단에 Footer 텍스트가 보인다. 빠르게 스크롤해도 초기 로딩이 즉시 끝난다. 데이터가 100개여도 화면에는 10~15개 정도만 실제로 컴포즈된다.
확인 포인트는 Logcat이다. items 내부에 로그를 넣으면 앱 시작 시 100개 로그가 찍히지 않는다. 화면에 보이는 범위만 찍히고, 스크롤할 때 추가로 찍힌다. 이게 Lazy의 ‘게으른 Composition’이다.
1android {
2 buildFeatures {
3 compose true
4 }
5 composeOptions {
6 kotlinCompilerExtensionVersion = "1.5.15"
7 }
8}
9
10dependencies {
11 implementation(platform("androidx.compose:compose-bom:2024.10.00"))
12 implementation("androidx.compose.material3:material3")
13 implementation("androidx.activity:activity-compose:1.9.3")
14}BOM을 쓰면 Compose 아티팩트 버전 충돌이 줄어든다. 컴파일러 확장 버전은 프로젝트의 Kotlin/AGP 조합에 맞춰야 한다. 버전이 안 맞으면 빌드 에러로 "Compose Compiler requires Kotlin version ..." 같은 메시지가 뜬다. 이 에러는 런타임 문제가 아니라 컴파일 단계에서 막힌다.
1import android.os.Bundle
2import android.util.Log
3import androidx.activity.ComponentActivity
4import androidx.activity.compose.setContent
5import androidx.compose.foundation.layout.PaddingValues
6import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.lazy.LazyColumn
8import androidx.compose.foundation.lazy.items
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17private const val TAG = "LazyStep1"
18
19class MainActivity : ComponentActivity() {
20 override fun onCreate(savedInstanceState: Bundle?) {
21 super.onCreate(savedInstanceState)
22 setContent {
23 MaterialTheme {
24 Surface(Modifier.fillMaxSize()) {
25 Step1Screen()
26 }
27 }
28 }
29 }
30}
31
32@Composable
33fun Step1Screen() {
34 val rows = List(100) { "Row #$it" }
35 LazyColumn(contentPadding = PaddingValues(12.dp)) {
36 item { Text("Header", style = MaterialTheme.typography.titleLarge) }
37 items(rows) { row ->
38 Log.d(TAG, "compose $row")
39 Text(row)
40 }
41 item { Text("Footer") }
42 }
43}
44
45@Preview(showBackground = true)
46@Composable
47private fun Step1Preview() {
48 MaterialTheme { Step1Screen() }
49}실행 후 앱을 켜자마자 Logcat에 Row #0~#99가 전부 찍히지 않으면 성공이다. 스크롤을 내릴 때 로그가 추가로 찍힌다. View 시스템에서 onBindViewHolder가 스크롤에 따라 호출되던 감각과 동일하지만, 여기서는 ‘컴포저블 호출’이 그 역할을 한다.
2단계: items에 key를 주고 상태가 섞이는 문제 재현/해결
목표는 “앞에 한 항목을 삽입했을 때 체크 상태가 유지되는지”다. key가 없으면 체크가 다른 줄로 이동한다. key가 있으면 그대로 유지된다. 이 차이는 Slot Table의 그룹 식별 기준이 인덱스인지 key인지에서 나온다.
확인 방법은 간단하다. 아무 줄이나 체크한 뒤, 상단의 버튼으로 ‘맨 앞에 새 사람 추가’를 누른다. key가 없을 때는 체크가 한 줄 아래로 밀리거나 엉뚱한 사람에게 붙는다. key가 있을 때는 체크가 원래 id에 남는다.
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.Row
3import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.material3.Button
8import androidx.compose.material3.Checkbox
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateListOf
13import androidx.compose.runtime.mutableStateMapOf
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 Step2KeyScreen(useKey: Boolean) {
22 val people = remember {
23 mutableStateListOf(
24 Person(1, "A"), Person(2, "B"), Person(3, "C"), Person(4, "D")
25 )
26 }
27 val checked = remember { mutableStateMapOf<Long, Boolean>() }
28
29 Column {
30 Button(
31 onClick = {
32 val newId = (people.maxOfOrNull { it.id } ?: 0L) + 1L
33 people.add(0, Person(newId, "New-$newId"))
34 },
35 modifier = Modifier.padding(12.dp)
36 ) { Text("Add to top") }
37
38 LazyColumn {
39 if (useKey) {
40 items(items = people, key = { it.id }) { person ->
41 val isChecked = checked[person.id] ?: false
42 Row(Modifier.fillMaxWidth().padding(16.dp)) {
43 Checkbox(isChecked, onCheckedChange = { checked[person.id] = it })
44 Text("${person.name} (id=${person.id})", Modifier.padding(start = 12.dp))
45 }
46 }
47 } else {
48 items(items = people) { person ->
49 val isChecked = checked[person.id] ?: false
50 Row(Modifier.fillMaxWidth().padding(16.dp)) {
51 Checkbox(isChecked, onCheckedChange = { checked[person.id] = it })
52 Text("${person.name} (id=${person.id})", Modifier.padding(start = 12.dp))
53 }
54 }
55 }
56 }
57 }
58}
59
60@Preview(showBackground = true)
61@Composable
62private fun Step2PreviewKey() {
63 Step2KeyScreen(useKey = true)
64}
65
66@Preview(showBackground = true)
67@Composable
68private fun Step2PreviewNoKey() {
69 Step2KeyScreen(useKey = false)
70}useKey=false에서 상태가 섞이는 이유는 체크 상태 저장소가 map이라서가 아니다. map은 id로 저장하니 논리적으로는 안전하다. 문제는 remember가 아니라 “항목 그룹 재사용”이다. key가 없으면 런타임은 인덱스 기반으로 항목 그룹을 재사용하고, 그 그룹 내부에서 읽는 값들이 다른 데이터로 치환된다. key를 주면 그룹이 id에 고정돼 치환이 멈춘다.
3단계: item과 items를 섞고 contentType으로 형태 분리
목표는 “헤더/광고/일반 행”을 섞는 구성이다. 실행하면 5개마다 광고 카드가 끼어 있고, 상단에는 헤더가 고정된다. 스크롤을 빠르게 하면 광고와 일반 행이 섞일 때 측정이 흔들리는지 체감할 수 있다. contentType을 주면 그 흔들림이 줄어든다.
확인 포인트는 레이아웃 재측정 횟수다. 수치로 보려면 Android Studio의 Layout Inspector + Compose Recomposition Counts를 켜고, 스크롤 중 특정 행의 recomposition/relayout이 과도하게 늘어나는지 확인한다. contentType이 없으면 서로 다른 형태를 같은 타입으로 취급하면서 캐시 효율이 떨어진다.
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.material3.Card
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.tooling.preview.Preview
12import androidx.compose.ui.unit.dp
13
14sealed class FeedItem {
15 data class Message(val id: Long, val text: String) : FeedItem()
16 data class Ad(val id: Long, val title: String) : FeedItem()
17}
18
19@Composable
20fun Step3MixedContent(feed: List<FeedItem>) {
21 LazyColumn {
22 item(key = "header", contentType = "header") {
23 Text(
24 "Feed",
25 style = MaterialTheme.typography.titleLarge,
26 modifier = Modifier.padding(16.dp)
27 )
28 }
29
30 items(
31 items = feed,
32 key = {
33 when (it) {
34 is FeedItem.Message -> "m:${it.id}"
35 is FeedItem.Ad -> "a:${it.id}"
36 }
37 },
38 contentType = {
39 when (it) {
40 is FeedItem.Message -> "message"
41 is FeedItem.Ad -> "ad"
42 }
43 }
44 ) { item ->
45 when (item) {
46 is FeedItem.Message -> Text(item.text, modifier = Modifier.padding(16.dp))
47 is FeedItem.Ad -> Card(Modifier.fillMaxWidth().padding(16.dp)) {
48 Column(Modifier.padding(16.dp)) {
49 Text("Sponsored", style = MaterialTheme.typography.labelSmall)
50 Text(item.title)
51 }
52 }
53 }
54 }
55 }
56}
57
58@Preview(showBackground = true)
59@Composable
60private fun Step3Preview() {
61 val feed = buildList {
62 var id = 0L
63 repeat(30) { index ->
64 add(FeedItem.Message(++id, "Message #$index"))
65 if (index % 5 == 4) add(FeedItem.Ad(++id, "Ad card at $index"))
66 }
67 }
68 MaterialTheme { Step3MixedContent(feed) }
69}contentType을 주면 런타임이 “이 그룹은 message 타입, 저 그룹은 ad 타입”으로 구분해 내부 캐시를 더 보수적으로 재사용한다. RecyclerView에서 viewType을 섞을 때 잘못된 재활용이 레이아웃 깨짐으로 이어지던 것과 비슷한 설계 동기다. Compose는 View 인스턴스를 재활용하진 않지만, 측정/배치/노드 구성을 재사용하려는 압력이 있기 때문에 타입 힌트가 중요해진다.
심화: Advanced 버전 만들기
사례 1: stickyHeader + item 조합에서 키 설계
실무 피드에서 ‘날짜 헤더(sticky)’ + ‘메시지 목록(items)’을 섞으면, 필터 변경 시 헤더가 순간적으로 다른 날짜를 표시하는 버그가 나온다. 처음 겪었을 때는 데이터가 틀린 줄 알고 Repository를 의심했다. Layout Inspector로 슬롯을 추적하니 헤더 그룹이 재사용되면서 remember 값이 남아 있었다.
원인은 헤더에 key를 안 준 것이었다. stickyHeader는 내부적으로도 item과 유사한 단일 항목 그룹인데, 데이터가 바뀔 때 헤더의 정체성이 흔들리면 이전 그룹이 재사용된다. 해결은 헤더에도 ‘날짜 문자열 같은 안정 키’를 주는 것이다. 헤더 텍스트를 remember로 캐싱하는 경우라면 더 치명적이다.
사례 2: 스크롤 위치 기반 UI(‘맨 위로’ 버튼)에서 derivedStateOf
LazyListState.firstVisibleItemIndex를 그대로 읽어 버튼 표시 여부를 결정하면 스크롤 중 거의 매 프레임 recomposition이 발생한다. 버튼 하나만 바뀌면 괜찮다고 생각하기 쉽지만, 그 값을 상단 AppBar 색상이나 여러 컴포저블이 공유하면 연쇄적으로 흔들린다.
derivedStateOf는 “입력 상태가 자주 변해도, 파생 결과가 변할 때만” 구독자에게 변경을 알린다. 예를 들어 firstVisibleItemIndex > 0 여부는 인덱스가 0에서 1로 넘어갈 때만 바뀐다. 스크롤 중 1,2,3… 변해도 Boolean은 계속 true이므로 recomposition을 막는다.
1import android.os.SystemClock
2import androidx.compose.foundation.layout.Row
3import androidx.compose.foundation.layout.padding
4import androidx.compose.material3.Button
5import androidx.compose.material3.CircularProgressIndicator
6import androidx.compose.material3.Icon
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.MutableLongState
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableLongStateOf
14import androidx.compose.runtime.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.unit.dp
21
22@Composable
23fun AdvancedButton(
24 text: String,
25 loading: Boolean,
26 icon: (@Composable () -> Unit)? = null,
27 debounceMs: Long = 400,
28 a11yLabel: String = text,
29 onLongPress: (() -> Unit)? = null,
30 onClick: () -> Unit
31) {
32 val lastClick: MutableLongState = remember { mutableLongStateOf(0L) }
33 var longPressArmed by remember { mutableStateOf(false) }
34
35 LaunchedEffect(longPressArmed) {
36 if (!longPressArmed) return@LaunchedEffect
37 // 교육용: 실제 long-press는 pointerInput으로 처리하는 편이 낫다
38 kotlinx.coroutines.delay(550)
39 onLongPress?.invoke()
40 longPressArmed = false
41 }
42
43 Button(
44 enabled = !loading,
45 onClick = {
46 val now = SystemClock.elapsedRealtime()
47 if (now - lastClick.longValue < debounceMs) return@Button
48 lastClick.longValue = now
49 onClick()
50 },
51 modifier = Modifier.semantics { contentDescription = a11yLabel }
52 ) {
53 Row(Modifier.padding(vertical = 2.dp)) {
54 if (loading) {
55 CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(end = 8.dp))
56 } else if (icon != null) {
57 icon()
58 }
59 Text(text, style = MaterialTheme.typography.labelLarge)
60 }
61 }
62}이 버튼은 LazyColumn과 직접 관련 없어 보이지만, 목록 행 내부에서 ‘연타 방지 + 로딩 + 접근성 라벨’이 필요한 경우가 많다. debounce는 클릭 이벤트 폭주로 인한 recomposition 폭주를 줄인다. loading은 enabled를 끄면서 interaction 흐름을 단순화한다. a11yLabel은 semantics 트리에 남아 TalkBack이 읽는다.
1import androidx.compose.foundation.lazy.LazyColumn
2import androidx.compose.foundation.lazy.items
3import androidx.compose.material3.MaterialTheme
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.mutableStateMapOf
7import androidx.compose.runtime.remember
8import androidx.compose.ui.tooling.preview.Preview
9
10@Composable
11fun AdvancedRowList(people: List<Person>) {
12 val loading = remember { mutableStateMapOf<Long, Boolean>() }
13
14 LazyColumn {
15 item {
16 Text("Actions", style = MaterialTheme.typography.titleLarge)
17 }
18
19 items(items = people, key = { it.id }, contentType = { "actionRow" }) { person ->
20 val isLoading = loading[person.id] == true
21 AdvancedButton(
22 text = "Invite ${person.name}",
23 loading = isLoading,
24 debounceMs = 500,
25 a11yLabel = "invite:${person.name}",
26 onLongPress = { loading[person.id] = false },
27 onClick = {
28 loading[person.id] = true
29 // 교육용: 네트워크 대신 즉시 종료
30 loading[person.id] = false
31 }
32 )
33 }
34 }
35}
36
37@Preview(showBackground = true)
38@Composable
39private fun AdvancedRowListPreview() {
40 MaterialTheme {
41 AdvancedRowList(listOf(Person(1, "A"), Person(2, "B"), Person(3, "C")))
42 }
43}내 흑역사 하나는 items 안에서 loading 상태를 Boolean 하나로 두고, 클릭하면 리스트 전체가 로딩으로 바뀌던 사건이다. 증상은 “한 행만 눌렀는데 전부 스피너”였다. 원인은 상태의 스코프를 항목 id로 나누지 않은 것이었다. Slot Table은 그룹 단위로 상태를 저장하지만, 상태 자체가 전역이면 어떤 그룹이든 같은 값을 읽는다.
또 다른 흑역사는 key를 index로 준 케이스다. 코드 리뷰에서 ‘key={index}면 충분’이라고 생각했는데, 서버에서 정렬 기준이 바뀌는 순간 상태가 전부 섞였다. Logcat에는 클릭한 id가 맞게 찍히는데 UI만 뒤바뀌어서, 한참 동안 Compose 버그라고 착각했다. key는 ‘현재 화면에서의 위치’가 아니라 ‘데이터의 정체성’이어야 한다.
자주 하는 실수
1) items에 key를 주지 않아 상태가 다른 행으로 이동
증상: 체크박스/토글/텍스트 입력이 스크롤하거나 데이터 삽입 후 다른 행으로 옮겨 간다. 특히 리스트 앞부분에 아이템을 추가하면 바로 재현된다.
원인: key가 없으면 항목 그룹이 인덱스 기반으로 재사용된다. Slot Table에서 remember 슬롯은 ‘호출 위치’에 묶이는데, Lazy에서는 그 호출 위치가 사실상 인덱스가 된다.
해결: 데이터의 고유 id로 key를 준다. id가 없다면 서버/DB 계층에서 만들어야 한다. 임시로는 UUID를 생성하되, 생성 시점을 remember가 아니라 데이터 생성 시점으로 둬야 한다.
2) LazyListState를 remember 없이 생성해 스크롤이 튐
증상: 상단 필터를 토글하거나 화면이 리컴포즈될 때 스크롤이 갑자기 맨 위로 이동한다. 사용자는 ‘스크롤이 고장’으로 인식한다.
원인: LazyListState는 스크롤 오프셋을 가진 상태 객체다. recomposition마다 새 인스턴스를 만들면 이전 스크롤 위치가 사라진다. 런타임은 객체 동일성 변화로 새 상태를 적용한다.
해결: rememberLazyListState()를 사용해 컴포지션 생명주기 동안 동일 인스턴스를 유지한다. 화면 이동/프로세스 재시작까지 복원하려면 rememberSaveable + Saver를 추가로 고려한다.
3) items 블록 안에서 비싼 계산/할당을 수행해 스크롤 중 끊김
증상: 초기 진입은 빠른데 스크롤할 때만 프레임 드랍이 생긴다. 프로파일러에서 스크롤 중 GC가 튄다.
원인: Lazy는 필요할 때만 항목을 compose하므로, 비싼 연산이 ‘스크롤 이벤트’에 묶여 터진다. 예: 날짜 포맷터 생성, 정규식 컴파일, 큰 문자열 빌드, 이미지 디코딩 트리거.
해결: 계산을 데이터 레이어로 내리거나 remember/derivedStateOf로 캐싱한다. 항목마다 새 객체를 만들지 말고, 불변 데이터(@Immutable)로 전달해 changed 비교에서 스킵이 가능하게 한다.
4) Modifier 순서 실수로 클릭/스크롤 충돌
증상: 행을 눌러도 클릭이 안 되거나, 클릭이 되면 스크롤이 끊긴다. 특히 padding과 clickable 순서를 바꾸면 체감이 크게 달라진다.
원인: Modifier는 리스트가 아니라 체인이고, 순서대로 노드를 감싼다. clickable이 바깥에 있으면 padding까지 터치 영역이 되고, 안쪽에 있으면 내용 영역만 터치된다. Lazy의 스크롤 제스처와 hit-test가 충돌한다.
해결: 의도한 터치 영역을 기준으로 순서를 고정한다. 일반적으로 fillMaxWidth → clickable/toggleable → padding 순서를 많이 쓴다. 스크롤 우선이 필요하면 indication/interaction을 조정한다.
5) contentType을 생략하고 서로 다른 형태를 섞어 재측정이 과도
증상: 광고 카드/일반 행/섹션 헤더가 섞인 피드에서 특정 구간 스크롤이 유독 무겁다. Recomposition count는 낮은데 Layout count가 높다.
원인: 런타임이 항목 형태를 구분할 힌트가 없으면, 내부 캐시가 보수적으로 무효화되거나 반대로 잘못 재사용돼 측정이 반복된다. 특히 높이가 크게 다른 항목이 섞이면 더 두드러진다.
해결: contentType을 명시해 형태 경계를 만든다. message/ad/header처럼 문자열이나 enum을 사용한다. 형태가 많아지면 sealed class의 KClass를 타입으로 쓰는 방식도 가능하다.
1import androidx.compose.foundation.lazy.LazyColumn
2import androidx.compose.foundation.lazy.items
3import androidx.compose.runtime.Composable
4
5@Composable
6fun KeyAndTypeFix(feed: List<FeedItem>) {
7 LazyColumn {
8 items(
9 items = feed,
10 key = {
11 when (it) {
12 is FeedItem.Message -> it.id
13 is FeedItem.Ad -> it.id
14 }
15 },
16 contentType = {
17 when (it) {
18 is FeedItem.Message -> "message"
19 is FeedItem.Ad -> "ad"
20 }
21 }
22 ) { /* row */ }
23 }
24}이 코드는 실수를 고치는 최소 형태다. key는 정체성, contentType은 형태다. 둘을 분리해 주면 런타임이 슬롯과 캐시를 더 정확히 재사용할 수 있다.
성능 최적화 체크리스트
- items(list)에는 가능한 한 key를 준다. 서버 id, DB pk, stable uuid 같은 ‘정체성’이어야 한다.
- key로 index를 쓰지 않는다. 정렬/삽입/삭제가 한 번이라도 있으면 상태가 섞인다.
- 서로 다른 행 형태를 섞으면 contentType을 준다. message/ad/header처럼 작은 분류면 충분하다.
- LazyListState는 rememberLazyListState로 고정한다. recomposition마다 새로 만들면 스크롤이 튄다.
- items 블록 안에서 날짜 포맷터/정규식/큰 객체를 생성하지 않는다. 스크롤 중 할당과 GC가 튄다.
- 행 내부 상태는 rememberSaveable보다 먼저 ‘상태를 어디에 둘지(id 기반 map, ViewModel)’부터 결정한다.
- Modifier 순서를 의도적으로 고정한다. clickable/toggleable과 padding의 순서가 터치 영역을 바꾼다.
- 불변 데이터 모델을 선호한다. data class + val 위주로 만들고, 변경은 새 인스턴스로 교체한다.
- List를 매 recomposition마다 새로 생성하지 않는다. 필터링 결과는 remember/derivedStateOf로 캐싱하거나 ViewModel에서 만든다.
- 스크롤 기반 UI(예: ‘맨 위로’ 버튼)는 derivedStateOf로 Boolean 같은 파생값으로 줄여 구독한다.
- Layout Inspector의 Recomposition/Relayout count를 함께 본다. recomposition이 적어도 relayout이 많을 수 있다.
- 프로파일러에서 스크롤 중 Allocations를 확인한다. 16ms 프레임 내에서 수백 개 할당이 보이면 items 내부를 의심한다.
자주 묻는 질문
LazyColumn에서 item과 items를 굳이 나눠 둔 이유가 뭔가? items로 전부 만들면 안 되나?
items로 전부 만들 수는 있다. 문제는 ‘의도’와 ‘정체성 경계’를 런타임에 전달하기가 어려워진다는 점이다. item은 단일 항목 그룹 하나를 만든다. 헤더/푸터/로딩 같은 요소는 반복 규칙이 없고, 보통 key도 고정 문자열이면 충분하다. 반면 items는 반복 그룹을 생성하며, key/contentType을 인덱스별로 계산할 수 있게 설계돼 있다. 이 분리 덕분에 헤더가 리스트 변경과 독립적으로 유지되고, Slot Table에서도 헤더 그룹과 반복 그룹이 분리돼 추적된다. 학습 키워드는 LazyListScope, item provider, key 안정성이다.
key를 주면 내부적으로 정확히 무엇이 달라지나? recomposition이 줄어드나?
key는 ‘이 항목 그룹이 누구인지’를 Slot Table에 기록하는 기준을 바꾼다. key가 없으면 기본은 위치(인덱스) 기반이라 데이터 이동 시 그룹이 다른 데이터에 재사용된다. key가 있으면 런타임이 그룹을 재배치할 근거를 얻는다. recomposition 횟수 자체가 항상 줄어드는 건 아니다. 대신 “잘못된 그룹 재사용”이 줄어들어 상태가 섞이는 버그가 사라지고, 변경이 필요한 항목만 정확히 다시 호출된다. 특히 remember가 항목 내부에 있을 때 차이가 크다. 학습 키워드는 Slot Table group key, movable content, lazy item reuse다.
items 안에서 remember를 써도 되나? 항목이 화면 밖으로 나가면 remember 값은 사라지나?
items 안에서 remember는 흔한 패턴이다. 다만 remember는 ‘컴포지션에 남아 있는 동안’만 유지된다. Lazy는 화면 밖 항목을 디컴포즈(비활성화)할 수 있으므로, 그 항목의 remember 값이 유지된다고 가정하면 위험하다. 체크 상태 같은 핵심 상태는 ViewModel이나 id 기반 map으로 올리는 편이 안전하다. 반대로 interactionSource처럼 “항목이 보이는 동안만 의미 있는 상태”는 remember로 두는 게 맞다. 항목이 다시 들어올 때 새로 만들어져도 UX 문제가 적다. 학습 키워드는 state hoisting, remember vs rememberSaveable, lazy item lifecycle이다.
contentType을 안 주면 실제로 어떤 비용이 생기나? 그냥 문자열 하나 추가하는 게 큰 차이가 있나?
contentType은 항목 형태가 섞일 때 측정/배치 캐시와 노드 구성을 더 안정적으로 재사용하기 위한 힌트다. 비용은 케이스에 따라 다르다. 높이가 크게 다른 행(광고 카드 vs 단문 텍스트)이 섞이면, 잘못된 재사용으로 인해 측정이 반복되거나 레이아웃이 흔들리는 구간이 생긴다. 이때는 recomposition count보다 relayout/remeasure count가 올라간다. contentType을 주면 런타임이 형태 경계를 인지해 캐시 무효화를 덜 공격적으로 하거나, 반대로 잘못된 재사용을 피한다. 측정은 스크롤 프레임에 직접 영향을 주므로 체감이 크다. 학습 키워드는 contentType, measure policy, lazy layout cache다.
LazyColumn 스크롤이 튀는 문제는 왜 rememberLazyListState로 해결되나? 상태 객체를 새로 만들면 왜 안 되나?
Compose는 ‘같은 호출 위치에서 같은 객체 참조’가 유지될 때 연속성을 가진다. LazyListState는 firstVisibleItemIndex/scrollOffset 같은 내부 값을 갖고, 스크롤 제스처 처리와 레이아웃 계산의 기준점이다. recomposition 때마다 새 인스턴스를 만들면 내부 값이 초기화되고, LazyColumn은 새 상태를 적용하느라 현재 위치를 잃는다. View 시스템에서 LayoutManager를 매번 새로 갈아끼우면 스크롤이 초기화되는 것과 비슷한 현상이다. rememberLazyListState는 Slot Table의 remember 슬롯에 상태 객체를 저장해 동일 인스턴스를 재사용하게 한다. 학습 키워드는 remember, object identity, state holder pattern이다.
리스트 데이터가 바뀔 때 왜 어떤 항목만 recomposition 되고 어떤 항목은 안 되나? 기준이 뭔가?
기준은 ‘State 읽기’와 ‘입력 파라미터 변경(changed)’이다. 항목 컴포저블이 어떤 State를 읽으면 그 그룹이 그 State를 구독한다. 값이 바뀌면 그 그룹이 무효화(invalidate)되고 다음 프레임에 재호출된다. 또, items에 전달된 데이터 객체가 바뀌면(참조/equals/안정성에 따라) changed 플래그가 달라져 스킵 여부가 결정된다. key가 안정적이면 “어떤 그룹이 어떤 데이터를 담당하는지”가 유지돼 필요한 항목만 정확히 다시 호출된다. key가 불안정하면 그룹-데이터 매핑이 흔들려 재호출 범위가 예측 불가능해진다. 학습 키워드는 snapshot state, invalidation, stability inference, changed flags다.
Layout Inspector의 Recomposition Count가 낮은데도 스크롤이 버벅인다. 무엇을 봐야 하나?
Recomposition은 ‘컴포저블 재실행’이고, 버벅임은 Layout/Drawing에서도 생긴다. Lazy는 스크롤 시 measure/place가 더 민감하다. Layout Inspector에서 Relayout/Remeasure(버전마다 표기 다름)를 함께 보고, Android Studio Profiler에서 스크롤 중 Allocations와 GC를 확인한다. items 내부에서 객체를 많이 만들거나, 서로 다른 형태를 contentType 없이 섞거나, Modifier로 과도한 레이아웃 작업(예: intrinsic 측정 유발)을 걸면 remeasure가 급증한다. 또 이미지 로딩/텍스트 측정은 Drawing 비용도 크다. 처방은 (1) items 내부 할당 제거, (2) contentType 지정, (3) 파생 상태 derivedStateOf로 축소, (4) 프로파일링으로 병목 함수 확인이다. 학습 키워드는 measure/layout pass, allocations during scroll, tracing(Perfetto)이다.