Compose Foreach Column


title: “How Compose Renders Repeated Composables (with Lazy keys & compiler metrics)” excerpt: “forEach와 Lazy*에서 반복되는 컴포저블이 실제로 어떻게 붙고(emit) 재사용되는지, Slot Table/Applier/Diff, 고급 key 전략, 그리고 Compose Compiler Metrics 정리.” categories: [“Android”, “Jetpack Compose”, “Kotlin”] tags: [“Compose”, “Recomposition”, “Slot Table”, “Composer”, “Applier”, “key”, “LazyColumn”, “Tracing”, “Performance”, “Compiler Metrics”] last_modified_at: 2025-11-17T18:40:00+09:00 —

Column + forEach의 내부 동작 요약

How Column + forEach behaves under the hood

Compose는 컴포저블 호출 = UI 노드 발행(emit) 모델을 사용합니다.
In Compose, calling a Composable = emitting a UI node.

forEachUnit을 반환하지만, 반복마다 컴포저블을 호출하고 부모 레이아웃(Column 등)에 자식 노드가 추가됩니다.
Although forEach returns Unit, each iteration calls a Composable which adds a child node to the parent.


빠른 리마인드: Composer / Slot Table / Applier

Quick reminder: Composer / Slot Table / Applier

  • Composer: 컴포지션을 지휘하고 그룹/키/위치/remember 메타데이터를 관리합니다.
    Orchestrates composition, manages groups/keys/positions/remember metadata.
  • Slot Table: “무엇이 어디 있었는지”의 청사진. 재구성 때 정확히 매칭/재사용을 가능하게 합니다.
    Blueprint of what was where; enables matching/reuse on recomposition.
  • Applier: 변경 목록을 실제 UI 트리(레이아웃 노드)에 삽입/이동/삭제로 적용합니다.
    Applies changes to the real UI tree via insert/move/remove.

의사 Slot Table 스냅샷

Pseudo Slot Table snapshot

실제 내부 포맷은 다르므로, 이해를 위한 개념적 스냅샷입니다.
Conceptual snapshot for understanding; actual internal format differs.

Pass #1 — data: [A, B, C]

Group 100: Column(key=Column)
  Group 110: content-lambda
    Group 201: SelectQuantityButton(key=A)  [remember: state@0xA]
    Group 202: SelectQuantityButton(key=B)  [remember: state@0xB]
    Group 203: SelectQuantityButton(key=C)  [remember: state@0xC]

Pass #2 — data: [B, A, C] (순서 변경 / reordered)

Group 100: Column(key=Column)
  Group 110: content-lambda
    Group 202: SelectQuantityButton(key=B)  // REUSE + MOVE
    Group 201: SelectQuantityButton(key=A)  // REUSE + MOVE
    Group 203: SelectQuantityButton(key=C)  // REUSE

키 기반 매칭으로 노드를 재생성하지 않고 이동/재사용합니다.
With key-based matching, nodes are moved/reused instead of recreated.


Diff 로그(의사 예시)

Pseudo diff log

Recomposition Start
  Match 202 (key=B): REUSE, MOVE 1 -> 0
  Match 201 (key=A): REUSE, MOVE 0 -> 1
  Match 203 (key=C): REUSE, STAY 2
Apply Changes
  Applier.move(B, 1 -> 0)
  Applier.move(A, 0 -> 1)
Recomposition End

성능 트레이싱 팁

Performance tracing tips

Layout Inspector — Composition 탭

  • 재구성 횟수트리 구조 확인. 상위에서 상태를 수집하면 하위 전체가 빈번히 갱신될 수 있습니다.
    Inspect recomposition counts & structure; collecting state high up can refresh a large subtree.

System Trace — 프레임 타임라인

  • Choreographer#doFrame, Recomposer, measure/layout/draw를 타임라인으로 파악.
    Visualize frame stages and where time is spent.
  • 코드에 trace 마커를 추가해 병목 구간을 구분.
    Add code trace markers to pinpoint hotspots.
