안드로이드에서 리스트에 수백 수천개의 아이템을 표시하기 위해서는 반드시 성능을 고려해야 합니다.
이때 JetPack Compose에서는 UI 버벅거림을 피하고 효율성을 개선하기 위해 필요에 따라 아이템을 동적으로 구성할 수 있게 해주는 Lazy List 라는 컴포넌트를 제공하고 있습니다.
Lazy List의 종류로는 크게 LazyColumn, LazyRow, LazyGrid가 있는데 하나씩 살펴봅시다!
📖 1. LazyColumn
LazyColumn은 화면에 보이는 요소들만 실시간으로 렌더링하는 세로 방향의 스크롤 리스트입니다.
전통적인 Android 개발에서의 RecyclerView와 유사한 역할을 하지만, 훨씬 직관적이고 적은 코드로 구현할 수 있다는 장점이 있습니다.
일반적인 Column은 리스트에 1,000개의 아이템이 있다면 화면에 보이든 안 보이든 1,000개를 한꺼번에 메모리에 올립니다. 이는 성능 저하와 앱 크래시의 원인이 됩니다.
반면 LazyColumn은 Lazy라는 이름에서 알 수 있듯이 현재 화면에 보이는 아이템만 랜더링 하고 화면 밖의 항목을 재활용하여 대규모 아이템을 효율적으로 렌더링 하도록 설계되었습니다. 덕분에 수만 개의 아이템이 있는 리스트도 부드럽게 보여줄 수 있습니다.
📖 1-1. LazyColumn 구조
@OptIn(ExperimentalFoundationApi::class) // for stickyHeader
@Composable
fun AdvancedLazyColumnExample() {
// 1. 샘플 데이터 (성씨별로 그룹화된 리스트)
val groupedData = mapOf(
"A" to listOf("Apple", "Avocade", "Almond"),
"B" to listOf("Banana", "Blueberry"),
"C" to listOf("Cherry", "Coconut")
)
LazyColumn(
// 2. contentPadding: 리스트 전체 테두리에 16dp 여백
contentPadding = PaddingValues(16.dp),
// 3. verticalArrangement: 아이템 간의 간격을 12dp로 설정
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
groupedData.forEach { (initial, names) ->
// 4. Sticky Header: 스크롤 시 상단에 고정되는 헤더
stickyHeader {
Text(
text = initial,
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
style = MaterialTheme.typography.h6
)
}
// 5. items: 리스트 내의 실제 데이터 항목들
items(names) { name ->
Card(
elevation = 4.dp,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = name,
modifier = Modifier.padding(24.dp)
)
}
}
}
}
}
- items(List): 데이터 리스트를 받아 각 항목을 순회하며 UI를 생성합니다.
- contentPadding: 리스트 전체 테두리에 여백을 줍니다. (스크롤 시 아이템이 잘리지 않게 함)
- verticalArrangement: 아이템 간의 간격(Arrangement.spacedBy(8.dp))을 조절합니다.
- Sticky Header: 특정 아이템을 상단에 고정시킬 수 있습니다.
📖 2. LazyRow
LazyColumn과 형제 관계인 컴포저블로, 화면에 보이는 요소들만 실시간으로 렌더링하는 가로(Horizontal) 방향의 스크롤 리스트입니다.
사용법은 LazyColumn과 거의 동일하지만, 축의 방향이 세로에서 가로로 바뀌었다고 이해하시면 됩니다.
LazyColumn 안에 여러 개의 LazyRow를 넣으면, 우리가 흔히 보는 앱(ex. 유튜브, 넷플릭스)의 메인 화면 같은 복합 레이아웃을 아주 쉽게 만들 수 있습니다.
📖 2-2. LazyRow 구성 요소
@Composable
fun FruitLazyRow() {
val fruits = listOf("Banana", "Blueberry", "Blackberry", "Cherry", "Coconut", "Cranberry")
LazyRow(
// 가로 간격 12dp
horizontalArrangement = Arrangement.spacedBy(12.dp),
// 상하 가운데 정렬
verticalAlignment = Alignment.CenterVertically,
// 시작과 끝에 16dp 여백
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth().height(100.dp)
) {
items(fruits) { fruit ->
FruitCard(name = fruit)
}
}
}
@Composable
fun FruitCard(name: String) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFFE3F2FD),
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(
text = name,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
style = MaterialTheme.typography.body1
)
}
}
- horizontalArrangement: 아이템 사이의 가로 간격을 조절합니다. (Arrangement.spacedBy(8.dp))
- verticalAlignment: 아이템들을 위, 아래, 혹은 가운데 중 어디에 정렬할지 결정합니다. (Alignment.CenterVertically)
- contentPadding: 리스트의 시작(왼쪽)과 끝(오른쪽)에 여백을 주어, 스크롤 시 아이템이 화면 끝에 딱 붙지 않게 합니다.
📖 3. LazyGrid
LazyColumn과 LazyRow가 한 방향으로만 아이템을 나열한다면, LazyGrid는 격자 모양(바둑판 모양)으로 아이템을 배치할 때 사용합니다. 갤러리 앱이나 쇼핑몰의 상품 목록 화면을 상상하시면 됩니다.
Jetpack Compose에서는 주로 LazyVerticalGrid(세로 스크롤)를 사용하며, 드물게 가로로 스크롤되는 LazyHorizontalGrid도 사용합니다.
📖 3-1. LazyGrid 구성 요소
@Composable
fun FruitGrid() {
val fruits = listOf("Banana", "Blueberry", "Blackberry", "Cherry", "Coconut", "Cranberry", "Cantaloupe")
LazyVerticalGrid(
// 1. 열 개수를 2개로 고정
columns = GridCells.Fixed(2),
// 2. 상하/좌우 간격 설정
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize()
) {
items(fruits) { fruit ->
Box(
modifier = Modifier
.aspectRatio(1f) // 정사각형 모양 유지
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFFFF9C4)),
contentAlignment = Alignment.Center
) {
Text(text = fruit, fontWeight = FontWeight.Bold)
}
}
}
}
- columns: 열을 어떻게 나눌지 결정하는 방식으로, 아래 두가지 방식을 제공합니다.
- Fixed: 열의 개수를 고정합니다. (예: 무조건 3줄)
- columns = GridCells.Fixed(3)
- Adaptive: 아이템의 최소 폭을 설정하면, 화면 크기에 맞춰 열 개수가 자동으로 조절됩니다. (태블릿이나 가로 모드 대응에 유리)
- columns = GridCells.Adaptive(minSize = 128.dp)
- Fixed: 열의 개수를 고정합니다. (예: 무조건 3줄)
- verticalArrangement: 행(Row) 사이의 간격
- horizontalArrangement: 열(Column) 사이의 간격
- Span: 특정 아이템만 혼자서 2칸 이상을 차지하게 만들 수 있습니다.
📖 키(Keys)를 사용한 성능 최적화
만약 위의 과일 리스트 예제에서 0번에 있던 Banana를 삭제한다고 가정해 봅시다.
원래 상태는 0번(Banana) > 1번(Cherry) > 2번(Apple) 순서입니다.
여기서 Banana를 삭제해봅시다. 이제 1번이었던 Cherry가 0번이 되고, 2번이었던 Apple이 1번이 됩니다.
이때 컴포즈는 "어? 0번 데이터가 Banana에서 Cherry로 바뀌었네? 0번 UI를 아예 새로 그려야겠다!"라고 판단하고, 이 과정에서 불필요한 Recomposition이 발생하게 되지요.
만약 0번 Banana에 체크박스를 체크해뒀다면, 그 '체크됨' 상태는 0번 자리와 연결되어 있는 값이기에 Banana는 사라졌음에도 그 자리에 새로 들어온 Cherry가 체크되어 있는 황당한 일이 벌어질 수 있습니다.
이때 아이템마다 고유한 키 값을 부여하면 상황은 달라집니다.
items(
items = fruitList,
key = { fruit -> fruit.id } // 각 과일의 고유 ID를 키로 지정
) { fruit ->
FruitItem(fruit)
}
리스트 재정렬 또는 아이템 삭제 시 Banana가 사라져도 컴포즈는 "0번이 바뀌었네?"가 아니라, "ID 101번(Banana)이 사라졌고, ID 102번(Cherry)은 위치만 위로 옮겨졌구나!"라고 정확히 이해합니다.
또한, 위치가 바뀐 아이템들은 Recomposition 없이 위치만 이동시키기 때문에 성능을 최적화 할 수 있고, 아이템의 순서가 바뀌더라도 내 데이터 상태가 유지됩니다.
⚠️ 주의사항: 키 값은 리스트 내에서 절대 중복되면 안 됩니다. 중복될 경우 앱이 크래시 날 수 있으니 보통 DB의 ID 값이나 고유한 문자열을 사용합니다.
참고
- Manifest Interview Android
'Android' 카테고리의 다른 글
| [Android] 안드로이드에서 예외(Exceptions) 추적하기 (0) | 2025.09.17 |
|---|---|
| [Android] 안드로이드의 Looper, Handler, HandlerThread 이해하기 (0) | 2025.09.17 |
| [Android] 런타임 권한(runtime permissions) 처리 (1) | 2025.09.14 |
| [Android] SparseArray에 대하여 (0) | 2025.09.14 |
| [Android] ActivityManager란? (0) | 2025.09.13 |
