Monday, November 26, 2018

안드로이드 RecyclerView 심층 탐구: 기본 원리부터 고급 최적화까지

현대 모바일 애플리케이션 개발에서 사용자의 눈을 사로잡고 유려한 경험을 제공하는 가장 중요한 요소 중 하나는 '스크롤 가능한 목록'을 효율적으로 구현하는 것입니다. 소셜 미디어 피드, 이메일 목록, 상품 리스트, 설정 화면 등 앱의 거의 모든 곳에서 우리는 방대한 양의 데이터를 담은 목록을 접하게 됩니다. 안드로이드에서는 이러한 목록 UI를 구현하기 위해 과거 ListView를 사용했지만, 현재는 성능, 유연성, 재사용성 측면에서 월등히 뛰어난 RecyclerView가 표준으로 자리 잡았습니다. 이 글에서는 RecyclerView의 작동 원리를 깊이 있게 파헤치고, 기본적인 구현 방법부터 시작하여 성능을 극대화하고 사용자 경험을 풍부하게 만드는 고급 전략까지 모든 것을 상세하게 다룹니다.

1. 서문: 스크롤 가능한 UI의 진화, 왜 RecyclerView인가?

RecyclerView를 이해하기 전에, 그 전신인 ListView가 가졌던 한계점을 먼저 살펴보는 것이 중요합니다. 이는 RecyclerView가 왜 그렇게 설계되었는지에 대한 근본적인 이해를 돕기 때문입니다.

1.1. ListView의 시대와 그 한계

ListView는 이름 그대로 목록 형태의 뷰를 보여주기 위한 위젯이었습니다. ListView 역시 '뷰 재활용(View Recycling)'이라는 개념을 도입하여 메모리 사용량과 성능을 최적화하려는 시도를 했습니다. 사용자가 스크롤하여 화면 밖으로 사라진 아이템 뷰(View)를 버리지 않고, 새로 화면에 나타날 아이템의 데이터를 표시하는 데 재사용하는 방식이었죠. 이는 매번 새로운 뷰 객체를 생성하고 파괴하는 비용을 줄여주었습니다.

하지만 ListView는 몇 가지 구조적인 문제점을 안고 있었습니다.

  • ViewHolder 패턴 강제 부재: 뷰를 재활용하더라도, 재활용된 뷰 내부의 자식 뷰들(예: TextView, ImageView)을 찾기 위해 매번 findViewById()를 호출해야 했습니다. 이는 CPU 자원을 불필요하게 소모시키는 원인이 되었고, 개발자들은 이를 피하기 위해 'ViewHolder'라는 디자인 패턴을 직접 구현해야만 했습니다. 이 패턴은 뷰의 참조를 담아두는 객체를 만들어 setTag(), getTag() 메소드로 뷰에 붙여 재사용하는 방식이었는데, 이는 필수가 아닌 권장 사항이었기 때문에 코드의 일관성을 해치고 실수를 유발하기 쉬웠습니다.
  • 유연하지 못한 레이아웃: ListView는 오직 수직 스크롤 목록만 지원했습니다. 수평 스크롤 목록이나 바둑판 형태의 그리드(Grid) 레이아웃을 구현하려면 복잡한 커스텀 뷰를 만들거나 비공식 라이브러리에 의존해야 했습니다.
  • 비효율적인 데이터 업데이트: 목록의 데이터가 변경되었을 때, ListView에게 이를 알리는 가장 일반적인 방법은 notifyDataSetChanged() 메소드를 호출하는 것이었습니다. 이 메소드는 매우 강력하지만, '무식한' 방식이었습니다. 어떤 아이템이 추가, 삭제, 또는 변경되었는지 구체적으로 알리지 않고 "데이터 전체가 바뀌었으니 처음부터 다시 그려라"라고 명령하는 것과 같았습니다. 이로 인해 현재 화면에 보이는 모든 뷰가 강제로 다시 그려지면서 깜빡임(Blink) 현상이 발생했고, 자연스러운 아이템 추가/삭제 애니메이션을 구현하기가 매우 까다로웠습니다.

