RecyclerView 톺아보기

윤성현·2025년 1월 10일

글쓰기 챌린지

목록 보기
1/5
post-thumbnail

RecyclerView에 대해서 알아보자

RecyclerView는 안드로이드 개발자가 리스트 형태의 UI를 만들 때 자주 사용하는 컴포넌트입니다. 이름에 적혀있듯 재활용을 한다고 언뜻 알고는 있지만, 어떤 원리로 재활용이 되고 있는지 조금 더 깊게 파보면서 RecyclerView에 대해 알아가봅시다!

서론

글에 들어가기에 앞서, 저와 RecyclerView는 꽤나 연이 깊습니다. 제가 안드로이드에 관심을 가지고 개발을 시작했을 때 처음 장벽을 느꼈던 부분이 RecyclerView였죠. 그 때는 혼자 학습을 했었는데, 혼자여서 그랬는지 이 부분을 학습하는 데 꽤나 어려웠던 기억이 납니다. 바로 다음학기에 모바일 프로그래밍 수업을 수강했었는데, 코로나 이슈로 인한 온라인 수업 + RecyclerView의 높은 난이도 두 가지의 여파로 많은 학우들이 수업을 드랍하겠다는 사람이 많았습니다.

저는 제가 처음 장벽을 느꼈던 부분에서 학우들이 포기하게 되는 상황이 아쉽다고 생각했고, 이에 RecyclerView를 설명하는 영상을 만들었습니다. (덧붙여 모바일 개발이라는 분야가 꽤나 재밌는데 학우들이 포기하지 않고 저처럼 흥미를 느끼면 좋겠다고 생각했었습니다. 😁) 처음에는 과제를 그대로 풀어버리는 영상을 만들어서 물의를 약간 빚었지만… 이후에는 유사한 과제를 따로 만들어서 설명하는 영상을 만들었고, 적지 않은 학생들에게 도움을 주었습니다. 영상 뿐만 아니라 질의응답방을 만들어서 잠시나마 질문을 올리는 카톡방도 따로 운영했었죠.

어떻게 보면 제가 안드로이드에 흥미를 가지게 된, 그리고 어떻게 보면 개발 분야에서의 첫번째 딥다이브 경험이 RecyclerView가 아니었을까 싶습니다. 이번에는 전반적인 원리를 살펴보며 조금 더 깊게 딥다이브 해보려는 취지에서 이번 글을 작성하게 되었습니다.

RecyclerView가 뭔가요?

안드로이드 환경에서 여러개의 아이템을 스크롤하는 화면을 만들고 싶을 때 어떻게 구성하면 좋을지 알아봅시다!

RecyclerView란?

RecyclerView는 안드로이드에서 리스트나 그리드와 같은 스크롤 가능한 UI를 구현하기 위해 사용되는 뷰 그룹(ViewGroup)입니다.

RecyclerView는 Android 5.0(Lollipop)에서 도입되었으며, 이전에 사용되던 ListView와 GridView를 대체하는 더 성능이 뛰어나고 확장 가능한 구성 요소입니다. 이를 통해 대량의 데이터를 효율적으로 표시하고 관리할 수 있습니다.

주요 목적

  • 대량의 데이터를 스크롤 가능한 형식으로 효율적으로 표시
  • 데이터의 변경 사항을 동적으로 반영
  • 사용자 경험을 향상시키기 위해 애니메이션과 다양한 형식의 UI를 제공

등장 배경

Android 초창기에는 목록을 구현하기 위해 ListView를 사용했지만, ListView는 다음과 같은 단점들이 있었습니다.

  1. 재사용의 제한성
    • ListView는 convertView를 사용해 뷰를 재활용했지만, 이 메커니즘은 뷰타입의 다양성을 제대로 처리하지 못했습니다.
    • 뷰타입이 여러 개일 경우, 개발자가 직접 추가 로직을 작성해야 했기 때문에 오류 가능성이 높아지고 코드 관리가 어려웠습니다.
  2. 레이아웃 관리의 제한
    • ListView는 세로 방향의 스크롤만 기본적으로 지원합니다. 가로 스크롤이나 그리드 레이아웃을 구현하려면 커스텀 어댑터와 뷰를 직접 관리해야 하며, 이는 코드 복잡성을 증가시키고 유지보수를 어렵게 만들었습니다.
    • LayoutManager와 같은 별도의 구조적 지원이 없기 때문에 개발자가 레이아웃과 뷰의 동작을 모두 수동으로 처리해야 했습니다.
  3. 데이터 변경 반영의 비효율성
    • ListView에서 데이터가 변경되면 notifyDataSetChanged() 메서드를 호출해 전체 목록을 다시 갱신해야 했습니다.
    • 이는 특정 데이터만 업데이트하는 경우에도 전체 뷰를 다시 그리기 때문에 비효율적이고 성능 저하로 이어졌습니다.
  4. 애니메이션의 제한
    • ListView는 아이템 추가, 삭제, 변경 시 기본적으로 애니메이션 전용 클래스를 제공하지 않았습니다. 대신 개발자는 ObjectAnimatorAnimatorSet과 같은 도구를 사용해 커스텀 애니메이션을 직접 구현해야 했습니다.
    • 이러한 방식은 추가적인 코드 작성과 디버깅을 요구하며, 애니메이션 구현의 일관성을 유지하기 어렵게 만들었습니다.

