안드로이드 RecyclerView 완벽 가이드: 코틀린으로 리스트 화면 제대로 만들기

Prompt Architect 편집팀 · 2026-06-18 · 8분

TL;DR — RecyclerView의 동작 원리부터 Adapter·ViewHolder 구현, ListAdapter+DiffUtil 활용, 흔한 크래시 해결까지 코틀린 예제로 단계별 정리했습니다.

안드로이드 앱을 만들다 보면 "여러 개의 항목을 세로로 쭉 나열하는 화면"이 거의 반드시 필요합니다. 채팅 목록, 상품 리스트, 피드, 설정 메뉴 등 대부분의 화면이 결국 리스트입니다. 이때 표준 도구가 바로 RecyclerView입니다. 이름 그대로 화면 밖으로 사라진 뷰(View)를 버리지 않고 재활용(Recycle) 하기 때문에, 항목이 수천 개여도 메모리를 적게 쓰며 부드럽게 스크롤됩니다.

이 글에서는 단순히 "동작하는 코드"를 넘어서, 왜 그렇게 구현해야 하는지와 실무에서 자주 마주치는 크래시까지 코틀린 기준으로 정리합니다.

1. RecyclerView가 빠른 이유 (동작 원리)

예전에 쓰던 ListView는 항목마다 새 뷰를 계속 만들어내는 경향이 있어 긴 리스트에서 끊김이 발생했습니다. RecyclerView는 다릅니다. 화면에 보이는 만큼의 뷰만 생성해두고, 스크롤로 위쪽 항목이 사라지면 그 뷰 객체를 버리지 않고 아래쪽 새 항목에 데이터만 갈아끼웁니다.

이 구조를 떠받치는 세 가지 핵심 요소가 있습니다.

  • ViewHolder: 항목 하나의 뷰들을 보관하는 상자. findViewById 비용을 한 번만 치르고 캐싱합니다.
  • Adapter: 데이터(리스트)와 ViewHolder를 연결하는 다리. "몇 개인지", "어떤 뷰를 만들지", "데이터를 어떻게 꽂을지"를 책임집니다.
  • LayoutManager: 항목을 세로로, 가로로, 또는 격자로 배치할지 결정하는 배치 담당자.

이 분리 덕분에 "데이터가 바뀌었으니 다시 그려라"라는 요청이 와도 RecyclerView는 보이는 부분만 최소한으로 갱신합니다.

2. 준비: 의존성과 레이아웃

먼저 모듈 수준 build.gradle(또는 build.gradle.kts)에 라이브러리를 추가합니다. 최신 버전은 공식 문서에서 확인하는 것이 좋습니다.

// build.gradle.kts (Module)
dependencies {
    implementation("androidx.recyclerview:recyclerview:1.3.2")
}

화면 레이아웃에 RecyclerView를 배치합니다. 여기서는 연락처 목록을 만든다고 가정하겠습니다.

<!-- activity_main.xml -->
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/contactList"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

3. 데이터 모델과 아이템 뷰

리스트에 표시할 데이터를 data class로 정의합니다. 단순 문자열 배열 대신 모델을 쓰면 항목이 복잡해져도 확장이 쉽습니다.

// 한 줄에 표시할 연락처 한 명의 정보
data class Contact(
    val name: String,
    val phone: String
)

다음으로 항목 한 칸의 모양을 정의하는 레이아웃을 만듭니다.

<!-- item_contact.xml -->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/nameText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="16sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/phoneText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="#777777" />
</LinearLayout>

4. Adapter와 ViewHolder 구현

이제 핵심인 어댑터입니다. 세 개의 필수 메서드가 각각 무슨 일을 하는지 주석으로 표시했습니다.

class ContactAdapter(
    private val items: List<Contact>
) : RecyclerView.Adapter<ContactAdapter.ContactViewHolder>() {

    // 항목 하나의 뷰들을 보관하는 ViewHolder
    class ContactViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val nameText: TextView = view.findViewById(R.id.nameText)
        val phoneText: TextView = view.findViewById(R.id.phoneText)
    }

    // 새 항목 뷰가 필요할 때만 호출됨 (재활용되면 호출 안 됨)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_contact, parent, false)
        return ContactViewHolder(view)
    }

    // 특정 위치의 데이터를 뷰에 꽂는 단계 (스크롤마다 자주 호출됨)
    override fun onBindViewHolder(holder: ContactViewHolder, position: Int) {
        val contact = items[position]
        holder.nameText.text = contact.name
        holder.phoneText.text = contact.phone
    }

    // 전체 항목 개수
    override fun getItemCount(): Int = items.size
}

inflate(..., parent, false)에서 마지막 인자 false가 중요합니다. true로 주면 뷰가 이중으로 붙어 레이아웃이 깨지므로 반드시 false를 사용합니다.

5. 액티비티/프래그먼트에 연결하기

마지막으로 RecyclerView에 LayoutManager와 Adapter를 붙입니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val contacts = listOf(
            Contact("김민지", "010-1111-2222"),
            Contact("박서준", "010-3333-4444"),
            Contact("이하늘", "010-5555-6666")
        )

        val recyclerView = findViewById<RecyclerView>(R.id.contactList)
        // 세로 1열 배치
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = ContactAdapter(contacts)
    }
}

격자로 만들고 싶다면 LinearLayoutManager 대신 GridLayoutManager(this, 2)처럼 열 개수를 지정하면 됩니다.

6. 한 단계 위: ListAdapter + DiffUtil

위 방식은 데이터가 바뀔 때마다 notifyDataSetChanged()로 전체를 다시 그려야 해서 비효율적입니다. 실무에서는 변경된 항목만 똑똑하게 갱신하는 ListAdapter를 권장합니다.

class ContactListAdapter :
    ListAdapter<Contact, ContactListAdapter.VH>(DIFF) {

    class VH(view: View) : RecyclerView.ViewHolder(view) {
        val nameText: TextView = view.findViewById(R.id.nameText)
        val phoneText: TextView = view.findViewById(R.id.phoneText)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_contact, parent, false)
        return VH(view)
    }

    override fun onBindViewHolder(holder: VH, position: Int) {
        val item = getItem(position)
        holder.nameText.text = item.name
        holder.phoneText.text = item.phone
    }

    companion object {
        // 두 항목이 같은 항목인지 / 내용이 같은지 비교 규칙
        val DIFF = object : DiffUtil.ItemCallback<Contact>() {
            override fun areItemsTheSame(old: Contact, new: Contact) =
                old.phone == new.phone // 전화번호를 고유 식별자로 사용

            override fun areContentsTheSame(old: Contact, new: Contact) =
                old == new
        }
    }
}

이후 데이터를 바꿀 때는 submitList(newList) 한 줄이면 끝입니다. RecyclerView가 차이를 계산해 애니메이션과 함께 필요한 부분만 갱신합니다.

adapter.submitList(updatedContacts)

7. 클릭 이벤트 처리

항목 클릭은 어댑터 생성자에 콜백 람다를 넘기는 방식이 깔끔합니다.

class ContactAdapter(
    private val items: List<Contact>,
    private val onClick: (Contact) -> Unit
) : RecyclerView.Adapter<ContactAdapter.ContactViewHolder>() {
    // ... 생략 ...
    override fun onBindViewHolder(holder: ContactViewHolder, position: Int) {
        val contact = items[position]
        holder.nameText.text = contact.name
        holder.phoneText.text = contact.phone
        holder.itemView.setOnClickListener { onClick(contact) }
    }
}

8. 자주 터지는 에러와 해결법