1.2. RecyclerView의 등장: 분리와 협력의 미학

RecyclerView는 ListView의 이러한 단점들을 해결하기 위해 완전히 새롭게 설계되었습니다. 핵심 철학은 '관심사의 분리(Separation of Concerns)'입니다.

RecyclerView는 거대한 단일 클래스가 아니라, 각자의 역할에 충실한 여러 컴포넌트들의 협력으로 동작합니다.

  • 아이템을 화면에 배치하는 역할: LayoutManager
  • 데이터와 뷰를 연결하는 역할: Adapter
  • 뷰를 재활용하고 관리하는 역할: ViewHolder & Recycler
  • 아이템 간의 장식(구분선 등)을 그리는 역할: ItemDecoration
  • 아이템 변경 애니메이션을 처리하는 역할: ItemAnimator

이러한 모듈식 구조 덕분에 개발자는 필요한 부분을 쉽게 교체하거나 커스터마이징할 수 있게 되었습니다. 예를 들어, 코드 한 줄만 바꾸면 수직 리스트를 수평 리스트나 그리드 레이아웃으로 변경할 수 있습니다. 이는 LayoutManager만 교체하면 되기 때문입니다. 이처럼 RecyclerView는 현대적인 안드로이드 UI 개발에 필수적인 유연성과 성능, 확장성을 제공합니다.

2. RecyclerView의 핵심 구성 요소: 4가지 기둥

RecyclerView를 능숙하게 다루기 위해서는 네 가지 핵심 구성 요소의 역할과 상호작용을 명확하게 이해해야 합니다.

2.1. Adapter: 데이터와 뷰의 중재자

Adapter는 RecyclerView의 심장과도 같습니다. 앱의 데이터(예: List<User>)와 각 아이템을 화면에 표시할 뷰(View) 사이의 다리 역할을 합니다. Adapter의 주된 책임은 다음과 같습니다.

  • 전체 아이템 개수 알리기: getItemCount() 메소드를 통해 RecyclerView에게 전체 데이터가 몇 개인지 알려줍니다.
  • 새로운 뷰 홀더 생성하기: onCreateViewHolder() 메소드에서 각 아이템을 위한 레이아웃 XML 파일을 인플레이트(inflate, 메모리 상의 객체로 변환)하고, 이 뷰를 관리할 ViewHolder 객체를 생성하여 반환합니다. 이 메소드는 화면에 보여야 할 뷰가 부족하여 새로 만들어야 할 때만 호출되므로 비교적 비용이 큰 작업입니다.
  • 데이터를 뷰에 바인딩하기: onBindViewHolder() 메소드에서 특정 위치(position)의 데이터를 가져와, 재사용되는 ViewHolder의 뷰들에 내용을 채워 넣습니다. 예를 들어, position 5에 해당하는 User 객체의 이름을 TextView에 설정하는 작업을 여기서 수행합니다. 이 메소드는 스크롤할 때마다 매우 빈번하게 호출되므로, 이 안에서 복잡하거나 시간이 오래 걸리는 연산을 수행해서는 안 됩니다.

2.2. ViewHolder: 뷰 재활용의 첨병

ViewHolder는 ListView 시절의 선택적 패턴을 아예 필수적인 구조로 만든 것입니다. 이름 그대로 '뷰를 담는(holding) 객체'이며, 각 아이템 뷰 및 그 자식 뷰들에 대한 참조를 멤버 변수로 가지고 있습니다.

ViewHolder를 사용함으로써 얻는 가장 큰 이점은 findViewById() 반복 호출을 없앤다는 점입니다. onCreateViewHolder()에서 ViewHolder가 처음 생성될 때 단 한 번만 findViewById()를 통해 자식 뷰의 참조를 찾아 변수에 저장해 둡니다. 그 후 onBindViewHolder()에서는 이미 저장된 참조를 사용하기만 하면 되므로, 스크롤 성능이 크게 향상됩니다. 모든 재활용은 ViewHolder 단위로 이루어지므로, RecyclerView의 성능 최적화는 ViewHolder의 올바른 설계에서 시작된다고 해도 과언이 아닙니다.