RecyclerView는 이러한 문제를 해결하기 위해 만들어졌습니다. RecyclerView는 재활용 메커니즘을 더 체계화하고, 모듈화된 구성 요소(예: Adapter, LayoutManager)를 도입하여 확장성을 높였습니다.

주요 특징

  1. 재활용 메커니즘
    RecyclerView는 화면에 표시되는 뷰(View)만 생성하고, 화면에서 사라지면 해당 뷰를 재활용합니다. 이를 통해 메모리 사용을 줄이고 성능을 최적화합니다. 스크롤 속도가 빠르고, 대량의 데이터를 처리하는 애플리케이션에서도 안정적으로 작동합니다.

  2. 모듈화된 구성 요소

    RecyclerView는 핵심 기능을 모듈화된 구성 요소로 나누어 개발자가 필요에 따라 조합하고 확장할 수 있게 설계되었습니다. 이 구조는 확장성과 유지보수를 용이하게 만듭니다.

    • Adapter: RecyclerView의 데이터와 뷰를 연결하는 필수 컴포넌트입니다. Adapter는 데이터 세트를 관리하며, 데이터가 어떤 뷰와 연결될지 결정합니다.
    • ViewHolder: 각 아이템의 뷰를 캡슐화하여 성능을 최적화합니다. ViewHolder는 뷰의 참조를 저장하여 findViewById() 호출을 최소화하고, 아이템 뷰가 재활용될 때 데이터만 업데이트하도록 설계되었습니다.
    • LayoutManager: RecyclerView의 레이아웃 배치를 관리하는 컴포넌트입니다. 개발자는 LayoutManager를 교체함으로써 세로/가로 방향을 바꾼다던지, 보여지는 뷰의 형식을 바꾸는 등 다양한 UI를 쉽게 구현할 수 있습니다.
  3. 유연한 애니메이션 및 전환 지원
    RecyclerView는 ItemAnimator를 통해 기본 애니메이션(아이템 추가, 삭제, 이동)을 지원합니다. 또한, 커스텀 애니메이션을 쉽게 추가할 수 있습니다.

  4. 데코레이터(Decorator) 지원
    RecyclerView는 ItemDecoration 클래스를 통해 아이템 간의 구분선, 여백, 배경 등을 손쉽게 추가할 수 있습니다. 이러한 기능은 코드를 간소화하고, UI 디자인 작업을 더 직관적으로 만듭니다.

ListView와의 비교

안드로이드 초기부터 사용되었던 ListView와 안드로이드 5.0에서 등장한 RecyclerView는 어떤 차이가 있는지 비교해봅시다!

ListView vs RecyclerView

특징ListViewRecyclerView
재사용 메커니즘제한적이며 뷰타입 관리가 어렵다ViewHolder를 사용한 체계적 재활용
레이아웃 관리단순한 세로 레이아웃만 지원LayoutManager를 통한 유연성
애니메이션 지원제공하지 않음기본 애니메이션 및 커스텀 지원
UI 전환지원하지 않음LayoutManager 변경으로 전환 가능
데이터 변경 처리전체 갱신 필요특정 항목만 갱신 가능
확장성제한적높은 확장성
성능대량 데이터 처리에 비효율적최적화된 성능

장단점

  • RecyclerView의 장점
    • 데이터가 많아도 메모리 사용을 줄여 성능이 좋음
    • 다양한 LayoutManager를 사용하여 UI 변경에 용이
    • 데이터 변경 사항을 자연스럽게 반영할 수 있는 애니메이션 제공
  • RecyclerView의 단점
    • 초기 설정이 ListView보다 복잡하고 코드량이 많아질 수 있음
    • 간단한 목록 구현에는 오히려 비효율적일 수 있음

참고

ListView에 대한 안드로이드 공식문서에서는 다음과 같이 말하고 있습니다.

For a more modern, flexible, and performant approach to displaying lists, use RecyclerView.
리스트를 표시하는 데 있어 더욱 현대적이고 유연하며 성능 좋은 방식을 원하시면 RecyclerView를 사용하세요.



어떻게 쓰나요?

7가지 단계와 그 이유

각 단계를 기계적으로 수행하다보면, 내가 왜 이런 작업을 하고 있는지 놓치게 됩니다.
적어도 내가 왜 이런 작업을 해야하는지, 각 순서가 왜 존재하는지 생각해봅시다!

1. 레이아웃 파일에서 RecyclerView를 선언한다.

RecyclerView는 위젯으로써 XML 레이아웃 파일에 선언되어야 화면의 구성 요소로 보여질 수 있습니다. RecyclerView를 사용하고자 하는 activity_~~.xml파일에서 RecyclerView가 보여질 위치와 크기(width & height)를 조정해야 합니다.

