
RecyclerView DiffUtil은 단순히 성능을 올리는 기술이 아닙니다. 무엇이 바뀌었는지 더 정확하게 알려줘서 깜빡임, 어색한 애니메이션, 과한 재바인딩을 줄이는 기준이라고 이해하면 핵심이 잘 잡힙니다.
처음에는 notifyDataSetChanged()만으로도 충분해 보이지만, 목록이 자주 바뀌는 화면에서는 금방 불안해집니다. 이번 글에서는 왜 DiffUtil이 필요한지, areItemsTheSame와 areContentsTheSame를 어떻게 나눠야 하는지, 그리고 notifyDataSetChanged()를 아직 써도 되는 경우는 언제인지 실무 감각으로 정리하겠습니다.
왜 notifyDataSetChanged만으로는 불안할까
notifyDataSetChanged()는 목록 전체가 바뀌었다는 신호에 가깝습니다. 문제는 어떤 아이템이 추가됐는지, 어떤 아이템이 삭제됐는지, 어떤 아이템 내용만 바뀌었는지를 설명하지 못한다는 점입니다.
- 댓글 1개의 좋아요 수만 바뀐 경우
- 맨 위에 새 댓글 1개가 추가된 경우
- 중간 댓글 1개가 삭제된 경우
이 세 상황은 UI 입장에서 전혀 다릅니다. 그런데 notifyDataSetChanged()는 이 차이를 거의 한 번에 뭉개버립니다. 그래서 화면은 필요한 만큼만 바꾸기보다 전체를 다시 읽는 쪽으로 흐르기 쉽습니다.
- 살짝 바뀐 것뿐인데 화면이 전체적으로 다시 움직이는 느낌이 남
- 기본 애니메이션이 기대와 다르게 보일 수 있음
- ViewHolder 재바인딩이 많아져 흐름 추적이 어려워짐
- 부분 갱신을 직접 계산하다가 인덱스 실수가 나기 쉬움
즉, 문제의 핵심은 속도 숫자보다도 변경 의미를 잃어버린다는 데 있습니다. AndroidX 쪽 문서 방향도 가능하면 더 구체적인 change event를 쓰고 notifyDataSetChanged()는 마지막 수단처럼 보라는 쪽에 가깝습니다.
RecyclerView DiffUtil은 무엇을 해결할까
DiffUtil 공식 문서 기준으로 보면, DiffUtil은 이전 리스트와 새 리스트의 차이를 계산해서 업데이트 작업을 만들어 줍니다. 쉽게 말해 전체를 다시 그릴지, 일부만 바꿀지, 어디에 삽입과 삭제가 있었는지를 정리해 주는 역할입니다.
이 설명이 중요한 이유는 DiffUtil을 성능 최적화 도구로만 이해하면 실제 장점을 놓치기 쉽기 때문입니다. 실무에서 먼저 체감되는 장점은 변경점이 코드 기준으로 분명해진다는 쪽입니다.
- 변경점이 코드 기준으로 분명해진다
- 부분 갱신 흐름을 만들기 쉬워진다
- 추가, 삭제, 변경에 맞는 애니메이션이 더 자연스러워진다
- 수동 notify 조합보다 실수 가능성이 줄어든다
즉, DiffUtil은 마법이 아니라 목록 변경을 설명 가능한 이벤트로 바꿔 주는 도구입니다.
깜빡임과 애니메이션이 달라지는 이유
RecyclerView는 아이템 추가, 삭제, 변경 같은 이벤트를 알면 그에 맞는 갱신을 더 잘 처리할 수 있습니다. 반대로 전체가 바뀌었다는 신호만 받으면 세밀한 맥락을 잃습니다.
예를 들어 채팅 목록에서 새 메시지 1개가 맨 위에 들어왔다면, 우리가 기대하는 화면은 새 아이템이 자연스럽게 보이고 기존 아이템 전체가 갑자기 다시 그려진 느낌이 적은 상태입니다. DiffUtil은 어떤 아이템이 그대로 있고, 어떤 아이템이 새로 들어왔고, 어떤 아이템의 내용만 바뀌었는지를 나눠 줍니다.
그래서 흔히 말하는 “DiffUtil을 쓰면 애니메이션이 좋아진다”는 표현은 정확히는 애니메이션에 필요한 변경 정보가 더 잘 전달된다는 뜻에 가깝습니다.
부분 갱신이 중요한 이유
부분 갱신은 단순히 리소스를 아끼는 기술이 아닙니다. 화면을 읽는 사람에게도 더 자연스럽습니다. 할 일 목록에서 체크 상태만 바뀌었다면 사용자는 그 한 줄만 바뀌길 기대합니다. 이때 전체 목록이 다시 반응하면 작은 변화가 과하게 느껴집니다.
- 어떤 변경을 의도했는지 추적하기 쉽다
- 재바인딩 범위가 줄어들어 디버깅이 편하다
- 왜 이 아이템까지 다시 그려졌는지에 대한 의문이 줄어든다
그래서 DiffUtil을 도입하는 이유를 “빠르니까”보다 UI 업데이트를 더 정확하게 만들기 위해서라고 이해하는 편이 오래 갑니다.
areItemsTheSame와 areContentsTheSame 기준
여기서 가장 많이 헷갈리는 부분이 DiffUtil.ItemCallback입니다. 특히 areItemsTheSame와 areContentsTheSame를 비슷하게 느껴서 둘 다 같은 비교를 넣는 경우가 많습니다. 하지만 이 둘은 질문 자체가 다릅니다.
areItemsTheSame
두 객체가 같은 아이템인가를 묻습니다. 보통 고유 ID를 비교합니다. 예를 들어 Post(id=10, title=”A”)와 Post(id=10, title=”B”)는 제목이 달라도 같은 게시글일 수 있습니다. 이 경우 areItemsTheSame는 true가 맞습니다.
areContentsTheSame
같은 아이템이라면 보여주는 내용까지 같은가를 묻습니다. 즉, 화면에 반영되는 값이 바뀌었는지를 보는 기준입니다. 같은 게시글이라도 제목이나 상태가 바뀌었다면 areContentsTheSame는 false가 되어야 합니다.
한 줄로 줄이면 같은 대상인가를 보는 질문은 areItemsTheSame, 같은 대상인데 화면 내용까지 같은가를 보는 질문은 areContentsTheSame입니다.
class MessageDiffCallback : DiffUtil.ItemCallback<Message>() {
override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean {
return oldItem == newItem
}
}이 예시는 가장 흔한 시작점입니다. 다만 oldItem == newItem가 항상 정답은 아닙니다. areContentsTheSame는 도메인 객체 전체가 완전히 같은가보다 화면에 반영되는 값이 같은가에 더 가깝기 때문입니다.
submitList 흐름이 안정적인 이유
ListAdapter를 같이 쓰면 이 흐름이 더 깔끔해집니다. 개발자는 이전 리스트를 직접 뒤틀며 notify를 조합하기보다 새 리스트를 만들어 submitList()에 넘기게 됩니다.
class MessageAdapter : ListAdapter<Message, MessageViewHolder>(MessageDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_message, parent, false)
return MessageViewHolder(view)
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
fun renderMessages(newMessages: List<Message>) {
adapter.submitList(newMessages)
}이 방식의 장점은 “어떤 notify를 언제 호출할까”보다 “지금 화면의 정답 목록이 무엇인가”에 집중하게 만든다는 점입니다. 보통 이쪽이 더 유지보수하기 쉽습니다.
비슷한 맥락은 RecyclerView와 ListAdapter 차이 글에서도 이어서 볼 수 있습니다.
실무에서 자주 나는 실수
- ID가 아닌 position으로 같은 아이템을 판단한다
- areItemsTheSame와 areContentsTheSame를 사실상 같은 의미로 구현한다
- 기존 리스트를 직접 수정한 뒤 다시 submitList한다
- DiffUtil이 모든 깜빡임을 해결해 줄 것이라고 기대한다
특히 기존 리스트를 직접 mutate하기보다 새 리스트를 만들어 넘기는 습관이 중요합니다. AndroidX 문서 설명도 diff 계산 중 리스트가 바뀌지 않는 흐름을 전제로 보는 쪽이라, 상태를 어디에 보관하고 어떻게 새 목록을 만들지까지 함께 생각해야 합니다. 이 관점은 화면 상태를 어디에 둘지나 비동기 작업과 구조를 연결하는 방식과도 이어집니다.
notifyDataSetChanged를 아직 써도 되는 경우
여기서 너무 강하게 가면 오히려 실무 감각이 흐려집니다. notifyDataSetChanged()는 여전히 쓸 수 있습니다. 다만 어디까지나 의도를 알고 쓰는 편이 좋습니다.
- 목록이 아주 짧고 단순해서 전체 재바인딩 비용이 사실상 문제 되지 않을 때
- 관리자용 내부 화면처럼 구현 단순성이 더 중요할 때
- 초기 프로토타입 단계에서 먼저 동작 검증이 중요할 때
- 필터가 크게 바뀌어 사실상 전체 목록을 새로 읽는 편이 더 자연스러울 때
반대로 아이템 수가 많고 자주 갱신되거나, 추가·삭제·수정이 섞여 들어오거나, 스크롤 중 부드러운 업데이트가 중요한 화면이라면 DiffUtil 쪽이 훨씬 낫습니다. 즉, notifyDataSetChanged()는 나쁜 메서드라기보다 변경 의미를 포기하고 전체 갱신으로 가는 선택에 가깝습니다.
정리
RecyclerView에서 DiffUtil이 필요한 이유를 한 문장으로 줄이면 목록이 어떻게 바뀌었는지를 전체 갱신이 아니라 설명 가능한 변경으로 다루기 위해서입니다. 그래서 도입할 때는 성능 수치보다 먼저 같은 아이템 기준, 화면 내용 변경 기준, 새 리스트 제출 흐름을 정리하는 편이 좋습니다.
- 같은 아이템의 기준을 먼저 정한다
- 화면 내용이 바뀌었다고 볼 기준을 정한다
- 가능하면 새 리스트를 만들어 submitList한다
- 전체 갱신이 정말 필요한 경우에만 notifyDataSetChanged를 남긴다
이 기준만 잡혀도 RecyclerView 업데이트 코드는 훨씬 덜 불안해집니다.
- RecyclerView와 ListAdapter 차이: 왜 submitList를 쓰면 갱신이 더 쉬워질까
- 안드로이드 앱 구조 입문 시리즈 (5) – 화면 상태는 어디에 두는 게 맞을까
- 안드로이드 앱 구조 입문 시리즈 (6) – 안드로이드에서 비동기 작업은 구조와 어떻게 연결될까
- DiffUtil | Android Developers
- ListAdapter | Android Developers
- RecyclerView.Adapter | Android Developers