아티클 목록으로 가기

LazyColumn의 내부 동작 원리

skydovesJaewoong Eum (skydoves)||9분 소요

LazyColumn의 내부 동작 원리

LazyColumn은 화면에 보이는 아이템만 렌더링합니다. 모든 자식을 미리 합성하는 일반 Column과 달리, LazyColumn은 합성을 레이아웃 단계까지 지연시키고 아이템이 스크롤되어 화면에 진입할 때 비로소 합성하며, 화면을 벗어나면 해제합니다. 이러한 지연 합성(lazy composition) 동작 덕분에 수천 개의 아이템을 가진 리스트도 메모리 부족이나 프레임 드롭 없이 표시할 수 있습니다. 그런데 런타임이 이를 달성하는 방식은 독특합니다. Compose의 일반적인 단계 순서를 깨뜨리고, 합성 단계가 아닌 측정 단계(measurement phase)에서 새로운 콘텐츠를 합성하기 때문입니다.

이 글에서는 LazyColumn에서 LazyLayout을 거쳐 SubcomposeLayout에 이르는 전체 렌더링 파이프라인을 살펴봅니다. subcompose()를 통해 측정 도중 아이템을 온디맨드로 합성하는 메커니즘, 어떤 아이템이 보이는지 결정하고 배치하는 측정 알고리즘, 콘텐츠 타입별로 최대 7개의 슬롯을 보존하여 재사용하는 아이템 재활용 시스템, 아이템이 화면에 보이기 전에 미리 합성하는 프리페치(prefetch) 메커니즘, 그리고 데이터 변경에도 스크롤 위치를 유지하기 위한 키 기반 추적 방식까지 모두 다루겠습니다.

근본적인 문제: 아직 보이지 않는 콘텐츠를 합성해야 하는 딜레마

일반적인 Column에서는 모든 자식 컴포저블이 합성 단계에서 합성됩니다. 측정이 시작되기 전에 이미 완료되는 것입니다. 만약 10,000개의 아이템이 들어 있다면, 한 번에 10~20개만 화면에 보이더라도 10,000개 전부를 합성하고 측정하여 메모리에 저장하게 됩니다. 실제 대규모 리스트에서 이 방식은 성능적으로 치명적입니다.

LazyColumn은 일반적인 단계 순서를 뒤집어 이 문제를 해결합니다. 모든 아이템을 미리 합성하는 대신, 뷰포트(viewport) 크기와 스크롤 위치를 알게 된 측정 단계 이후에 아이템을 합성합니다. 이렇게 하면 런타임이 어떤 아이템이 보이는지 계산한 뒤 해당 아이템만 합성할 수 있습니다. 뷰포트 밖의 아이템은 아예 합성되지 않습니다.

이러한 순서 역전은 SubcomposeLayout 덕분에 가능합니다. SubcomposeLayout은 측정 블록 내부에서 subcompose() 함수를 제공하는 특별한 레이아웃 컴포저블입니다. 측정 중에 subcompose()를 호출하면 제공된 콘텐츠의 합성이 즉시 트리거되며, 같은 패스 안에서 측정하고 배치할 수 있는 Measurable 객체들이 반환됩니다. Compose의 모든 지연 레이아웃(lazy layout)은 이 기반 위에 구축되어 있습니다.

계층 구조

LazyColumn은 네 개의 레이어로 구성되며, 각 레이어가 고유한 역할을 담당합니다.

  1. LazyColumn/LazyRow (공개 API): LazyListScope DSL을 받아 item(), items(), stickyHeader() 함수를 제공합니다.
  2. LazyList (내부): 아이템 프로바이더와 측정 정책을 생성하고, 시맨틱스, 스크롤 처리, 애니메이션을 위한 Modifier를 적용합니다.
  3. LazyLayout (foundation): 콘텐츠 팩토리, 서브컴포즈 레이아웃 상태, 프리페치 인프라를 설정합니다.
  4. SubcomposeLayout (ui): 측정 단계에서의 합성을 가능하게 합니다.

LazyListLazyLayout을 호출하는 방식을 살펴보겠습니다.

@Composable
internal fun LazyList(
    state: LazyListState,
    content: LazyListScope.() -> Unit,
    // ... other params
) {
    val itemProviderLambda = rememberLazyListItemProviderLambda(state, content)
    val measurePolicy = rememberLazyListMeasurePolicy(/* ... */)

    LazyLayout(
        modifier = modifier
            .then(state.remeasurementModifier)
            .then(state.awaitLayoutModifier)
            .lazyLayoutSemantics(/* ... */)
            .then(beyondBoundsModifier)
            .lazyLayoutItemAnimator(state.itemAnimator)
            .scrollableArea(/* ... */),
        prefetchState = state.prefetchState,
        measurePolicy = measurePolicy,
        itemProvider = itemProviderLambda,
    )
}

LazyLayoutSubcomposeLayout을 지연 인프라로 감싸는 역할을 합니다.

@Composable
fun LazyLayout(
    itemProvider: () -> LazyLayoutItemProvider,
    modifier: Modifier = Modifier,
    prefetchState: LazyLayoutPrefetchState? = null,
    measurePolicy: LazyLayoutMeasurePolicy,
) {
    val currentItemProvider = rememberUpdatedState(itemProvider)
    LazySaveableStateHolderProvider { saveableStateHolder ->
        val itemContentFactory = remember {
            LazyLayoutItemContentFactory(saveableStateHolder) {
                currentItemProvider.value()
            }
        }
        val subcomposeLayoutState = remember {
            SubcomposeLayoutState(LazyLayoutItemReusePolicy(itemContentFactory))
        }

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기