2. 한 항목이 어떻게 보여질 것인지 정의하는 xml파일을 만든다.

RecyclerView는 결국 "여러 데이터"를 특정 뷰 요소로 보여주는 위젯입니다. 한 덩어리의 데이터를 어떻게 배치할 것인지를 결정함으로써 한 항목이 어떻게 보여질 것인지에 대해서 정의할 수 있게 됩니다. 이는 뷰 요소양식(혹은 포맷)을 만들었다고도 표현할 수 있습니다.

예를 들면, 프로필 이미지는 좌측 상단에, 유저이름은 프로필 이미지 바로 우측에 두는 등의 "배치 방법"을 하나의 양식으로 만드는 과정이라고 생각하시면 됩니다. 한 항목에 대한 xml 파일을 제작하는 것으로 화면의 반복되는 뷰 요소의 양식을 만들게 된 것입니다.

일반적으로 item_~~.xml 이름의 파일을 만들어 각 항목의 레이아웃을 정의합니다.

3. 한 항목에 보여질 데이터를 저장하는 Data Class를 작성한다.

2번의 설명을 다시 보면 뷰 요소에는 어떤 덩어리 단위의 데이터가 필요하다는 것을 알 수 있습니다. 그리고 좀 더 자세히 읽어보면 그 데이터는 하나의 덩어리 형태로 이루어져야 한다는 것을 유추해볼 수 있습니다.

이제 우리는 그 하나의 덩어리 형태의 데이터를 만드는 작업을 할 것입니다. 위에서 만들었던 item_~~.xml 파일에 정의된 데이터 구조를 참고하여 필요한 필드를 가진 Data Class를 작성합니다. 예시로 댓글창에서 댓글 하나의 요소를 담는다면 아래와 비슷할 것입니다.

data class Comment(
	val id: Long,
	val profileUrl: String,
	val username: String,
	val content: String,
)

4. 하나의 항목을 나타내는 XML 파일과 데이터를 연결하는 ViewHolder를 작성한다.

‘갑자기 뷰홀더가 왜 필요하지?’라는 궁금증이 생길 수 있습니다. 하지만 잘 생각해보면 우리는 지금 1~3번의 과정에서 한 항목의 뷰 요소와 데이터 덩어리만 만들었습니다. 뷰 요소데이터 덩어리 두 가지가 연결되지 않는다면, 서로 관계 없는 데이터 조각들에 불과합니다.

뷰홀더는 하나의 뷰 요소에 대해서 데이터를 어떻게 넣어줄 것인지 정의하는 곳입니다. 자세하게 설명하면 어떤 TextView에 어떤 데이터 값을 넣을 것인지, 어떤 ImageView에는 어떤 값을 넣을 것인지 결정함으로써 데이터와 뷰 요소를 연결하게 됩니다.

그렇기 때문에 ViewHolder는 일반적으로 bind 함수만 가지고 있습니다. 위에서 보여드렸던 것처럼 댓글창을 예시로 한다면 다음과 비슷한 코드가 나올 것입니다.

// ViewBinding 사용시
class CommentViewHolder(
	private val binding: ItemCommentBindning,
) : RecyclerView.ViewHolder(binding.root) {
	fun bind(comment: Comment) {
		binding.tvUsername.text = comment.username
		binding.tvContent.text = comment.content
		// 이미지는 생략합니다.
	}
}
// ViewBinding 미 사용시
class CommentViewHolder(
	 itemView: View,
) : RecyclerView.ViewHolder(itemView) {
	private val tvUsername: TextView = itemView.findViewById(R.id.tvUsername)
	private val tvContent: TextView = itemView.findViewById(R.id.tvContent)

	fun bind(comment: Comment) {
		tvUsername.text = comment.username
		tvContent.text = comment.content
	}
}

5. 리스트 형태의 데이터를 여러 개의 뷰 요소로 렌더링 할 Adapter를 작성한다.

RecyclerView를 사용하려고 했다면, 당신은 아마도 하나의 데이터를 보여주는 것보다는 컬렉션 형태(높은 확률로 List)의 데이터를 보여주고 싶었을 것입니다. 4번까지의 과정에서 우리는 하나의 뷰요소에 하나의 데이터를 연결했는데, 아직 여러개의 데이터를 연결하지는 못했습니다. Adapter를 통해서 컬렉션 형태의 데이터가 어떻게 여러개의 뷰 요소로 보여지는지 살펴보도록 하겠습니다.

Adapter에서 필수적으로 구현해야하는 세 가지의 override 함수와 필수는 아니지만 하나의 추가적인 일반 함수를 살펴보면서 어댑터가 어떤 일을 하고, 왜 필요한지 알아보도록 하겠습니다. 위에서 예시를 들었던 것과 같이 계속해서 댓글창에 대한 예시로 진행해보겠습니다.

class CommentAdapter(
		private val comments: MutableList<Comment>
		// 추가적인 설명을 위해 MutableList를 사용하지만, 가급적 List를 사용하세요.
): RecyclerView.Adapter<CommentViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
        val binding = ItemCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return CommentViewHolder(binding)
    }

    override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
        holder.bind(comments[position])
    }

    override fun getItemCount(): Int = comments.size

		// 데이터를 변경하는 커스텀 함수
    fun submitList(newComments: List<Comment>) {
        comments.clear()
        comments.addAll(newComments)
        notifyDataSetChanged()
    }
}
  • onCreateViewHolder

    • 새로운 ViewHolder가 필요할 때 호출되는 함수입니다.
    • 화면에 보여질 아이템의 레이아웃을 inflate하고 ViewHolder를 생성합니다.
      • parent는 RecyclerView 자체를 의미하며, 이를 통해 context를 얻어올 수 있습니다.
      • viewType은 여러 종류의 아이템 뷰를 사용할 때 구분하기 위한 파라미터입니다.
    • 이 함수는 RecyclerView가 처음 그려질 때와 추가적인 ViewHolder가 필요하다고 판단되는 지점에서 호출됩니다. 다르게 말하면, 충분한 ViewHolder가 존재한다면 호출되지 않는 함수입니다.
  • onBindViewHolder

    • ViewHolder에 데이터를 바인딩할 때 호출되는 함수입니다.
    • position을 통해 현재 바인딩할 아이템의 위치를 알 수 있습니다.
      • 해당 position의 데이터를 ViewHolder의 bind 함수를 통해 뷰에 설정합니다.
      • 스크롤할 때마다 화면에 보이는 아이템들에 대해 이 함수가 호출됩니다.
  • getItemCount

    • RecyclerView가 표시할 전체 아이템의 개수를 반환하는 함수입니다.
    • 데이터 리스트의 크기를 반환하여 RecyclerView가 몇 개의 아이템을 표시해야 하는지 알려줍니다.
      • 이 값에 따라 RecyclerView는 스크롤의 범위를 결정합니다.
  • submitList (이 부분은 궁금하지 않다면 넘어가도 좋습니다)

    이 함수는 커스텀 함수로, 새로운 데이터 리스트를 설정할 때 사용합니다.
    이 커스텀 함수를 통해 두 가지 알아볼 것이 있습니다.

    1. 새로운 데이터 셋을 어떻게 보여줄 수 있을까요?

      현재 존재하는 데이터 셋에서 무언가 하나가 지워져야 하거나, 새로운 데이터 셋을 보여줘야하는 상황이라고 생각해봅시다. 이때 comments를 clear하고 addAll 함으로써 데이터 셋을 변경했다고 가정해봅시다. 그럼 보여지고 있는 RecyclerView의 화면이 바뀔까요? 아쉽게도 바뀌지 않습니다. 😢

      이는 데이터가 바뀌었다는 것을 RecyclerView에게 알려주지 않았기 때문입니다. RecyclerView는 데이터가 바뀌었다는 사실을 알 수 있는 방법이 없기 때문에, 우리가 직접 알려줘야 합니다.

      이를 위해 RecyclerView.Adapter 클래스는 데이터셋의 변경을 알리는 다양한 notify 함수들을 제공합니다.

      • notifyDataSetChanged(): 전체 데이터셋이 변경되었음을 알립니다. 모든 아이템을 다시 바인딩합니다.
      • notifyItemChanged(position): 특정 위치의 아이템이 변경되었음을 알립니다.
      • notifyItemInserted(position): 특정 위치에 새 아이템이 추가되었음을 알립니다.
      • notifyItemRemoved(position): 특정 위치의 아이템이 제거되었음을 알립니다.

      따라서 데이터셋을 변경한 후에는 반드시 적절한 notify 함수를 호출해야 UI가 업데이트됩니다.

    2. ListAdapter를 활용하면 좋습니다.

      사실 submitList는 ListAdapter 클래스의 메서드입니다. 따라서 ListAdapter를 사용하면 submitList 함수를 override해서 구현할 수 있습니다. (자세한 사항은 AsyncListDiffer.java의 submitList를 참고해보시면 좋습니다.)

      데이터 셋이 변경되는 일은 꽤 자주 일어납니다. 그럴때마다 notifyDataSetChanged()를 사용하면 전체 뷰를 다시 그리기 때문에 성능상으로도 좋지 않으며, 다시 그려짐으로 인해서 화면 또한 한 번 깜빡거리게 됩니다. 이를 최적화하기 위해 데이터를 비교하는 기준인 DiffUtil을 만들고 ListAdapter의 인자로 넣어주면, submitList를 호출할 때마다 이전 리스트와 새로운 리스트의 차이를 자동으로 계산하여 효율적으로 업데이트를 하게 됩니다.

      덤으로 notify 함수에서 position 값을 넣어주면서 변경 지점까지 알려줬던 것과 다르게, 그저 리스트를 전달함으로써 변경점을 신경쓰지 않고도 업데이트를 할 수 있는 편리성까지 얻을 수 있게 됩니다.

      ListAdapter를 사용한 예시 코드는 다음과 같습니다.

      class CommentListAdapter : ListAdapter<Comment, CommentViewHolder>(DiffUtil()) {
          override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
              // ViewHolder 생성 로직
          }
      
          override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
              holder.bind(getItem(position))
          }
          
          override fun submitList(list: List<Musical>) {
      		    super.submitList(list)
          }
          
          companion object {
      		    val DiffUtil = object: 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
      				    }
      		    }
          }
      }

