LazyColumn — Compose에서 효율적인 리스트란

저자: IT Sectr 게시일: 2026-02-24 읽는 시간: 10 분

LazyColumn — 지연 로딩으로 스크롤 가능한 리스트를 표시하는 Jetpack Compose 컴포넌트입니다. RecyclerView와 달리 LazyColumn은 ViewHolder를 만들지 않고 XML을 사용하지 않습니다 — 모든 요소는 composable 함수를 통해 선언적으로 설명됩니다. LazyColumn이 무엇인지, LazyListState와 items API가 어떻게 작동하는지, 60 FPS 성능을 달성하는 방법을 보여줍니다. IT Sectr에서는 모든 새로운 Compose 프로젝트에서 LazyColumn을 사용합니다. 클래식 접근 방식과의 비교를 위해 RecyclerView에 관한 글을 읽어보세요.

주요 내용

  • LazyColumn — 지연 로딩이 있는 세로 리스트를 위한 Jetpack Compose 컴포넌트, Material Design 3의 일부.
  • LazyListState — 리스트 상태: 스크롤 위치, 표시 요소, 프로그래매틱 스크롤.
  • items API — 데이터 바인딩을 위한 키가 있는 items(), itemsIndexed(), items() 함수.
  • key — 요소의 안정적인 식별을 위한 매개변수, Compose가 컴포지션을 재사용할 수 있게 합니다.
  • LazyVerticalGrid — 고정 열 수의 그리드를 위한 LazyColumn 버전.

Jetpack Compose에서 LazyColumn이란?

LazyColumn은 Foundation Compose 라이브러리의 composable 함수로, 지연 로딩으로 항목의 세로 리스트를 표시합니다: 화면에 보이거나 가까운 항목(프리페치 영역)만 생성되고 컴포즈됩니다. LazyColumn은 LazyRow(가로 리스트) 및 LazyVerticalGrid(그리드)와 함께 Compose 1.0(2021)에 등장했습니다.

지연 로딩SubcomposeLayout 메커니즘을 통해 작동합니다: LazyColumn이 사용 가능한 공간을 측정하고, LayoutInfo에 표시 범위를 요청하며, 해당 범위의 항목만 컴포즈합니다. 화면 밖으로 스크롤된 항목은 컴포지션을 떠납니다(프리페치 버퍼 제외). Google(Android Performance, 2026)에 따르면 LazyColumn은 중급 기기에서 10000+ 항목 리스트를 스크롤할 때 60 FPS를 유지합니다.

Column + ScrollState와의 차이점

Column과 세로 스크롤 — verticalScroll 수정자를 적용한 모든 항목을 위아래로 배열하는 일반적인 방식입니다. Column은 지연되지 않습니다: 보이지 않더라도 모든 하위 요소가 즉시 컴포즈됩니다. 200개 이상의 항목 리스트의 경우 Column은 시작 시 심각한 지연을 유발합니다. LazyColumn은 표시 항목 + 버퍼(기본적으로 앞/뒤 1화면)만 컴포즈하므로 긴 리스트에 필수적입니다.

items API: 리스트에 데이터 바인딩

items API는 LazyColumn의 확장 함수 세트로, 데이터 리스트와 각 항목에 대한 DSL 설명자를 받습니다. 주요 함수는 다음과 같습니다: 고정 개수의 items(count, key, itemContent), 리스트의 items(list, key, itemContent), 인덱스가 있는 itemsIndexed(list, key, itemContent). key 매개변수는 성능에 필수적입니다 — Compose에 안정적인 요소 식별자를 제공합니다.

kotlin
// 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: 스크롤 위치 관리

LazyListState는 리스트 상태를 저장하는 객체입니다: 현재 스크롤 위치(firstVisibleItemIndex, firstVisibleItemScrollOffset), 표시 항목(layoutInfo), 프로그래매틱 스크롤 메서드(scrollToItem, animateScrollToItem). LazyListState는 rememberLazyListState()를 통해 생성되고 state 매개변수를 통해 LazyColumn에 전달됩니다.

kotlin
// 스크롤 위치 유지 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 vs RecyclerView: 접근 방식 비교

LazyColumnRecyclerView는 동일한 문제를 해결합니다 — 긴 리스트의 효율적인 표시. 차이점은 아키텍처에 있습니다: RecyclerView는 ViewHolder와 Adapter(클래식 Android View)를 사용하고, LazyColumn은 XML이나 ViewHolder 없이 선언적 composable 함수를 사용합니다. 선택은 프로젝트의 기술 스택에 따라 다릅니다: Compose vs View 기반 UI.

매개변수LazyColumn(Compose)RecyclerView(View)
레이아웃Composable 함수(Kotlin DSL)XML 파일 + ViewBinding
어댑터items() / itemsIndexed() DSLRecyclerView.Adapter + ViewHolder
정렬/필터링Snapshot state + derivedStateOfDiffUtil + AsyncListDiffer
애니메이션AnimatedVisibility + Modifier.animateItemDefaultItemAnimator
다른 항목 유형item {}/items() + 유형별 whengetItemViewType + 여러 ViewHolder
위치로 스크롤listState.animateScrollToItem()layoutManager.scrollToPosition()
프리페치인프로세스(SubcomposeLayout 버퍼)RecycledViewPool + GapWorker
네트워크/페이지네이션collectAsLazyPagingItems() + Paging 3PagingDataAdapter + Paging 3

Google의 권장 사항(Android Developers, 2026): Jetpack Compose의 새 프로젝트에는 LazyColumn을 사용하세요. View 기반 프로젝트에는 RecyclerView를 사용하세요. LazyColumn에는 Compose가 필요하며 APK에 약 3–5MB가 추가됩니다 — 구형 기기 지원 시 고려하세요. 프로젝트가 이미 View 시스템을 사용하는 경우, LazyColumn은 AndroidView 없이 XML 레이아웃과 동일한 화면에 공존할 수 없습니다 — 이러한 경우 RecyclerView를 계속 사용하는 것이 더 쉽습니다.

LazyVerticalGrid 및 LazyHorizontalGrid: 그리드

LazyVerticalGrid는 고정 열 수의 그리드를 위한 LazyColumn 버전입니다. LazyHorizontalGrid는 고정 행의 가로 그리드입니다. 두 컴포넌트 모두 LazyColumn과 동일한 지연 로딩 원칙과 동일한 items API를 사용합니다. columns 매개변수는 GridCells.Fixed(N) 또는 GridCells.Adaptive(minSize)를 통해 열 수를 결정합니다.

kotlin
// 적응형 열 그리드
@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, prefetch

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+).
kotlin
// 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을 추가합니다.

자주 묻는 질문

LazyColumn 스크롤이 느립니다 — 어떻게 해야 하나요?

세 가지 매개변수를 확인하세요: (1) key — 안정적인 키가 없으면 Compose가 컴포지션을 재사용할 수 없습니다. (2) contentType — 다른 항목 유형의 리스트용. (3) beyondBoundsPageCount — 이미지의 경우 2로 늘리세요. itemContent 내의 무거운 계산은 데이터별 키와 함께 remember로 이동하세요. 단순 그래픽에는 Image 대신 Modifier.drawWithContent를 사용하세요.

LazyColumn 항목 사이에 구분선을 추가하려면 어떻게 하나요?

항목 사이의 간격에는 LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp))를 사용하세요. 선이 있는 시각적 구분선의 경우 items 사이에 item { Divider() }를 추가하세요. 자동 구분선: items(items, key = { it.id }) { item -> ... }, 각 요소 사이에 item { HorizontalDivider() }를 삽입합니다. 이를 위해 LazyListScope.items(list, key) { /* 요소 */ }와 별도로 LazyListScope.item { Divider() }를 사용하세요.

Column 내부의 LazyColumn이 작동하지 않습니다 — 이유가 무엇인가요?

LazyColumn은 기본적으로 무한 높이(fillMaxSize)를 가집니다. 고정 높이가 없는 Column 내부에서 LazyColumn은 크기를 결정할 수 없습니다. 해결책: (1) LazyColumn에 고정 높이 설정: Modifier.height(400.dp). (2) Column 내부에서 Modifier.weight(1f) 사용. (3) LazyColumn을 세로 스크롤 가능한 컨테이너 내에 중첩하지 마세요 — 헤더와 푸터에 item {}을 사용하여 LazyColumn을 루트 요소로 사용하세요.

데이터가 변경될 때 LazyColumn을 업데이트하려면 어떻게 하나요?

LazyColumn은 State 변경에 자동으로 반응합니다. 데이터가 mutableStateListOf 또는 mutableStateOf에 저장된 경우 Compose는 변경된 요소만 재컴포즈합니다. ViewModel의 리스트에는 Flow와 함께 collectAsState()를 사용하세요. key로 리스트를 업데이트하면 Compose가 Modifier.animateItemPlacement()를 통해 변경 사항(추가, 제거, 재정렬)을 자동으로 애니메이션화합니다.

LazyColumn vs LazyVerticalGrid — 무엇을 선택해야 하나요?

LazyColumn — 단일 열 리스트용(피드, 채팅, 댓글). LazyVerticalGrid — 그리드용(갤러리, 카탈로그, 아이콘). 항목이 휴대폰에서는 한 열, 태블릿에서는 두 열이어야 하는 경우 — GridCells.Adaptive와 함께 LazyVerticalGrid를 사용하세요. 혼합 그리드가 필요한 경우(한 섹션에서 다른 열 수) — LazyColumn을 사용하고 items 내에 가로 LazyRow 또는 중첩된 LazyVerticalGrid를 삽입하세요.

요약

  • LazyColumn — 선언적 API를 갖춘 지연 세로 리스트를 위한 Jetpack Compose 컴포넌트.
  • items() API는 리스트와 각 항목의 DSL 블록을 받습니다. 성능을 위해 key가 필수입니다.
  • LazyListState는 스크롤 위치를 관리하고 프로그래매틱 스크롤을 위한 suspend 함수를 제공합니다.
  • contentType은 다른 항목 유형의 리스트를 위한 컴포지션 풀을 분리합니다.
  • LazyVerticalGrid / LazyRow — 동일한 items API를 가진 그리드 및 가로 리스트 변형.
  • LazyColumn은 SubcomposeLayout 및 프리페치 버퍼 덕분에 10000+ 항목에서 60 FPS를 유지합니다.
  • 새로운 Compose 프로젝트에는 LazyColumn을 사용하고 View 기반 프로젝트에는 RecyclerView를 사용하세요.

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기