2.3. LayoutManager: 아이템 배치 설계자

LayoutManager는 아이템 뷰를 RecyclerView 내부에 어떻게 배치하고, 스크롤은 어떻게 동작할지를 결정하는 책임자입니다. RecyclerView 자체는 아이템을 배치하는 방법을 전혀 알지 못하며, 이 모든 것을 LayoutManager에게 위임합니다.

안드로이드 SDK는 기본적으로 세 가지 LayoutManager를 제공합니다.

  • LinearLayoutManager: 가장 일반적인 형태로, 수직 또는 수평 방향의 리스트를 만듭니다.
  • GridLayoutManager: 갤러리 앱처럼 아이템을 바둑판 형태의 격자로 배치합니다.
  • StaggeredGridLayoutManager: Pinterest와 같이 아이템의 높이가 제각각인 그리드 레이아웃을 만듭니다.

개발자는 단지 원하는 LayoutManager 객체를 생성하여 recyclerView.layoutManager = ... 와 같이 설정해주기만 하면 됩니다. 이처럼 레이아웃 정책을 뷰 자체에서 완전히 분리했기 때문에 엄청난 유연성을 확보할 수 있습니다.

2.4. Recycler (내부 캐시 시스템)

Recycler는 개발자가 직접 다루는 컴포넌트는 아니지만, RecyclerView의 재활용 메커니즘을 이해하는 데 핵심적인 역할을 합니다. Recycler는 화면에서 사라진 ViewHolder들을 임시로 저장하고 관리하는 캐시 시스템입니다. RecyclerView는 새로운 아이템을 표시해야 할 때, 먼저 Recycler에게 "이 위치에 맞는 재활용할 ViewHolder가 있는가?"라고 물어봅니다. 만약 적절한 ViewHolder가 캐시에 있다면, 그것을 가져와 onBindViewHolder()를 통해 데이터만 새로 입혀서 즉시 사용합니다. 만약 없다면, 그때서야 Adapter의 onCreateViewHolder()를 호출하여 새로운 ViewHolder를 생성합니다. 이 과정을 통해 객체 생성과 가비지 컬렉션(GC) 발생을 최소화하여 부드러운 스크롤을 가능하게 합니다.

3. RecyclerView 기본 구현: 단계별 실전 가이드

이제 이론을 바탕으로 실제 RecyclerView를 구현하는 전체 과정을 단계별로 살펴보겠습니다. 연락처 목록을 표시하는 간단한 앱을 예제로 사용합니다.

Step 1: 프로젝트에 의존성 추가하기

먼저 `build.gradle.kts` (또는 `build.gradle`) 파일의 `dependencies` 블록에 RecyclerView 라이브러리를 추가합니다.


// build.gradle.kts (Module)

dependencies {
    // ...
    implementation("androidx.recyclerview:recyclerview:1.3.2")
    // 최신 버전은 안드로이드 공식 문서에서 확인하는 것이 좋습니다.
}

Step 2: 데이터 모델 클래스 정의하기

각 아이템에 표시될 데이터를 담을 클래스를 만듭니다. Kotlin의 `data class`를 사용하면 편리합니다.


// Profile.kt
data class Profile(
    val imageResId: Int, // 프로필 이미지 리소스 ID
    val name: String,
    val phoneNumber: String,
    val isOnline: Boolean
)

Step 3: 각 아이템의 레이아웃 XML 만들기

RecyclerView의 한 칸 한 칸을 차지할 아이템의 UI를 디자인합니다. `res/layout/item_profile.xml` 파일을 생성합니다.



<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/iv_profile_image"
        android:layout_width="60dp"
        android:layout_height="60dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:src="@mipmap/ic_launcher_round" />

    <TextView
        android:id="@+id/tv_name"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:textSize="18sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/iv_profile_image"
        app:layout_constraintTop_toTopOf="@id/iv_profile_image"
        tools:text="홍길동" />

    <TextView
        android:id="@+id/tv_phone_number"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:textSize="14sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@id/tv_name"
        app:layout_constraintTop_toBottomOf="@id/tv_name"
        tools:text="010-1234-5678" />

    <View
        android:id="@+id/view_online_status"
        android:layout_width="12dp"
        android:layout_height="12dp"
        android:background="@drawable/shape_circle_green"
        app:layout_constraintCircle="@id/iv_profile_image"
        app:layout_constraintCircleAngle="135"
        app:layout_constraintCircleRadius="35dp"
        tools:background="@drawable/shape_circle_gray" />

</androidx.constraintlayout.widget.ConstraintLayout>

💡 개발 팁: `tools` 네임스페이스 활용하기

위 코드에서 `xmlns:tools="http://schemas.android.com/tools"` 를 선언하고, 각 뷰에 `tools:text`나 `tools:src` 같은 속성을 사용한 것을 볼 수 있습니다. `tools` 네임스페이스는 앱을 빌드하고 실행할 때는 적용되지 않고, 오직 안드로이드 스튜디오의 레이아웃 편집기에서 미리보기를 위해 사용됩니다. 이를 통해 실제 데이터를 넣었을 때 UI가 어떻게 보일지 예측하며 작업할 수 있어 개발 생산성이 크게 향상됩니다.

Step 4: Adapter와 ViewHolder 구현하기

이제 데이터와 레이아웃을 연결할 Adapter와 ViewHolder를 만듭니다. 보통 하나의 파일에 두 클래스를 함께 정의합니다.


// ProfileAdapter.kt
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class ProfileAdapter(private val profileList: List<Profile>) :
    RecyclerView.Adapter<ProfileAdapter.ProfileViewHolder>() {

    // ViewHolder 클래스를 Adapter 클래스의 inner class로 정의
    class ProfileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        // item_profile.xml 레이아웃의 뷰들에 대한 참조를 저장
        private val profileImage: ImageView = itemView.findViewById(R.id.iv_profile_image)
        private val name: TextView = itemView.findViewById(R.id.tv_name)
        private val phoneNumber: TextView = itemView.findViewById(R.id.tv_phone_number)
        private val onlineStatus: View = itemView.findViewById(R.id.view_online_status)
        
        // 데이터를 뷰에 바인딩하는 함수
        fun bind(profile: Profile) {
            profileImage.setImageResource(profile.imageResId)
            name.text = profile.name
            phoneNumber.text = profile.phoneNumber
            
            if (profile.isOnline) {
                onlineStatus.setBackgroundResource(R.drawable.shape_circle_green)
            } else {
                onlineStatus.setBackgroundResource(R.drawable.shape_circle_gray)
            }
        }
    }

    // ViewHolder를 생성해야 할 때 호출
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder {
        // item_profile.xml 레이아웃을 인플레이트
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_profile, parent, false)
        return ProfileViewHolder(view)
    }

    // ViewHolder에 데이터를 바인딩해야 할 때 호출
    override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) {
        val currentProfile = profileList[position]
        holder.bind(currentProfile)
    }

    // 전체 아이템의 개수를 반환
    override fun getItemCount(): Int {
        return profileList.size
    }
}

Step 5: Activity 또는 Fragment에 RecyclerView 배치하기

메인 화면의 레이아웃 파일(`res/layout/activity_main.xml`)에 RecyclerView 위젯을 추가합니다.



<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_profiles"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:listitem="@layout/item_profile" /> 
        
</androidx.constraintlayout.widget.ConstraintLayout>

💡 개발 팁: `tools:listitem`으로 미리보기

여기서도 `tools` 네임스페이스의 강력한 기능을 볼 수 있습니다. `tools:listitem="@layout/item_profile"` 속성을 RecyclerView에 추가하면, 안드로이드 스튜디오 레이아웃 편집기에서 실제 앱을 실행하지 않고도 `item_profile.xml` 레이아웃이 반복적으로 표시되는 모습을 미리 확인할 수 있습니다. 디자인 단계에서 전체적인 목록의 모양과 간격을 직관적으로 파악하는 데 매우 유용합니다.

tools:listitem 속성을 사용한 레이아웃 미리보기
그림 2. `tools:listitem` 속성으로 디자인 타임에 목록 미리보기

Step 6: Activity 또는 Fragment에서 모든 것 연결하기

마지막으로, Activity나 Fragment의 Kotlin 코드에서 앞에서 만든 모든 구성 요소를 하나로 조립합니다.


// MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

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

        // 1. RecyclerView 뷰를 찾기
        val recyclerView: RecyclerView = findViewById(R.id.rv_profiles)

        // 2. 표시할 샘플 데이터 생성
        val profileList = loadSampleData()

        // 3. Adapter 인스턴스 생성
        val adapter = ProfileAdapter(profileList)
        recyclerView.adapter = adapter

        // 4. LayoutManager 인스턴스 생성 및 설정
        val layoutManager = LinearLayoutManager(this) // Context를 전달
        recyclerView.layoutManager = layoutManager

        // (선택 사항) 성능 최적화: 아이템 뷰의 크기가 변경되지 않을 경우
        // 이 설정을 추가하면 RecyclerView의 내부 레이아웃 계산을 최적화할 수 있음
        recyclerView.setHasFixedSize(true)
    }

    private fun loadSampleData(): List<Profile> {
        // 실제 앱에서는 서버, 데이터베이스 등에서 데이터를 불러옵니다.
        return listOf(
            Profile(R.drawable.ic_profile_1, "김철수", "010-1111-2222", true),
            Profile(R.drawable.ic_profile_2, "이영희", "010-3333-4444", false),
            Profile(R.drawable.ic_profile_3, "박민준", "010-5555-6666", true),
            // ... (데이터 20~30개 추가)
        )
    }
}

이제 앱을 실행하면 `loadSampleData()`에서 생성된 프로필 목록이 화면에 아름답게 표시되는 것을 확인할 수 있습니다.

4. 성능 극대화를 위한 고급 전략: DiffUtil과 ListAdapter

지금까지의 구현은 정적인 목록을 보여주는 데는 충분합니다. 하지만 실제 앱에서는 데이터가 수시로 변경됩니다. 사용자가 아이템을 추가하거나, 삭제하거나, 순서를 바꾸거나, 기존 아이템의 내용이 업데이트될 수 있습니다. 이때, 앞에서 언급한 `notifyDataSetChanged()`를 사용하는 것은 최악의 선택입니다.

4.1. `notifyDataSetChanged()`의 문제점과 대안

`notifyDataSetChanged()`는 RecyclerView에게 데이터 목록 전체가 무효화되었음을 알립니다. 그 결과 RecyclerView는 현재 보이는 모든 ViewHolder를 폐기(scrap)하고, 레이아웃을 처음부터 다시 계산하며, 모든 아이템을 다시 바인딩합니다. 이 과정은 다음과 같은 문제를 야기합니다.

  • 성능 저하: 불필요한 연산이 대량으로 발생합니다.
  • 나쁜 사용자 경험: 목록 전체가 '깜빡'이며 새로고침되어 사용자의 시각적 흐름을 방해합니다.
  • 애니메이션 부재: 어떤 아이템이 추가되거나 삭제되었는지 모르기 때문에 자연스러운 애니메이션(fade, slide 등)을 보여줄 수 없습니다.

대안은 notifyItemInserted(position), notifyItemRemoved(position), notifyItemChanged(position) 등 더 구체적인 알림 메소드를 사용하는 것입니다. 하지만 이를 수동으로 관리하는 것은 매우 복잡하고 오류가 발생하기 쉽습니다. 예를 들어, 목록의 맨 앞에서 아이템을 하나 삭제하고 중간에 두 개를 추가하는 경우, 정확한 인덱스를 계산하여 올바른 순서로 `notify...` 메소드들을 호출해야 합니다.

4.2. 구원투수, `DiffUtil`

`DiffUtil`은 이러한 복잡성을 해결하기 위해 등장한 유틸리티 클래스입니다. `DiffUtil`의 역할은 이전 데이터 목록(old list)과 새로운 데이터 목록(new list)을 비교하여, 두 목록 간의 최소한의 차이점(어떤 아이템이 추가/삭제/이동/변경되었는지)을 계산해주는 것입니다. 그리고 이 계산 결과를 바탕으로 Adapter에게 어떤 `notify...` 메소드를 호출해야 하는지 알려줍니다.

DiffUtil을 사용하려면 `DiffUtil.Callback`을 상속받는 클래스를 만들어야 합니다.


// ProfileDiffCallback.kt
class ProfileDiffCallback(
    private val oldList: List<Profile>,
    private val newList: List<Profile>
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int = oldList.size
    override fun getNewListSize(): Int = newList.size

    // 두 아이템이 동일한 객체를 참조하는지 확인 (보통 고유 ID로 비교)
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        // 예를 들어 Profile에 고유 ID가 있다면, 그것으로 비교하는 것이 가장 정확합니다.
        // 여기서는 간단히 phoneNumber를 고유 식별자로 사용합니다.
        return oldList[oldItemPosition].phoneNumber == newList[newItemPosition].phoneNumber
    }

    // 두 아이템의 내용(데이터)이 같은지 확인 (areItemsTheSame이 true일 때만 호출됨)
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        // Kotlin data class의 equals()는 모든 프로퍼티를 비교하므로, 객체 자체를 비교하면 편리합니다.
        return oldList[oldItemPosition] == newList[newItemPosition]
    }
}

이제 Adapter에 데이터를 업데이트하는 함수를 다음과 같이 수정할 수 있습니다.


// ProfileAdapter.kt 내부에 추가
fun updateList(newList: List<Profile>) {
    val diffCallback = ProfileDiffCallback(this.profileList, newList)
    val diffResult = DiffUtil.calculateDiff(diffCallback)

    this.profileList.clear()
    this.profileList.addAll(newList)
    diffResult.dispatchUpdatesTo(this) // 계산된 결과를 Adapter에 적용 (자동으로 notify... 호출)
}

이렇게 하면 데이터가 변경될 때마다 최소한의 뷰만 업데이트되고, 안드로이드가 기본으로 제공하는 아름다운 애니메이션 효과가 자동으로 적용됩니다.

4.3. 최종 진화, `ListAdapter`

`DiffUtil`은 훌륭하지만, 매번 `calculateDiff`를 호출하고 결과를 `dispatchUpdatesTo`로 전달하는 상용구(boilerplate) 코드가 필요합니다. 또한 `calculateDiff`는 데이터 양이 많아지면 메인 스레드에서 실행하기에 부담스러울 수 있습니다. 이러한 문제들을 해결한 최종 진화 형태가 바로 `ListAdapter`입니다.

`ListAdapter`는 `RecyclerView.Adapter`를 상속하면서 내부에 `DiffUtil` 로직을 포함하고 있는 편리한 추상 클래스입니다. `ListAdapter`는 다음과 같은 장점을 가집니다.

  • 백그라운드 스레드에서 Diff 계산: 새로운 목록이 제출되면, `ListAdapter`는 자동으로 백그라운드 스레드에서 Diff 연산을 수행합니다. 이로써 UI 스레드(메인 스레드)가 멈추는 현상(Jank)을 방지하여 앱의 반응성을 유지합니다.
  • 간결한 API: 개발자는 단지 `adapter.submitList(newList)` 메소드만 호출하면 됩니다. Diff 계산 및 결과 적용은 `ListAdapter`가 알아서 처리해줍니다.
  • 항상 최신 목록 유지: 내부적으로 항상 최신 상태의 목록을 관리해주어 데이터 불일치 문제를 방지합니다.

`ListAdapter`를 사용하도록 `ProfileAdapter`를 리팩토링해봅시다.


// ProfileListAdapter.kt
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView

// RecyclerView.Adapter 대신 ListAdapter를 상속받는다.
// 제네릭 타입으로 <데이터 모델, ViewHolder>를 전달한다.
class ProfileListAdapter : ListAdapter<Profile, ProfileListAdapter.ProfileViewHolder>(ProfileDiffCallback) {

    // ViewHolder 코드는 이전과 동일
    class ProfileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        // ... (bind 함수 포함)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder {
        // ... (이전과 동일)
    }

    override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) {
        // getItem(position)을 통해 현재 위치의 아이템을 가져온다.
        val currentProfile = getItem(position)
        holder.bind(currentProfile)
    }

    // DiffUtil.Callback을 object(싱글톤)로 정의한다.
    // ListAdapter는 이 객체를 사용하여 리스트의 차이를 계산한다.
    companion object ProfileDiffCallback : DiffUtil.ItemCallback<Profile>() {
        override fun areItemsTheSame(oldItem: Profile, newItem: Profile): Boolean {
            return oldItem.phoneNumber == newItem.phoneNumber
        }

        override fun areContentsTheSame(oldItem: Profile, newItem: Profile): Boolean {
            return oldItem == newItem
        }
    }
}

가장 큰 변화는 `ListAdapter`가 더 이상 생성자에서 데이터 목록을 직접 받지 않는다는 점입니다. `getItemCount()` 메소드도 구현할 필요가 없습니다. 데이터 관리는 전적으로 `ListAdapter`에게 위임됩니다.

Activity에서는 다음과 같이 사용합니다.


// MainActivity.kt (ListAdapter 사용 버전)

// ...
val adapter = ProfileListAdapter() // 생성 시 데이터를 넘기지 않음
recyclerView.adapter = adapter
// ...

// 데이터가 변경되었을 때 (예: 버튼 클릭 시)
fun onDataUpdated() {
    val newProfileList = loadNewData() // 새로운 데이터를 가져옴
    adapter.submitList(newProfileList) // submitList() 호출 한 번으로 끝!
}
// ...

이처럼 `ListAdapter`를 사용하면 효율적이고 안정적이며, 코드도 훨씬 깔끔해집니다. 특별한 이유가 없는 한, 동적으로 데이터가 변하는 RecyclerView를 구현할 때는 `ListAdapter`를 사용하는 것이 강력히 권장됩니다.

5. 사용자 경험 향상: 커스터마이징 및 상호작용

기능적으로 동작하는 것을 넘어, 사용자에게 더 나은 경험을 제공하기 위한 몇 가지 추가적인 기법들을 알아봅시다.

5.1. 아이템 간 구분선 및 간격 추가: `ItemDecoration`

리스트의 아이템들을 시각적으로 구분하기 위해 구분선(divider)이나 간격(spacing)을 추가하고 싶을 때 `ItemDecoration`을 사용합니다. 이는 뷰의 레이아웃 자체를 건드리지 않고, RecyclerView의 그리기 과정에 개입하여 추가적인 내용을 그리거나(구분선) 아이템의 영역을 밀어내는(간격) 역할을 합니다.

기본적으로 제공되는 `DividerItemDecoration`을 사용하면 쉽게 구분선을 추가할 수 있습니다.


// MainActivity.kt의 onCreate
val layoutManager = LinearLayoutManager(this)
recyclerView.layoutManager = layoutManager

// 수직 방향 리스트에 대한 구분선 추가
val dividerItemDecoration = DividerItemDecoration(this, layoutManager.orientation)
recyclerView.addItemDecoration(dividerItemDecoration)

더 복잡한 간격이나 디자인을 원한다면 `RecyclerView.ItemDecoration`을 직접 상속받아 `getItemOffsets()` 메소드를 오버라이드하여 각 아이템의 상하좌우에 원하는 만큼의 공간을 할당할 수 있습니다.

5.2. 아이템 클릭 이벤트 처리하기

사용자가 리스트의 특정 아이템을 클릭했을 때 반응하도록 만드는 것은 필수적입니다. 이를 구현하는 가장 깔끔하고 권장되는 방법은 인터페이스나 고차 함수(람다)를 활용하는 것입니다.

Adapter에 클릭 리스너를 위한 람다 함수를 멤버 변수로 추가합니다.


// ProfileListAdapter.kt

class ProfileListAdapter(
    private val onItemClicked: (Profile) -> Unit // 클릭 시 호출될 람다 함수
) : ListAdapter<Profile, ProfileListAdapter.ProfileViewHolder>(ProfileDiffCallback) {

    // ViewHolder 클래스 내부 수정
    class ProfileViewHolder(...) : RecyclerView.ViewHolder(...) {
        fun bind(profile: Profile, onItemClicked: (Profile) -> Unit) {
            // ... (기존 바인딩 코드)
            
            // 아이템 뷰 전체에 클릭 리스너 설정
            itemView.setOnClickListener {
                onItemClicked(profile)
            }
        }
    }

    override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) {
        val currentProfile = getItem(position)
        // bind 함수 호출 시 람다 함수도 함께 전달
        holder.bind(currentProfile, onItemClicked)
    }

    // ... (DiffCallback은 동일)
}

그리고 Activity에서 Adapter를 생성할 때, 클릭 시 실행될 동작을 람다 표현식으로 전달합니다.


// MainActivity.kt

// ...
val adapter = ProfileListAdapter { profile ->
    // 아이템 클릭 시 실행될 코드
    // 'profile' 객체에 클릭된 아이템의 정보가 들어있음
    Toast.makeText(this, "${profile.name}님을 선택했습니다.", Toast.LENGTH_SHORT).show()
    // 예: 상세 화면으로 이동
    // val intent = Intent(this, ProfileDetailActivity::class.java)
    // intent.putExtra("PROFILE_ID", profile.id)
    // startActivity(intent)
}
recyclerView.adapter = adapter
// ...

이 방식은 Activity/Fragment의 역할(클릭 시 무엇을 할지 결정)과 Adapter/ViewHolder의 역할(클릭 이벤트를 전달)을 명확하게 분리하여 테스트하기 쉽고 재사용성 높은 코드를 만들어줍니다.

6. 마무리하며: 끊임없이 발전하는 RecyclerView 생태계

이 글을 통해 우리는 RecyclerView가 단순한 목록 뷰를 넘어, 안드로이드 UI 개발의 핵심적인 프레임워크로 자리 잡은 이유를 살펴보았습니다. 핵심 구성 요소들의 유기적인 협력 관계를 이해하는 것부터 시작하여, `ListAdapter`와 `DiffUtil`을 통한 동적 데이터의 효율적인 관리, 그리고 `ItemDecoration`과 클릭 리스너를 통한 사용자 경험 향상까지, RecyclerView의 광범위한 세계를 탐험했습니다.

여기에 소개된 내용 외에도 RecyclerView는 `ItemAnimator`를 사용한 커스텀 애니메이션, `RecycledViewPool` 공유를 통한 중첩 RecyclerView 성능 최적화, `SnapHelper`를 이용한 페이징 효과 구현 등 훨씬 더 깊이 있는 주제들을 품고 있습니다.

가장 중요한 것은 RecyclerView의 기본 원칙인 '관심사의 분리'와 '재활용'을 항상 염두에 두는 것입니다. 이 원칙을 바탕으로 오늘 배운 기술들을 실제 프로젝트에 적용하고 실험해본다면, 여러분은 어떤 복잡한 목록 UI라도 자신감 있게 구현할 수 있는 유능한 안드로이드 개발자로 성장할 수 있을 것입니다.


0 개의 댓글:

Post a Comment