LazyColumn — 지연 로딩으로 스크롤 가능한 리스트를 표시하는 Jetpack Compose 컴포넌트입니다. RecyclerView와 달리 LazyColumn은 ViewHolder를 만들지 않고 XML을 사용하지 않습니다 — 모든 요소는 composable 함수를 통해 선언적으로 설명됩니다. LazyColumn이 무엇인지, LazyListState와 items API가 어떻게 작동하는지, 60 FPS 성능을 달성하는 방법을 보여줍니다. IT Sectr에서는 모든 새로운 Compose 프로젝트에서 LazyColumn을 사용합니다. 클래식 접근 방식과의 비교를 위해 RecyclerView에 관한 글을 읽어보세요.
주요 내용
items(), itemsIndexed(), items() 함수.LazyColumn은 Foundation Compose 라이브러리의 composable 함수로, 지연 로딩으로 항목의 세로 리스트를 표시합니다: 화면에 보이거나 가까운 항목(프리페치 영역)만 생성되고 컴포즈됩니다. LazyColumn은 LazyRow(가로 리스트) 및 LazyVerticalGrid(그리드)와 함께 Compose 1.0(2021)에 등장했습니다.
지연 로딩은 SubcomposeLayout 메커니즘을 통해 작동합니다: LazyColumn이 사용 가능한 공간을 측정하고, LayoutInfo에 표시 범위를 요청하며, 해당 범위의 항목만 컴포즈합니다. 화면 밖으로 스크롤된 항목은 컴포지션을 떠납니다(프리페치 버퍼 제외). Google(Android Performance, 2026)에 따르면 LazyColumn은 중급 기기에서 10000+ 항목 리스트를 스크롤할 때 60 FPS를 유지합니다.
Column과 세로 스크롤 — verticalScroll 수정자를 적용한 모든 항목을 위아래로 배열하는 일반적인 방식입니다. Column은 지연되지 않습니다: 보이지 않더라도 모든 하위 요소가 즉시 컴포즈됩니다. 200개 이상의 항목 리스트의 경우 Column은 시작 시 심각한 지연을 유발합니다. LazyColumn은 표시 항목 + 버퍼(기본적으로 앞/뒤 1화면)만 컴포즈하므로 긴 리스트에 필수적입니다.
items API는 LazyColumn의 확장 함수 세트로, 데이터 리스트와 각 항목에 대한 DSL 설명자를 받습니다. 주요 함수는 다음과 같습니다: 고정 개수의 items(count, key, itemContent), 리스트의 items(list, key, itemContent), 인덱스가 있는 itemsIndexed(list, key, itemContent). key 매개변수는 성능에 필수적입니다 — Compose에 안정적인 요소 식별자를 제공합니다.
// itemContent가 있는 기본 LazyColumn
@Composable
fun ArticleList(articles: List<Article>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header — 지연되지 않음, 항상 처음
item {
Text("최신 기사", style = MaterialTheme.typography.headlineMedium)
}
// id별 키가 있는 기사 리스트
items(articles, key = { it.id }) { article ->
ArticleCard(
title = article.title,
summary = article.summary,
onClick = { onArticleClick(article.id) }
)
}
// 로딩 표시기가 있는 Footer
item {
CircularProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
}
}
}
// 사용자 정의 리스트 항목
@Composable
fun ArticleCard(title: String, summary: String, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = summary, style = MaterialTheme.typography.bodyMedium)
}
}
}
item {} vs items(): 리스트에 항상 존재하는 단일 항목(헤더, 푸터, 구분선)에는 item {}을 사용합니다. 동적 데이터 리스트에는 items()를 사용합니다. items()는 Iterable 또는 Int와 각 요소에 대해 호출되는 DSL 블록을 받습니다. 하나의 LazyColumn에서 item과 items을 결합합니다: 호출 순서가 표시 순서를 결정합니다.
LazyListState는 리스트 상태를 저장하는 객체입니다: 현재 스크롤 위치(firstVisibleItemIndex, firstVisibleItemScrollOffset), 표시 항목(layoutInfo), 프로그래매틱 스크롤 메서드(scrollToItem, animateScrollToItem). LazyListState는 rememberLazyListState()를 통해 생성되고 state 매개변수를 통해 LazyColumn에 전달됩니다.
// 스크롤 위치 유지 LazyColumn
@Composable
fun ScrollingList(items: List<Item>) {
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(items, key = { it.id }) { item ->
ListItemView(item = item)
}
}
// "위로" 버튼이 10번째 요소 이후에 나타납니다
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 10 }
}
AnimatedVisibility(visible = showButton) {
FloatingActionButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
}
) {
Icon(Icons.Default.KeyboardArrowUp, "맨 위로")
}
}
}
}
// 표시 항목 정보 가져오기
@Composable
fun ListDebugInfo(listState: LazyListState) {
val visibleItems = listState.layoutInfo.visibleItemsInfo
val totalItems = listState.layoutInfo.totalItemsCount
val firstItem = visibleItems.firstOrNull()?.index
val lastItem = visibleItems.lastOrNull()?.index
Text("$totalItems 중 $firstItem–$lastItem 표시")
}
프로그래매틱 스크롤은 listState의 suspend 함수를 통해 수행됩니다: scrollToItem(index, scrollOffset) — 즉시 이동(애니메이션 없음), animateScrollToItem(index) — 애니메이션 포함. 코루틴에서 스크롤하려면 LaunchedEffect 또는 coroutineScope.launch를 사용합니다. 중요: 컴포지션 내(@Composable 내부)에서 suspend 함수를 호출하지 마세요 — 람다(onClick, LaunchedEffect)를 사용하세요.
LazyColumn과 RecyclerView는 동일한 문제를 해결합니다 — 긴 리스트의 효율적인 표시. 차이점은 아키텍처에 있습니다: RecyclerView는 ViewHolder와 Adapter(클래식 Android View)를 사용하고, LazyColumn은 XML이나 ViewHolder 없이 선언적 composable 함수를 사용합니다. 선택은 프로젝트의 기술 스택에 따라 다릅니다: Compose vs View 기반 UI.
| 매개변수 | LazyColumn(Compose) | RecyclerView(View) |
|---|---|---|
| 레이아웃 | Composable 함수(Kotlin DSL) | XML 파일 + ViewBinding |
| 어댑터 | items() / itemsIndexed() DSL | RecyclerView.Adapter + ViewHolder |
| 정렬/필터링 | Snapshot state + derivedStateOf | DiffUtil + AsyncListDiffer |
| 애니메이션 | AnimatedVisibility + Modifier.animateItem | DefaultItemAnimator |
| 다른 항목 유형 | item {}/items() + 유형별 when | getItemViewType + 여러 ViewHolder |
| 위치로 스크롤 | listState.animateScrollToItem() | layoutManager.scrollToPosition() |
| 프리페치 | 인프로세스(SubcomposeLayout 버퍼) | RecycledViewPool + GapWorker |
| 네트워크/페이지네이션 | collectAsLazyPagingItems() + Paging 3 | PagingDataAdapter + Paging 3 |
Google의 권장 사항(Android Developers, 2026): Jetpack Compose의 새 프로젝트에는 LazyColumn을 사용하세요. View 기반 프로젝트에는 RecyclerView를 사용하세요. LazyColumn에는 Compose가 필요하며 APK에 약 3–5MB가 추가됩니다 — 구형 기기 지원 시 고려하세요. 프로젝트가 이미 View 시스템을 사용하는 경우, LazyColumn은 AndroidView 없이 XML 레이아웃과 동일한 화면에 공존할 수 없습니다 — 이러한 경우 RecyclerView를 계속 사용하는 것이 더 쉽습니다.
LazyVerticalGrid는 고정 열 수의 그리드를 위한 LazyColumn 버전입니다. LazyHorizontalGrid는 고정 행의 가로 그리드입니다. 두 컴포넌트 모두 LazyColumn과 동일한 지연 로딩 원칙과 동일한 items API를 사용합니다. columns 매개변수는 GridCells.Fixed(N) 또는 GridCells.Adaptive(minSize)를 통해 열 수를 결정합니다.
// 적응형 열 그리드
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
contentDescription = photo.title,
modifier = Modifier.aspectRatio(1f)
)
}
}
}
// 가로 리스트(LazyRow)
@Composable
fun CategoryCarousel(categories: List<Category>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories, key = { it.id }) { category ->
FilterChip(
selected = category.isSelected,
onClick = { onCategoryClick(category.id) },
label = { Text(category.name) }
)
}
}
}
GridCells.Adaptive는 최소 셀 크기를 기반으로 자동으로 열 수를 계산합니다. 예를 들어 GridCells.Adaptive(128.dp)는 360dp 너비 화면에 2열, 600dp 태블릿에 4열을 배치합니다. GridCells.Fixed(2)는 항상 정확히 2열을 표시합니다. Adaptive는 다중 화면 적응에 선호됩니다 — 미디어 쿼리 없이 자동으로 화면 너비에 맞게 조정됩니다.
LazyColumn의 성능은 세 가지 요소에 따라 달라집니다: 키 안정성(key), 콘텐츠 유형(contentType), 프리페치 버퍼(beyondBounds). 기본적으로 LazyColumn은 앞으로 1화면, 뒤로 1화면을 버퍼링합니다. Google(Android Developers, 2026)은 다른 항목 유형이나 복잡한 컴포지션이 있는 리스트에 대해 이러한 매개변수를 구성할 것을 권장합니다.
| 매개변수 | 설명 | 권장 사항 |
|---|---|---|
| key | 안정적인 요소 식별자 | 항상 key = { it.id }를 지정하세요. key가 없으면 Compose는 인덱스를 사용하고 항목 재정렬 시 애니메이션이 깨집니다. |
| contentType | 풀 분리를 위한 콘텐츠 유형 | 다른 항목 유형(텍스트 + 이미지 + 광고)이 있는 리스트에 지정하세요. Compose는 동일한 contentType 내에서만 컴포지션을 재사용합니다. |
| beyondBounds | 프리페치 화면 수 | 무거운 이미지가 있는 리스트의 경우 2~3으로 늘리세요. 기본값은 1화면입니다. |
| Modifier.animateItem | 위치 변경 애니메이션 | 애니메이션 정렬 및 필터링에 사용하세요(Compose 1.7+). |
// contentType 및 prefetch로 최적화된 LazyColumn
@Composable
fun OptimizedFeed(items: List<FeedItem>) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 2 // 2화면 프리페치
) {
items(
items = items,
key = { it.id },
contentType = { it.type } // 유형별 분할
) { item ->
when (item) {
is FeedItem.Post -> PostView(item)
is FeedItem.Ad -> AdView(item)
is FeedItem.Suggested -> SuggestedView(item)
}
}
}
}
// listState를 통한 성능 측정
@Composable
fun ScrollPerformance(listState: LazyListState) {
val scrollInfo = listState.layoutInfo
val visibleCount = scrollInfo.visibleItemsInfo.size
val total = scrollInfo.totalItemsCount
// derivedStateOf — 값이 변경되지 않으면 재컴포즈하지 않음
val scrollProgress by remember {
derivedStateOf {
if (total > 0) visibleCount.toFloat() / total else 0f
}
}
LinearProgressIndicator(
progress = scrollProgress,
modifier = Modifier.fillMaxWidth().height(2.dp)
)
}
일반적인 실수: (1) key 누락 — 리스트가 변경될 때마다 요소가 재컴포즈됩니다. (2) 스냅샷 플로우 없이 mutableStateListOf 사용 — 변경 사항이 추적되지 않을 수 있습니다. (3) itemContent 내에서 suspend 함수 호출 — 컴포지션이 중단됩니다. (4) 혼합 유형에 contentType 없음 — Compose가 텍스트 게시물에 광고 컴포지션을 재사용하여 시각적 아티팩트가 발생합니다. IT Sectr에서는 세 가지 이상의 콘텐츠 유형이 있는 모든 리스트에 contentType을 추가합니다.
자주 묻는 질문
세 가지 매개변수를 확인하세요: (1) key — 안정적인 키가 없으면 Compose가 컴포지션을 재사용할 수 없습니다. (2) contentType — 다른 항목 유형의 리스트용. (3) beyondBoundsPageCount — 이미지의 경우 2로 늘리세요. itemContent 내의 무거운 계산은 데이터별 키와 함께 remember로 이동하세요. 단순 그래픽에는 Image 대신 Modifier.drawWithContent를 사용하세요.
항목 사이의 간격에는 LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp))를 사용하세요. 선이 있는 시각적 구분선의 경우 items 사이에 item { Divider() }를 추가하세요. 자동 구분선: items(items, key = { it.id }) { item -> ... }, 각 요소 사이에 item { HorizontalDivider() }를 삽입합니다. 이를 위해 LazyListScope.items(list, key) { /* 요소 */ }와 별도로 LazyListScope.item { Divider() }를 사용하세요.
LazyColumn은 기본적으로 무한 높이(fillMaxSize)를 가집니다. 고정 높이가 없는 Column 내부에서 LazyColumn은 크기를 결정할 수 없습니다. 해결책: (1) LazyColumn에 고정 높이 설정: Modifier.height(400.dp). (2) Column 내부에서 Modifier.weight(1f) 사용. (3) LazyColumn을 세로 스크롤 가능한 컨테이너 내에 중첩하지 마세요 — 헤더와 푸터에 item {}을 사용하여 LazyColumn을 루트 요소로 사용하세요.
LazyColumn은 State 변경에 자동으로 반응합니다. 데이터가 mutableStateListOf 또는 mutableStateOf에 저장된 경우 Compose는 변경된 요소만 재컴포즈합니다. ViewModel의 리스트에는 Flow와 함께 collectAsState()를 사용하세요. key로 리스트를 업데이트하면 Compose가 Modifier.animateItemPlacement()를 통해 변경 사항(추가, 제거, 재정렬)을 자동으로 애니메이션화합니다.
LazyColumn — 단일 열 리스트용(피드, 채팅, 댓글). LazyVerticalGrid — 그리드용(갤러리, 카탈로그, 아이콘). 항목이 휴대폰에서는 한 열, 태블릿에서는 두 열이어야 하는 경우 — GridCells.Adaptive와 함께 LazyVerticalGrid를 사용하세요. 혼합 그리드가 필요한 경우(한 섹션에서 다른 열 수) — LazyColumn을 사용하고 items 내에 가로 LazyRow 또는 중첩된 LazyVerticalGrid를 삽입하세요.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.