Jetpack Paging 안 쓰고 바퀴 재발명하기

손쉽게 무한 스크롤을 구현하기

Andy • Android Team

  • 테크 인사이트

들어가며

여러분은 안드로이드 앱을 만들 때 한 번에 서버에서 다 불러올 수 없을 정도로 많은 양의 목록 데이터를 화면에 표시해야 할 때 어떻게 처리하시나요? 각 프로젝트의 요구사항에 따라서 꽤 많은 방식을 사용할 수 있을 것 같은데요. 이 글에서는 채널의 안드로이드 엔지니어 앤디가 안드로이드 팀에서 무한 스크롤을 어떻게 처리하는지 소개해 드릴게요.

무한 스크롤이 뭔가요?

모바일 앱을 사용하시는 분들이라면 무한 스크롤을 꽤 많이 경험해 보셨을 거라고 생각하는데요, 말 그대로 표시해야 하는 항목들을 전부 표시해 줄 때까지 스크롤을 계속 할 수 있는 사용자 경험을 뜻합니다. 여러분이 자주 사용하는 SNS, 쇼핑 앱 등 여러 곳에서 애용되고 있어요. 채널팀에서도 물론 많이 사용하고 있습니다. 채널팀에서 새롭게 만들고 있는 “채널엑스”의 피드 화면에서는 아래와 같은 경험이 있죠. 전형적인 무한 스크롤의 예시예요.

무한 스크롤이 그렇게 어렵나요?

무한 스크롤이 구현하기 어렵나 하면 그건 아니라고 생각해요. 이미 구현하는 방법에 대한 글은 구글에 조금만 찾아봐도 나오죠. 하지만 채널엑스에서 달성하고자 하는 기획을 따르기에는 여러모로 애로사항이 많았어요. 예를 들자면:

  • 이미 무한 스크롤에 추가된 항목의 정보 일부에 변경이 있는 경우 이를 추적하고 업데이트 하기 까다로워요

  • 업데이트를 해야 하는 화면이 너무 많아서 일일이 업데이트 해주기 힘들어요

조금 더 자세히 방금 위에서 보여드린 피드 화면을 다시 예로 들어볼게요. 사용자가 자신이 작성한 게시글을 피드에서 발견하고 포스트로 들어가서 수정을 한다고 생각해보죠. 포스트를 수정한 다음에 다시 피드로 돌아오면 수정한 내용이 반영되어 있는 게 자연스러울 거예요.

이런 동작을 달성하려면 피드 목록을 불러오는 코드와 더불어 일부 항목이 수정되었을 때 업데이트해주는 로직도 포함되어야 할 거예요.

Kotlin

우선 가장 쉽게 생각할 수 있는 방법은 일일이 바뀐 모델을 업데이트 해주는 방법이에요. 다시 말해서 한 화면에서 모델의 정보가 업데이트 되면 이전 화면으로 돌아갈 때 모델의 정보가 업데이트 됐음을 전달해주는 것이죠.

이전 화면으로 돌아올 때 updatePost() 같은 메서드를 호출해서 모델을 업데이트 해줄 수 있어요. 하지만 채널엑스에서는 거의 모든 화면, 거의 모든 모델에 대해서 이런 로직을 적용해야 하기 때문에 해야 할 일이 너무 많아져요.

그래서 채널엑스에서는 화면끼리 모델을 전달하는 대신 모델이 업데이트 되면 인메모리 데이터베이스에 저장된 모델을 업데이트 해요. 각 화면은 데이터베이스가 업데이트 되는지 보고 있다가 업데이트 되면 UseCase를 통해 모델을 받아서 업데이트 하죠. 이렇게 처리하면 각 화면이 정보를 불러오는 곳이 UseCase 한 곳으로 모이기 때문에 작성과 관리가 편해요.

한 가지 더 큰 장점이 있는데요, 몇몇 모델은 또 다른 업데이트 해야 하는 모델을 품고 있는 경우도 UseCase 하나에 모델 업데이트를 모을 수 있어요. 예를 들어서 포스트 모델을 생각해볼게요.

Kotlin

FeedPost 클래스는 title, user 같은 정보를 가지고 있어요. 제목(title 필드)이 수정될 때뿐만 아니라 유저 정보(user 필드)가 업데이트 되어도 포스트 모델이 업데이트 되어야 해요. 프로필 사진이 바뀌면 바로 반영해줘야 하기 때문이에요. UseCase에서는 이런 식으로 모델의 몇 단계에 걸친 업데이트에 대해서도 처리할 수 있도록 해줘요.

이해를 위해서 FeedPost 모델을 업데이트 받기 위한 UseCase의 예시를 보여드릴게요.

Kotlin

여기서 주목할 점은 UseCase의 반환 값이에요. next를 이용해서 Flow<FeedPostPage>를 반환함으로써 ViewModel은 한 페이지에 대한 업데이트를 모두 받을 수 있어요.

이제 ViewModel은 UseCase에서 받은 각 페이지 정보를 화면에 무한 스크롤 형태로 표시하기 위해 리스트 하나로 합쳐야 해요. 채널엑스에서는 이런 형태의 페이지가 아주 많다보니 최대한 보일러플레이트를 줄이고 싶었어요.

혹시 쓸데없이 바퀴를 재발명하는 건 아닐지…

Jetpack Paging은 구글이 무한 스크롤을 쉽게 할 수 있도록 만든 라이브러리예요. 실제로 라이브러리를 사용하면 비교적 손쉽게 무한 스크롤을 도입할 수 있죠. 다만 채널엑스에 필요한 조건을 맞추는 데는 부족했어요.

첫 번째로 페이징 라이브러리는 이미 리스트에 있는 항목의 내용을 수정하기가 어려워요. 이 기능은 이 글을 쓰는 시점인 2025년 2월을 기준으로 거의 4년 이상 해결이 안 되고 있죠.

Google Issue Tracker

약간의 편법을 써서 항목을 수정할 수는 있어요. 아래 코드는 아이템을 클릭했을 때 1000을 더해주는 코드예요.

Kotlin

위와 같은 방식으로 changes 같이 PagingData를 덮는 값을 만들어서 관리해야 해요. 말 그대로 changes 필드에 수정하고 싶은 값을 넣어두고 Pager의 Flow에서 해당되는 값이 내려오면 changes에 저장된 값으로 바꿔치기 하는 코드죠.

하지만 채널엑스에서 위처럼 관리하면 불편해지는 부분이 있어요. 우선 채널엑스에서는 이미 언급했듯 하나의 UseCase에서 네트워크로 불러온 정보와 업데이트 된 정보를 하나의 Flow로 전달하는 구조로 되어 있어요. 하지만 Paging에서는 리스트를 한 번 만든 이후에는 아예 리셋하지 않는 이상 리스트를 수정할 수 없기 때문에, 임의로 변경시키기 위해서 변경사항을 따로 추적해야 해요.

즉, changes 변수를 업데이트 해야 하는데 이때 별도의 UseCase가 있어야 하죠. 그래서 하나의 정보를 표시하는 데 UseCase가 2개 필요해져요. Paging에서 네트워크를 통해 맨 처음 정보를 받을 UseCase, 그리고 데이터가 수정되었다는 정보를 전달하는 UseCase죠. 라이브러리의 한계 때문에 하나의 정보를 표시하는 데 UseCase가 하나 더 늘어나는 보일러플레이트가 생기게 돼요.

우리가 하고 싶은 것

이제 ViewModel에서 무한 스크롤을 최소한의 코드로 작성하기 위해서 필요한 라이브러리를 작성할 건데요, 요구사항은 크게 2가지가 있어요.

  1. 각 페이지별 업데이트: 위에서 보여드린 “피드” 예시에서처럼 업데이트된 정보가 있으면 즉시 UI에 반영되어야 해요.

  2. 확장성 고려: 고생해서 만든 만큼 “무한 스크롤”이 필요한 곳이라면 어디서든 쓸 수 있도록 하고 싶어요. “사진 선택기”도 무한 스크롤을 사용하니까 안드로이드의 Cursor 객체로도 무한 스크롤이 가능했으면 좋겠어요. 정말 임의의 객체나 정보로도 페이징을 할 수 있으면 좋겠어요.

물론 이런 모든 요구사항을 모두 반영하고서도 쓰기 쉽게 만들어야 해요. 라이브러리를 쓰면서 편하다는 게 체감이 안 되면 만든 의미가 없을 테니까요.

스포를 약간 하자면, 최종적으로 사용되는 API는 아래와 같아요.

Kotlin

ViewModel과 UI에서는 이 정도 코드만으로 정보가 자동으로 업데이트 되는 무한 스크롤을 구현할 수 있게 됐어요. 바로 아래에서 어떻게 구현했는지 알아보도록 하시죠.

구현

앞서 말씀드린 목표에 맞춰서 무한스크롤 라이브러리를 만들어 볼게요. 채널팀에서는 이 라이브러리의 이름을 InfiniteScrollModule로 정했어요. (앞으로는 “모듈”이라고만 할게요)

채널의 프로젝트는 안드로이드 공식 문서에서 권장하는 아키텍처를 큰 틀에서 따르기 때문에 읽어보신 적 없다면 먼저 읽어보시는 걸 추천드릴게요. 추가로 Jetpack Compose코루틴에 대한 기본적인 이해가 필요할 수 있어요.

0. 다음 페이지를 불러와야 하는지 확인하기

무한 스크롤은 UI의 스크롤 상태를 보면서 거의 끝에 도달했다고 판단하면 다음 페이지를 불러오도록 구현하는 게 일반적이에요. 보통 무한 스크롤 구현 방법을 검색하면 아래 같은 방법을 많이 찾을 수 있어요.

Kotlin

하지만 이런 방식은 UI 쪽에서 리스트가 끝에 왔는지 확인하는 코드를 매 화면마다 따로 작성해야 하기 때문에 여러모로 불편해요. 그런데 구글이 제공하는 페이징 라이브러리에서는 이런 코드가 필요 없다는 게 신기하지 않나요? 비밀은 바로 페이징 라이브러리의 리스트 객체에 있어요.

리스트에 접근하는 걸 감지해서 다음 페이지를 불러오는 방식을 사용하죠. 좀 더 구체적으로 말하자면 페이징 라이브러리의 PagingDataPresenter 객체에서 리스트 접근이 일어나면 바로 HintReceiver에 리스트의 읽기가 일어났다고 알려주고 있어요.

Kotlin

Paging 라이브러리는 이 힌트를 이용해서 리스트의 끝에 거의 도달했다고 판단되면 다음 페이지를 요청해요. 이 방식을 사용하면 별도로 UI에 코드를 작성할 필요가 없게 돼요. 단순히 리스트 접근을 감지할 수 있기만 하면 되니까요.

모듈에서도 비슷하게 List<E>get 메서드를 오버라이드해서 get이 호출되면 리스트의 끝에 도달한 것으로 판단하기로 했어요. 구글의 페이징 라이브러리에서 이 방식을 채택하고 있는 만큼LazyListRecyclerView의 동작이 바뀌어서 모듈이 고장나는 상황에 대한 걱정은 크게 하지 않아도 될 거라고 판단했어요.

1. 각 페이지별 실시간 업데이트

우선 위에서 정리한 내용을 다시 한 번 리마인드 해볼게요. 각 UseCase에서는 우선 네트워크 요청을 해서 페이지 정보를 내려주고, 그 다음 관련된 모델에 변경사항이 생기면 해당 페이지에 대한 업데이트를 내려줘요.

상황을 잘 분해해서 “실시간 업데이트”를 구현하려면 크게 2가지를 챙겨야 해요.

  • 네트워크 API를 통해서 페이지 초기 데이터를 불러오기

  • 각 페이지의 정보가 업데이트 되면 리스트에 반영하기

모듈은 각 페이지를 잘 모아서 전체 리스트를 만들고, 수정 요청이 들어오면 해당되는 페이지를 수정하는 역할을 할 거예요.

모듈의 API 형태를 정하기 전에 우선 사용하는 입장이 돼서 페이지 정보를 어떻게 모듈로 넘기는지 생각해볼게요. UseCase는 위에서 설명한 것처럼 각 페이지에 대한 Flow를 반환하는데요, 이걸 그대로 모듈에 넘기는 게 가장 사용하기 편할 거예요. 그럼 모듈을 사용하는 입장에서는 이런 형태의 코드가 가장 자연스럽겠죠.

Kotlin

FlowPage라는 클래스를 만들어서 모듈이 각 페이지를 인식할 수 있도록 만들었어요. 이 정보를 받은 모듈은 여러 페이지를 하나의 리스트에 모을 거예요.

어떤 걸 구현하고 싶은지는 대략적으로 정의를 했으니 이제 구현에 필요한 게 어떤 게 있는지 정리해볼게요.

  • 당연하게도 각 페이지는 Flow를 통해서 스스로 업데이트 할 수 있어야 해요.

  • 이때 여러 페이지가 동시에 리스트를 수정하려고 시도할 수 있기 때문에 동시성도 신경 써줘야 해요.

  • 무한 리스트를 표시하는 화면이 없어지면 각 페이지를 업데이트 하고 있던 코루틴을 취소해줘야겠죠. 그래야 연관된 리소스 낭비가 없을 테니까요.

구현

이제 어떻게 각 페이지의 업데이트를 반영할 수 있을지 생각해볼게요. 위에서 언급했듯 각 페이지는 Flow를 통해서 정보가 업데이트가 됐음을 전달해요. 따라서 무한 리스트가 모든 페이지의 변경사항을 전달 받을 수 있으려면 페이지 개수만큼의 코루틴이 필요해요. 아주 간단하게 코드로 표현하면 아래와 같이 될 수 있어요.

Kotlin

그림으로는 이렇게 표현할 수 있어요.

모든 페이지에 대해서 코루틴을 새로 만들기 위해 launch를 호출하고, 그 안에서 페이지 Flow의 업데이트를 반영하고 있어요. 무한 리스트를 사용하는 쪽에서는 모듈의 flow 필드를 사용해서 리스트를 표시할 수 있죠. 물론 실제 구현이 이렇게 간단하지는 않아요.

이유로는 첫째로 여러 코루틴에서 동시에 수정하려고 시도할 수 있기 때문에 Mutex로 적절한 부분을 감싸줘야 했어요. 다행히도 감싸줘야 할 부분이 페이지를 리스트에 반영하는 부분이라는 게 너무 명확해서 여기서는 크게 어려움을 느끼지는 않았어요.

두 번째로 mergeToList에서는 무한 리스트의 어느 범위에 각 페이지가 위치해 있는지를 관리하고 있어야 해요. 그래야 페이지의 길이가 변경돼도 정상적으로 내용을 바꿀 수 있도록 할 수 있기 때문이죠.

그리고 더 이상 안 쓰는 페이지의 코루틴 등 자원을 유지하면 안 되겠죠. 유저가 화면을 들어갔다 나오는 건 상당히 빈번하게 일어나는 동작일 테니까요. 이 문제는 간단하게 해결됐어요. 단순하게 channelFlow가 제공하는 CoroutineScope 안에서만 코루틴을 생성하면 되었죠. 무한 스크롤과 관련된 코루틴이 이 스코프 밖에서 생성되는 일이 없도록 주의해서 작성했어요.

마지막으로 제일 중요한 건 새로운 페이지가 추가될 때 기존 페이지들의 코루틴이 중단되고 재시작되어서는 안 돼요. 각 페이지의 Flow는 보통 처음 시작할 때 네트워크를 통해 데이터를 불러오는데, 페이지가 추가될 때마다 모든 기존 페이지들이 다시 네트워크 API를 호출하는 것은 바람직하지 않기 때문이죠.

그래서 실제 코드에서는 이런 상황들을 처리하기 위해서 짧지 않은 코드가 작성되어 있는데요, 이 부분은 독자 여러분들을 위한 연습 문제로 남겨둘게요. 이렇게 만들어진 리스트는 모듈을 통해서 ViewModel로 넘어가고, 사용자가 또 다시 리스트의 맨 끝에 도달하면 Flow를 페이지에 추가하고 하는 과정이 반복돼요.

2. 확장성 고려하기

무한 스크롤은 아무리 간단하게 짜려고 해도 귀찮을 수밖에 없기 때문에 채널엑스에서는 무한 스크롤이 활용되는 모든 곳에 모듈이 사용될 수 있기를 바랐어요.

하지만 이 목표를 실현하는 게 쉽지만은 않았어요. 사진 선택기는 무한 스크롤을 활용함에 있어서 가장 이질적인 기능이죠. 보통 네트워크 API를 통한 요청을 할 때는 다음 페이지를 불러올 때 “since”라는 문자열 값을 사용했는데요, 안드로이드의 ContentResolver를 통해서 이미지를 가져올 때는 이런 방식을 사용할 수 없어요. Cursor 객체를 사용하기 때문이죠.

위의 1번 섹션에서 디자인 한 모듈대로라면 “since”와 같이 다음 페이지를 불러올 수 있는 키가 필요해요. 하지만 Cursor는 이터레이터 같이 사용하기 때문에 이런 “키 값”을 얻어낼 수가 없죠. 따라서 단순히 “since”의 데이터 타입을 일반화 하는 것만으로는 문제를 해결하기 어려웠어요.

대신 아예 모듈에서 “다음 페이지를 불러오기 위한 키”라는 개념(=next)을 없애는 게 자연스러울 거예요. 좀 더 일반적으로 만들기 위해서 “페이지를 추가하는 함수”와 “다음 페이지가 필요할 때까지 기다리는 함수”만 만들어둘 거예요.

아래 예제 코드에서 위 예제 코드에서 있던 next 변수가 사라진 걸 주목해주세요. 대신 페이지 객체를 반환하는 대신 appendPage를 호출해서 추가하고 있어요. 그리고 사용자가 다음 페이지를 필요로 할 때까지 기다리는 awaitNextPage도 있죠.

Kotlin

이렇게 모듈이 있어야 하는 핵심 기능인 “페이지 관리”가 appendPageawaitNextPage로 충족됐어요.

3. 그럼에도 쓰기 쉽도록

바로 위에서 확장성을 위해서 모듈에서 next 값을 관리하지 않도록 바꿨어요. 하지만 next를 쓰는 대부분의 케이스에서는 오히려 아래처럼 많은 보일러플레이트가 발생하는 상황을 만들게 돼버렸어요. 원래 getFeedFlowUseCase(…)만 쓰면 됐던 코드가 이렇게나 늘어난 거죠.

Kotlin

하지만 문제는 없어요. 이렇게 흔한 상황을 위한 확장 함수를 만들어서 쉽게 해결이 가능했어요. 최종적으로 일반적인 케이스에서 ViewModel에 들어가는 코드는 아래와 같아요. 실제 프로덕션 코드에도 과장 없이 아래 같은 코드에서 에러 처리만 추가된 코드가 많이 작성되어 있어요.

Kotlin

기존의 형태와 비슷하게 onEachPage의 람다에서 FlowPage를 반환하면 알아서 페이지를 추가해주고 next 정보까지 관리해줘요. 확장성은 확장성대로 가져가고 편리함은 편리함대로 가져갈 수 있게 됐죠.

마무리

채널의 제품에는 무한 스크롤이 필요한 기능이 정말 많아요. 만약 원래처럼 일일이 보일러플레이트 코드를 작성했다면 할 일이 늘어나는 건 물론, 여러 파일을 왔다갔다 하면서 무한 스크롤이 기획에 추가될 때마다 심리적인 부담까지 안고 갔을 것 같은데요. 모듈을 만들고 나서는 ViewModel에 5줄 정도만 추가하면 손쉽게 무한 스크롤을 구현할 수 있게 됐으니 사실상 무한 스크롤이 없는 화면과 개발 코스트 차이가 없어졌어요.

사실 이게 끝이 아니고 좀 더 소개할 내용이 남아있는데요, 아쉽게도 여백이 부족해서 이번에는 설명을 다 못하게 됐어요. 다음에 기회가 되면 남은 내용을 설명해 드리도록 할게요. 아니면 채널톡에 지원하고 직접 개발에 참여해 보시는 건 어떠신가요? 언제나 환영해요.

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요