[Android] Jetpack Compose 에서 Lazy 레이아웃 사용하기

easyone·2026년 5월 19일

UMC

목록 보기
7/7

UMC 안드로이드 8주차 미션 블로그 챌린지입니다.

Android - Jetpack Compose 에서 Lazy 레이아웃 사용하기

개념정리

RecyclerView vs Lazy 레이아웃의 차이

RecyclerView는 기존 View 방식

  • RecyclerView, Adapter, ViewHolder, 각 항목의 레이아웃 XML이 필요하다.
  • 즉 XML파일, 코틀린 파일 분리해서 관리해야 한다.

Lazy 레이아웃

  • LazyColumn, LazyRow로 전부 대체 가능
  • Compose에서는 화면에 보이는 영역에 보이는 아이템만 컴포즈하고 배치
  • 스크롤로 화면 밖을 벗어난 아이템은 컴포지션에서 제거됨
    - Lazy 레이아웃은 컴포즈의 컴포지션 단계를 제어함
    - 컴포지션(Composition) → 레이아웃(Layout) → 그리기(Drawing)
    - 일반 컬럼에서는 화면 밖이더라도 컴포즈됨
    - LazyColumn은 화면 밖에 있다면 컴포지션 트리에 존재하지 않음
    - 즉 스크롤할 때 화면 밖으로 나가면 컴포지션에서도 제거되며, onDispose 호출 + remember로 들고 있던 상태도 사라짐
    - 스크롤하면서 새로 화면에 보이게된 요소는 새로 컴포즈될 때 상태가 초기화됨
  • 즉 화면에 보이지 않는 아이템은 컴포지션 트리에서도 존재하지 않기 때문에, 메모리나 CPU 낭비가 없다.
  • Key를 통한 아이템 재배치
    - Key가 없다면 위치 기반으로 상태를 추적한다. 이렇게되면 아이템 순서가 바뀔 수 있기 때문에, id 기반으로 상태를 추적해서 순서가 변경된다면 key에 따라서 재배치되어 정확성이 보장됨

LazyColumn / LazyRow의 기본 사용법 숙지

LazyColumn

  • 아이템을 수직으로 배치해서 세로 스크롤을 지원
LazyColumn {
    item { Text("헤더") }
    items(5) { index -> Text("Item $index") }
    item { Text("푸터") }
}

LazyRow

  • 아이템 수평으로 배치해서 가로 스크롤 지원
LazyRow {
    items(photos) { photo -> PhotoCard(photo) }
}
  • 대부분의 Compose 레이아웃은 @Composable 콘텐츠 블록을 직접 받지만, Lazy 컴포넌트는 LazyListScope.() DSL 블록을 받음
  • 레이아웃과 스크롤 위치에 따라 필요한 아이템을 알아서 추가

옵션

Content Padding — 콘텐츠 가장자리 여백

  • LazyColumn 자체가 아닌 아이템들에 패딩이 적용됨
  • Modifier.padding()으로 직접 붙이면 컴포저블 자체에 여백이 생김
  • contentPadding은 스크롤 가능한 콘텐츠 영역에 여백 추가
LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { /* ... */ }

Content Spacing — 아이템 간격

  • Arrangement.spacedBy() 사용
  • 아이템 사이에 간격을 줌
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }

LazyListScope DSL의 다양한 함수 활용

  • 레이아웃 내에 아이템 관련 다양한 함수 제공

  • item(){} : 단일 아이템

  • item(count){} : 개수로 아이템 추가

  • item(list): 컬렉션으로 아이템 추가

  • itemIndexed(): 인덱스로 아이템 추가, 아이템의 순서가 필요할 때 사용

  • contentType: 성능 최적화, 다양한 타입 아이템이 혼재할 때 지정하면 컴포즈가 동일 타입끼리만 컴포즈 재사용해서 성능을 높일 수 있음

LazyVerticalGrid / LazyHorizontalGrid 그리드 레이아웃 이해

  • 아이템을 그리드 형태로 표시할 때 사용

