Android RecyclerView 여러 아이템 타입 구현: sealed class와 ViewBinding

이 글은 2022년에 작성한 RecyclerView 다중 아이템 예제를 현재 Android 개발 방식에 맞춰 전면 개정한 글입니다. 기존 URL과 주제는 유지하면서 스크린샷 대신 복사해 적용할 수 있는 Kotlin 코드, ListAdapter, DiffUtil, ViewBinding을 사용합니다.

채팅 목록에는 내가 보낸 메시지와 상대방 메시지가 서로 다른 모양으로 나타납니다. 피드에는 본문뿐 아니라 날짜 구분선, 광고, 로딩 상태가 함께 들어갑니다. 이렇게 하나의 RecyclerView에 여러 레이아웃을 표시하려면 데이터의 종류와 화면의 종류를 명확히 연결해야 합니다.

핵심은 세 가지입니다.

  1. sealed interface로 화면에 나타날 데이터 종류를 제한한다.
  2. getItemViewType()에서 각 데이터 종류를 정수 타입으로 변환한다.
  3. onCreateViewHolder()onBindViewHolder()에서 같은 규칙을 사용한다.

먼저 보는 핵심
데이터 타입을 기준으로 ViewHolder를 선택하고, ViewBinding으로 각 레이아웃을 안전하게 참조합니다. 목록 갱신은 notifyDataSetChanged() 대신 ListAdapterDiffUtil에 맡깁니다.

질문
여러 레이아웃은 어디서 구분하나? getItemViewType()
레이아웃은 언제 생성하나? onCreateViewHolder()
데이터는 언제 표시하나? onBindViewHolder()
목록 변경은 어떻게 계산하나? ListAdapterDiffUtil.ItemCallback
클릭 시 position을 저장해도 되나? 저장하지 말고 현재 바인딩한 item을 전달

완성할 예제

예제 목록에는 다음 세 종류의 행이 들어갑니다.

  • 날짜를 표시하는 헤더
  • 내가 보낸 메시지
  • 상대방이 보낸 메시지

데이터가 isMine: Boolean 같은 플래그 하나에 의존하도록 만들 수도 있지만, 종류가 늘어날수록 조건문이 여러 파일에 흩어집니다. 제한된 타입 계층을 만들면 컴파일러가 빠진 분기를 알려주고 각 타입이 필요한 값도 분리할 수 있습니다.

sealed interface ChatItem {
    val stableKey: String

    data class DateHeader(
        override val stableKey: String,
        val label: String,
    ) : ChatItem

    data class MyMessage(
        override val stableKey: String,
        val text: String,
        val sentAt: String,
    ) : ChatItem

    data class OtherMessage(
        override val stableKey: String,
        val sender: String,
        val text: String,
        val sentAt: String,
    ) : ChatItem
}

stableKey는 화면에서 같은 항목인지 판별하는 식별자입니다. 데이터베이스 ID가 있다면 그것을 사용하고, 헤더처럼 별도 ID가 없다면 date:2026-09-02처럼 충돌하지 않는 키를 만듭니다.


ViewBinding 활성화

앱 모듈의 build.gradle.kts에서 ViewBinding을 켭니다.

android {
    buildFeatures {
        viewBinding = true
    }
}

다음 레이아웃 파일을 만든다고 가정하겠습니다.

레이아웃 생성되는 binding class 용도
item_chat_date.xml ItemChatDateBinding 날짜 구분선
item_chat_mine.xml ItemChatMineBinding 내가 보낸 메시지
item_chat_other.xml ItemChatOtherBinding 상대방 메시지

ViewBinding은 ID가 있는 View의 직접 참조를 생성하므로 findViewById()와 잘못된 캐스팅을 줄여 줍니다. 각 레이아웃에는 아래 코드가 참조하는 dateText, messageText, timeText, senderText ID가 있어야 합니다.


ViewHolder를 타입별로 분리하기

ViewHolder도 제한된 계층으로 만들면 어댑터의 분기와 같은 구조를 유지할 수 있습니다.

sealed class ChatViewHolder(
    itemView: View,
) : RecyclerView.ViewHolder(itemView) {

    class Date(
        private val binding: ItemChatDateBinding,
    ) : ChatViewHolder(binding.root) {
        fun bind(item: ChatItem.DateHeader) {
            binding.dateText.text = item.label
        }
    }

    class Mine(
        private val binding: ItemChatMineBinding,
        private val onClick: (ChatItem.MyMessage) -> Unit,
    ) : ChatViewHolder(binding.root) {
        fun bind(item: ChatItem.MyMessage) = with(binding) {
            messageText.text = item.text
            timeText.text = item.sentAt
            root.setOnClickListener { onClick(item) }
        }
    }

    class Other(
        private val binding: ItemChatOtherBinding,
        private val onClick: (ChatItem.OtherMessage) -> Unit,
    ) : ChatViewHolder(binding.root) {
        fun bind(item: ChatItem.OtherMessage) = with(binding) {
            senderText.text = item.sender
            messageText.text = item.text
            timeText.text = item.sentAt
            root.setOnClickListener { onClick(item) }
        }
    }
}

클릭 리스너가 생성 당시의 position을 저장하지 않는 점이 중요합니다. 삽입이나 삭제 후 위치가 바뀔 수 있으므로, 바인딩 시 받은 item 자체를 전달하는 편이 안전합니다.


ListAdapter 전체 구현

RecyclerView.Adapter에 가변 목록을 직접 넣을 수도 있지만, 여기서는 변경된 행만 계산해 갱신하는 ListAdapter를 사용합니다.

class ChatAdapter(
    private val onMyMessageClick: (ChatItem.MyMessage) -> Unit,
    private val onOtherMessageClick: (ChatItem.OtherMessage) -> Unit,
) : ListAdapter<ChatItem, ChatViewHolder>(DiffCallback) {

    override fun getItemViewType(position: Int): Int =
        when (getItem(position)) {
            is ChatItem.DateHeader -> VIEW_TYPE_DATE
            is ChatItem.MyMessage -> VIEW_TYPE_MINE
            is ChatItem.OtherMessage -> VIEW_TYPE_OTHER
        }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int,
    ): ChatViewHolder {
        val inflater = LayoutInflater.from(parent.context)

        return when (viewType) {
            VIEW_TYPE_DATE -> ChatViewHolder.Date(
                ItemChatDateBinding.inflate(inflater, parent, false),
            )

            VIEW_TYPE_MINE -> ChatViewHolder.Mine(
                ItemChatMineBinding.inflate(inflater, parent, false),
                onMyMessageClick,
            )

            VIEW_TYPE_OTHER -> ChatViewHolder.Other(
                ItemChatOtherBinding.inflate(inflater, parent, false),
                onOtherMessageClick,
            )

            else -> error("Unknown viewType: $viewType")
        }
    }

    override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
        when (val item = getItem(position)) {
            is ChatItem.DateHeader -> (holder as ChatViewHolder.Date).bind(item)
            is ChatItem.MyMessage -> (holder as ChatViewHolder.Mine).bind(item)
            is ChatItem.OtherMessage -> (holder as ChatViewHolder.Other).bind(item)
        }
    }

    companion object {
        private const val VIEW_TYPE_DATE = 1
        private const val VIEW_TYPE_MINE = 2
        private const val VIEW_TYPE_OTHER = 3

        private val DiffCallback = object : DiffUtil.ItemCallback<ChatItem>() {
            override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean =
                oldItem::class == newItem::class &&
                    oldItem.stableKey == newItem.stableKey

            override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean =
                oldItem == newItem
        }
    }
}

getItemViewType()에서 반환한 값과 onCreateViewHolder()의 분기가 반드시 일치해야 합니다. 알 수 없는 타입을 조용히 기본 레이아웃으로 처리하면 데이터 오류가 감춰지므로 error()로 즉시 드러내는 편이 낫습니다.

areItemsTheSame()은 동일한 항목인지, areContentsTheSame()은 표시 내용까지 같은지 판단합니다. 식별자는 같지만 메시지가 수정됐다면 첫 함수는 true, 둘째 함수는 false가 되어 해당 행만 다시 바인딩됩니다.


Fragment에서 연결하기

private val chatAdapter by lazy {
    ChatAdapter(
        onMyMessageClick = { message ->
            viewModel.selectMessage(message.stableKey)
        },
        onOtherMessageClick = { message ->
            viewModel.selectMessage(message.stableKey)
        },
    )
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    binding.chatList.apply {
        layoutManager = LinearLayoutManager(requireContext())
        adapter = chatAdapter
    }

    chatAdapter.stateRestorationPolicy =
        RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY

    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.items.collectLatest(chatAdapter::submitList)
        }
    }
}

비동기로 데이터를 가져오는 목록에서는 복원 시점에 어댑터가 비어 있을 수 있습니다. PREVENT_WHEN_EMPTY를 설정하면 데이터가 들어오기 전에 스크롤 상태가 잘못 복원되는 상황을 줄일 수 있습니다.


자주 발생하는 오류

잘못된 ViewHolder 캐스팅

getItemViewType()onCreateViewHolder()의 매핑이 다르면 onBindViewHolder()에서 ClassCastException이 발생합니다. 타입 상수는 한곳에 두고 세 함수의 분기를 함께 수정하세요.

모든 변경에 notifyDataSetChanged 사용

전체 갱신은 어떤 항목이 변했는지 RecyclerView에 알려주지 못합니다. 애니메이션과 성능이 나빠지고 화면 상태를 추적하기도 어렵습니다. 새 불변 목록을 만들어 submitList(newList)로 전달하세요. 기존 목록 객체를 직접 수정한 뒤 다시 제출하지 않는 것이 중요합니다.

position을 클릭 리스너에 캡처

다음 코드는 피해야 합니다.

holder.itemView.setOnClickListener {
    onClick(items[position])
}

클릭 시점에는 position이 더 이상 같은 항목을 가리키지 않을 수 있습니다. 이 글의 예제처럼 바인딩한 item을 직접 전달하거나, 꼭 위치가 필요하면 클릭 시점에 bindingAdapterPositionNO_POSITION이 아닌지 확인하세요.

타입 수가 계속 늘어나는 거대한 어댑터

서로 독립적인 섹션을 단순히 이어 붙이는 화면이라면 하나의 거대한 다중 타입 어댑터보다 ConcatAdapter가 더 읽기 쉽습니다. 예를 들어 헤더 어댑터, 콘텐츠 어댑터, 로딩 어댑터를 별도로 테스트하고 합칠 수 있습니다.


RecyclerView와 Compose 중 무엇을 선택할까

새 화면을 전부 Compose로 작성한다면 LazyColumn과 sealed 타입 분기로 같은 구조를 더 짧게 구현할 수 있습니다. 그러나 기존 XML 화면, 복잡한 View 기반 위젯, 점진적 마이그레이션이 필요한 앱에서는 RecyclerView가 여전히 실용적입니다.

선택 기준은 “어느 기술이 최신인가”가 아니라 현재 화면이 어떤 UI 체계에 속하는가입니다. View 기반 화면 하나 때문에 Compose를 억지로 섞거나, Compose 앱에 새 RecyclerView를 추가할 필요는 없습니다.


완료 점검

  • 데이터 타입과 레이아웃 타입이 일대일로 대응한다.
  • 각 XML에 맞는 ViewBinding class를 사용한다.
  • 알 수 없는 view type을 기본값으로 숨기지 않는다.
  • DiffUtil의 ID 비교와 내용 비교를 구분한다.
  • 클릭 리스너가 오래된 position을 저장하지 않는다.
  • 새 목록을 submitList()로 전달한다.
  • 빈 목록과 빠른 연속 갱신에서도 동작을 확인한다.

다중 아이템 RecyclerView의 본질은 조건문을 많이 쓰는 것이 아닙니다. 데이터 모델, ViewHolder, 레이아웃의 대응 관계를 한눈에 확인할 수 있게 만드는 것입니다. 이 관계가 명확하면 네 번째와 다섯 번째 타입이 추가되어도 구조가 무너지지 않습니다.


참고 자료

댓글

이 블로그의 인기 게시물

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

Xamarin WebView 예제를 .NET MAUI로 다시 만들기

Android Koin 적용하기: Compose와 ViewModel 의존성 주입