6. RecyclerView에 만든 Adapter를 넣는다.

이제, RecyclerView를 사용할 준비가 끝났으며, 사용하기만 하면 됩니다. RecyclerView를 보여줄 Activity 클래스로 이동해서, 해당 RecyclerView의 adapter 속성에 지금까지 만든 Adapter를 넣어주면 됩니다. (이때, 직접 만든 Adapter가 입력 파라미터를 가지고 있다면 선언할 때 같이 넣어주면 됩니다.)

RecyclerView.java파일을 보면 setAdapter라는 함수를 통해서 어댑터를 설정할 수 있다는 것을 알 수 있습니다. 이 함수를 통해 직접 만든 adapter를 넣어주면 됩니다.

class CommentActivity: AppCompatActivity() {
	 private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
	 private val comments = listOf<Comment> ( ... )
	 
	 override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
				
		binding.rvComments.adapter = CommentAdapter(comments)
		// RecyclerView 의 mAdapter에 직접 접근하는 것처럼 보이지만,
		// 코드를 타고 들어가거나 역컴파일을 해보면
		// RecyclerView의 setAdapter 함수를 통해서 adapter를 넣어주고 있음을 알 수 있습니다.
	}
}

7. LayoutManager를 설정한다.

6번까지의 과정을 따라왔는데 RecyclerView가 제대로 보이지 않는다면, LayoutManager 속성을 설정하지 않았을 확률이 높습니다. LayoutManager는 RecyclerView의 아이템들을 어떤 방식으로 배치하고 스크롤할지를 결정하는 핵심 컴포넌트입니다. 이는 각 항목의 위치 지정, 레이아웃의 방향, 재사용될 뷰의 정책 등을 관리합니다.

LayoutManager 없이는 RecyclerView가 어떻게 아이템을 배치해야 할지 알 수 없기 때문에, RecyclerView 를 사용할 때는 반드시 (RecyclerView를 선언한) xml 파일이나 Activity 클래스 파일 중 한 곳에서 LayoutManager 속성을 설정해줘야 합니다. (일반적인 수직 스크롤에서는 LinearLayoutManager를 넣어주면 됩니다.)

// activity_comment.xml
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/rv_comment"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
    .../>

// CommentActivity
override fun onCreate(savedInstanceState: Bundle?) {
	// 이외 코드
	binding.rvComments.adapter = CommentAdapter()
	binding.rvComments.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this)
}
  • LinearLayoutManager: 가장 기본적인 레이아웃 매니저로, 아이템을 수직 또는 수평으로 배치

    • 수직 스크롤: 일반적인 리스트 형태 (기본값)

    • 수평 스크롤: LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)

  • GridLayoutManager: 아이템을 그리드 형태로 배치

    • spanCount로 한 행/열에 들어갈 아이템 수를 지정

    • 갤러리나 상품 목록 등에 적합

      binding.recyclerView.layoutManager = GridLayoutManager(context, spanCount = 2)
  • StaggeredGridLayoutManager: 다양한 크기의 아이템을 그리드 형태로 배치

    • 아이템의 크기가 불규칙할 때 공간을 효율적으로 활용

      binding.recyclerView.layoutManager = StaggeredGridLayoutManager(
          spanCount = 2, 
          StaggeredGridLayoutManager.VERTICAL
      )



RecyclerView 원리가 뭔가요?

재활용의 원리가 궁금해요 🤔

RecyclerView의 뷰 재활용 메커니즘은 다음과 같은 원리로 작동합니다.

알고 가면 좋을 메서드

  1. onAttachedToRecyclerView : 어댑터가 RecyclerView에 붙었을 때 최초 한 번 호출된다.
  2. onCreateViewHolder : 새로운 뷰홀더를 생성할 때 호출된다.
  3. onBindViewHolder : 뷰홀더에 데이터 바인딩 시 호출된다.
  4. onViewAttachedToWindow : 뷰홀더가 화면에 보일 때 호출된다.
  5. onViewDetachedFromWindow : 뷰홀더가 화면에서 완전히 사라질 때 호출된다.
  6. onViewRecycled : 해당 뷰홀더를 재활용하기 직전에 호출된다.
  7. onDetachedFromRecyclerView : 어댑터가 RecyclerView에서 제거되었을 때 마지막 한 번 호출된다.

콜백 함수 호출로 알아보는 RecyclerView의 생명주기 맛보기 🍪

위에서 살펴본 오버라이드 함수에 다음과 같이 코드를 추가하면 로그를 살펴볼 수 있습니다!

