Android RecyclerView Kotlin 예제: ViewBinding과 ListAdapter로 목록 만들기
이 글은 2022년에 작성한 RecyclerView 예제를 2026년 기준으로 전면 개정한 글입니다. 기존 URL과 검색 의도는 유지하면서 깨진 이미지와 실행되지 않는 코드를 제거하고, ViewBinding·ListAdapter·DiffUtil을 연결한 완성형 Kotlin 예제로 다시 작성했습니다.
Android RecyclerView Kotlin 예제: ViewBinding과 ListAdapter로 목록 만들기

RecyclerView는 많은 데이터를 목록이나 그리드로 표시할 때 화면 밖의 아이템 뷰를 재사용하는 Android View 컴포넌트입니다. 모든 행을 한꺼번에 만들지 않기 때문에 메모리 사용과 뷰 생성 비용을 줄일 수 있습니다.
이 글에서는 단순히 목록이 보이는 데서 끝나지 않고 다음 요소를 하나의 예제로 연결합니다.
| 구성 요소 | 역할 |
|---|---|
RecyclerView |
목록을 화면에 표시하고 아이템 뷰를 재사용 |
LinearLayoutManager |
아이템을 세로 방향으로 배치 |
ViewHolder |
한 행의 ViewBinding을 보관 |
ListAdapter |
현재 목록을 관리하고 변경 목록을 제출받음 |
DiffUtil.ItemCallback |
이전 목록과 새 목록의 차이를 계산 |
| ViewBinding | findViewById() 없이 XML 뷰에 안전하게 접근 |
Android 공식 문서는 새 UI에 Jetpack Compose 사용을 권장하며 RecyclerView 라이브러리는 유지보수 모드입니다. 다만 기존 XML 기반 앱을 유지하거나 View 시스템이 필요한 화면에서는 RecyclerView가 여전히 중요한 선택입니다.
완성할 예제
예제 화면에는 사용자 프로필 목록이 표시됩니다. 아이템을 누르면 즐겨찾기 상태가 바뀌고, 전체 목록이 아니라 변경된 행만 갱신됩니다.
RecyclerViewSample/
├─ MainActivity.kt
├─ Profile.kt
├─ ProfileAdapter.kt
└─ res/layout/
├─ activity_main.xml
└─ item_profile.xml
예제의 핵심 데이터 흐름은 다음과 같습니다.

