
RecyclerView와 ListAdapter 차이는 안드로이드에서 목록 UI를 만들 때 생각보다 빨리 부딪히는 주제입니다. RecyclerView.Adapter는 갱신 책임을 내가 더 많이 지는 방식이고, ListAdapter는 submitList와 DiffUtil로 그 부담을 줄여 주는 방식이라고 보면 핵심이 잘 잡힙니다.
특히 목록이 자주 바뀌는 화면에서는 notifyDataSetChanged()를 언제 써야 하는지, 왜 일부 아이템만 바뀌었는데 전체가 다시 그려지는지, 왜 인덱스 실수로 UI가 꼬이는지에서 자주 막힙니다. 이번 글에서는 그 지점을 실무 감각으로 쉽게 정리해보겠습니다.
RecyclerView와 ListAdapter 차이 한눈에 보기
먼저 감각부터 잡으면 쉽습니다. 둘 다 RecyclerView에 아이템을 그리지만, 목록이 바뀌었을 때 변경점을 누가 계산하느냐가 다릅니다. 즉, ViewHolder를 만드는 방식보다 갱신 책임이 어디에 있느냐가 핵심입니다.

- RecyclerView.Adapter는 데이터 보관과 notify 호출 흐름을 개발자가 더 직접 관리한다
- ListAdapter는 submitList로 새 목록을 넘기고 DiffUtil이 변경점을 계산한다
- 그래서 ListAdapter는 목록 갱신 코드가 더 단순해지기 쉽다
RecyclerView.Adapter
RecyclerView.Adapter는 가장 기본이 되는 방식입니다. 직접 리스트를 들고 있고, onCreateViewHolder, onBindViewHolder, getItemCount를 구현합니다. 자유도는 높지만 목록이 바뀌는 순간부터는 개발자가 챙겨야 할 일이 많아집니다.
class CommentAdapter : RecyclerView.Adapter<CommentViewHolder>() {
private val items = mutableListOf<Comment>()
fun updateItems(newItems: List<Comment>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_comment, parent, false)
return CommentViewHolder(view)
}
override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}처음에는 이 방식이 제일 이해하기 쉽습니다. 하지만 새 데이터가 자주 들어오는 화면에서는 무엇이 바뀌었는지 직접 판단하고 맞는 notify 메서드를 골라야 한다는 부담이 커집니다.
- 하나만 바뀌어도 전체를 다시 그리기 쉽다
- 추가, 삭제, 수정 위치를 개발자가 직접 맞추다가 실수하기 쉽다
- 부분 갱신 코드가 늘수록 유지보수 난도가 올라간다
notifyDataSetChanged
notifyDataSetChanged()가 항상 틀린 것은 아닙니다. 다만 이 메서드는 무엇이 바뀌었는지를 말해주지 못합니다. 공식 소스 설명도 가능하면 더 구체적인 change event를 쓰고, notifyDataSetChanged()는 last resort처럼 보라고 안내합니다.
예를 들어 100개 목록에서 제목 1개만 바뀐 경우, 맨 위에 새 아이템 1개가 추가된 경우, 중간 아이템 1개가 삭제된 경우는 UI 입장에서 전혀 다릅니다. 그런데 notifyDataSetChanged()는 이 차이를 세밀하게 드러내지 못합니다.
그래서 어댑터를 직접 관리할수록 notifyItemChanged, notifyItemInserted, notifyItemRemoved 같은 메서드를 더 자주 직접 다루게 됩니다. 문제는 이 흐름이 조금만 복잡해져도 인덱스 실수와 타이밍 실수가 섞인다는 점입니다.
ListAdapter
ListAdapter는 이런 갱신 부담을 줄여 주는 편의 래퍼입니다. 공식 문서 기준으로 RecyclerView.Adapter 기반 클래스이고, 내부에서 AsyncListDiffer를 사용해 리스트 차이를 계산합니다.
class CommentAdapter : ListAdapter<Comment, CommentViewHolder>(CommentDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_comment, parent, false)
return CommentViewHolder(view)
}
override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
holder.bind(getItem(position))
}
}ViewHolder를 만드는 방식은 크게 다르지 않습니다. 대신 목록 갱신 코드는 adapter.submitList(newComments)처럼 훨씬 짧아집니다. 이 차이가 큽니다.
submitList
submitList()의 핵심은 새 목록을 넘긴다는 데 있습니다. 공식 소스 설명 기준으로 현재 목록이 이미 표시 중이면 diff 계산은 background thread에서 이뤄지고, 결과에 따른 Adapter.notifyItem... 이벤트는 main thread로 전달됩니다.
- 개발자는 지금 화면에 보여줄 새 목록을 만든다
- adapter.submitList(newList)를 호출한다
- 내부에서 이전 목록과 새 목록 차이를 계산한다
- RecyclerView에는 필요한 변경 이벤트만 반영된다
즉, 개발자는 위치를 손으로 맞추는 일보다 지금 화면의 정답 목록이 무엇인지에 더 집중할 수 있습니다. 이 구조 덕분에 갱신 코드가 읽기 쉬워지고, 수동 notify 실수도 줄어듭니다.
DiffUtil
사실 ListAdapter가 편한 진짜 이유는 DiffUtil입니다. DiffUtil은 이전 리스트와 새 리스트를 비교해 어떤 항목이 추가, 삭제, 변경됐는지 계산합니다.
class CommentDiffCallback : DiffUtil.ItemCallback<Comment>() {
override fun areItemsTheSame(oldItem: Comment, newItem: Comment): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Comment, newItem: Comment): Boolean {
return oldItem == newItem
}
}areItemsTheSame
이 메서드는 두 객체가 같은 항목인지 묻습니다. 보통 고유 ID를 비교합니다. 쉽게 말해 같은 사람인가를 보는 질문입니다.
areContentsTheSame
이 메서드는 같은 항목이라면 내용까지 같은지 묻습니다. 제목, 체크 상태, 설명 문구처럼 화면에 보이는 값이 바뀌었는지를 봅니다. 즉, 같은 사람인데 정보가 바뀌었는가를 보는 질문에 가깝습니다.
같은 아이템인지와 내용이 같은지는 다른 질문입니다. 이 둘을 나눠두면 UI 갱신 기준이 훨씬 또렷해집니다.
왜 버그가 줄어들까
submitList()를 쓴다고 버그가 자동으로 사라지는 것은 아닙니다. 그래도 UI 갱신 버그가 줄어드는 이유는 분명합니다. 실수하기 쉬운 책임이 줄어들기 때문입니다.
- 수동 notify 코드가 줄어든다
- 이전 목록과 새 목록 차이를 사람이 직접 덜 계산한다
- 변경 기준이 DiffUtil.ItemCallback에 모여 유지보수가 쉬워진다
- 목록을 직접 비틀기보다 새 상태를 제출하는 흐름이라 읽기가 쉽다
그래서 ListAdapter는 성능 최적화 도구이기만 한 것이 아니라, 실수하기 쉬운 갱신 절차를 더 선언적인 흐름으로 바꾸는 도구라고 보는 편이 더 정확합니다.
자주 하는 실수
같은 리스트를 직접 수정하기
DiffUtil 계열은 diff 계산 중 리스트가 마음대로 바뀌지 않는 흐름을 기대합니다. 그래서 보통은 현재 리스트를 직접 비틀기보다, 새 리스트를 만들어 submitList에 넘기는 습관이 안전합니다.
val updated = adapter.currentList.toMutableList()
updated.add(0, newItem)
adapter.submitList(updated)ID 기준이 흔들리는 경우
areItemsTheSame()에서 position 같은 값을 비교 기준으로 쓰면 금방 꼬입니다. 고정된 고유 ID를 기준으로 잡는 편이 안전합니다.
내용 비교를 너무 느슨하게 쓰는 경우
areContentsTheSame()가 항상 true처럼 동작하면 화면이 바뀌지 않은 것처럼 보일 수 있습니다. 반대로 너무 무겁게 비교하면 불필요한 갱신이 늘 수 있으니, 화면에 실제로 반영되는 값 중심으로 비교하는 편이 좋습니다.
언제 무엇을 쓸까
아주 단순하고 거의 안 바뀌는 목록이라면 RecyclerView.Adapter만으로도 충분합니다. 반대로 서버 응답, DB 변경, 검색 결과처럼 추가·삭제·수정이 자주 섞이는 화면이라면 ListAdapter가 기본 선택지로 꽤 좋습니다.
- 목록 갱신이 거의 없고 특수 제어가 많다 -> RecyclerView.Adapter
- 새 목록 단위로 상태가 자주 바뀐다 -> ListAdapter
- 갱신 버그를 줄이고 싶다 -> ListAdapter
- 다중 타입과 특수 제어가 아주 많다면 RecyclerView.Adapter + AsyncListDiffer 직접 사용도 고려
특히 ViewModel에서 화면 상태를 새 리스트로 만들고, 화면에서는 그 결과를 받아 submitList()만 호출하는 패턴은 구조가 단순합니다. 관련해서 상태를 어디에 보관해야 할지 헷갈린다면 화면 상태는 어디에 두는 게 맞을까 글을 같이 보면 좋습니다. 또 목록 갱신이 비동기 작업과 어떻게 연결되는지 보고 싶다면 안드로이드에서 비동기 작업은 구조와 어떻게 연결될까도 이어서 읽어볼 만합니다.
기억할 기준
- RecyclerView.Adapter는 갱신 책임을 내가 더 많이 진다
- ListAdapter는 submitList와 DiffUtil로 변경 계산 부담을 줄여 준다
- submitList는 마법이 아니라 변경 계산을 덜 직접 하게 해 주는 구조다
즉, RecyclerView와 ListAdapter 차이를 제대로 이해하면 단순 문법 비교를 넘어서, 왜 어떤 화면에서 UI 갱신 코드가 유독 자주 꼬였는지도 같이 이해하게 됩니다.
마무리
RecyclerView와 ListAdapter 차이는 클래스 이름 차이보다 목록 갱신을 누가 더 많이 책임지느냐의 차이에 가깝습니다. 작고 거의 안 바뀌는 목록이라면 RecyclerView.Adapter도 충분하지만, 목록이 자주 바뀌는 화면에서는 ListAdapter와 submitList가 훨씬 편해지는 경우가 많습니다.
특히 초반에 자주 만나는 UI 갱신 버그는 비즈니스 로직보다 변경 계산과 notify 타이밍에서 많이 생깁니다. 그 부담을 줄이고 싶다면 ListAdapter는 꽤 좋은 기본 선택지입니다. 공식 기준은 ListAdapter API reference, DiffUtil API reference, RecyclerView.Adapter API reference를 함께 보면 가장 정확합니다.