‼️ 아래와 같이 로그를 찍게 되면, “함수가 호출된 횟수”를 보게 됩니다. 정확히 몇 번째의 아이템의 생명주기를 보는 것과는 조금 다릅니다. 대신, 위로만 스크롤한다면 onBindViewHolder의 로그에 찍힌 숫자와 가리키는 아이템의 위치(몇 번째 아이템인지)는 동일하게 됩니다.

class CommentAdapter : RecyclerView.Adapter<CommentViewHolder>() {
		val counts = MutableList(7) { 0 } // 각 함수 호출 횟수를 저장
		
    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        super.onAttachedToRecyclerView(recyclerView)
        println("onAttachedToRecyclerView: ${++counts[0]}")
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
        println("onAttachedToRecyclerView: ${++counts[1]}")
        return CommentViewHolder(/* ... */)
    }

    override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
        println("onBindViewHolder: ${++counts[2]}")
    }

    // 기타 함수들도 동일하게 사용
    // Logcat에서 System.out으로 필터를 설정하면 println으로 찍은 로그들만 따로 모아볼 수 있습니다.
}

RecyclerView가 초기화된 화면에서 이루어지는 동작들을 살펴보자!

  1. RecylcerView가 보여지는 순간 맨 처음에는 onAttachedToRecyclerView가 호출됩니다. 그 이유는, 처음으로 어댑터가 RecyclerView에 붙은 시점이기 때문입니다.

  2. 각 뷰홀더는 “생성” → “바인딩” → “뷰 그룹에 붙임” 순서를 거쳐 화면에 보이게 됩니다.
    그렇기 때문에 onCreateViewHolder → onBindViewHolder → onViewAttachedToWindow 순서로 메서드가 호출되는 것을 볼 수 있습니다.

  3. 추가적으로 살펴볼 수 있는 것은, 한 번에 여러 개의 뷰홀더를 생성하거나 한 번에 여러 개를 바인딩하는 것이 아니라 순서대로 생성하고 바인딩하는 것을 볼 수 있습니다. 이를 통해 RecyclerView는 뷰 요소(항목)가 필요한 시점에 대해서 순차적으로 뷰홀더를 채워나가는 방식을 사용하고 있음을 알 수 있습니다.

스크롤을 조금 내려보면서 함수 호출을 들여다보자!

  1. 7번째 뷰홀더가 생성되고 attached 된 후에, 가장 상단에 있던 아이템(뷰홀더)가 화면에서 완전히 사라지면서 onViewDetachedFromWindow함수가 호출된 것을 확인할 수 있습니다.

  2. 11번째 onBindViewHolder를 보면, 11번째 onCreateViewHolder가 호출되지 않았음에도 bind가 진행되고 있음을 볼 수 있습니다. 하지만, onCreateViewHolder 대신 onViewRecycled가 처음 호출된 것을 같이 확인할 수 있는데, 이를 통해 더 이상 뷰홀더가 직접 생성되는 것이 아니라 만들어진 뷰홀더가 재활용되고 있음을 알 수 있습니다.

  3. 9번째 뷰부터 발생하는데, onCreateViewHolderonBindViewHolder 이후에 바로 onViewAttachedToWindow가 오지 않을 수 있다는 것을 볼 수 있습니다. 이는 미리 뷰홀더를 준비해두고 필요한 시점에 attach하는 “프리페칭(Prefetching)”을 하고 있음을 볼 수 있습니다. 마찬가지로 onCreateViewHolder가 호출되었다고 바로 onBindViewHolder가 호출되는 것 역시 아닙니다. 2번에서 소개했던 세 종류의 메서드는 각자의 역할에 따라서 호출되기 때문에, 순서는 유지되지만 항상 함께 호출되는 것은 아닙니다.

과정별로 알아보는 ViewHolder 생명주기

RecyclerView의 동작 원리를 이해하려면 위에서 말했던 LayoutManager, Adapter 그리고 이제부터 설명할 Cache영역과 Recycled Pool에 대해서 알아야합니다. 뷰홀더의 생성과 재활용은 이 다섯 가지 컴포넌트들의 상호작용으로 이루어집니다.

1. (초기) 레이아웃 구성 과정

RecyclerView가 처음 화면에 그려질 때는 다음과 같은 순서로 진행됩니다.

  1. RecyclerView가 LayoutManager에게 초기 레이아웃 요청을 보냅니다.
  2. LayoutManager는 필요한 아이템의 위치와 개수를 계산합니다.
  3. RecyclerView는 Adapter에게 ViewHolder 생성을 요청합니다.
  4. Adapter는 getItemViewType()을 통해 각 position의 뷰 타입을 결정합니다.

2. ViewHolder 생성 및 바인딩 과정 (ViewHolder를 필요로 할 때)