GridCells.Adaptive — 최소 너비로 열 수 자동 결정

  • 화면 너비에 따라 열 수가 자동으로 결정됨

  • 다양한 화면 크기를 설정할 수 있음

    	```kotlin
    	LazyVerticalGrid(

    columns = GridCells.Adaptive(minSize = 128.dp)
    ) {
    items(photos) { photo -> PhotoItem(photo) }
    }

    	```

GridCells.Fixed — 고정 열 수

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item -> PhotoItem(item) }
}

GridItemSpan - 특정 아이템에 커스텀 span 적용

  • 특정 아이템이 여러 열을 차지하게 할 때 span 파라미터를 활용
  • 아이템이 몇 칸을 차지할지 span 파라미터로 지정
  • maxLineSpan은 현재 행의 전체 컬럼 수 -> 한 행을 통째로 점유 가능
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 30.dp)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        CategoryCard("Fruits")  // 전체 행을 차지
    }
    items(items) { item -> ItemCard(item) }
}

LazyVerticalStaggeredGrid

  • 각 아이템의 높이 또는 너비가 다를 수 있는 그리드
  • 핀터레스트처럼 불규칙한 높이를 가진 그리드를 생성
  • 벽돌을 쌓듯이 빈 공간을 채움
  • 짧은 컬럼에 다음 아이템을 채우는 방식

columns = StaggeredGridCells.Adaptive(200.dp)

  • 각 컬럼이 최소 200dp가 되도록 화면 너비에 따라 컬럼 수 자동 결정
  • 고정하고 싶으면 StaggeredGridCells.Fixed(2)처럼 개수 지정

verticalItemSpacing = 4.dp

  • 세로 방향 아이템 간 간격
  • 같은 컬럼 내 위/아래 아이템 사이 여백

horizontalArrangement = Arrangement.spacedBy(4.dp)

  • 가로 방향 컬럼 간 간격

아이템 블록

modifier = Modifier.fillMaxWidth().wrapContentHeight()
  • fillMaxWidth(): 컬럼 너비를 꽉 채움
  • wrapContentHeight(): 이미지 원본 비율에 맞춰 높이 결정, staggered 효과가 생김

아이템 Key, 애니메이션, Sticky Header 등 심화 기능 활용

아이템 Key

  • 각 아이템의 상태는 리스트에서 아이템의 위치를 키로 사용해서, 데이터가 변경되면 위치가 바뀐 아이템은 상태를 잃을 수 있음
  • 아이템에서 Key를 사용하면, 아이템 위치가 변경되더라도 컴포즈가 상태(remember한 값 등)를 아이템과 함께 이동시킴
LazyColumn {
    items(
        items = messages,
        key = { message -> message.id }  // key 사용
    ) { message ->
        MessageRow(message)
    }
}

아이템 변경 애니메이션

  • animateItem() modifier를 사용하면 아이템 추가·삭제·이동 시 애니메이션을 자동으로 적용할 수 있음, Key와 함게 사용해야 효과적
    	```kotlin
    	LazyColumn {
    	    items(messages, key = { it.id }) { message ->
    	        MessageRow(
    	            message,
    	            modifier = Modifier.animateItem()
    	        )
    	    }
    	}
    	```

Sticky Header — 고정 헤더

  • 그룹화된 데이터를 표시할 때 유용함
  • stickyHeader()를 사용

단일 헤더

@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader { Header() }
        items(items) { item -> ItemRow(item) }
    }
}

여러 헤더 (이름 첫 글자별 그룹)

  • groupBy: 리스트를 어떤 기준으로 묶어서 Map으로 만들어 줌
  • Key는 첫글자, value는 첫글자로 시작하는 연락처 목록 -> Map으로 선언
  • 그룹별로 선언하고, CharacterHeader로 해당 그룹의 헤더를 그림, 스크롤해도 상단에 붙어있게 할 수 있음
// 그룹핑은 ViewModel에서 처리하는 것을 권장
val grouped = contacts.groupBy { it.firstName[0] }

@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader { CharacterHeader(initial) }
            items(contactsForInitial) { contact -> ContactListItem(contact) }
        }
    }
}

주의 - 같은 방향 스크롤 중첩 금지

  • 크기가 정해지지 않은 LazyColumn을 세로 스크롤 Column 안에 중첩하면 IllegalStateException이 발생
  • 방향이 다른 경우 허용: 가로 스크롤 Row 안에 세로 LazyColumn 은 가능

주의 - 하나의 item 블록에 여러 요소 넣지 않기

  • 하나의 item { } 블록에 여러 컴포저블을 넣으면 하나의 단위로 취급되어 성능이 안좋아짐
  • Divider는 이전 아이템과 같은 블록에 넣어도 됨

스크롤 상태 제어 (LazyListState) 이해

  • LazyListState: LazyColumn, Row의 현재 상태를 담고 있는 객체 - 스크롤, 아이템
  • state 파라미터에 상태를 넘겨주면 , 상태를 통해 리스트를 조회하거나 조작할 수 있음
  • 리스트의 스크롤 상태를 읽거나 제어하고 싶을 때 사용
@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) { /* ... */ }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                listState.animateScrollToItem(index = 0)  // 부드럽게 상단으로
            }
        }
    )
}
API설명
firstVisibleItemIndex현재 화면에 보이는 첫 번째 아이템의 인덱스
firstVisibleItemScrollOffset첫 번째 아이템의 스크롤 오프셋
scrollToItem(index)즉시 해당 위치로 이동
animateScrollToItem(index)애니메이션과 함께 부드럽게 이동 (smooth scroll)
  • scrollToItem()animateScrollToItem() 모두 suspend 함수이므로 반드시 코루틴 내에서 호출해야 함
  • 코루틴으로 해야하는 이유: 부드럽게 스크롤을 하는 함수인데 시간이 걸리는 작업이라서, 코루틴을 시작하고 그 안에서 호출 가능
  • rememberCoroutineScope() : 해당 컴포저블이 살아있는 동안 사용하는 코루틴

derivedStateOf 최적화

  • firstVisibleItemIndex는 스크롤하면 계속 바뀌는 값인데, 컴포즈에서 State값이 바뀌면 그걸 읽는 컴포저블은 리컴포지션되어 다시 그러짐

  • 즉 인덱스가 바뀔 때마다 매번 화면을 다시 그림, 결과가 똑같으면 매번 다시 그릴 필요가 없음

  • derivedStateOf: 결과값을 감지해서, 변경이 된다고 하면 그때 리컴포지션이 일어남

  • 즉 스크롤할때마다 리컴포지션이 발생하지 않고, 값이 실제로 바뀔때만 된다~

  • 스크롤 이벤트는 매우 빈번하게 발생하므로, firstVisibleItemIndex를 읽을 때는 derivedStateOf로 감싸 결과값이 실제로 변경될 때만 Recomposition이 발생하도록 최적화해야 함

  • rememberLazyListState() — LazyListState를 컴포지션에 기억

  • rememberCoroutineScope() — 컴포저블 수명에 묶인 코루틴 스코프

  • derivedStateOf — 다른 State에서 파생된 State, 변화 감지 최적화

  • Arrangement — 아이템 배치 전략 (spacedBy, Center, SpaceBetween 등)

  • PaddingValues — contentPadding에 사용하는 패딩 값 래퍼


구현 내용

화면 구성

  • : 메인 배너 이미지 + What's new 가로 스크롤 상품 목록
  • 구매하기: 탭 바(전체/Tops&T-Shirts/sale) + 2열 상품 그리드, BestSeller 뱃지
  • 위시리스트: 좋아요한 상품만 필터링해서 표시, 비어있을 때 빈 상태 처리
  • 상품 상세: 이미지, 상품 정보, 뒤로가기

아키텍처

  • Route/Screen 분리Route에서 ViewModel 연결 및 상태 관리, Screen은 UI만 담당
  • Hilt: ViewModel 의존성 주입
  • UiState: 각 화면마다 sealed class로 상태 관리

