최근 외부 기술 블로그를 살펴보다가, LazyColumn의 contentType 파라미터에 아이템마다 다른 값을 넘겼다가 스크롤할수록 메모리가 계속 늘어나는 문제를 겪었다는 사례(The One-Liner That Was Eating Our Memory)를 접하게 되었다. 한 줄짜리 코드가 메모리 누수로 이어진다는 점이 흥미로워서, 필자도 직접 ComposeSample 프로젝트에 재현 예제를 만들어보며 원인을 파헤쳐보았다.
이번 글에서는 LazyColumn의 재사용 풀(reuse pool)이 어떻게 동작하는지, 그리고 contentType을 잘못 지정했을 때 왜 메모리가 회수되지 않는지 실측 코드와 함께 정리해보고자 한다.
LazyColumn은 화면 밖 아이템을 어떻게 처리할까
LazyColumn은 화면 밖으로 나간 아이템의 컴포지션(슬롯)을 바로 버리지 않는다. 대신 재사용 풀(reuse pool)에 잠시 보관해뒀다가, 같은 종류의 새 아이템이 나타나면 그 슬롯을 그대로 돌려 쓴다. 매번 새로 컴포즈하는 것보다 훨씬 저렴하기 때문이다.
이때 "어떤 슬롯을 어떤 아이템에 돌려 써도 되는가"를 가르는 기준이 바로 contentType이다. 공식 문서에서도 이종 아이템이 섞인 리스트에서는 contentType을 지정해 재사용 효율을 높이라고 안내한다.
items(
items = rows,
key = { it.id }, // 식별자: 아이템마다 달라야 한다
contentType = { it.type } // 분류: 레이아웃 종류만큼만 있어야 한다
) { row ->
RowContent(row)
}
문제는 필자도 이번에 알게 된 사실인데, 이 재사용 풀의 정리 규칙이 타입별이라는 점이다. Compose는 contentType 하나당 슬롯을 최대 몇 개까지만 유지하고 초과분을 버리는데(foundation 1.11.1 기준 내부 구현으로 7개), 전체 슬롯 수에는 상한이 없다. 그래서 contentType에 아이템마다 다른 값을 넘기면 버킷이 아이템 수만큼 생기고, 각 버킷에는 1개씩만 들어있어 정리 조건에 영원히 도달하지 못한다.
재현 예제 구성
말로만 들으면 감이 잘 안 와서, contentType에 넘기는 값을 세 가지 모드로 바꿔가며 실측할 수 있는 예제를 만들었다.
private enum class ReuseContentTypeMode(
val label: String,
val bucketCount: Int,
) {
NONE(label = "지정 안 함", bucketCount = 1), // 전부 null → 버킷 1개
BY_CLASS(label = "클래스 단위", bucketCount = 2), // Node/Leaf → 버킷 2개 (권장)
BY_ITEM(label = "아이템 고유값", bucketCount = 200), // 아이템마다 다름 → 버킷 200개 (함정)
;
fun contentTypeOf(item: ReuseItem): Any? = when (this) {
NONE -> null
BY_CLASS -> item::class
BY_ITEM -> item.label
}
}
실제로 관측되는 리스트는 이렇게 구성했다. 핵심은 contentType 딱 한 줄이다.
LazyColumn(state = listState) {
items(
items = items,
key = { item -> item.id },
// 이 한 줄이 이 예제의 전부다 — 여기에 무엇을 넘기느냐로
// 재사용 풀의 크기가 결정된다
contentType = { item -> mode.contentTypeOf(item) }
) { item ->
ReuseItemRow(item = item, tracker = tracker)
}
}
각 아이템 컴포지션은 64KB짜리 더미 데이터를 remember로 들고 있게 해서, 슬롯이 재사용 풀에 얼마나 남아있는지를 메모리 크기로 체감할 수 있게 했다.
val payload = remember(item.id) {
ReusePayload(label = item.label, tint = tint).also(tracker::register)
}
살아남은 객체 수를 어떻게 셀까
처음에는 단순히 "생성된 개수"만 세면 될 줄 알았는데, 그건 재사용 풀에 남아있든 이미 회수됐든 상관없이 계속 늘어나기만 하는 숫자라 의미가 없었다. 실제로 궁금한 건 "지금 이 순간 GC가 회수하지 못하고 있는 객체가 몇 개인가"였다.
그래서 WeakReference로 아이템을 추적하고, GC를 유도한 뒤 아직 살아있는(=회수되지 않은) 참조 수를 세는 방식을 사용했다.
private class ReuseTracker {
private val refs = mutableListOf<WeakReference<ReusePayload>>()
fun register(payload: ReusePayload) {
refs += WeakReference(payload)
}
suspend fun measure() {
// System.gc()는 "수집해달라"는 힌트일 뿐 즉시 실행을 보장하지 않는다.
// 그래서 짧은 지연을 두고 여러 번 호출한다.
repeat(3) {
System.gc()
System.runFinalization()
delay(120)
}
refs.removeAll { it.get() == null }
// refs.size 가 곧 "아직 회수되지 않은 페이로드 수"
}
}
여기서 한 가지 주의할 점이 있다. 이 추적기 자신이 아이템을 강하게 들고 있으면 측정 자체가 누수가 되어버린다. WeakReference로만 들고 있어야 "재사용 풀 때문에 살아남은 것"만 정확히 셀 수 있다.
실측 결과
아이템 200개, 리스트를 끝까지 스크롤한 뒤 GC를 유도하고 측정하면 세 모드의 차이가 뚜렷하게 나타난다.
- 지정 안 함(null): 버킷 1개, 보유 상한 7개 → 스크롤을 아무리 많이 해도 살아남는 페이로드는 소수에 그친다.
- 클래스 단위: 버킷 2개(Node/Leaf), 보유 상한 14개 → 마찬가지로 안전한 범위.
- 아이템 고유값: 버킷이 아이템 수만큼(최대 200개) 생겨서, 스크롤로 지나친 아이템의 페이로드가 거의 그대로 살아남는다.
- 아이템 고유값: 버킷이 아이템 수만큼(최대 200개) 생겨서, 스크롤로 지나친 아이템의 페이로드가 거의 그대로 살아남는다.
세 번째 모드에서 남아있는 슬롯은 단순한 빈 껍데기가 아니다. 그 컴포지션이 remember로 들고 있던 값과, drawBehind 같은 modifier 람다가 캡처한 객체까지 도달 가능한 상태로 함께 붙들려 있다. 아이템 하나가 비트맵이나 파싱 결과처럼 무거운 데이터를 참조하고 있었다면, 그 손해가 그대로 누적되는 셈이다.
더 성가신 건 이 유형의 문제가 크래시로 드러나지 않는다는 점이다. 스크롤을 많이 하는 화면에서 서서히 힙이 오르는 형태라, 프로파일러로 힙 덤프를 직접 떠보지 않으면 알아채기 어렵다.
key와 contentType, 헷갈리지 말자
둘 다 items()에 넘기는 람다라 비슷해 보이지만 역할이 정반대다.
// ❌ 아이템마다 고유한 값 → 버킷이 아이템 수만큼 생김
items(
items = rows,
key = { it.id },
contentType = { it.data }
) { ... }
// ✅ 실제 레이아웃 종류만큼만
items(
items = rows,
key = { it.id },
contentType = { it.data?.let { data -> data::class } }
) { ... }
key는 "이 아이템이 그 아이템인가"를 가리는 식별자라서 아이템마다 달라야 하고, contentType은 "이 슬롯을 저 아이템에 돌려 써도 되는가"를 가리는 분류라서 종류 수만큼만 있어야 한다. key를 쓰던 감각 그대로 contentType에 아이템 고유값을 넣는 순간 이번 글에서 다룬 함정에 빠지게 된다.
판별 기준은 간단하게 정리할 수 있을 것 같다. contentType으로 넘길 값의 가짓수가 데이터 양에 비례해서 늘어난다면 잘못 쓰고 있다고 보면 된다.
이번에는 LazyColumn의 contentType이 메모리에 미치는 영향에 대하여 작성해 보았다. 평소에 별생각 없이 넘기던 파라미터 하나가 이런 구조로 동작하고 있었다는 걸 알고 나니, 다른 Compose 내부 동작들도 한 번씩 파헤쳐볼 필요가 있겠다는 생각이 든다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com