원문에서는 다루지 않았지만, 실무에서 RecyclerView를 쓰다 보면 거의 반드시 만나게 되는 문제들입니다.

  • "No adapter attached; skipping layout" 경고가 뜨고 리스트가 비어 있음 : Adapter나 LayoutManager 둘 중 하나를 설정하지 않은 경우입니다. 두 줄(layoutManager, adapter)을 모두 설정했는지 확인하세요. 데이터를 비동기로 받아온다면 빈 어댑터를 먼저 붙여두고 나중에 submitList로 채우는 패턴이 안전합니다.

  • NullPointerException (findViewById가 null 반환) : ViewHolder에서 참조하는 ID가 액티비티 레이아웃이 아니라 아이템 레이아웃(item_contact.xml)에 있어야 합니다. inflate한 레이아웃과 ID가 일치하는지 확인하세요.

  • IndexOutOfBounds: Inconsistency detected : 데이터를 백그라운드 스레드에서 바꾸고 메인 스레드에 알리지 않았거나, 리스트 크기와 notify 호출이 어긋났을 때 발생합니다. notifyDataSetChanged() 남용을 피하고 ListAdapter+submitList로 전환하면 대부분 사라집니다.

  • 스크롤할 때 체크박스/이미지 상태가 엉뚱한 항목에 나타남 : 뷰 재활용의 대표적 함정입니다. onBindViewHolder에서 모든 상태를 매번 명시적으로 설정해야 합니다. "체크된 경우만 체크 표시"가 아니라 "체크 여부에 따라 true/false를 항상 지정"해야 재활용된 뷰의 이전 상태가 남지 않습니다.

  • 항목이 한 줄도 안 보임 : RecyclerView의 높이가 wrap_content인데 부모가 높이를 못 정하는 경우, 또는 아이템 레이아웃 루트가 match_parent 높이로 화면을 가득 채워 한 개만 보이는 경우가 흔합니다. 아이템 루트 높이는 보통 wrap_content로 둡니다.

9. 요약

RecyclerView의 핵심은 뷰 재활용이며, 그것을 ViewHolder·Adapter·LayoutManager 세 요소가 분담합니다. 입문 단계에서는 기본 RecyclerView.Adapter로 구조를 이해하고, 실무에서는 ListAdapter+DiffUtil로 넘어가 효율적인 부분 갱신을 누리는 것이 정석입니다. 재활용 함정(상태 누락)과 어댑터/레이아웃매니저 누락만 조심하면 안정적인 리스트 화면을 만들 수 있습니다.

AI에게 물어볼 때 (프롬프트 팁)

같은 RecyclerView 문제라도 AI에게 어떻게 묻느냐에 따라 답의 정확도가 크게 달라집니다. 막연히 "RecyclerView 안 돼요"라고 묻기보다, 아래처럼 맥락·코드·기대 결과를 함께 주면 훨씬 정확한 답을 받습니다.

  • 구현 요청형

    "코틀린으로 RecyclerView를 만들려고 합니다. 데이터 모델은 data class Contact(name, phone)이고, ListAdapter와 DiffUtil을 사용해 항목 변경 시 부분 갱신되도록 하고 싶습니다. Adapter, ViewHolder, 액티비티 연결 코드 전체를 주석과 함께 보여주세요."

  • 디버깅형

    "RecyclerView를 스크롤하면 체크박스 상태가 엉뚱한 항목에 나타납니다. 아래는 제 onBindViewHolder 코드입니다. [코드 붙여넣기] 뷰 재활용 관점에서 원인과 수정 방법을 설명해 주세요."

  • 설계 비교형

    "RecyclerView에서 notifyDataSetChanged()ListAdapter.submitList()의 차이를 성능·코드 복잡도·애니메이션 관점에서 비교하고, 어떤 상황에 무엇을 선택해야 하는지 표로 정리해 주세요."

이처럼 목표 / 현재 코드 / 기대 결과를 명확히 분리해 전달하는 것이 좋은 프롬프트의 기본입니다. Prompt Architect의 프롬프트 분석기를 활용하면 이런 질문을 더 구조적으로 다듬어, AI에게서 한 번에 정확한 코드를 받아낼 수 있습니다.