17. LazyColumn이란? 재사용·리컴포지션·SlotTable로 이해하는 원리
LazyColumn의 지연 구성, SlotTable 저장 방식, key에 따른 아이템 식별, 리컴포지션 범위와 성능 함정을 내부 동작 관점에서 설명한다. 실습 코드 포함. 2026 기준 Compose Runtime 관점으로 서술한다. 2026 기준 Compose Runtime 관점으로 서술한다.
LazyColumn이란? 재사용·리컴포지션·SlotTable로 이해하는 원리
스크롤 리스트를 LazyColumn으로 바꿨는데, 버튼 하나 눌렀을 뿐인데도 아이템 전체가 다시 그려지는 것처럼 보이거나 스크롤 위치가 튀는 일이 생긴다. Logcat에 찍어보면 아이템 Composable이 예상보다 많이 호출되고, Layout Inspector에서는 노드가 계속 바뀌는 것처럼 보인다. 문제는 코드가 “틀려서”가 아니라 LazyColumn이 아이템을 식별하고 SlotTable에 저장하고 재사용하는 방식과, 상태 읽기 범위가 맞물려서 생긴다. 리스트 UI는 단순히 “스크롤되는 Column”이 아니다. 화면에 보이는 항목만 구성하고, 스크롤에 따라 항목을 교체하며, 상태와 키를 기준으로 이전 슬롯을 재사용한다. 이 글은 LazyColumn이 왜 이런 API를 가졌는지, Compose 컴파일러/
핵심 개념
LazyColumn은 “필요한 항목만 구성하는 Column”이라는 설명으로 끝나면 실제 문제를 못 푼다. 핵심은 세 가지다. (1) 아이템을 언제 Composition에 올릴지, (2) 아이템을 SlotTable에서 어떤 identity로 찾을지, (3) 상태 변경이 어떤 범위를 Recomposition 대상으로 표시할지다. 이 세 가지가 합쳐져서 스크롤 성능과 ‘왜 전체가 다시 호출되는 것처럼 보이는지’가 결정된다.
View 시스템의 RecyclerView는 ViewHolder 재사용이 중심이다. 데이터가 바뀌면 adapter가 notify를 보내고, RecyclerView가 View를 붙였다 떼며 재활용 풀에서 꺼낸다. Compose의 LazyColumn은 View 재사용 대신 ‘컴포저블 호출의 재사용’을 한다. 실제로는 UI 트리를 매 프레임 새로 만드는 게 아니라, SlotTable에 저장된 이전 호출 결과(그룹/슬롯)를 기준으로 변경된 부분만 다시 실행한다.
용어를 사용 맥락으로 정의한다. - item identity: LazyColumn이 특정 아이템을 “같은 아이템”으로 취급하는 기준이다. 기본은 index 기반이고, key를 주면 key 기반이다. 이 기준이 바뀌면 SlotTable에서 이전 상태 슬롯을 못 찾아서 스크롤 위치/remember 상태가 튈 수 있다. - SlotTable: Compose Runtime이 Composable 호출의 구조를 저장하는 테이블이다. LazyColumn의 각 아이템은 내부적으로 그룹으로 기록되고, key가 있으면 그 그룹이 ‘이동 가능한 그룹(movable group)’ 성격을 띤다. - Recomposition scope: 상태를 읽은 지점부터 그 하위 호출이 다시 실행될 수 있는 범위다. LazyColumn 자체가 아니라, 아이템 콘텐츠 람다에서 어떤 상태를 읽었는지가 범위를 만든다. - contentType: 아이템의 레이아웃/구성 타입 힌트다. 서로 다른 타입을 같은 타입처럼 섞으면 측정/배치 캐시가 깨지고 더 많은 work가 발생한다.
처음에 나도 LazyColumn에서 key를 안 주고 서버 데이터(삽입/삭제가 잦은 타임라인)를 붙였다가, 스크롤 중간에서 ‘좋아요’ 누르면 다른 아이템이 좋아요 된 것처럼 보이는 버그를 만들었다. 로그로 item 내부 remember 값이 다른 데이터에 붙어버린 걸 확인했고, 원인은 index 기반 identity였다. key를 안정적으로 주자 SlotTable에서 그룹이 이동하면서도 상태가 데이터에 따라가며 해결됐다.
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.layout.PaddingValues
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.unit.dp
10
11data class RowModel(val id: Long, val title: String)
12
13@Composable
14fun BasicLazyColumn(models: List<RowModel>, modifier: Modifier = Modifier) {
15 LazyColumn(
16 modifier = modifier,
17 contentPadding = PaddingValues(vertical = 8.dp)
18 ) {
19 items(
20 items = models,
21 key = { it.id }
22 ) { model ->
23 Text(text = model.title)
24 }
25 }
26}이 코드를 실행하면 스크롤 중간에서 데이터가 삽입/삭제되어도(예: 상단에 새 글 추가) 기존 아이템의 remember 상태가 다른 행으로 “밀려 붙는” 현상이 줄어든다. 이유는 key가 SlotTable에서 아이템 그룹을 찾는 identity가 되기 때문이다. key가 없으면 index가 identity가 되고, 리스트 앞부분에 삽입이 일어나면 모든 뒤쪽 아이템의 identity가 바뀌어 ‘다른 아이템’으로 취급되기 쉽다.
한 문단 요약: LazyColumn의 성능과 안정성은 ‘필요한 아이템만 구성한다’보다 ‘아이템 identity(key)로 SlotTable 그룹을 재사용하고, 상태 읽기 범위로 리컴포지션 범위를 줄인다’에서 갈린다.
컴포넌트 해부
LazyColumn의 시그니처는 파라미터가 많지 않아 보이지만, 실제로는 LazyListState, fling/overscroll, padding, arrangement, reverseLayout, userScrollEnabled 같은 ‘스크롤 컨테이너’의 선택지가 한 번에 몰려 있다. 그리고 더 중요한 파라미터는 LazyColumn 바깥이 아니라, DSL(items/item) 안에 숨어 있다. key, contentType, 그리고 아이템 콘텐츠 람다가 상태를 어디서 읽는지가 진짜 동작을 바꾼다.
- modifier: 노출되는 노드의 크기/배치/입력/세만틱을 체이닝으로 합성한다. LazyColumn은 modifier 순서에 따라 clip/scroll/semantics가 달라진다.
- state: LazyListState. 스크롤 오프셋/첫 보이는 인덱스를 상태로 유지한다. remember 없으면 재구성 시 새 인스턴스가 생겨 스크롤이 튄다.
- contentPadding: 리스트 내부 여백. 아이템 자체 padding과 달리 스크롤 가능한 영역의 시작/끝을 바꾼다.
- reverseLayout: 아이템 배치 방향을 뒤집는다. 채팅 UI에서 자주 쓰며, 첫 아이템의 의미가 바뀐다.
- verticalArrangement: 아이템 간 간격/정렬. 간격은 각 아이템 측정 결과를 바탕으로 배치 단계에서 반영된다.
- horizontalAlignment: 아이템의 가로 정렬. 각 아이템의 place 단계에서 x 오프셋을 만든다.
- flingBehavior: 플링 감쇠/스냅을 바꾼다. 스크롤 이벤트가 state에 기록되는 빈도와 타이밍에 영향이 있다.
- userScrollEnabled: 터치 스크롤 차단. 접근성 스크롤 액션과 별개로 동작할 수 있다.
- beyondBoundsItemCount(버전별): 화면 밖 아이템을 얼마나 미리 구성할지. 프리패치와 메모리 사용량이 트레이드오프다.
- items(items, key, contentType): key는 SlotTable identity, contentType은 재사용/측정 캐시 힌트다.
- itemsIndexed: index를 UI에 반영할 때 쓰지만, index를 key로 쓰면 삽입/삭제에서 취약해진다.
- stickyHeader: 헤더를 고정한다. 내부적으로 일반 아이템과 다른 배치 규칙을 가진다.
- prefetchStrategy(버전별): 스크롤 방향을 예측해 미리 compose/measure한다. jank를 줄이는 대신 백그라운드 work가 늘 수 있다.
Surface 계층과 Content 계층을 나눠서 생각하면 디버깅이 쉬워진다. LazyColumn은 ‘스크롤 가능한 컨테이너(Surface 성격)’와 ‘아이템을 제공하는 컨텐츠 슬롯(Content)’이 분리된 설계다. Surface는 스크롤/오버스크롤/세만틱/포커스 같은 입력 시스템과 맞물리고, Content는 아이템 Composable 호출 구조를 만든다.
Surface 관점에서 LazyColumn은 단일 Layout 노드가 아니라, 내부적으로 스크롤을 처리하는 Modifier(예: scrollable)와 레이아웃 정책(측정/배치) 조합으로 만들어진다. 그래서 modifier 순서가 중요해진다. 예를 들어 clip을 스크롤 전에 두면 오버스크롤 글로우가 잘리거나, semantics를 스크롤 뒤에 두면 접근성 트리에서 스크롤 액션이 다르게 노출될 수 있다.
Content 관점에서 중요한 건 ‘아이템 람다의 호출이 SlotTable에 어떤 그룹으로 기록되는지’다. LazyColumn의 DSL 블록은 단순히 리스트를 순회하는 게 아니라, 아이템마다 그룹을 만들고 key/contentType을 기록해 이후 프레임에서 같은 그룹을 찾는다. key를 안 주면 index가 사실상 identity가 되어, 데이터의 물리적 위치 변화가 곧 identity 변화가 된다.
1package com.example.lazycolumnguide
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5
6// 교육용 스케치: 실제 LazyColumn 구현이 아니라, 구조를 이해하기 위한 형태
7class FakeSlotTable {
8 private val groups = LinkedHashMap<Any, String>()
9 fun startGroup(key: Any) { groups.putIfAbsent(key, "created") }
10 fun endGroup() {}
11 fun dump(): String = groups.keys.joinToString(prefix = "keys=", separator = ",")
12}
13
14@Composable
15fun SketchLazyItems(
16 keys: List<Any>,
17 itemContent: @Composable (Any) -> Unit
18) {
19 val table = remember { FakeSlotTable() }
20 for (k in keys) {
21 table.startGroup(k)
22 itemContent(k)
23 table.endGroup()
24 }
25 // 실제론 SlotTable을 외부로 노출하지 않지만, key가 그룹 identity라는 감각을 만든다.
26}이 스케치 코드를 그대로 실행하면 UI는 안 나오지만, 머릿속 모델이 바뀐다. 아이템마다 startGroup(key)가 호출되고, 다음 프레임에도 같은 key면 같은 그룹을 찾아 이어 붙이는 식이다. 실제 Compose SlotTable은 훨씬 복잡하고, key가 없을 때는 컴파일러가 만든 호출 위치 정보와 인덱스가 그룹 identity에 섞인다. 그래서 리스트 앞에 삽입이 생기면 ‘호출 위치 기반 identity’가 흔들린다.
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.padding
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.graphics.Color
15import androidx.compose.ui.unit.dp
16
17data class Message(val id: String, val author: String, val body: String)
18
19@Composable
20fun MessageList(messages: List<Message>) {
21 LazyColumn {
22 items(
23 items = messages,
24 key = { it.id },
25 contentType = { "message" }
26 ) { msg ->
27 Surface(
28 tonalElevation = 1.dp,
29 shape = MaterialTheme.shapes.medium,
30 modifier = Modifier
31 .fillMaxWidth()
32 .padding(horizontal = 12.dp, vertical = 6.dp)
33 ) {
34 Row(
35 modifier = Modifier
36 .background(Color(0xFFF7F7F7))
37 .padding(12.dp)
38 ) {
39 Text(text = "${msg.author}: ")
40 Text(text = msg.body)
41 }
42 }
43 }
44 }
45}이 예제에서 Surface는 아이템 카드의 시각적 계층이고, LazyColumn은 스크롤 컨테이너다. contentType을 고정 문자열로 준 이유는 ‘이 리스트는 전부 같은 타입’이라는 힌트를 주기 위해서다. 타입이 섞이는 피드(광고/본문/헤더)라면 contentType을 데이터 타입별로 나눠야 측정/배치 캐시가 덜 흔들린다. contentType을 무시해도 동작은 하지만, 스크롤 중 측정이 늘어나는 형태로 비용이 튈 수 있다.
내부 동작 원리
LazyColumn을 이해하려면 Compose의 3단계 파이프라인을 LazyList 맥락으로 연결해야 한다. Composition 단계에서 ‘어떤 아이템을 호출할지’가 결정되고, Layout 단계에서 ‘각 아이템을 측정/배치할지’가 결정되며, Drawing 단계에서 실제로 픽셀이 그려진다. LazyColumn의 “Lazy”는 주로 Composition/Measure를 지연시키는 쪽에 걸려 있고, Drawing은 당연히 보이는 것만 그린다.
Composition 단계: LazyColumn은 스크롤 상태(firstVisibleItemIndex/offset)와 뷰포트 크기 제약을 바탕으로, 현재 프레임에 필요한 아이템 범위를 계산한다. 그 범위에 해당하는 아이템 콘텐츠 람다만 호출해 UI 트리(정확히는 Compose 노드 + 슬롯 그룹)를 만든다. 이때 key/contentType이 그룹 메타데이터로 기록된다.
Layout 단계: LazyList의 measure policy는 ‘보이는 범위 + 프리패치 범위’만 측정한다. 아이템은 각각 독립적으로 측정되고, 누적 높이로 배치 위치가 정해진다. 스크롤이 조금 움직이면 모든 아이템을 재측정하지 않고, 일부 아이템만 새로 측정하거나 위치만 이동시키는 경로가 있다. 하지만 아이템 높이가 동적으로 바뀌면(예: 이미지 로딩 후 높이 변경) 그 아래 아이템의 배치가 연쇄적으로 흔들릴 수 있다.
Drawing 단계: LazyColumn 자체는 스크롤 오프셋을 적용해 아이템을 translate해서 그리는 것처럼 보이지만, Compose는 레이아웃 배치 결과를 기반으로 각 노드를 draw한다. 중요한 건 ‘안 보이는 아이템은 draw 호출 자체가 없다’는 점이고, 그래서 아이템 내부에 drawBehind 같은 커스텀 드로잉이 있어도 보이지 않으면 비용이 없다.
Recomposition 트리거: mutableStateOf 같은 Snapshot State를 아이템 콘텐츠에서 읽으면, 런타임은 그 읽기(read)를 현재 RecomposeScope에 기록한다. 이후 그 state가 바뀌면, 해당 scope가 invalidated 된다. LazyColumn을 썼다고 해서 리스트 전체가 다시 호출되는 게 아니라, 상태를 읽은 위치에 따라 아이템 하나만 다시 호출될 수도 있고, 반대로 LazyColumn 상위에서 상태를 읽으면 리스트 전체가 다시 실행될 수도 있다.
Compose Compiler 관점: @Composable 함수는 컴파일 시 추가 파라미터(Composer, changed flags 등)를 받는 형태로 변환된다. 그리고 함수 본문은 ‘그룹 시작/종료’와 ‘스킵 가능 여부 검사’를 포함하는 코드로 바뀐다. LazyColumn의 아이템 람다도 동일하게 변환되며, key가 있으면 런타임이 movable group 경로를 선택할 여지가 생긴다. 이게 “아이템이 이동해도 remember가 유지되는” 핵심이다.
1package com.example.lazycolumnguide
2
3import android.util.Log
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableIntStateOf
14import androidx.compose.runtime.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.unit.dp
19
20private const val TAG = "LazyColumnRecompose"
21
22data class CounterRow(val id: Int, val title: String)
23
24@Composable
25fun RecomposeProbeList() {
26 var global by remember { mutableIntStateOf(0) }
27 val rows = remember {
28 List(30) { i -> CounterRow(id = i, title = "row-$i") }
29 }
30
31 LazyColumn {
32 item(key = "header") {
33 Log.d(TAG, "compose header global=$global")
34 Text(
35 text = "global=$global (tap)",
36 modifier = Modifier
37 .fillMaxWidth()
38 .clickable { global++ }
39 .padding(16.dp)
40 )
41 }
42
43 items(rows, key = { it.id }) { row ->
44 RowItem(row = row)
45 }
46 }
47}
48
49@Composable
50private fun RowItem(row: CounterRow) {
51 var local by remember(row.id) { mutableStateOf(0) }
52 Log.d(TAG, "compose ${row.title} local=$local")
53
54 Row(
55 modifier = Modifier
56 .fillMaxWidth()
57 .clickable { local++ }
58 .padding(horizontal = 16.dp, vertical = 10.dp)
59 ) {
60 Text(text = row.title)
61 Text(text = " local=$local")
62 }
63}이 코드를 실행하고 Logcat 필터를 TAG로 걸면, 화면에서 특정 row를 탭할 때 그 row의 로그만 다시 찍히는 패턴을 관찰할 수 있다. 반면 header를 탭하면 header만 다시 찍히는 게 아니라, 상황에 따라 보이는 아이템 일부가 같이 찍힐 수 있다. 이유는 global 상태를 어디서 읽었는지와, LazyColumn이 현재 프레임에 보이는 아이템을 다시 호출하는 타이밍이 맞물리기 때문이다. ‘전체 30개가 다 찍히지 않는다’는 사실이 리컴포지션 범위의 감각을 만든다.
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.foundation.selection.toggleable
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.mutableStateMapOf
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
16
17@Composable
18fun SemanticsAndInteractionList() {
19 val checked = remember { mutableStateMapOf<Int, Boolean>() }
20
21 LazyColumn {
22 items((0 until 50).toList(), key = { it }, contentType = { "toggle" }) { id ->
23 val interaction = remember { MutableInteractionSource() }
24 val value = checked[id] == true
25
26 Text(
27 text = "item $id checked=$value",
28 modifier = Modifier
29 .fillMaxWidth()
30 .semantics { contentDescription = "toggle-item-$id" }
31 .toggleable(
32 value = value,
33 role = Role.Checkbox,
34 interactionSource = interaction,
35 indication = null
36 ) {
37 checked[id] = !value
38 }
39 )
40 }
41 }
42}이 코드는 Modifier 체이닝 순서가 왜 중요한지 보여준다. semantics는 접근성 트리에 노드를 추가하고, toggleable은 입력 처리와 상태 토글을 연결한다. interactionSource를 remember로 고정하지 않으면, 재구성 때마다 새 인스턴스가 생겨 Press/Focus 상태가 끊길 수 있다. 실제 디버깅에서 ‘터치 리플이 중간에 사라진다’ 같은 증상은 interactionSource 재생성에서 시작하는 경우가 있다.
실습하기
실습 목표는 세 가지다. (1) LazyColumn이 실제로 화면에 보이는 것만 구성한다는 감각을 로그로 확인한다. (2) remember/키가 없을 때 스크롤 위치와 아이템 상태가 왜 튀는지 재현한다. (3) contentPadding, item spacing, divider 같은 커스터마이징이 Layout 단계에 어떤 영향을 주는지 눈으로 확인한다.
환경 힌트: Compose BOM을 쓰면 버전 충돌이 줄어든다. Android Studio의 Layout Inspector에서 recomposition counts를 같이 보면 ‘많이 다시 호출되는 것처럼 보이는’ 상황을 수치로 분리할 수 있다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4}
5
6android {
7 namespace = "com.example.lazycolumnguide"
8 compileSdk = 34
9
10 defaultConfig {
11 applicationId = "com.example.lazycolumnguide"
12 minSdk = 24
13 targetSdk = 34
14 }
15
16 buildFeatures { compose = true }
17}
18
19dependencies {
20 implementation(platform("androidx.compose:compose-bom:2025.01.00"))
21 implementation("androidx.activity:activity-compose:1.9.3")
22 implementation("androidx.compose.ui:ui")
23 implementation("androidx.compose.material3:material3")
24 debugImplementation("androidx.compose.ui:ui-tooling")
25}이 의존성으로 프로젝트를 만들면 바로 실행 가능한 수준이 된다. BOM 버전은 팀 표준에 맞춰 고정하는 게 좋고, 실습에서는 ui-tooling을 debug에만 넣어 Preview/Inspector를 켠다.
1단계: 가장 기본 형태
첫 단계에서는 ‘정말 필요한 아이템만 호출되는지’를 확인한다. 아이템 내부에 Log를 넣고, 화면에 보이는 범위를 넘어서는 아이템 로그가 찍히지 않는지 본다. 스크롤하면 그때 새 아이템 로그가 추가로 찍히는 흐름이 보인다.
실행하면 상단 앱바 없이 단순 리스트가 보이고, 스크롤을 내릴수록 Logcat에 compose row-N 로그가 순차적으로 늘어난다. 한 번에 1..200이 다 찍히면 Lazy가 아닌 것이다. 보통은 화면+프리패치 정도만 찍힌다.
1package com.example.lazycolumnguide
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
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
14
15private const val TAG = "LazyStep1"
16
17class MainActivity : ComponentActivity() {
18 override fun onCreate(savedInstanceState: Bundle?) {
19 super.onCreate(savedInstanceState)
20 setContent {
21 MaterialTheme {
22 Surface {
23 Step1Basic()
24 }
25 }
26 }
27 }
28}
29
30@Composable
31fun Step1Basic(modifier: Modifier = Modifier) {
32 val data = (1..200).map { it }
33 LazyColumn(modifier = modifier) {
34 items(data, key = { it }) { n ->
35 Log.d(TAG, "compose row-$n")
36 Text(text = "row $n")
37 }
38 }
39}이 단계에서 자주 착각하는 지점이 있다. “로그가 많이 찍히는데 Lazy가 아닌가?”라는 질문이 나온다. Lazy는 ‘0회’가 아니라 ‘필요한 만큼’이다. 초기 진입 시 화면 크기, 폰트 스케일, 프리패치 전략에 따라 15~40개 정도가 찍히는 게 흔하다. 스크롤을 멈추면 더 이상 새 로그가 안 찍히는지까지 확인해야 한다.
2단계: 상태 연동(스크롤 위치와 아이템 상태)
두 번째 단계는 스크롤 위치 유지와 아이템 로컬 상태 유지다. LazyListState를 remember로 잡지 않으면 구성 변경이나 상위 상태 변경 때 스크롤이 튄다. 또한 key가 불안정하면 아이템 로컬 상태(remember)가 다른 데이터로 이동한다.
실행하면 상단에 ‘prepend’ 버튼이 있고, 누르면 리스트 앞에 새 아이템이 삽입된다. key를 끄면(코드에서 key 제거) 스크롤 중간에서 prepend 시 화면이 순간적으로 흔들리거나, 로컬 카운터가 다른 행으로 옮겨간다. key를 켜면 로컬 카운터가 데이터 id에 붙어서 유지된다.
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.fillMaxSize
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.lazy.LazyColumn
7import androidx.compose.foundation.lazy.items
8import androidx.compose.foundation.lazy.rememberLazyListState
9import androidx.compose.material3.Button
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateListOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.runtime.mutableIntStateOf
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun Step2Stateful() {
22 val listState = rememberLazyListState()
23 val items = remember { mutableStateListOf<RowModel>() }
24 var nextId by remember { mutableIntStateOf(0) }
25
26 if (items.isEmpty()) {
27 repeat(50) {
28 items.add(RowModel(id = nextId.toLong(), title = "item-$nextId"))
29 nextId++
30 }
31 }
32
33 Column(Modifier.fillMaxSize()) {
34 Button(
35 onClick = {
36 items.add(0, RowModel(id = nextId.toLong(), title = "prepended-$nextId"))
37 nextId++
38 },
39 modifier = Modifier.padding(12.dp)
40 ) {
41 Text("prepend")
42 }
43
44 LazyColumn(state = listState) {
45 items(
46 items = items,
47 key = { it.id }
48 ) { model ->
49 RowWithLocalCounter(model)
50 }
51 }
52 }
53}
54
55@Composable
56private fun RowWithLocalCounter(model: RowModel) {
57 var c by remember(model.id) { mutableIntStateOf(0) }
58 Text(
59 text = "${model.title} local=$c (tap)",
60 modifier = Modifier
61 .padding(horizontal = 16.dp, vertical = 10.dp)
62 .then(Modifier)
63 )
64}여기서 RowWithLocalCounter에 clickable을 일부러 빼뒀다. 초보 단계에서 modifier 체이닝과 입력을 한 번에 넣으면 원인 분리가 어렵다. local 상태가 데이터 id에 종속되는지(remember(model.id))가 먼저다. 그 다음 clickable을 추가해 local이 증가하는지 확인하면 된다.
3단계: 커스터마이징(contentPadding, 간격, 구분선, 타입 분리)
세 번째 단계는 ‘보기 좋은 리스트’가 목적이 아니라, Layout 단계에서 어떤 계산이 늘어나는지 체감하는 단계다. 아이템 간격을 arrangement로 줄지, 아이템 자체 padding으로 줄지에 따라 측정/배치 비용이 달라질 수 있다. 또한 여러 타입이 섞이면 contentType이 없을 때 스크롤 중 측정이 더 자주 발생하는 패턴을 볼 수 있다.
실행하면 헤더/본문/광고가 섞인 리스트가 보인다. 광고 타입은 배경색이 다르고 높이가 크다. 스크롤을 빠르게 하면 광고가 등장하는 지점에서 프레임이 살짝 끊기는 느낌이 날 수 있는데, contentType을 분리하면 그 지점의 측정 churn이 줄어드는 경향이 있다(기기/버전에 따라 체감 차이 존재).
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.PaddingValues
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.graphics.Color
14import androidx.compose.ui.unit.dp
15
16sealed interface FeedItem {
17 val id: String
18 data class Header(override val id: String, val title: String) : FeedItem
19 data class Post(override val id: String, val text: String) : FeedItem
20 data class Ad(override val id: String, val label: String) : FeedItem
21}
22
23@Composable
24fun Step3Customized(feed: List<FeedItem>) {
25 LazyColumn(
26 contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
27 verticalArrangement = Arrangement.spacedBy(8.dp)
28 ) {
29 items(
30 items = feed,
31 key = { it.id },
32 contentType = {
33 when (it) {
34 is FeedItem.Header -> "header"
35 is FeedItem.Post -> "post"
36 is FeedItem.Ad -> "ad"
37 }
38 }
39 ) { item ->
40 when (item) {
41 is FeedItem.Header -> Text(
42 text = item.title,
43 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)
44 )
45 is FeedItem.Post -> Text(
46 text = item.text,
47 modifier = Modifier
48 .fillMaxWidth()
49 .background(Color(0xFFF2F2F2))
50 .padding(12.dp)
51 )
52 is FeedItem.Ad -> Text(
53 text = "AD: ${item.label}",
54 modifier = Modifier
55 .fillMaxWidth()
56 .background(Color(0xFFFFF3CD))
57 .padding(18.dp)
58 )
59 }
60 }
61 }
62}여기서 확인 포인트는 두 가지다. 첫째, contentPadding은 리스트의 시작/끝 여백이라서 첫 아이템/마지막 아이템이 화면 가장자리에서 떨어진다. 둘째, spacedBy는 배치 단계에서 간격을 추가하므로 아이템 자체 padding과 의미가 다르다. 아이템 간격을 padding으로 구현하면 각 아이템 측정 결과가 커지고, spacedBy로 구현하면 측정 결과는 그대로 두고 배치에서 간격을 더한다.
심화: Advanced 버전 만들기
실무 리스트는 ‘그냥 items로 뿌리기’에서 끝나지 않는다. 스크롤에 따라 상단 바가 접히거나, 무한 스크롤 로딩을 트리거하거나, 아이템에 debounce 클릭을 걸거나, 길게 누르기/접근성 라벨을 붙인다. 이때 LazyColumn 자체보다 ‘아이템 콘텐츠가 어떤 상태를 읽고 어떤 객체를 매번 생성하는지’가 더 큰 비용을 만든다.
사례 1: 무한 스크롤 로딩 트리거를 derivedStateOf로 고정
무한 스크롤은 흔히 listState.layoutInfo.visibleItemsInfo를 매 프레임 읽고, 마지막 아이템에 가까우면 로드를 건다. 문제는 그 값을 읽는 위치가 잘못되면 스크롤 중에 불필요한 리컴포지션이 폭발한다는 점이다. derivedStateOf로 ‘관심 있는 값(마지막 보이는 인덱스)’만 추출하면 invalidation 범위를 줄일 수 있다.
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.lazy.LazyColumn
4import androidx.compose.foundation.lazy.LazyListState
5import androidx.compose.foundation.lazy.items
6import androidx.compose.foundation.lazy.rememberLazyListState
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.derivedStateOf
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.remember
14import androidx.compose.ui.Modifier
15
16@Composable
17fun InfiniteList(
18 rows: List<RowModel>,
19 isLoading: Boolean,
20 onLoadMore: () -> Unit,
21 modifier: Modifier = Modifier
22) {
23 val state = rememberLazyListState()
24 InfiniteListInternal(rows, isLoading, onLoadMore, state, modifier)
25}
26
27@Composable
28private fun InfiniteListInternal(
29 rows: List<RowModel>,
30 isLoading: Boolean,
31 onLoadMore: () -> Unit,
32 state: LazyListState,
33 modifier: Modifier
34) {
35 val shouldLoadMore by remember(state, rows.size) {
36 derivedStateOf {
37 val lastVisible = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
38 lastVisible >= (rows.size - 6)
39 }
40 }
41
42 LaunchedEffect(shouldLoadMore, isLoading) {
43 if (shouldLoadMore && !isLoading) onLoadMore()
44 }
45
46 LazyColumn(state = state, modifier = modifier) {
47 items(rows, key = { it.id }) { Text(it.title) }
48 item(key = "loading") {
49 if (isLoading) CircularProgressIndicator()
50 }
51 }
52}이 코드를 실행하면 스크롤이 바닥에 가까워질 때만 onLoadMore가 호출된다. derivedStateOf가 중요한 이유는, layoutInfo 전체를 직접 상태처럼 여기고 읽으면 스크롤 중 매 픽셀 이동마다 더 넓은 범위가 invalidation 될 수 있기 때문이다. derivedStateOf는 읽기 범위를 축소하고, 바뀐 값이 동일하면 downstream recomposition을 막는다.
사례 2: 아이템 클릭 debounce + 접근성 라벨을 가진 Row 컴포넌트
리스트 아이템 클릭을 debounce 없이 처리하면, 빠른 연타로 네트워크 요청이 중복되거나 네비게이션이 두 번 발생한다. 이 문제는 LazyColumn이 아니라 아이템 컴포넌트에서 해결해야 한다. 동시에 TalkBack 사용자를 위해 contentDescription을 안정적으로 제공해야 한다.
1package com.example.lazycolumnguide
2
3import android.os.SystemClock
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.Icon
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableLongStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.semantics.contentDescription
17import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun DebouncedRow(
22 title: String,
23 icon: @Composable (() -> Unit)? = null,
24 debounceMs: Long = 600L,
25 a11yLabel: String = title,
26 onClick: () -> Unit
27) {
28 var lastClickAt by remember { mutableLongStateOf(0L) }
29
30 Row(
31 modifier = Modifier
32 .fillMaxWidth()
33 .semantics { contentDescription = a11yLabel }
34 .clickable {
35 val now = SystemClock.elapsedRealtime()
36 if (now - lastClickAt >= debounceMs) {
37 lastClickAt = now
38 onClick()
39 }
40 }
41 .padding(horizontal = 16.dp, vertical = 12.dp)
42 ) {
43 if (icon != null) {
44 icon()
45 }
46 Text(text = title)
47 }
48}이 Row를 LazyColumn 아이템에서 쓰면, 클릭 연타가 들어와도 onClick이 debounceMs마다 한 번만 실행된다. lastClickAt을 remember로 유지하는 이유는 재구성으로 값이 초기화되면 debounce가 무력화되기 때문이다. 또한 semantics를 clickable보다 앞에 두면, 접근성 라벨이 안정적으로 노출된다(프로젝트의 semantics 병합 정책에 따라 차이가 있을 수 있다).
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.lazy.LazyColumn
4import androidx.compose.foundation.lazy.items
5import androidx.compose.material3.Icon
6import androidx.compose.material3.Text
7import androidx.compose.material3.icons.Icons
8import androidx.compose.material3.icons.filled.ChevronRight
9import androidx.compose.runtime.Composable
10
11@Composable
12fun AdvancedListUsage(rows: List<RowModel>, onRowClick: (RowModel) -> Unit) {
13 LazyColumn {
14 items(rows, key = { it.id }, contentType = { "debounced" }) { row ->
15 DebouncedRow(
16 title = row.title,
17 icon = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
18 a11yLabel = "open ${row.title}",
19 onClick = { onRowClick(row) }
20 )
21 }
22 item(key = "footer") {
23 Text("footer")
24 }
25 }
26}내가 이거 잘못 써서 생긴 흑역사가 있다. 무한 스크롤을 만들면서 shouldLoadMore 계산을 derivedStateOf 없이 composable 본문에서 직접 if로 계산했고, 그 안에서 layoutInfo를 매번 읽었다. 증상은 Pixel 6에서 스크롤 시 1~2초마다 프레임 드랍이 생기고, 프로파일러에서 Compose Recomposer가 16ms를 넘기는 구간이 반복적으로 나타났다.
원인은 ‘스크롤 중 자주 변하는 값’을 너무 상위에서 읽어서 invalidation이 넓어진 것이었다. Layout Inspector에서 recomposition count가 리스트 전체에 퍼져 있었고, trace에서 measure/layout이 연쇄적으로 늘었다. derivedStateOf로 관심 값을 축소하고, LaunchedEffect 키를 shouldLoadMore로 제한하니 로드 트리거는 유지되면서 recomposition 노이즈가 크게 줄었다. 같은 코드가 에뮬레이터에서는 멀쩡해 보여서 3시간 삽질했다. 실제 기기에서만 jank가 드러났다.
자주 하는 실수
1) key를 안 주거나 index를 key로 사용
증상: 리스트 앞에 삽입/삭제가 일어나면 스크롤 위치가 튀거나, 어떤 행의 토글/텍스트 입력/로컬 카운터가 다른 행으로 옮겨간다. QA가 ‘좋아요가 다른 글에 눌린다’라고 보고한다.
원인: LazyColumn은 아이템 그룹을 SlotTable에서 찾을 때 identity가 필요하다. key가 없으면 호출 위치/인덱스 기반 identity가 강해지고, 데이터의 물리적 위치 변화가 곧 다른 그룹으로 인식되는 트리거가 된다. index를 key로 주면 이 문제를 더 단단히 고정해버린다.
해결: 서버가 주는 고유 id를 key로 사용한다. id가 없다면 안정적으로 생성된 uuid를 데이터에 포함시킨다. 화면에 보이는 순서가 바뀌는 리스트(정렬/필터/삽입/삭제)일수록 key는 필수다.
2) LazyListState를 remember 없이 생성
증상: 상위 상태가 변할 때마다 스크롤이 맨 위로 돌아간다. 화면 회전이나 다크모드 토글 같은 구성 변경에서 스크롤 위치가 유지되지 않는다.
원인: LazyListState는 스크롤 위치를 들고 있는 상태 객체다. Composable 본문에서 LazyListState()를 직접 만들면, 재구성 때 새 인스턴스가 생성되어 이전 위치를 잃는다. SlotTable은 호출 구조를 재사용하지만, 새 객체 생성 자체는 막지 않는다.
해결: rememberLazyListState()로 고정한다. 프로세스 종료 후 복원까지 필요하면 rememberSaveable + LazyListState.Saver를 고려한다(버전별 지원 확인 필요).
3) 스크롤 중 변하는 값을 아이템 전체에서 읽어 리컴포지션 폭발
증상: 스크롤할 때 CPU 사용량이 올라가고, 아이템 로그가 계속 찍힌다. Layout Inspector의 recomposition count가 빠르게 증가한다. 체감상 ‘그냥 스크롤인데 뭔가 계속 다시 그린다’가 된다.
원인: state.layoutInfo 같은 값은 스크롤에 따라 계속 변한다. 이 값을 LazyColumn 상위나 아이템 람다에서 직접 읽으면, 그 읽기 지점의 RecomposeScope가 스크롤 이벤트마다 invalidated 된다. Lazy는 아이템 수를 줄여도, invalidation이 넓으면 이점이 줄어든다.
해결: derivedStateOf로 필요한 값만 추출하고, 읽는 위치를 최소화한다. 스크롤 기반 효과(로드 트리거/상단바 애니메이션)는 LaunchedEffect/SideEffect를 적절히 분리해 UI 트리 재호출과 섞지 않는다.
4) contentType을 무시한 채 여러 타입을 섞음
증상: 피드에서 특정 타입(광고/추천 카드)이 등장하는 구간에서만 스크롤이 뻑뻑해진다. 프로파일러에서 measure/layout 시간이 간헐적으로 튄다.
원인: 서로 다른 레이아웃 구조가 섞이면 측정/배치 캐시가 재사용되기 어렵다. contentType 힌트가 없으면 런타임이 더 보수적으로 처리하거나, 타입 전환 구간에서 추가 work가 발생할 수 있다.
해결: items의 contentType을 데이터 타입별로 분리한다. 같은 타입 내에서만 재사용 힌트를 주는 게 목적이다. 타입이 단일이면 고정 문자열로 충분하다.
5) 아이템 내부에서 매번 큰 객체를 생성(특히 Modifier/Interaction/Formatter)
증상: GC가 잦고, 스크롤 중 드랍이 발생한다. ‘리컴포지션이 많지 않은데도’ 느리다. Allocation tracker에서 아이템 구성 시 객체가 많이 생성된다.
원인: Compose는 함수 호출을 재사용해도, 코드가 매번 새 객체를 만들면 그 비용은 그대로다. 예를 들어 매 아이템에서 DateTimeFormatter 생성, MutableInteractionSource 생성, 복잡한 문자열 포매팅을 수행하면 스크롤 중 할당이 늘어난다.
해결: remember로 객체를 캐시하거나, 데이터 레이어에서 미리 가공한다. Modifier는 값 객체라 비교적 가볍지만, 람다 캡처/formatter/interactionSource처럼 무거운 객체는 아이템 단위로 고정한다.
1package com.example.lazycolumnguide
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.remember
9
10@Composable
11fun MistakeFixInteraction(rows: List<RowModel>) {
12 LazyColumn {
13 items(rows, key = { it.id }) { row ->
14 val interaction = remember(row.id) { MutableInteractionSource() }
15 // interaction을 clickable/toggleable 등에 전달한다고 가정
16 Text(text = row.title)
17 }
18 }
19}이 예시는 ‘무거운 객체를 아이템 단위로 고정’하는 패턴을 보여준다. remember(row.id)로 묶으면 key가 안정적일 때 아이템 이동에도 같은 interactionSource가 따라간다. key가 불안정하면 remember 키도 흔들려서 다시 생성된다.
성능 최적화 체크리스트
- 리스트 데이터에 안정적인 고유 id가 있고, items(key=)로 연결되어 있는가
- 삽입/삭제/정렬/필터가 발생하는 리스트에서 index를 key로 쓰지 않았는가
- 여러 아이템 타입(헤더/광고/본문)이 섞이면 contentType을 타입별로 분리했는가
- LazyListState를 rememberLazyListState로 고정했고, 필요 시 rememberSaveable 복원 전략을 검토했는가
- 스크롤에 따라 자주 변하는 값(layoutInfo 등)을 UI 트리 상위에서 직접 읽지 않았는가
- 무한 스크롤 트리거는 derivedStateOf + LaunchedEffect로 분리되어 있는가
- 아이템 내부에서 DateTimeFormatter/Regex/MutableInteractionSource 같은 객체를 매번 생성하지 않는가
- 아이템 Composable 파라미터에 불안정한 컬렉션/람다를 매번 새로 만들어 전달하지 않는가(필요 시 remember로 고정)
- 아이템 높이가 동적으로 크게 변하는 경우(이미지 로딩) placeholder로 높이를 안정화했는가
- Layout Inspector에서 recomposition count와 실제 jank(프레임 드랍)를 분리해서 관찰했는가
- Android Studio Profiler에서 Allocation/GC가 스크롤 중 튀는 지점을 확인했는가
- 스크롤 성능 문제를 에뮬레이터가 아니라 실제 중급기기(60Hz)에서도 재현했는가
자주 묻는 질문
LazyColumn이 RecyclerView보다 항상 빠른가
항상 빠르지 않다. LazyColumn은 ‘보이는 아이템만 compose/measure’하는 구조라서 리스트가 큰 경우 유리한 경우가 많지만, 아이템 내부에서 매번 큰 객체를 생성하거나(포매터, 이미지 디코드, 무거운 문자열 가공), 스크롤에 따라 변하는 상태를 넓은 범위에서 읽으면 리컴포지션/측정이 늘어 jank가 생긴다. RecyclerView는 ViewHolder 재사용이 강제되지만, Compose는 호출 재사용이므로 코드가 할당을 많이 만들면 그대로 비용이 된다. 비교할 때는 (1) 스크롤 중 CPU, (2) Allocation/GC, (3) measure/layout 시간, (4) 실제 프레임 타임을 같이 본다. 학습 키워드는 ‘Recomposition scope’, ‘Allocation tracker’, ‘LazyList measure policy’이다.
key를 주면 내부에서 정확히 무엇이 달라지나
key는 LazyColumn 아이템 그룹의 identity로 사용된다. Compose Runtime은 SlotTable에 Composable 호출 구조를 그룹 단위로 저장하고, 다음 프레임에 같은 그룹을 찾아 재사용한다. key가 없으면 인덱스/호출 위치 기반으로 그룹을 찾는 성격이 강해져서, 리스트 앞에 삽입이 생기면 뒤쪽 아이템의 identity가 연쇄적으로 바뀐다. 그 결과 remember로 저장한 로컬 상태가 다른 데이터에 붙거나, 아이템이 ‘새로 생긴 것’처럼 취급되어 더 많은 compose가 발생할 수 있다. key를 주면 그룹이 이동(movable)할 수 있어 데이터의 재배치에도 상태가 데이터 id를 따라간다. 학습 키워드는 ‘movable group’, ‘SlotTable’, ‘stable identity’이다.
왜 LazyColumn에서 rememberLazyListState가 필요하나
LazyListState는 스크롤 위치(첫 보이는 아이템 인덱스와 오프셋)를 들고 있는 상태 객체다. Composable은 재구성될 수 있고, 재구성은 함수 재호출이므로 본문에서 LazyListState()를 만들면 매번 새 인스턴스가 생길 수 있다. SlotTable은 이전 호출의 구조를 재사용하지만, 개발자가 생성한 객체의 생명주기까지 자동으로 고정하지 않는다. rememberLazyListState는 Composition에 상태 객체를 저장해 동일 인스턴스를 재사용하게 만든다. 구성 변경 후에도 복원이 필요하면 rememberSaveable과 Saver를 추가로 고려해야 한다(버전별 API 확인). 학습 키워드는 ‘remember vs rememberSaveable’, ‘state object lifetime’이다.
스크롤할 때 아이템 로그가 계속 찍히는데, Lazy가 아닌가
로그가 찍힌다는 사실만으로 Lazy가 아닌 것은 아니다. LazyColumn은 초기 진입 시 화면에 필요한 아이템과 프리패치 범위를 compose/measure한다. 또한 스크롤 중에는 새로 들어오는 아이템이 compose되며, 기존 아이템도 상태 변화나 측정 조건 변화가 있으면 다시 호출될 수 있다. 문제는 ‘필요 이상으로 넓은 범위’가 다시 호출되는지다. 확인 방법은 (1) 아이템 로그에 id를 찍고 특정 아이템만 눌렀을 때 그 아이템만 다시 찍히는지, (2) Layout Inspector에서 recomposition count가 특정 아이템에 국한되는지, (3) 스크롤 중 layoutInfo 같은 값을 어디서 읽는지 찾는 것이다. 학습 키워드는 ‘state read tracking’, ‘derivedStateOf’, ‘Inspector recomposition count’이다.
contentType을 꼭 줘야 하나
필수는 아니지만, 타입이 섞이는 리스트에서는 비용 차이가 날 수 있다. contentType은 ‘이 아이템은 어떤 종류의 레이아웃/구성인가’라는 힌트로 사용되며, 런타임이 재사용/캐시 전략을 세우는 데 도움을 준다. 예를 들어 헤더/본문/광고가 섞인 피드에서 contentType이 없으면 타입 전환 구간에서 측정/배치가 더 자주 발생하는 패턴이 나올 수 있다. 반대로 모든 아이템이 동일한 구조면 고정 문자열 하나로 충분하거나 아예 생략해도 큰 차이가 없을 수 있다. 성능 이슈가 있을 때는 contentType을 넣고 스크롤 구간별 measure/layout 시간을 비교해 판단한다. 학습 키워드는 ‘heterogeneous list’, ‘measure cache’, ‘contentType’이다.
LazyColumn에서 아이템 내부 상태(remember)가 서버 데이터 갱신에 따라 날아가는 이유는 무엇인가
아이템 내부 remember는 SlotTable의 ‘그 아이템 그룹’에 저장된다. 서버 데이터 갱신으로 리스트가 새로 만들어지거나 순서가 바뀌면, 런타임이 이전 그룹을 같은 그룹으로 매칭하지 못할 수 있다. 대표 원인은 (1) key가 없어서 index 기반 identity가 바뀌는 경우, (2) key가 있더라도 key 값이 안정적이지 않은 경우(임시 id, 매번 새로 생성한 uuid), (3) 리스트 자체를 매번 새 인스턴스로 만들면서도 내부 데이터 id가 흔들리는 경우다. 해결은 데이터 모델에 안정적인 id를 두고 key로 연결하는 것이다. 입력 폼처럼 상태를 강하게 유지해야 하면 rememberSaveable을 검토하되, 저장 비용과 복원 정책도 같이 설계해야 한다. 학습 키워드는 ‘identity’, ‘remember slot’, ‘saveable state’이다.
Modifier 순서가 LazyColumn 성능과도 관련이 있나
관련이 있다. Modifier는 체인 순서대로 노드에 래핑되며, 입력/레이아웃/드로잉/세만틱이 그 순서에 따라 적용된다. 예를 들어 clip을 스크롤 이전에 두면 오버스크롤 효과나 그림자가 잘릴 수 있고, 불필요한 레이어가 생기면 드로잉 비용이 늘 수 있다. semantics를 잘못된 위치에 두면 접근성 트리 병합이 바뀌어 포커스 이동이 달라질 수도 있다. 성능 관점에서는 ‘불필요한 graphicsLayer, clip, shadow’를 아이템마다 남발하면 오프스크린 버퍼가 늘어 메모리와 GPU 비용이 튈 수 있다. 해결은 modifier를 최소화하고, 필요한 효과만 정확한 위치에 둔 뒤 프로파일러로 레이어/드로우콜 변화를 확인하는 것이다. 학습 키워드는 ‘Modifier chain’, ‘graphicsLayer’, ‘semantics tree’이다.