안드로이드 개발자라면 누구나 한 번쯤은 붉은색 오류 로그와 함께 앱이 강제 종료되는 악몽을 경험했을 것입니다. 수많은 런타임 예외 중에서도 특히 RecyclerView를 다룰 때 마주치는 java.lang.IndexOutOfBoundsException: Inconsistency detected. 오류는 개발자를 깊은 좌절에 빠뜨리곤 합니다. 이 오류 메시지는 마치 "네 데이터와 내 상태가 맞지 않아! 혼란스러우니 그냥 중단할게."라고 외치는 RecyclerView의 비명과도 같습니다. 단순히 notifyDataSetChanged()를 깜빡한 사소한 실수일 수도 있지만, 대부분의 경우 문제는 훨씬 더 복잡하고 교묘한 곳에 숨어있습니다.
풀스택 개발자로서 저는 백엔드에서 내려온 데이터가 프론트엔드에서 어떻게 그려지는지, 그 과정에서 어떤 병목과 위험이 도사리고 있는지 항상 예의주시합니다. 비동기 데이터 로딩, 실시간 데이터 필터링, 복잡한 사용자 인터랙션이 얽히고설키는 현대 앱 환경에서 RecyclerView의 데이터 불일치 문제는 언제 터질지 모르는 시한폭탄과 같습니다. 이 글에서는 'Inconsistency detected' 오류가 발생하는 근본적인 메커니즘을 뼛속까지 파헤치고, 구시대적인 해결책을 넘어 DiffUtil과 ListAdapter라는 현대적이고 우아한 해법을 통해 이 지긋지긋한 오류와 영원히 작별하는 방법을 상세하게 안내하겠습니다.
- 'Inconsistency detected' 오류의 진짜 의미와 발생 시나리오 이해
- 백그라운드 스레드, 데이터 참조 문제 등 주요 원인에 대한 깊이 있는 분석
notifyDataSetChanged()를 사용하면 안 되는 이유와 성능 저하 문제- 오류를 근본적으로 차단하는
DiffUtil과ListAdapter의 완벽한 사용법 - 안정적이고 성능이 뛰어난
RecyclerView를 구현하기 위한 실전 팁
오류의 심장부: RecyclerView는 어떻게 배신하는가
이 오류를 정복하려면 먼저 RecyclerView의 작동 방식을 이해해야 합니다. RecyclerView는 이름 그대로 '뷰를 재활용(Recycle)'하여 극강의 성능을 자랑합니다. 수백, 수천 개의 아이템이 있어도 실제로는 화면에 보이는 몇 개의 뷰(ViewHolder)만 생성하고, 스크롤되어 화면 밖으로 사라지는 뷰는 버리지 않고 보관했다가 새롭게 나타날 아이템을 위해 재사용합니다. 이 과정은 매우 효율적이지만, 한 가지 치명적인 전제 조건이 있습니다. 바로 RecyclerView가 어댑터(Adapter)의 데이터를 절대적으로 신뢰한다는 것입니다.
어댑터의 getItemCount()가 100을 반환하면, RecyclerView는 '아, 100개의 아이템이 있구나. 0번부터 99번까지는 언제든 데이터를 요청하면 받을 수 있겠지.'라고 굳게 믿습니다. 이 믿음이 깨지는 순간 'Inconsistency detected'가 발생합니다.
오류의 본질은 간단합니다. 데이터는 이미 바뀌었는데, 그 사실을 RecyclerView에게 제때, 그리고 '올바른 방식'으로 알려주지 않았기 때문입니다. 이는 마치 레스토랑 주방(RecyclerView)은 이전 메뉴판을 보고 요리를 준비하는데, 홀(개발자 코드)에서는 손님에게 최신 메뉴판을 보여주며 주문을 받는 것과 같은 상황입니다. 주방에 없는 메뉴를 주문하면 결국 문제가 터질 수밖에 없습니다.
A Full-stack Developer's Analogy
상황을 좀 더 구체적으로 시뮬레이션해 보겠습니다.
- 초기 상태: 어댑터는 10개의 아이템을 가지고 있고,
getItemCount()는 10을 반환합니다.RecyclerView는 이 사실을 알고 있습니다. - 레이아웃 요청:
RecyclerView의LayoutManager가 화면을 그리기 위해 "5번 위치(position)의 뷰를 줘"라고 어댑터에 요청합니다.LayoutManager는 총 10개의 아이템이 있으니 5번 위치는 당연히 유효하다고 판단합니다. - 비동기적 데이터 변경: 바로 그 순간, 사용자가 화면을 당겨 새로고침을 했고, 백그라운드 스레드에서 API를 호출하여 새로운 데이터를 받아왔습니다. 공교롭게도 새로운 데이터는 3개뿐입니다. 개발자 코드가 이 3개의 아이템을 가진 새 리스트로 어댑터의 데이터 리스트를 '몰래' 교체해버립니다.
- 중요한 포인트: 3번의 데이터 변경 사실을
notify...()메서드를 통해RecyclerView에게 아직 알리지 않았습니다.RecyclerView는 여전히 아이템이 10개라고 믿고 있는 상태입니다. - 충돌 발생: 어댑터는 2번에서 요청받은 5번 위치의 데이터를 실제 데이터 리스트에서 가져오려고 합니다. 하지만 실제 리스트의 크기는 이제 3입니다.
list.get(5)를 시도하는 순간, 자바는 가차 없이IndexOutOfBoundsException을 던집니다. - 최종 판결:
RecyclerView는 이 예외를 내부적으로 포착(catch)하고, "내부 상태(아이템 10개)와 실제 데이터(3개)가 일치하지 않는 모순이 감지되었다"는 친절하지만 무서운Inconsistency detected메시지를 덧붙여 앱을 강제 종료시킵니다.
이처럼 오류의 핵심은 데이터의 '실제 상태'와 RecyclerView가 '인식하는 상태' 사이의 간극입니다. 그렇다면 어떤 코드들이 이런 위험한 간극을 만들어낼까요?
'Inconsistency Detected' 오류를 유발하는 주범들
1. 백그라운드 스레드의 반란 (가장 흔한 원인)
현대 안드로이드 앱에서 네트워크, 데이터베이스 I/O 등의 무거운 작업은 UI 스레드(메인 스레드)를 막지 않기 위해 반드시 백그라운드 스레드에서 처리해야 합니다. Kotlin Coroutines, RxJava, Hilt/Dagger의 비동기 주입 등은 모두 이를 위한 도구입니다. 문제는 비동기 작업이 끝난 후, 그 결과를 처리하는 과정에서 발생합니다.
네트워크 요청을 통해 새 데이터를 성공적으로 받아온 기쁨에 취해, 그 데이터를 받은 백그라운드 스레드에서 곧바로 어댑터의 리스트를 수정하고 notifyDataSetChanged()를 호출하는 코드는 이 오류를 예약하는 것과 같습니다. 안드로이드의 UI 툴킷은 근본적으로 스레드에 안전하지 않습니다(not thread-safe). 모든 UI 관련 작업(뷰의 속성 변경, 어댑터 데이터 업데이트, RecyclerView에 변경 알림 등)은 반드시 UI 스레드에서만 이루어져야 한다는 대원칙이 있습니다.
백그라운드 스레드가 데이터를 바꾸는 찰나에, UI 스레드에서는 RecyclerView가 스크롤 애니메이션을 처리하거나 레이아웃을 재계산하고 있을 수 있습니다. 두 스레드가 동시에 같은 데이터(또는 그 데이터를 기반으로 한 상태)에 접근하여 수정하려 할 때 경합 상태(Race Condition)가 발생하며, 이는 데이터 불일치를 초래하는 가장 확실한 방법입니다.
잘못된 예시: Kotlin Coroutines
// ViewModel 또는 Repository
fun fetchData() {
viewModelScope.launch(Dispatchers.IO) { // IO 스레드에서 작업 시작
try {
val newData = api.fetchItems() // 네트워크 호출 (시간 소요)
// !!! 재앙의 시작: IO 스레드에서 직접 어댑터 데이터를 변경 !!!
// 이 코드가 실행되는 순간, UI 스레드에서는 다른 작업을 하고 있을 수 있다.
myAdapter.updateList(newData)
} catch (e: Exception) {
// 예외 처리
}
}
}
// Adapter
fun updateList(newList: List<Item>) {
this.items.clear()
this.items.addAll(newList)
// 이 호출 역시 백그라운드 스레드에서 실행되므로 매우 위험하다.
notifyDataSetChanged()
}
위 코드는 Dispatchers.IO라는 명백한 백그라운드 컨텍스트에서 UI 컴포넌트인 어댑터를 직접 건드리고 있습니다. 이는 'Inconsistency detected'로 가는 지름길입니다.
2. 불안정한 데이터 리스트 참조 관리
어댑터가 사용하는 데이터 리스트의 참조(reference)를 잘못 다루는 것 또한 흔한 실수입니다. 많은 개발자들이 편의를 위해 어댑터의 리스트 객체 자체를 새로운 객체로 교체하곤 합니다.
class MyAdapter(private var items: List<Item>) : RecyclerView.Adapter<MyViewHolder>() {
fun updateList(newList: List<Item>) {
// ▼▼▼ 잘못된 방식 ▼▼▼
// 'items'라는 변수가 가리키는 메모리 주소 자체를 'newList'의 주소로 바꿔버린다.
this.items = newList
notifyDataSetChanged()
}
override fun getItemCount(): Int {
return items.size
}
// ...
}
// Activity / Fragment
val initialList = mutableListOf(Item(1), Item(2))
val myAdapter = MyAdapter(initialList)
recyclerView.adapter = myAdapter
// 나중에...
val newListFromServer = listOf(Item(3), Item(4))
myAdapter.updateList(newListFromServer)
// 이제 'initialList'를 수정해도 어댑터는 아무것도 모른다.
// 어댑터의 items는 'newListFromServer'를 가리키고 있기 때문이다.
initialList.add(Item(5)) // 이 변경은 RecyclerView에 절대 반영되지 않는다.
이 방식의 문제는 데이터 소스가 분산될 위험이 있다는 것입니다. 어댑터를 생성할 때 넘겨준 `initialList`와 `updateList`를 통해 새로 할당된 `newListFromServer`는 완전히 별개의 객체입니다. 만약 Activity나 Fragment의 다른 로직이 여전히 `initialList`를 참조하고 있으면서 그 내용을 수정한다면, 어댑터는 그 변경을 전혀 감지하지 못합니다. 이런 구조는 데이터 흐름을 예측하기 어렵게 만들고, 결국 어디선가 데이터 불일치를 일으키는 원인이 됩니다.
가장 안전하고 권장되는 방법은, 어댑터가 내부적으로 관리하는 리스트의 참조는 생성 시점 그대로 고정하고, 리스트의 내용물만 비우고 새로 채우는 것입니다. (list.clear(), list.addAll(newData))
3. 부정확하고 뒤죽박죽인 notify...() 호출
성능 최적화를 위해 notifyDataSetChanged() 대신 notifyItemInserted(), notifyItemRemoved(), notifyItemChanged() 등 더 세분화된 알림 메서드를 사용할 때, 순서가 꼬이면 바로 오류로 이어집니다.
규칙은 단 하나입니다. 데이터를 먼저 바꾸고, 그 다음에 바뀐 내용을 알린다.
잘못된 순서의 예시
fun removeItemAndRefreshNext(position: Int) {
if (position < 0 || position >= items.size) return
// 실수 1: 데이터를 지우기 전에 먼저 RecyclerView에 알려버린다.
notifyItemRemoved(position)
// 이 알림이 처리되는 순간 RecyclerView는 아이템 개수가 (size - 1)이라고 기대하게 된다.
// 하지만 아직 실제 데이터는 그대로이다. 여기서 이미 불일치가 발생할 수 있다.
items.removeAt(position)
// 실수 2: 제거된 아이템 개수를 고려하지 않은 인덱스 사용
// 아이템이 제거되었으므로, 기존 position 위치에는 이제 다음 아이템이 와있다.
// 하지만 position 인덱스를 그대로 사용하면 엉뚱한 아이템이 갱신될 수 있다.
// 만약 마지막 아이템을 지웠다면, items.size == position이 되어 여기서 IndexOutOfBoundsException 발생 가능성도 있다.
if (position < items.size) {
notifyItemChanged(position)
}
}
항상 기억하세요. 데이터 리스트가 '진실의 원천(Source of Truth)'이고, notify 호출은 그 진실의 변경 사항을 RecyclerView에게 전달하는 '전령'입니다. 진실이 확정되기도 전에 전령이 잘못된 소식을 들고 출발하면 안 됩니다.
해결의 여정: 기본부터 최첨단 솔루션까지
원인을 알았으니 이제 해결책을 찾아 나설 차례입니다. 가장 기본적인 방어 수칙부터 시작하여, 궁극의 해결책인 ListAdapter까지 단계별로 정복해 보겠습니다.
Level 1: 기본 수칙 - UI 스레드 사수와 안정적인 데이터 참조
모든 해결책의 기반이 되는 두 가지 황금률입니다. 어떤 복잡한 로직을 사용하더라도 이 두 가지는 반드시 지켜야 합니다.
- 모든 어댑터 관련 작업은 UI 스레드에서 수행하라.
- 어댑터의 데이터 리스트는 내부에서만 관리하고, 내용물만 교체하라.
올바르게 수정한 예시 (Kotlin Coroutines)
앞서 본 잘못된 코드를 이 원칙에 따라 올바르게 고쳐보겠습니다.
// ViewModel
fun fetchData() {
viewModelScope.launch { // CoroutineScope의 기본 Context는 Main (UI) 스레드
try {
// withContext는 지정된 스레드에서 블록을 실행하고, 끝나면 원래 스레드로 자동 복귀한다.
val newData = withContext(Dispatchers.IO) {
api.fetchItems() // 이 네트워크 호출만 IO 스레드에서 실행됨
}
// withContext 블록이 끝나면 여기는 다시 Main 스레드.
myAdapter.updateList(newData) // 안심하고 어댑터 업데이트
} catch (e: Exception) {
// ...
}
}
}
// Adapter
class MyAdapter : RecyclerView.Adapter<MyViewHolder>() {
// private val로 선언하여 외부에서 이 리스트의 참조를 직접 교체할 수 없도록 캡슐화한다.
private val items = mutableListOf<Item>()
// 메서드 이름을 submitList 또는 setItems 등으로 명확하게 짓는 것이 좋다.
fun submitList(newList: List<Item>) {
// 1. 기존 리스트의 내용물을 모두 지운다.
items.clear()
// 2. 새로운 내용물을 추가한다. (items 객체의 참조는 그대로 유지됨)
items.addAll(newList)
// 3. UI 스레드에서 변경 사항을 알린다.
notifyDataSetChanged()
}
override fun getItemCount(): Int {
return items.size
}
// ...
}
이 코드는 withContext(Dispatchers.IO)를 사용하여 시간이 오래 걸리는 네트워크 작업만 백그라운드로 위임하고, 결과를 받은 후의 UI 업데이트는 안전하게 원래의 UI 스레드로 돌아와 수행합니다. 또한 어댑터가 데이터를 내부적으로 private val mutableListOf로 관리함으로써 외부의 간섭으로부터 데이터를 보호하고 참조를 안정적으로 유지합니다.
Level 2: 성능 개선 - notifyDataSetChanged()와의 결별
Level 1의 방법은 오류를 막아주지만, notifyDataSetChanged()는 여전히 문제입니다. 이 메서드는 RecyclerView에게 "데이터가 어떻게 바뀌었는지 모르겠으니, 그냥 처음부터 끝까지 모든 뷰를 버리고 다시 그려!"라고 명령하는 것과 같습니다. 이는 다음과 같은 심각한 단점을 가집니다.
- 성능 저하: 아이템이 수십, 수백 개일 때 전체 리스트를 다시 그리는 비용은 매우 큽니다. 이는 사용자가 스크롤할 때 버벅임(Jank)의 직접적인 원인이 됩니다.
- 애니메이션 소실: 아이템이 추가되거나 삭제될 때 부드럽게 나타나고 사라지는 멋진 애니메이션 효과가 모두 사라집니다. 화면이 그저 '깜빡'하며 바뀔 뿐입니다.
- 사용자 경험 저하: 깜빡임 현상은 사용자에게 시각적 피로감을 주고, 앱이 불안정하다는 인상을 줍니다.
이 문제를 해결하려면 변경된 부분만 정확히 알려주는 세분화된 notify...() 메서드를 사용해야 합니다.
| 메서드 | 설명 | 장점 | 단점 |
|---|---|---|---|
notifyDataSetChanged() |
전체 데이터가 변경되었음을 알립니다. | 사용하기 매우 간편하다. | 성능이 매우 나쁘고, 애니메이션이 동작하지 않는다. |
notifyItemInserted(pos) |
pos 위치에 아이템이 추가되었음을 알립니다. |
해당 아이템만 추가하고, 자연스러운 '삽입' 애니메이션이 적용된다. | 개발자가 직접 추가된 위치를 계산해야 한다. |
notifyItemRemoved(pos) |
pos 위치의 아이템이 삭제되었음을 알립니다. |
해당 아이템만 제거하고, '삭제' 애니메이션이 적용된다. | 삭제 위치 계산 및 삭제 후 인덱스 관리가 복잡하다. |
notifyItemChanged(pos) |
pos 위치 아이템의 '내용'이 변경되었음을 알립니다. |
뷰를 재활용하여 내용만 갱신하고, 'Cross-fade' 애니메이션이 적용된다. | 어떤 아이템이 변경되었는지 직접 추적해야 한다. |
notifyItemMoved(from, to) |
아이템이 from 위치에서 to 위치로 이동했음을 알립니다. |
자연스러운 '이동' 애니메이션이 적용된다. | 이전 위치와 새 위치를 모두 계산해야 하므로 매우 복잡하다. |
이 방식은 분명 성능과 사용자 경험 면에서 월등하지만, 새로운 리스트가 들어왔을 때 이전 리스트와 일일이 비교하여 어떤 아이템이 추가/삭제/변경/이동되었는지 알아내는 로직을 개발자가 직접 구현해야 한다는 거대한 장벽이 존재합니다. 이 로직은 복잡하고 오류가 발생하기 쉬워 새로운 버그의 온상이 될 수 있습니다.
Level 3: 궁극의 솔루션 - DiffUtil과 ListAdapter
이전 리스트와 새 리스트의 차이점을 계산하는 복잡하고 지루한 작업을 개발자 대신, 그것도 매우 효율적인 알고리즘으로 처리해주는 구원투수가 바로 DiffUtil입니다. 그리고 이 DiffUtil을 더욱더 편리하고 안전하게 사용할 수 있도록 래핑(wrapping)한 클래스가 바로 ListAdapter입니다.
결론부터 말하자면, 안드로이드에서 RecyclerView를 사용한다면 더 이상 RecyclerView.Adapter를 직접 상속받을 이유가 거의 없습니다. ListAdapter를 사용하는 것이 현대적인 안드로이드 개발의 표준입니다.
DiffUtil의 작동 원리
DiffUtil은 유진 마이어스(Eugene Myers)의 차분(difference) 알고리즘에 기반하여 두 리스트 간의 최소한의 차이점(diff)을 계산합니다. 이 과정은 두 단계로 이루어집니다.
- 동일 아이템 확인: 먼저 두 리스트를 비교하여 같은 아이템인지 확인합니다. 보통 아이템의 고유 ID를 비교합니다. 이를 통해 어떤 아이템이 사라졌고(삭제), 새로 생겼으며(추가), 그대로 있는지(유지 또는 이동)를 파악합니다.
- 내용 변경 확인: 1번 단계에서 '그대로 있는' 아이템으로 판단된 것들에 대해, 실제 내용(예: 이름, 이미지 URL 등)이 변경되었는지 확인합니다.
이 계산 결과를 바탕으로 RecyclerView에 가장 효율적인 업데이트 명령(notifyItemInserted, notifyItemRemoved, notifyItemChanged 등)을 자동으로 내립니다.
ListAdapter 구현의 3단계
ListAdapter를 사용하면 이 모든 과정이 마법처럼 간단해집니다.
1단계: DiffUtil.ItemCallback 구현
먼저 DiffUtil이 두 아이템을 어떻게 비교할지 규칙을 알려줘야 합니다. DiffUtil.ItemCallback을 상속받아 두 개의 메서드를 구현합니다.
// 비교할 아이템의 데이터 클래스. val로 선언하여 불변성을 유지하는 것이 좋다.
data class User(val id: Long, val name: String, val profileImageUrl: String)
// User 객체를 비교하기 위한 DiffUtil.ItemCallback 구현
class UserDiffCallback : DiffUtil.ItemCallback<User>() {
// 두 아이템이 동일한 항목을 나타내는지(즉, 같은 객체인지) 확인.
// 보통 고유 ID를 비교한다.
// 이 메서드가 true를 반환해야만 areContentsTheSame이 호출된다.
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
// 두 아이템의 데이터 내용이 같은지 확인.
// areItemsTheSame이 true일 때만 호출된다.
// 내용이 다르면 RecyclerView는 해당 아이템 뷰의 내용만 갱신하는 애니메이션을 적용한다.
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
// Kotlin의 data class는 자동으로 equals()를 구현해주므로 간단하게 비교할 수 있다.
// 만약 일반 class라면 name, profileImageUrl 등 모든 속성을 직접 비교해야 한다.
return oldItem == newItem
}
}
areContentsTheSame에서 객체 전체를 비교(oldItem == newItem)하는 것은 data class일 때 매우 편리합니다. 만약 일반 클래스를 사용하고 내부 속성이 변경 가능하다면(mutable), 모든 속성을 일일이 비교(oldItem.name == newItem.name && oldItem.age == newItem.age ...)해야 정확한 비교가 가능합니다.
2단계: ListAdapter 상속 및 ViewHolder 설정
이제 RecyclerView.Adapter 대신 ListAdapter를 상속받습니다. 생성자에는 방금 만든 ItemCallback 객체를 전달합니다.
// RecyclerView.Adapter 대신 ListAdapter를 상속받는다.
// 제네릭 타입으로 <데이터타입, 뷰홀더타입>을 지정하고, 생성자에 DiffCallback을 전달한다.
class UserAdapter : ListAdapter<User, UserAdapter.UserViewHolder>(UserDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val binding = ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return UserViewHolder(binding)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
// getItem() 메서드를 사용하여 특정 위치의 아이템에 안전하게 접근할 수 있다.
val user = getItem(position)
holder.bind(user)
}
// ListAdapter는 자체적으로 리스트를 관리하므로,
// 데이터 리스트 필드를 직접 선언할 필요도 없고, getItemCount()를 오버라이드할 필요도 없다!
class UserViewHolder(private val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.userNameTextView.text = user.name
// Glide, Coil 등 이미지 로딩 라이브러리 사용
Glide.with(binding.root.context)
.load(user.profileImageUrl)
.circleCrop()
.into(binding.profileImageView)
}
}
}
코드가 훨씬 간결해진 것을 볼 수 있습니다. 어댑터 내에 데이터를 저장할 리스트 변수가 필요 없고, 리스트의 크기를 반환하는 getItemCount()도 구현할 필요가 없습니다. ListAdapter가 이 모든 것을 알아서 처리해줍니다.
3단계: submitList()로 데이터 제출
마지막으로, Activity나 Fragment에서 새로운 데이터가 생겼을 때 어댑터에 전달하는 방법입니다. notify...()나 직접 만든 업데이트 메서드 대신, ListAdapter가 제공하는 submitList() 메서드를 사용합니다.
// ViewModel
val userList: LiveData<List<User>> get() = _userList
private val _userList = MutableLiveData<List<User>>()
fun fetchUsers() {
viewModelScope.launch {
val newUsers = withContext(Dispatchers.IO) { repository.getUsers() }
_userList.postValue(newUsers) // 백그라운드 스레드에서 LiveData 값 업데이트
}
}
// Fragment 또는 Activity
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val userAdapter = UserAdapter()
binding.recyclerView.adapter = userAdapter
viewModel.userList.observe(viewLifecycleOwner) { users ->
// 새로운 리스트를 submitList에 전달하면 모든 것이 끝난다.
// ListAdapter가 알아서 이전 리스트와 비교하고, 변경 사항을 적용해준다.
userAdapter.submitList(users)
}
}
submitList()는 내부적으로 어떤 일을 할까?
userAdapter.submitList(users) 한 줄의 코드는 다음과 같은 놀라운 일들을 자동으로 처리합니다.
- 백그라운드 스레딩:
DiffUtil을 이용한 리스트 비교 작업은 아이템 개수가 많으면 시간이 걸릴 수 있습니다.submitList()는 이 계산을 내부적으로 백그라운드 스레드에서 실행하여 UI 끊김을 방지합니다. - 차이점 계산: 백그라운드에서 우리가 정의한
UserDiffCallback을 사용하여 이전 리스트와 새로 들어온users리스트의 차이점을 계산합니다. - UI 스레드로 결과 전달: 계산이 완료되면 그 결과(diff 결과)를 UI 스레드로 안전하게 전달합니다.
- 자동 알림: 계산된 차이점을 기반으로
notifyItemInserted,notifyItemRemoved,notifyItemChanged등의 메서드를 가장 효율적인 방식으로 정확하게 호출하여RecyclerView를 업데이트합니다.
결과적으로 개발자는 더 이상 스레드 관리에 대해 걱정하거나, 복잡한 인덱스 계산을 하거나, 어떤 notify 메서드를 호출해야 할지 고민할 필요가 없습니다. 그저 새로운 데이터를 submitList()에 던져주기만 하면, 'Inconsistency detected' 오류를 근본적으로 방지하고, 최적의 성능과 부드러운 애니메이션을 모두 얻게 됩니다.
결론: 불일치 없는 RecyclerView를 위한 최종 체크리스트
RecyclerView의 'Inconsistency detected' 오류는 처음 마주하면 당황스럽고 디버깅하기 까다롭지만, 그 원인은 '데이터 상태와 뷰 상태의 불일치'라는 명확한 한 가지로 귀결됩니다. 이 문제를 완벽하게 해결하고, 안정적이며 반응성이 뛰어난 앱을 만들기 위해 다음 원칙들을 프로젝트의 규칙으로 삼으시길 바랍니다.
- ListAdapter를 기본으로 사용하라: 새로운 화면을 개발하거나 기존 코드를 리팩토링할 때,
RecyclerView.Adapter대신 ListAdapter를 사용하는 것을 최우선으로 고려하세요. 이것이 'Inconsistency detected' 오류를 포함한 수많은 문제를 해결해주는 가장 현대적이고 강력한 방법입니다. - 데이터 업데이트는 항상 UI 스레드에서:
ListAdapter를 사용하지 못하는 불가피한 상황이라면, 어댑터의 데이터를 변경하거나notify...()를 호출하는 모든 코드가 반드시 UI 스레드에서 실행되도록 보장해야 합니다. Kotlin Coroutines의 기본launch,withContext(Dispatchers.Main), 또는view.post { ... }등을 적극적으로 활용하세요. - 데이터는 캡슐화하고 불변성을 지향하라: 어댑터가 사용하는 데이터 리스트는
private으로 선언하여 외부의 직접적인 수정을 막으세요. ViewModel에서 어댑터로 전달하는 데이터 리스트 또한 가능하면 읽기 전용(List)으로 전달하고, 데이터 클래스는val속성을 사용하여 불변(immutable) 객체로 만드는 것이 데이터 흐름을 예측 가능하게 하고 버그를 줄이는 좋은 습관입니다. notifyDataSetChanged()는 잊어라: 이 메서드는 성능과 사용자 경험의 적입니다. 정말로 리스트 전체가 완전히 다른 내용으로 바뀌는 극히 예외적인 경우를 제외하고는 사용을 피해야 합니다.ListAdapter는 이 원칙을 자동으로 지켜줍니다.
이제 여러분은 RecyclerView의 변덕에 더 이상 휘둘리지 않을 것입니다. 데이터와 뷰의 상태를 일치시키는 명확한 원칙과 ListAdapter라는 강력한 무기를 손에 쥐었기 때문입니다. 이 가이드를 통해 'Inconsistency detected' 오류에 대한 막연한 두려움을 자신감으로 바꾸고, 사용자에게는 부드럽고 안정적인 경험을 선사하는 개발자가 되시기를 바랍니다.
Post a Comment