네비게이션

  • @Serializable data object/class 기반 타입 안전 네비게이션
  • navigation<MainGraph>로 중첩 그래프 구성
  • 하단 탭 바: 현재 route 기반으로 선택 탭 자동 반영

기타

  • CoilAsyncImage로 네트워크 이미지 로딩
  • BackHandler: 홈 화면에서 2초 내 두 번 뒤로가기 시 앱 종료
  • Mock 데이터: 실제 API 연동 전 Mock 데이터로 UI 검증
  • Desgin System: 자주 사용하는 Color, text style을 디자인 시스템 패키지로 분리

트러블 슈팅

1 . Navigation Graph Scope 방식으로 변경해서 ViewModel 공유

문제

좋아요 뷰모델을 만들어서 여러 화면에서 사용하려고 했는데, 이러면 Main에 등록해야 하는데, 공식문서를 찾아보니까 좋은 방식이 아니었다.

  • MainScreen → AppNavHost → 각 Route로 계속 파라미터 전달해야 함
  • ViewModel 생명주기가 Activity 전체에 묶임 (앱 켜있는 내내 살아있음)
  • 공식 Android에서 권장하지 않는 방향..

해결

Navigation Graph Scope 방식으로 변경, hiltViewModel로 같은 NavGraph 안에서 동일한 인스턴스를 공유하도록 해서 좋아요 상태가 공유되도록 설정했다.

val parentEntry = remember(backStackEntry) {
    navController.getBackStackEntry<MainGraph>()
}
val favoriteViewModel: FavoriteViewModel = hiltViewModel(parentEntry)
  • 좋아요 클릭 -> FavoirteViewModel.toggleLike(id) 호출
  • Wish 화면에서 likedProductsIds를 받아오므로, 변경 감지해서 해당 상품 표시되도록
  • 같은 그래프 내부이므로 뷰모델이 같은 인스턴스라서 상태가 공유된다.
  • MainGraph를 벗어나면 ViewModel도 자동 소멸하는 방식이다.

2. ## 상세 페이지 진입 시 하단 탭 구매하기 유지

  • 상품 상세 페이지로 이동하면 하단 탭이 아무것도 선택되지 않은 상태가 되어버린다.
  • this?.hasRoute<PurchaseDetail>() == true -> BottomNavItem.PURCHASE
  • 이렇게 하면 상세보기의 경우에도 구매하기 탭 선택된 상태로 매핑된다.
this?.hasRoute<PurchaseDetail>() == true -> BottomNavItem.PURCHASE

3. AsyncImage 큰 사이즈 적용 안됨

Box 안에서 이미지를 사용하는 방식으로 구현했는데,
AsyncImage에서 직접 사이즈 지정했는데 적용이 안되었다. .
Box에서 사이즈 고정, 이미지에서 max로 채우도록 변경했다.

Box(  
    modifier = Modifier  
        .size(314.dp)  
        .background(Color(0xFFF5F5F5))  
        .aspectRatio(1f)  
) {  
    AsyncImage(  
        model = item.productImage,  
        contentDescription = item.name,  
        modifier = Modifier.fillMaxSize(),  
        contentScale = ContentScale.Crop  
    )  
}

4. TabBar 언더라인이 화면 전체를 채움 / Text기준으로 채워지지도 않음

문제

탭을 왼쪽 정렬하려고 weight(1f)를 제거했더니 선택된 탭의 언더라인이 화면 전체 너비를 채워버렸다 ...

원인

weight(1f) 제거 후에 Column에 너비 제약이 없어지면서 내부 fillMaxWidth()가 부모의 max constraint을 그대로 사용해버렸다.

해결

  • RowfillMaxWidth() + weight(1f) 제거 → 탭이 왼쪽부터 붙음, padding(start = 9.dp) 은 유지
  • ColumnModifier.width(IntrinsicSize.Max)로 Column을 텍스트 너비로 고정해 해결,
    horizontal 패딩은 Column에서 Text로 이동해 언더라인이 탭 전체 너비를 정확히 채우도록 수정함
profile
백엔드 개발자 지망 대학생

0개의 댓글