import androidx.tracing.trace

fun expensiveWork() = trace("expensiveWork") {
    // heavy code here
}

🔸 Lazy 계열에서 key 전략 심화

Advanced key strategies for Lazy containers

Lazy 계열(LazyColumn, LazyRow, LazyVerticalGrid)은 지연 컴포지션 + 가상화가 핵심입니다. 키 전략은 노드 재사용과 스크롤 포지션, 애니메이션 안정성에 직접적 영향을 줍니다.
Lazy containers virtualize items; key strategy directly affects node reuse, scroll position, and animations stability.

1) 올바른 key 지정의 기본

Basics

LazyColumn {
    items(
        items = users,
        key = { user -> user.id } // ✅ stable, unique
    ) { user ->
        UserRow(user)
    }
}
  • 고유하고 변하지 않는 식별자(DB id, UUID 등)를 키로 사용.
    Use a unique, stable identifier (DB id, UUID).
  • 인덱스를 키로 쓰면 중간 삽입/삭제에서 모든 이후 아이템이 다른 항목으로 매칭되어 스크롤 위치, 애니메이션, remember 상태가 흔들립니다.
    Using index causes mis-match on mid insert/remove, breaking scroll/animations/remember.

2) items vs itemsIndexed — 언제 어떤 것을?

When to use items vs itemsIndexed

  • items(list, key=...)키만 필요할 때 간단합니다.
    Use items when you only need keys.
  • 인덱스가 필요하면 itemsIndexed(list, key=...)를 사용하되 반드시 key를 제공하세요.
    If you need index, use itemsIndexed but still provide a key.
itemsIndexed(
    items = messages,
    key = { _, msg -> msg.id } // ✅ provide stable key
) { index, msg ->
    MessageRow(index, msg)
}

3) 복합 키 전략 (fallback)

Composite keys (fallback)

데이터에 고유 id가 없다면, 안정 속성들의 조합으로 키를 구성하세요.
If no single id, compose a key from stable fields.

key = { item -> "${item.type}:${item.code}:${item.version}" }

주의: 자주 변하는 필드를 포함하면 키가 바뀌어 재생성이 발생합니다.
Beware: Including frequently changing fields makes keys unstable.

4) remember / rememberSaveable와의 상호작용

Interaction with remember / rememberSaveable

  • remember슬롯 위치+키에 저장됩니다. 키가 흔들리면 상태가 초기화됩니다.
    remember is stored at slot position + key; unstable keys reset state.
  • 스크롤 위치/입력값 보존이 필요하면 rememberSaveable을 쓰고, 안정 키를 유지하세요.
    For preserving scroll/input, use rememberSaveable with stable keys.

5) 애니메이션과 key — animateItemPlacement()

Animations and keys

items(items = todos, key = { it.id }) {
    TodoRow(
        todo = it,
        modifier = Modifier.animateItemPlacement()
    )
}

키가 바뀌면 애니메이션 연속성이 깨집니다. 항상 동일 item은 동일 key가 되도록 보장하세요.
Changing keys breaks animation continuity; same item must keep the same key.

6) Paging/Streaming 시나리오

Paging/Streaming scenarios

  • 페이지 경계가 바뀌어도 항목의 id는 유지되어야 합니다.
    Keep item id stable across page boundaries.
  • 서버가 아이템을 치환하는 API라면, 클라이언트에서 불변 id 생성(예: hash of immutable fields)을 고려하세요.
    If server replaces entries, consider client-side immutable ids (e.g., hash of stable fields).

7) 성능 팁

Performance tips

  • key를 제공하면 Compose가 MOVE로 처리할 수 있어 재조립/재측정 감소에 유리합니다.
    Keys enable MOVE instead of recreate; fewer remeasures/rebuilds.
  • 다만 항상 키를 계산하는 비용도 있으므로, 데이터가 진짜로 안정 id를 가지고 있을 때만 지정하세요.
    There’s a small cost; only specify keys when data truly has stable ids.

