Android 개발자라면 RecyclerView를 수없이 사용해봤을 것이다. 하지만 내부적으로 어떻게 View를 재활용하는지, 캐시 시스템은 어떤 구조인지 깊이 이해하고 있는 개발자는 많지 않다. 이 글에서는 RecyclerView의 핵심 메커니즘부터 성능 최적화 기법까지 상세히 다뤄보고자 한다.
RecyclerView는 이름 그대로 View를 재활용(Recycle)하는 것이 핵심이다. 대량의 데이터를 스크롤할 때 매번 새로운 View를 생성하는 대신, 화면에서 벗어난 View를 재사용하여 메모리와 성능을 최적화한다.
RecyclerView는 크게 네 가지 핵심 컴포넌트로 구성된다.
Recycler 클래스는 RecyclerView 성능의 핵심이다. 다단계 캐시 시스템을 통해 ViewHolder를 효율적으로 관리한다.
1단계: Scrap (mAttachedScrap / mChangedScrap)
현재 화면에 attach되어 있지만 레이아웃 과정에서 임시로 분리된 ViewHolder들이 저장되는 곳이다. notifyItemChanged() 호출 시에는 mChangedScrap에, 그 외의 경우에는 mAttachedScrap에 저장된다. 이 캐시에서 가져온 ViewHolder는 rebind 없이 즉시 재사용이 가능하다.
2단계: Cache (mCachedViews)
화면 밖으로 스크롤된 ViewHolder를 position 기반으로 저장하는 캐시이다. 기본 크기는 2개이며, 사용자가 스크롤 방향을 바꿀 때 빠른 복원을 위해 존재한다. 여기서 가져온 ViewHolder 역시 rebind가 필요 없다.
3단계: ViewCacheExtension
개발자가 커스텀 캐싱 로직을 구현할 수 있는 선택적 레이어이다. 특수한 캐싱 요구사항이 있을 때 활용할 수 있다.
4단계: RecycledViewPool
ViewType별로 ViewHolder를 저장하는 객체 풀이다. 기본적으로 ViewType당 5개까지 저장하며, 여기서 가져온 ViewHolder는 반드시 rebind가 필요하다. position 정보가 없기 때문이다.
Recycler가 ViewHolder를 찾는 순서는 다음과 같다.
tryGetViewHolderForPositionByDeadline() 호출 시:
1. mChangedScrap 확인 (position 매칭)
2. mAttachedScrap 확인 (position 매칭)
3. mCachedViews 확인 (position 매칭)
4. ViewCacheExtension 확인 (있는 경우)
5. RecycledViewPool 확인 (viewType 매칭)
6. 모두 실패 → Adapter.createViewHolder() 호출
상위 캐시에서 찾을수록 rebind 비용을 아낄 수 있으므로 성능상 유리하다.
사용자가 스크롤할 때 실제로 어떤 일이 일어나는지 단계별로 살펴보자.
onBindViewHolder()로 데이터를 바인딩한다onCreateViewHolder()로 새로 생성한다이 과정이 매 스크롤마다 반복되면서 제한된 수의 ViewHolder로 무한한 데이터를 표시할 수 있게 되는 것이다.
RecyclerView가 객체 풀 패턴을 사용하는 이유는 명확하다.
1000개의 아이템이 있어도 화면에 보이는 10개 + α 개의 ViewHolder만 유지하면 된다. 이는 메모리 사용량을 획기적으로 줄이고, GC(Garbage Collection) 부담도 크게 감소시킨다.
View 생성과 XML inflation은 비용이 큰 작업이다. 복잡한 레이아웃일수록 inflation 시간이 길어지는데, 재활용을 통해 이 비용을 최소화할 수 있다. 특히 중첩된 ViewGroup이 많은 아이템에서 효과가 두드러진다.
여러 RecyclerView가 동일한 ViewType을 사용한다면 Pool을 공유하여 더 효율적으로 운영할 수 있다.
// ViewPool 공유 예시
val sharedPool = RecyclerView.RecycledViewPool()
recyclerView1.setRecycledViewPool(sharedPool)
recyclerView2.setRecycledViewPool(sharedPool)
ViewPager2 내부의 Fragment들이 각각 RecyclerView를 가지고 있을 때 이 기법을 활용하면 메모리 효율을 높일 수 있다.
하나의 RecyclerView에서 여러 종류의 아이템을 표시해야 하는 경우는 매우 흔하다. Header, Content, Footer가 섞인 리스트나 채팅 화면의 내 메시지/상대방 메시지 등이 대표적인 예시이다.
class MultiTypeAdapter(
private val items: List<Any>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
const val TYPE_HEADER = 0
const val TYPE_ITEM = 1
const val TYPE_FOOTER = 2
}
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is HeaderData -> TYPE_HEADER
is ItemData -> TYPE_ITEM
is FooterData -> TYPE_FOOTER
else -> throw IllegalArgumentException("Unknown type")
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> HeaderViewHolder(
inflater.inflate(R.layout.item_header, parent, false)
)
TYPE_ITEM -> ItemViewHolder(
inflater.inflate(R.layout.item_content, parent, false)
)
TYPE_FOOTER -> FooterViewHolder(
inflater.inflate(R.layout.item_footer, parent, false)
)
else -> throw IllegalArgumentException("Unknown viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> holder.bind(items[position] as HeaderData)
is ItemViewHolder -> holder.bind(items[position] as ItemData)
is FooterViewHolder -> holder.bind(items[position] as FooterData)
}
}
override fun getItemCount() = items.size
}
getItemViewType()에서 반환하는 값이 onCreateViewHolder()의 viewType 파라미터로 전달된다는 점을 기억하자. RecycledViewPool은 이 viewType을 기준으로 ViewHolder를 분류하여 저장한다.
Kotlin의 sealed class를 활용하면 컴파일 타임에 타입 안전성을 보장받을 수 있다.
sealed class ListItem {
data class Header(val title: String) : ListItem()
data class Content(val data: ItemData) : ListItem()
data class Divider(val id: String) : ListItem()
}
class TypeSafeAdapter : ListAdapter<ListItem, RecyclerView.ViewHolder>(DiffCallback()) {
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is ListItem.Header -> 0
is ListItem.Content -> 1
is ListItem.Divider -> 2
}
}
// when 문에서 else 분기가 필요 없어 새로운 타입 추가 시
// 컴파일 에러로 누락을 방지할 수 있다
}
RecyclerView의 성능을 극대화하기 위한 기법들을 살펴보자.
notifyDataSetChanged()는 전체 리스트를 갱신하므로 비효율적이다. DiffUtil을 사용하면 변경된 아이템만 업데이트할 수 있다.
class ItemDiffCallback : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
// 동일한 아이템인지 판별 (주로 ID 비교)
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
// 내용이 변경되었는지 판별
return oldItem == newItem
}
// 부분 업데이트를 위한 payload
override fun getChangePayload(oldItem: Item, newItem: Item): Any? {
return if (oldItem.title != newItem.title) {
Bundle().apply { putString("title", newItem.title) }
} else null
}
}
ListAdapter를 사용하면 DiffUtil 계산이 백그라운드 스레드에서 자동으로 처리된다.
기본 캐시 크기가 부족하다면 조정할 수 있다.
// 스크롤이 빈번한 경우 캐시 크기 늘리기
recyclerView.setItemViewCacheSize(20)
// 특정 ViewType이 많은 경우 Pool 크기 조정
recyclerView.recycledViewPool.setMaxRecycledViews(TYPE_ITEM, 15)
다만 캐시를 무작정 늘리면 메모리 사용량이 증가하므로 적절한 균형을 찾아야 한다.
RecyclerView의 성능은 결국 얼마나 ViewHolder 생성을 줄이고, 바인딩을 가볍게 하느냐에 달려 있다. Recycler의 캐시 계층 구조를 이해하면 왜 특정 최적화 기법이 효과적인지 명확해진다.
이러한 원리를 이해하고 적용한다면 대용량 리스트에서도 부드러운 스크롤 경험을 제공할 수 있을 것이다.