안드로이드 RecyclerView 완벽 가이드: 코틀린으로 리스트 화면 제대로 만들기
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에게서 한 번에 정확한 코드를 받아낼 수 있습니다.