🔸 Compose Compiler Metrics 설정

Compose Compiler Metrics configuration

버전에 따라 설정 문법이 조금씩 다릅니다. 아래는 Kotlin DSL 예시를 두 가지 버전(Kotlin 1.9.x / Kotlin 2.x)로 제공합니다.
Syntax varies by versions; below are Kotlin DSL examples for Kotlin 1.9.x and Kotlin 2.x.

(A) Kotlin 1.9.x / Compose 1.5~1.6 계열 예시

Example for Kotlin 1.9.x

module-level build.gradle.kts

android {
    // ...
}

kotlin {
    sourceSets.all {
        languageSettings.optIn("androidx.compose.compiler.plugins.kotlin.ComposeCompilerApi")
    }
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    kotlinOptions {
        freeCompilerArgs += listOf(
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${"$"}{project.buildDir}/compose-reports",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${"$"}{project.buildDir}/compose-metrics",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:strongSkipping=true"
        )
    }
}
  • reportsDestination: 재구성 가능성/건너뛰기(skippability) 보고서.
    Where textual reports (restartable/skippable, etc.) are written.
  • metricsDestination: 함수/클래스 단위 메트릭(안정성/재구성 특성 등).
    Destination for metrics (stability, recomposition traits).
  • strongSkipping: 더 공격적인 스킵 최적화 시도. (프로젝트에 따라 영향 다름)
    Enables stronger skipping optimizations (project-dependent effect).

(B) Kotlin 2.x / Compose 1.7+ 계열 예시

Example for Kotlin 2.x

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

android {
    // ...
}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll(
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${"$"}{project.buildDir}/compose-reports",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${"$"}{project.buildDir}/compose-metrics",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:strongSkipping=true"
        )
        jvmTarget.set(JvmTarget.JVM_17)
    }
}

결과 파일 읽는 법

How to read outputs

  • build/compose-reports
    • composables.txt: 각 컴포저블의 restartable, skippable, readonly, isolated 등의 플래그와 호출 그래프.
      Flags per composable and call graphs.
  • build/compose-metrics
    • 안정성(inferred stability) 분석, 그룹/슬롯 관련 수치.
      Inferred stability, groups/slots related metrics.

리포트에서 skippable이 많을수록, 동일 입력에 대해 재구성 스킵 가능성이 큽니다.
More skippable generally means more recompositions can be skipped for equal inputs.

흔한 해석 팁

Practical reading tips

  • 특정 컴포저블이 restartable만 있고 skippable이 없다면, 불안정 파라미터캡처된 람다를 점검.
    If a composable is restartable but not skippable, check unstable params or captured lambdas.
  • @Stable 주석은 재구성 스킵을 보장하지 않습니다. 멤버 안정성/변경 추적이 실제로 성립해야 합니다.
    @Stable does not guarantee skipping; true stability semantics must hold.

Lazy vs Column — 언제 어떤 것을?

When to use Lazy vs Column

  • Column + forEach: 항목 수가 적고 스크롤이 없다면 간단하고 비용이 적음.
    For small, non-scrolling content, simple and cheap.
  • LazyColumn: 항목이 많거나 스크롤이 필요하면 지연 컴포지션/측정으로 효율적. 반드시 key 전략을 고려.
    For large/scrolling lists, efficient via virtualization; consider key strategy.

최종 체크리스트

Final checklist

  • 동적 목록엔 안정 키 제공 (items(list, key=)).
    Provide stable keys.
  • remember/rememberSaveable 상태는 키+위치에 종속. 키가 흔들리지 않게 설계.
    Remembered state depends on key+position; keep keys steady.
  • 과도한 재구성은 상태 수집 위치를 낮추고, metrics/reports로 확인.
    Reduce recompositions by collecting state lower; verify with metrics/reports.
  • 성능 분석은 Layout Inspector + System Trace + trace 마커로 삼각 측량.
    Triangulate performance with Layout Inspector + System Trace + trace markers.

업데이트: