RecyclerView 란?

aufcl4858·2025년 11월 30일

들어가며

Android 개발자라면 RecyclerView를 수없이 사용해봤을 것이다. 하지만 내부적으로 어떻게 View를 재활용하는지, 캐시 시스템은 어떤 구조인지 깊이 이해하고 있는 개발자는 많지 않다. 이 글에서는 RecyclerView의 핵심 메커니즘부터 성능 최적화 기법까지 상세히 다뤄보고자 한다.


핵심 아키텍처

RecyclerView는 이름 그대로 View를 재활용(Recycle)하는 것이 핵심이다. 대량의 데이터를 스크롤할 때 매번 새로운 View를 생성하는 대신, 화면에서 벗어난 View를 재사용하여 메모리와 성능을 최적화한다.

주요 컴포넌트 구조

RecyclerView는 크게 네 가지 핵심 컴포넌트로 구성된다.

  • LayoutManager: 아이템의 배치 방식(Linear, Grid, Staggered)을 결정한다
  • Adapter: 데이터와 ViewHolder를 연결하는 역할을 담당한다
  • ViewHolder: 각 아이템 View의 참조를 캐싱하여 findViewById 호출을 최소화한다
  • Recycler: ViewHolder의 재활용을 관리하는 핵심 클래스이다

Recycler 내부 메커니즘

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 정보가 없기 때문이다.

ViewHolder 획득 순서

Recycler가 ViewHolder를 찾는 순서는 다음과 같다.

tryGetViewHolderForPositionByDeadline() 호출 시:

1. mChangedScrap 확인 (position 매칭)
2. mAttachedScrap 확인 (position 매칭)
3. mCachedViews 확인 (position 매칭)
4. ViewCacheExtension 확인 (있는 경우)
5. RecycledViewPool 확인 (viewType 매칭)
6. 모두 실패 → Adapter.createViewHolder() 호출

상위 캐시에서 찾을수록 rebind 비용을 아낄 수 있으므로 성능상 유리하다.


재활용 메커니즘 동작 과정

사용자가 스크롤할 때 실제로 어떤 일이 일어나는지 단계별로 살펴보자.

  1. LayoutManager가 새로 보여질 position을 계산한다
  2. Recycler에 해당 position의 ViewHolder를 요청한다
  3. Recycler는 캐시 계층을 순회하며 재사용 가능한 ViewHolder를 찾는다
  4. 찾았다면 필요시 onBindViewHolder()로 데이터를 바인딩한다
  5. 찾지 못했다면 onCreateViewHolder()로 새로 생성한다
  6. 한편 화면에서 벗어난 ViewHolder는 mCachedViews → RecycledViewPool 순으로 이동한다

이 과정이 매 스크롤마다 반복되면서 제한된 수의 ViewHolder로 무한한 데이터를 표시할 수 있게 되는 것이다.


객체 풀 접근 방식의 장점

RecyclerView가 객체 풀 패턴을 사용하는 이유는 명확하다.

메모리 효율성

1000개의 아이템이 있어도 화면에 보이는 10개 + α 개의 ViewHolder만 유지하면 된다. 이는 메모리 사용량을 획기적으로 줄이고, GC(Garbage Collection) 부담도 크게 감소시킨다.

성능 향상

View 생성과 XML inflation은 비용이 큰 작업이다. 복잡한 레이아웃일수록 inflation 시간이 길어지는데, 재활용을 통해 이 비용을 최소화할 수 있다. 특히 중첩된 ViewGroup이 많은 아이템에서 효과가 두드러진다.

ViewPool 공유

여러 RecyclerView가 동일한 ViewType을 사용한다면 Pool을 공유하여 더 효율적으로 운영할 수 있다.

// ViewPool 공유 예시
val sharedPool = RecyclerView.RecycledViewPool()
recyclerView1.setRecycledViewPool(sharedPool)
recyclerView2.setRecycledViewPool(sharedPool)

ViewPager2 내부의 Fragment들이 각각 RecyclerView를 가지고 있을 때 이 기법을 활용하면 메모리 효율을 높일 수 있다.


다중 ViewType 구현

하나의 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를 분류하여 저장한다.

Sealed Class를 활용한 타입 안전한 구현

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의 성능을 극대화하기 위한 기법들을 살펴보자.

1. DiffUtil 활용

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 계산이 백그라운드 스레드에서 자동으로 처리된다.

2. 캐시 크기 조정

기본 캐시 크기가 부족하다면 조정할 수 있다.

// 스크롤이 빈번한 경우 캐시 크기 늘리기
recyclerView.setItemViewCacheSize(20)

// 특정 ViewType이 많은 경우 Pool 크기 조정
recyclerView.recycledViewPool.setMaxRecycledViews(TYPE_ITEM, 15)

다만 캐시를 무작정 늘리면 메모리 사용량이 증가하므로 적절한 균형을 찾아야 한다.


마치며

RecyclerView의 성능은 결국 얼마나 ViewHolder 생성을 줄이고, 바인딩을 가볍게 하느냐에 달려 있다. Recycler의 캐시 계층 구조를 이해하면 왜 특정 최적화 기법이 효과적인지 명확해진다.

이러한 원리를 이해하고 적용한다면 대용량 리스트에서도 부드러운 스크롤 경험을 제공할 수 있을 것이다.

profile
데브누누

0개의 댓글