Develop

Jetpack Compose LazyColumn 성능 최적화 가이드 — 스크롤 버벅임 없애는 방법

Jetpack Compose LazyColumn에서 발생하는 스크롤 버벅임의 원인을 분석하고, key 파라미터, remember, 이미지 로딩 최적화로 성능을 개선하는 방법을 정리하였다.

★★★★★적극 추천
AndroidJetpackComposeLazyColumn성능최적화Kotlin
Jetpack Compose LazyColumn 성능 최적화 가이드 — 스크롤 버벅임 없애는 방법
  • ·LazyColumn은 RecyclerView를 대체하는 Compose의 지연 렌더링 리스트 컴포넌트
  • ·key 파라미터를 지정하지 않으면 아이템 순서 변경 시 전체 recomposition이 발생
  • ·remember 없이 람다를 직접 전달하면 스크롤마다 새 객체가 생성됨
  • ·Coil의 rememberAsyncImagePainter는 내부적으로 캐싱을 처리하지만 crossfade 설정이 성능에 영향을 줌
피드 앱을 개발할 때 LazyColumn 스크롤이 눈에 띄게 끊겼다. 처음에는 이미지 로딩 문제로 짐작했지만 프로파일러로 추적해보니 key 파라미터 누락으로 인한 불필요한 recomposition이 주범이었다. key를 아이템 id로 지정하자 스크롤 성능이 즉시 개선되었고, 이후 remember로 람다를 캐시하니 추가로 안정됐다.

LazyColumn 성능 저하의 주요 원인

LazyColumn key 파라미터 누락이 스크롤 성능에 미치는 영향

LazyColumn의 items 블록에서 key를 지정하지 않으면 Compose는 아이템을 인덱스 기준으로 추적한다. 리스트 상단에 새 아이템이 추가되거나 순서가 바뀔 때 인덱스가 변경되면 Compose는 해당 위치의 컴포저블을 처음부터 다시 그린다. 실제 피드 앱에서 실시간으로 새 글이 올라오는 상황을 시뮬레이션했더니 스크롤 중 짧은 프레임 드롭이 반복되었고 Android Studio 레이아웃 인스펙터로 확인하면 다수의 컴포저블이 동시에 recomposition 상태였다. items(posts, key = { it.id })처럼 아이템 고유 식별자를 key로 넘기면 Compose가 아이템 정체성을 올바르게 추적하여 실제로 변경된 아이템만 다시 그린다. 이 한 줄의 변경이 스크롤 버벅임을 가장 빠르게 해소하는 방법이며 리스트 성능 최적화의 첫 번째 체크포인트다.

LazyColumn {
    items(posts, key = { it.id }) { post ->
        PostCard(post = post)
    }
}

LazyColumn 내 람다 캐싱 — remember와 rememberUpdatedState 활용

Composable 함수는 recomposition이 발생할 때마다 함수 본문이 재실행된다. LazyColumn 아이템 내부에서 onClick = { viewModel.onLike(post.id) }처럼 람다를 직접 선언하면 매번 새 람다 인스턴스가 만들어지고, Compose는 파라미터가 달라졌다고 판단해 자식 컴포저블까지 재실행시킨다. remember { { viewModel.onLike(post.id) } } 형태로 캐시하면 동일 컴포지션 생명주기 내에서 동일 객체 참조가 유지되어 불필요한 recomposition을 방지한다. 단, post.id처럼 외부 변수를 캡처할 때는 해당 변수가 변경될 수 있으면 rememberUpdatedState로 감싸야 오래된 값을 참조하는 클로저 버그를 피할 수 있다. 실제 벤치마크에서 람다 캐싱 전후로 recomposition 횟수가 30~50% 줄어드는 결과를 확인했다.

LazyColumn 이미지 로딩 최적화

LazyColumn Coil 이미지 로딩 성능 설정

Coil의 AsyncImage는 편리하지만 기본 설정 그대로 LazyColumn에 사용하면 스크롤 성능이 저하될 수 있다. crossfade(true)는 페이드 애니메이션을 위한 추가 렌더링 패스를 발생시키므로 아이템이 많은 피드에서는 끄거나 200ms 이하로 제한하는 것이 좋다. 썸네일처럼 고정 크기 이미지는 size(width, height)로 디코딩 크기를 명시하면 메모리 사용량이 줄고 비트맵 할당 빈도가 낮아진다. 또한 ImageLoader를 싱글턴으로 구성해 앱 전체에서 공유하지 않으면 LazyColumn 스크롤 중 여러 ImageLoader 인스턴스가 동시에 생성돼 메모리 압박이 발생한다. 직접 피드 앱에서 ImageLoader 싱글턴 전환 후 메모리 사용량이 약 15% 감소하는 걸 확인했다.

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(post.thumbnailUrl)
        .size(320, 180)
        .crossfade(150)
        .build(),
    contentDescription = post.title
)

LazyColumn 성능 측정과 프로파일링

LazyColumn recomposition 횟수를 Android Studio로 측정하는 방법

최적화 작업은 측정 없이 진행하면 방향을 잃기 쉽다. Android Studio의 Layout Inspector에서 Show Recomposition Counts를 활성화하면 각 컴포저블 옆에 recomposition 횟수가 표시된다. LazyColumn을 빠르게 스크롤하면서 숫자가 급격히 올라가는 컴포저블을 찾는 것이 최적화의 시작점이다. Perfetto나 Android Profiler의 CPU 트레이스를 캡처하면 어느 컴포저블이 메인 스레드 시간을 얼마나 차지하는지 확인할 수 있다. 마지막으로 Macrobenchmark 라이브러리로 스크롤 프레임 시간을 자동 측정하는 벤치마크를 작성하면 최적화 전후 수치를 객관적으로 비교할 수 있어, 리뷰에서 설득력 있는 근거를 제시할 수 있다.

자주 묻는 질문

LazyColumn과 RecyclerView 중 성능이 더 좋은 것은 무엇인가요?+

최적화된 RecyclerView가 여전히 극단적인 케이스에서 미세하게 유리하지만, 일반적인 피드 앱에서는 LazyColumn도 충분한 성능을 냅니다. key와 remember를 올바르게 적용하면 체감 차이는 거의 없습니다.

LazyColumn에서 divider를 삽입하는 가장 효율적인 방법은 무엇인가요?+

HorizontalDivider를 아이템 컴포저블 내부에 넣기보다 itemsIndexed를 사용해 마지막 아이템이 아닐 때만 Divider를 렌더링하는 방식이 불필요한 컴포저블 생성을 줄입니다.

LazyColumn 스크롤 위치를 저장하고 복원하려면 어떻게 하나요?+

rememberLazyListState()로 상태를 보관하고 ViewModel이나 SavedStateHandle에 firstVisibleItemIndex와 firstVisibleItemScrollOffset을 저장하면 화면 복귀 후 위치를 복원할 수 있습니다.

관련 글