Android RecyclerView Kotlin 예제: ViewBinding과 ListAdapter로 목록 만들기

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

Android RecyclerView Kotlin 예제: ViewBinding과 ListAdapter로 목록 만들기

Android RecyclerView가 행을 재사용하며 목록을 표시하는 구조

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

예제의 핵심 데이터 흐름은 다음과 같습니다.

ListAdapter가 새 목록의 차이를 계산하고 변경된 행만 갱신하는 과정

  1. Activity가 변경된 새 목록을 만듭니다.
  2. submitList()로 새 목록을 ListAdapter에 전달합니다.
  3. DiffUtil이 이전 목록과 새 목록을 백그라운드에서 비교합니다.
  4. 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_widthlayout_height0dp이면서 제약 조건이 빠져 있으면 목록이 보이지 않을 수 있습니다. 이 예제는 루트 뷰로 사용하므로 두 값을 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 오류도 확인하세요.

목록이 비어 있음

다음 네 가지를 순서대로 확인합니다.

  1. RecyclerView에 LayoutManager가 설정됐는가
  2. Adapter가 연결됐는가
  3. submitList()가 호출됐는가
  4. 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으로 단순화합니다.
  • 데이터는 가능한 한 불변 객체와 새 목록으로 관리합니다.
  • ListAdapterDiffUtil.ItemCallback으로 변경 범위를 계산합니다.
  • 아이템의 동일성은 위치가 아니라 안정적인 ID로 비교합니다.
  • 클릭 콜백은 가능하면 위치 대신 아이템을 전달합니다.
  • 새 UI라면 Compose LazyColumn도 함께 비교합니다.

이 구조를 기준으로 정렬, 검색, Room, Flow나 Paging 3를 추가해도 Adapter의 기본 역할을 유지할 수 있습니다.


공식 자료

댓글

이 블로그의 인기 게시물

pyautogui 예제 모니터 특정 위치 색상 구하고 비교해서 클릭 이벤트 하기

vscode 에서 WSL 개발환경 동작하지 않는 경우

React에서 Socket.IO Client 연결하기: CORS와 useEffect 정리