새로운 ViewHolder가 필요할 때는 다음과 같은 단계를 거칩니다. 각 단계에서 필요한 ViewHolder를 찾았다면 더 찾지 않고 해당 단계를 마무리합니다. (return 한다고 생각하시면 됩니다.)

  1. Cache 확인

    • 먼저 mCachedViews에서 position에 맞는 ViewHolder를 찾습니다.
    • Cache에서 일치하는 ViewHolder가 발견되면 바로 해당 ViewHolder를 재사용합니다.
  2. RecycledViewPool 확인

    • Cache에서 ViewHolder를 찾지 못하면 RecycledViewPool에서 해당 viewType에 맞는 ViewHolder를 검색합니다.
    • 이때 getItemViewType(position)의 반환값을 기준으로 검색합니다.
  3. 새로운 ViewHolder 생성

    • Pool에도 적절한 ViewHolder가 없다면 onCreateViewHolder()를 호출하여 새로 생성합니다.
    • 이 시점에서 ViewHolder는 특정 viewType에 맞는 레이아웃으로 생성됩니다.

3. ViewHolder의 재활용 메커니즘

ViewHolder가 화면에서 사라질 때 어떻게 작동하는지 과정과 원리를 살펴봅시다!

3.1. 스크롤 시 ViewHolder가 재활용되는 과정

사용자가 RecyclerView를 스크롤하면, 이미 화면에 있던 아이템 일부가 사라지고 새로운 아이템이 화면에 등장합니다. 이때 사라지는 ViewHolder는 아래 순서로 처리됩니다.

  1. onViewDetachedFromWindow() 콜백 호출

    • 스크롤로 인해 ViewHolder가 화면에서 분리(Detach)될 때 불립니다.
    • 여기서 ViewHolder는 더 이상 화면에 표시되지 않으므로, 재활용 프로세스를 밟게 됩니다.
  2. mCachedViews(캐시)에 우선 저장

    • 분리된 ViewHolder는 '곧 다시 쓸지도 모른다'는 전제하에 mCachedViews(캐시)에 임시 보관됩니다.
    • mCachedViews는 기본 크기가 2로, 설정을 통해 늘릴 수 있습니다. (recyclerView.setItemViewCacheSize(size))
  3. mCachedViews가 가득 차면 RecycledViewPool(풀)로 이동

    • 캐시가 이미 가득 찬 경우, 가장 오래된 ViewHolderRecycledViewPool로 넘어가게 됩니다.
    • RecycledViewPool에 들어간 ViewHolder는 내부 데이터가 초기화된 채, 장기간 '재활용 대기' 상태가 됩니다.

3.2. ViewHolder의 상태 관리

RecyclerView 내부에서 ViewHolder는 크게 세 가지 상태를 거치며 이동합니다.

3.2.1 Scrap

  • 화면에서 막 분리되었지만, 곧 다시 사용될 가능성이 높아 빠르게 재바인딩을 시도할 수 있습니다.
  • Cache(mCachedViews)로 이동하기 전의 임시 상태입니다.
  • 예: 스크롤 시 레이아웃을 다시 계산하는 과정에서 잠시 분리되었다가 곧바로 다시 사용되는 경우입니다.

3.2.2 Cache (mCachedViews)

  • 최근에 화면에서 사라졌지만, 데이터와 레이아웃 상태를 그대로 유지하고 있는 "임시 보관" 영역입니다.
  • 기본 저장 개수는 2개이며, 이를 초과하면 가장 오래된 ViewHolder가 Recycled Pool로 넘어갑니다.
  • 캐시에 있는 ViewHolder는 해당 position의 아이템이 다시 필요해지는 상황에서 빠르게 재사용될 수 있습니다.

3.2.3 Recycled Pool (mRecyclerPool)

  • 캐시의 용량이 꽉 찼을 때, 추가로 사라지는 ViewHolder는 이 풀로 넘어갑니다
  • 풀에 저장된 ViewHolder는 내부 데이터가 초기화되어 재사용을 위한 대기 상태로 전환됩니다.
  • 각각의 뷰 타입에 따라 별도로 관리되어, 다양한 ViewType을 사용하는 RecyclerView에서도 효율적인 재활용이 가능합니다.

재활용 성능 비교 방법

재활용이 어떻게 되고 있는지, 성능이 정말 최적화 되는지 그 방법과 결과를 확인해봅시다. RecyclerView가 재활용이 되었을 때와 재활용이 되지 않을 때를 비교해봅시다.

‼️ 편의상 ListView와 비교하며 성능을 보는데, ListView도 convertView를 활용하면 뷰를 재사용할 수 있습니다. 이 챕터에서는 “재활용의 성능”에 초점을 맞추고자 재활용하지 않는 ListView와 비교하고 있습니다.

로그 찍어보기

재활용이 되고 있는지 간단하게 확인할 수 있는 방법은 로그를 찍어보는 방법입니다. RecyclerView는 자동적으로 뷰를 재활용하기 때문에 ListView와 비교해보겠습니다.

1. ListView의 경우

class CommentListViewAdapter(...): BaseAdapter() {
 	var counts = 0
 		
 	override fun getView(
        position: Int,
        convertView: View?,
        viewGroup: ViewGroup?,
    ): View {
        val view: View = LayoutInflater.from(context).inflate(R.layout.item_comment, null).also {
            println("ListView - getView: ${counts++}") // Layout이 inflate 될 때마다 출력
        }       
        // ...
    }
}

ListView의 어댑터는 일반적으로 BaseAdapter를 사용하는데, 뷰를 생성하는 로직이 있는 getView()에서 뷰 생성시마다 counts와 함께 한줄의 출력을 작성합니다. 그리고 스크롤을 계속 아래로 내리면 아래와 같이 계속해서 뷰가 생성된다는 것을 볼 수 있습니다.

  1. RecyclerView의 경우

    class CommentRecyclerAdapter(...) : RecyclerView.Adapter<CommentViewHolder>() {
        val counts = 0
    
        override fun onCreateViewHolder(...): CommentViewHolder {
            println("onCreateViewHolder: ${counts++}")
            // binding 후 뷰 return 로직
        }
    }

    RecyclerView는 위에서 설명한 대로 어댑터의 onCreateViewHolder가 호출되며 뷰가 생성됩니다. 따라서 onCreateViewHolder메서드에서 뷰가 생성될 때마다 로그를 찍어보면 뷰가 계속 생성되는지 여부를 알 수 있습니다. 아래 이미지를 보면, ListView와는 달리 초기에 스크롤 할 때 몇 개의 뷰가 더 생성되고, 이후에는 스크롤을 해도 새로운 뷰를 생성하지 않고 기존 뷰를 재활용하는 것을 확인할 수 있습니다.

Profiler 활용하기

Profiler를 활용하면, 현재 내가 개발하고 있는 앱이 메모리를 얼마나 사용하고 있는지 등의 상태를 실시간으로 확인해볼 수 있으며, 그래프로 보여주기 때문에 변화의 추이도 확인할 수 있습니다.

  1. ListView의 경우

    ListView의 경우 초기에는 102MB 정도의 메모리를 사용하고 있지만, 스크롤을 계속해서 내리다보면 메모리 사용량이 약 172MB까지 지속적으로 증가하는 것을 확인할 수 있습니다. 이는 새로운 뷰가 계속해서 생성되면서 메모리를 더 많이 소비하게 되기 때문입니다. 스크롤을 더 내릴수록 메모리 사용량은 그만큼 증가하게 됩니다.

  2. RecyclerView의 경우

    RecyclerView의 경우에는 초기 메모리 사용량이 약 75MB에서 시작하여, 스크롤을 계속 내려도 메모리 사용량이 크게 증가하지 않고 초기와 비슷한 정도로 유지되는 것을 확인할 수 있습니다. 이는 새로운 뷰를 생성하지 않고 기존 뷰를 재활용함으로써 메모리를 효율적으로 사용하기 때문입니다. 이를 통해 RecyclerView의 재활용 메커니즘이 실제로 메모리 효율성 측면에서 큰 이점을 제공한다는 것을 확인할 수 있습니다. 스크롤을 더 내려도 메모리 사용량에는 큰 변화가 없으며, 빠른 스크롤 시에는 잠깐 메모리 사용량이 올라갈 수 있지만 곧 다시 초기와 비슷해집니다.

결론

총평

RecyclerView를 사용할 때, 종종 “자동으로 성능이 최적화된다”는 막연한 믿음만으로 재활용의 원리에 대해서는 큰 신경을 쓰지 않는 경우가 많습니다.(제가 지금까지 그래왔습니다.. 😅) 그러나 실제로는 RecyclerView 내부에서 지금까지 살펴본 여러 원리가 정교하게 맞물려 작동하고 있으며, 이로 인해 개발자가 직접 신경 쓰지 않아도 재활용이 매끄럽게 이루어지는 것이었습니다.

이 글을 통해 RecyclerView의 내부 동작 원리를 조금이나마 이해하셨기를 바라며, 다음 글에서 새로운 주제로 다시 찾아뵙겠습니다!

소감

어떤 동작 원리에 대해서 깊게 파고 들어가본 적은 처음인데, 여전히 파고 들어갈 수록 알아야할 내용은 끝이 없다는 것을 알게 되었습니다. 다만, 이번의 글쓰기 덕분에 처음으로 java 파일까지 파헤쳐보며 동작 원리를 살펴보며 꽤 즐거운 시간이 되었습니다. 덤으로 자주 보던(혹은 보였던) 글의 작성자를 다시 보며 더 많은 주제에 관심을 가져보게 된 것 같습니다. 처음이라 미숙한 글이지만, 누군가에게는 도움이 되면 좋겠습니다 😄

참고자료

RecyclerView 원리와 성능 올리기

RecyclerView Deep Dive - 1. RecyclerView 정의와 동작원리 및 생명주기

RecyclerView Deep Dive with Google I/O 2016

RecyclerView: Highly Optimized Collections for Android Apps

안드로이드 공식문서 - ListView

안드로이드 공식문서 - RecyclerView

안드로이드 공식문서 - RecyclerView.RecycledViewPool

안드로이드 공식문서 - Android Lollipop

0개의 댓글