- Activity가 변경된 새 목록을 만듭니다.
submitList()로 새 목록을 ListAdapter에 전달합니다.- DiffUtil이 이전 목록과 새 목록을 백그라운드에서 비교합니다.
- RecyclerView는 달라진 행만 다시 바인딩하고 필요한 애니메이션을 실행합니다.
RecyclerView와 ViewBinding 설정
앱 모듈의 build.gradle.kts에서 ViewBinding을 활성화하고 RecyclerView 의존성을 추가합니다.
android {
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.recyclerview:recyclerview:1.4.0")
}
RecyclerView 1.4.0은 2026년 7월 확인 기준 안정 버전이며 compileSdk 35 이상이 필요합니다. 프로젝트의 Android Gradle Plugin과 compileSdk도 함께 확인하세요.
ViewBinding을 활성화하면 레이아웃 이름을 기준으로 binding 클래스가 생성됩니다.
| 레이아웃 | 생성되는 클래스 |
|---|---|
activity_main.xml |
ActivityMainBinding |
item_profile.xml |
ItemProfileBinding |
데이터 모델 만들기
각 아이템을 나타낼 불변 데이터 클래스를 만듭니다.
package com.example.recyclerviewsample
data class Profile(
val id: Long,
val name: String,
val description: String,
val isFavorite: Boolean = false
)
id는 아이템의 고유 식별자입니다. 이름이나 설명이 바뀌어도 같은 사용자인지 판단할 수 있도록 화면 위치가 아닌 안정적인 값을 사용해야 합니다.
Activity 레이아웃 작성
res/layout/activity_main.xml에 RecyclerView를 추가합니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/profileList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:scrollbars="vertical" />
RecyclerView의 layout_width와 layout_height가 0dp이면서 제약 조건이 빠져 있으면 목록이 보이지 않을 수 있습니다. 이 예제는 루트 뷰로 사용하므로 두 값을 match_parent로 지정합니다.
아이템 레이아웃 작성
res/layout/item_profile.xml은 한 행의 모양을 정의합니다. 별도의 이미지 라이브러리 없이 실행할 수 있도록 첫 글자를 아바타로 표시합니다.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="6dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:minHeight="72dp"
android:orientation="horizontal"
android:padding="12dp">
<TextView
android:id="@+id/avatarText"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="#E8F0FE"
android:gravity="center"
android:textColor="#2457C5"
android:textSize="20sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/nameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#1F1F1F"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/descriptionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#666666"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:id="@+id/favoriteText"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="즐겨찾기 상태"
android:gravity="center"
android:textSize="22sp" />
</LinearLayout>
실제 앱에서는 색상을 colors.xml과 테마 속성으로 분리하는 것이 좋습니다. 여기서는 파일 수를 줄여 예제 흐름에 집중했습니다.
ListAdapter와 ViewHolder 구현
ProfileAdapter.kt를 만듭니다.
package com.example.recyclerviewsample
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.recyclerviewsample.databinding.ItemProfileBinding
class ProfileAdapter(
private val onItemClick: (Profile) -> Unit
) : ListAdapter<Profile, ProfileAdapter.ProfileViewHolder>(ProfileDiffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ProfileViewHolder {
val binding = ItemProfileBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ProfileViewHolder(binding, onItemClick)
}
override fun onBindViewHolder(
holder: ProfileViewHolder,
position: Int
) {
holder.bind(getItem(position))
}
class ProfileViewHolder(
private val binding: ItemProfileBinding,
private val onItemClick: (Profile) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: Profile) {
binding.avatarText.text = item.name.firstOrNull()
?.uppercase()
.orEmpty()
binding.nameText.text = item.name
binding.descriptionText.text = item.description
binding.favoriteText.text = if (item.isFavorite) "★" else "☆"
binding.root.contentDescription =
"${item.name}, ${item.description}, " +
if (item.isFavorite) "즐겨찾기됨" else "즐겨찾기 안 됨"
binding.root.setOnClickListener {
onItemClick(item)
}
}
}
private object ProfileDiffCallback : DiffUtil.ItemCallback<Profile>() {
override fun areItemsTheSame(
oldItem: Profile,
newItem: Profile
): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: Profile,
newItem: Profile
): Boolean = oldItem == newItem
}
}
두 비교 함수의 차이
| 함수 | 확인하는 내용 | 이 예제의 기준 |
|---|---|---|
areItemsTheSame() |
두 객체가 같은 아이템인지 | id가 같은지 |
areContentsTheSame() |
화면에 표시할 내용도 같은지 | data class 전체 값 |
두 함수에 모두 oldItem == newItem을 사용하면 이름이 바뀐 같은 사용자를 삭제 후 추가된 아이템처럼 취급할 수 있습니다. 반대로 areContentsTheSame()에서 항상 true를 반환하면 데이터가 바뀌어도 화면이 갱신되지 않습니다.
MainActivity에서 목록 연결
MainActivity.kt에서 Activity ViewBinding, LayoutManager와 Adapter를 연결합니다.
package com.example.recyclerviewsample
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.recyclerviewsample.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val profileAdapter = ProfileAdapter { selected ->
toggleFavorite(selected.id)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.profileList.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = profileAdapter
setHasFixedSize(true)
}
profileAdapter.submitList(sampleProfiles())
}
private fun toggleFavorite(profileId: Long) {
val updated = profileAdapter.currentList.map { profile ->
if (profile.id == profileId) {
profile.copy(isFavorite = !profile.isFavorite)
} else {
profile
}
}
profileAdapter.submitList(updated)
}
private fun sampleProfiles(): List<Profile> = listOf(
Profile(
id = 1,
name = "김하늘",
description = "Android UI 개발"
),
Profile(
id = 2,
name = "이바다",
description = "Kotlin과 Coroutine 학습 중"
),
Profile(
id = 3,
name = "박초록",
description = "접근성 좋은 앱 만들기"
),
Profile(
id = 4,
name = "최여름",
description = "RecyclerView 성능 개선"
)
)
}
currentList를 직접 수정하면 안 됩니다. map()으로 새 목록을 만들고 submitList()에 전달해야 DiffUtil이 이전 상태와 새 상태를 정확히 비교할 수 있습니다.
클릭 위치보다 아이템을 전달하는 이유
예전 예제에서는 클릭 콜백으로 position을 전달하는 경우가 많았습니다.
// 권장하지 않는 형태
onItemClick(bindingAdapterPosition)
어댑터 갱신이나 애니메이션 도중에는 위치가 바뀌거나 NO_POSITION이 될 수 있습니다. 이 예제처럼 bind()가 받은 불변 아이템을 클릭 콜백으로 전달하면 호출 시점의 위치에 덜 의존합니다.
위치가 꼭 필요하다면 클릭 순간에 bindingAdapterPosition을 읽고 RecyclerView.NO_POSITION인지 확인하세요.
notifyDataSetChanged()보다 ListAdapter를 쓰는 이유
notifyDataSetChanged()는 모든 아이템이 바뀌었다고 RecyclerView에 알립니다. 작은 고정 목록에서는 동작하지만, 변경 범위를 알 수 없어 불필요한 바인딩이 늘고 기본 변경 애니메이션도 활용하기 어렵습니다.
| 방식 | 변경 계산 | 장점 | 주의점 |
|---|---|---|---|
notifyDataSetChanged() |
없음 | 가장 단순 | 전체 목록 다시 바인딩 |
notifyItemChanged() 등 |
직접 관리 | 세밀한 제어 | 인덱스 실수 가능 |
ListAdapter + DiffUtil |
자동 | 최소 범위 갱신, 백그라운드 비교 | 불변 목록 사용 필요 |
네트워크나 데이터베이스에서 목록 전체를 새로 받는 일반적인 화면이라면 Android 공식 문서도 ListAdapter부터 시작하는 방식을 권장합니다.
자주 발생하는 문제
ViewBinding 클래스가 생성되지 않음
buildFeatures { viewBinding = true }가 앱 모듈에 들어 있는지 확인하고 프로젝트를 다시 동기화합니다. 레이아웃 파일 이름과 XML 오류도 확인하세요.
목록이 비어 있음
다음 네 가지를 순서대로 확인합니다.
- RecyclerView에
LayoutManager가 설정됐는가 - Adapter가 연결됐는가
submitList()가 호출됐는가- RecyclerView 크기와 제약 조건이 올바른가
값을 바꿨는데 화면이 갱신되지 않음
현재 목록의 객체나 MutableList를 직접 변경하지 말고 새로운 객체와 새로운 리스트를 만들어 제출합니다.
val updated = profileAdapter.currentList.map {
if (it.id == targetId) it.copy(name = "새 이름") else it
}
profileAdapter.submitList(updated)
스크롤이 버벅임
onBindViewHolder()에서 파일 읽기, 큰 이미지 변환이나 네트워크 요청을 직접 실행하지 마세요. 이미지 로딩 라이브러리를 사용할 때도 ViewHolder 재사용으로 잘못된 이미지가 남지 않도록 요청 취소와 placeholder 처리를 확인해야 합니다.
아이템 높이가 서로 다른데 setHasFixedSize(true)를 사용함
setHasFixedSize(true)는 RecyclerView 자체 크기가 아이템 변경으로 달라지지 않을 때 사용합니다. 목록 내용에 따라 RecyclerView 크기가 바뀌는 구조라면 제거하고 실제 레이아웃을 측정하세요.
RecyclerView와 Compose 중 무엇을 선택할까
| 상황 | 권장 선택 |
|---|---|
| 기존 XML 화면 유지보수 | RecyclerView |
| 복잡한 View 기반 라이브러리 통합 | RecyclerView |
| 새 앱 또는 새 화면 | Jetpack Compose의 LazyColumn 우선 검토 |
| View와 Compose를 단계적으로 혼용 | 화면 경계와 상태 소유권을 먼저 설계 |
RecyclerView가 당장 사라지는 것은 아니지만 새 기능 개발보다는 중요한 수정 위주로 유지되는 단계입니다. 기존 프로젝트에서는 무리하게 전부 바꾸기보다 화면 단위의 이전 비용과 테스트 범위를 보고 결정하는 편이 안전합니다.
정리
실무에서 안정적인 RecyclerView 예제를 만들려면 RecyclerView.Adapter만 구현해서 끝내지 말고 데이터 변경과 클릭 처리까지 함께 설계해야 합니다.
- XML 뷰 접근은 ViewBinding으로 단순화합니다.
- 데이터는 가능한 한 불변 객체와 새 목록으로 관리합니다.
ListAdapter와DiffUtil.ItemCallback으로 변경 범위를 계산합니다.- 아이템의 동일성은 위치가 아니라 안정적인 ID로 비교합니다.
- 클릭 콜백은 가능하면 위치 대신 아이템을 전달합니다.
- 새 UI라면 Compose
LazyColumn도 함께 비교합니다.
이 구조를 기준으로 정렬, 검색, Room, Flow나 Paging 3를 추가해도 Adapter의 기본 역할을 유지할 수 있습니다.
댓글
댓글 쓰기