Paging Library, 그것이 쓰고싶다

한로니
21 min readMay 2, 2018

--

안드로이드 아키텍처 페이징 라이브러리는 2017년 9월 발표가 됐으며 대량의 데이터 셋을 청크 단위로 RecyclerView에 쉽게 로드할 수 있다고 소개됐습니다. 어쩌면 모바일 앱에서의 페이징은 그다지 새로울 게 없는 부분일 수도 있습니다. 무한 스크롤링이라 불리는 기법은 모바일 앱 개발 시 가장 빈번하게 사용되는 패턴 중 하나이고, 이에 대한 구현 또한 널리 알려져 있습니다. 그럼에도 아키텍처 컴포넌트의 페이징 라이브러리는 이를 완전히 새로운 방식으로 구현했고, 이 문제를 우아하게 풀어냈다.라는 표현이 잘 어울린다고 생각합니다. 이 글에서는 드로이드 나이츠 2018에서 페이징 라이브러리를 주제로 발표한 내용과, 발표에는 포함되지 않았지만 준비하면서 알게 된 내용에 대한 이야기를 담았습니다.

왜 페이징 라이브러리인가?

페이징 라이브러리의 필요성은 Room이 대량의 쿼리 결과를 어떻게 다룰지에서 비롯됐습니다. SQLiteCursor는 내부적으로 CursorWindow를 통해 2MB의 고정된 페이징을 구현합니다. 이는 쿼리의 결과가 10MB라고 가정했을 때, 쿼리 결과를 한 번에 메모리에 적재하지 않고, 이 중 일부인 2MB 만을 CursorWindow에 로드하는 것을 의미합니다. 만일 SQLiteCursor가 존재하지 않는 행을 요청하게 되면 CursorWindow는 해당 행이 존재하는 청크를 불러와 윈도우를 다시 로드하는 형태로 페이징을 수행합니다.

아키텍처 컴포넌트의 페이징 라이브러리 개발자인 Chris Craik에 의하면 CursorWindow를 이용한 내부 페이징은 몇 가지 이유로 성능 문제를 일으킬 수 있으며 예측 불가능한 부분이 있다고 밝혔습니다. 대표적인 예로 Cursor가 10번째 윈도우에 위치한 행에 접근한다고 가정했을 때, 내부적으로 1~9번째 윈도우에 해당하는 불필요한 행들을 모두 읽도록 구현되어 있습니다. 달리 말하면 리스트의 1000~1009번째에 해당하는 10개의 항목을 읽기 위해서 1000번째 인덱스에 바로 접근하는 것이 아니라, 반복문으로 인덱스를 0~999까지 증가시켜 1000번째 이전의 항목을 스킵 한 후 접근하는 방식입니다. 이는 SQL의 OFFSET 키워드가 동작하는 방식과 일치하는데 OFFSET의 인덱스가 클수록 쿼리의 성능이 느려지는 이유 또한 같습니다.

MySQL LIMIT offset performance

SQLiteCursor를 실험하면서 발견한 대부분의 이슈는 쿼리 크기가 2MB 이상일 때 동작하는 SQLiteCursor의 내부 페이징에 기인합니다. 이런 이유로 아키텍처 컴포넌트의 페이징 라이브러리는 단일 CursorWindow 내에서 동작하는(=SQLiteCursor의 내부 페이징이 동작하지 않도록) 스몰 쿼리를 지향하는 것이 핵심입니다.

페이징이란?

페이징은 데이터베이스의 데이터를 일정한 덩어리로 나눠서 제공하는 것을 의미합니다. 대상은 안드로이드의 SQLite가 될 수도 있고, 서버-클라이언트 모델에서는 주로 서버에서 페이징을 구현한 뒤, 클라이언트를 통해 사용자가 열람한 페이지의 정보를 보여주는 것이 일반적입니다. 예를 들면, 구글 검색에서 Android라는 키워드를 입력했을 때, 구글은 약 28억 개에 해당하는 모든 검색 결과를 클라이언트로 내려주는 대신 상위 10개의 결과만을 보여줍니다.

이렇게 함으로써 구글은 사용자에게 원하는 결과를 빠르고 제공할 수 있고, 클라이언트에서는 이를 통해 성능, 메모리, 네트워크 비용을 효과적으로 다룰 수 있습니다. 만일 페이징을 하지 않는다면 어떻게 될까요? 검색 결과 하나당 크기를 1Byte라고만 가정을 해도 이는 약 2.8Gbyte라는 크기가 됩니다. 서버 측에서는 여러 테이블에 저장된 데이터를 풀 스캔해야 하는 상황을 피할 수 없고, 클라이언트에서는 제한된 자원으로 이를 보여주지 못할 가능성이 높으며 무엇보다도 이런 불필요한 네트워크 비용을 감수하면서 서비스를 이용하는 사용지는 많지 않을 것입니다.

이런 이유로 페이징은 서버에서 대량의 데이터를 제공하는 보편적인 방법입니다. 서버 개발자는 REST API를 디자인할 때, 다음과 같이 사용자가 열람한 페이지의 데이터만 가져올 수 있도록 API를 설계합니다.

GET /users?since=135
GET /androiddev/hot.json?after=t3_89vvsb&limit=30
GET /search?q=android&page=2&per_page=10

그럼 안드로이드 개발자는 페이지드 된 데이터를 어떤 방식으로 보여주고 있을까요? 바로 스크롤을 이용해 데이터를 점진적으로 불러오는 무한 스크롤링 기법입니다.

이를 구현하는 일반적인 방법은 OnScrollListener를 이용해 스크롤이 리스트의 하단에 도달하는 것을 감지하는 것입니다. 혹시 페이징 라이브러리도 이런 식으로 구현되어 있을까요? 미리 답을 얘기하자면 페이징 라이브러리는 OnScrollListener를 사용하지 않습니다.

Unbounded / Countable List

페이징 라이브러리는 단순히 무한 스크롤링을 지원하기 위한 구현체가 아닙니다. 안드로이드에서는 로컬 SQLite에 저장된 대량의 데이터를 RecyclerView로 표현할 때, 두 가지 선택지 중 하나를 선택할 수 있습니다. 하나는 위에서 언급한 무한 스크롤링을 이용한 방법이고, 다른 하나는 CursorAdapter 방식의 지연 로딩 기법입니다. 페이스북과 같은 피드 형태의 앱에서는 무한 스크롤링이, 연락처와 같은 앱에서는 패스트 스크롤이 가능한 지연 로딩 기법이 적합할 수 있습니다.

페이징 라이브러리는 기본적으로 이 둘을 지원하도록 디자인되어 있습니다. 이는 페이징 라이브러리가 CusorAdapter의 대체재라는 의미이기도 합니다. 페이징 라이브러리에서 이 둘을 Unbounded List와, Countable List로 구분해서 설명할 수 있고, 앱이 어떤 데이터를 보여줄지, 사용자에게 어떤 경험을 전달하지에 따라 선택할 수 있습니다.

Unbounded List는 페이스북과 같이 전체 리스트를 보여주는 것이 중요하지 않은, 피드 타입의 앱에서 무한 스크롤링으로 데이터를 점진적으로 불러오는 기법에 대응되는 개념입니다. Unbounded List는 스크롤을 할 때마다 리스트의 사이즈가 증가합니다.

Unbounded List의 예

Countable List는 리스트의 전체 사이즈가 필요하며, 외형적으로는 기존의 CursorAdapter를 사용한 형태와 같지만 내부적으로는 많은 차이점이 있습니다. CursorAdapter가 UI 스레드에서 데이터베이스에 접근했던 것과 달리 페이징 라이브러리는 데이터베이스 접근을 백그라운드 스레드에서 수행하고, 청크 단위로 데이터를 요청하며, 이와 관련된 옵션을 설정 가능한 형태로 제공합니다.

Countable List의 예시

페이징 라이브러리

페이징 라이브러리는 PagedList, DataSource, PagedListAdpater로 구성되어 있습니다. 이를 통해 UI와 분리된 코드, 비싼 비용이 드는 작업을 백그라운드 스레드에서 수행, 리스트의 Diffing 처리로 효과적인 UI 업데이트, Database only, Network only, Database + Network와 같은 여러 형태의 데이터 아키텍처 지원, 플레이스홀더등 기존의 개발자 커뮤니티 주도로 만들어진 구현체와는 비교할 수 없을 만큼 유연하며 최적화된 성능과, 확장성을 갖추고 있습니다. 다만 구글 이슈에 등록된 내용에 의하면 페이징 라이브러리가 Room과 SQLite을 우선 지원하는 형태로 개발됐고, 버전이 업데이트 되면서 네트워크 API를 고려한 부분이 추가됐기 때문에 이 부분에 대한 지원이 (아직 정식버전이 배포되기 전이긴 하지만) 완전하지는 않습니다. 예를 들면, PageKeyedDataSource의LoadInitialParams에는 Initial Key가 없기 때문에 새로운 PagedList가 생성될 때 업데이트가 발생한 페이지를 추적할 수 없습니다. 또한 PagedListAdapter를 통해 아이템을 직접 수정, 삭제, 추가할 수 없기 때문에 이런 식으로 동작하는 코드를 페이징 라이브러리로 리팩토링 한다면 많은 부분의 코드가 변경되어야 할 수 있습니다.

PagedList

PagedList는 AbstractList의 구현체이자, 지연 로딩을 지원하는 일종의 Lazy List입니다. Countable list의 경우, 리스트의 사이즈가 1000이라면 모든 항목을 한 번에 메모리에 적재하는 것이 아니라 이 중 RecyclerView에 보일 30개의 항목만 메모리에 할당하고, 나머지는 null이 될 수 있음을 의미합니다. 사용자가 스크롤을 해 null인 요소에 접근을 하면 PagedList는 해당 요소가 속한 청크를 DataSource에 요청하고, (이때 일시적으로 null을 반환했다가) 데이터 로드가 완료되면 PagedListAdpater의 onBindViewHolder 함수가 자동으로 호출되면서 뷰를 구성하는 형태입니다.

개발자는 PagedList.Config.Builder를 이용해 PagedList가 데이터를 불러오는 옵션을 작성할 수 있습니다.

val config = PagedList.Config.Builder()
.setInitialLoadSizeHint(20)
.setPageSize(10)
.setPrefetchDistance(5)
.setEnablePlaceholders(true)
.build()
val pagedList = PagedList.Builder(XxxDataSource(), config)

PagedList는 PagedListAdapter 내부에서 다뤄지고, DataSource를 통해서 새로운 데이터가 추가만 될 수 있기 때문에 개발자가 PagedList를 직접 조작할 수 없도록 설계되어 있습니다. 따라서 PagedList의 add(), remove() 함수 호출 시, UnsupportedException이 발생합니다.

DataSource

DataSource는 PagedList에 데이터를 제공하는 프로바이더 역할을 합니다. 대상은 안드로이드의 SQLite가 될 수도 있고, 네트워크 상의 리소스가 될 수도 있습니다. DataSource는 서버 혹은 클라이언트가 페이징 하는 방식에 따라서 추상 클래스인 PageKeyedDataSource, ItemKeyedDataSource, PositionalDataSource 중 하나를 상속받도록 디자인되어 있습니다.

일반적으로 서버에서 페이징을 구현하는 방법은 다음의 세 가지 방식으로 나눌 수 있습니다.

  • Cursor-based Pagination
  • Time-based Pagination
  • Offset-based Pagination

페이징 라이브러리가 제공하는 각각의 DataSource는 바로 위의 페이징 방식에 1:1로 대응하는 구현체입니다. 따라서 개발자는 DataSource를 밑바닥에서부터 만들 필요가 없고, 상황에 맞는 클래스를 상속받아 추상 함수를 구현하는 것으로 충분합니다.

PageKeyedDataSource는 다음과 같이 응답으로 인접 페이지에 대한 정보가 내려올 때 사용할 수 있습니다.

-> GET /api/feeds.json?after=mta_xNT3&limit=20
...
<- Status: 200 OK
"data": {
"after": "t3_87zdql",
"before": "za_bdc23f",
"feeds": [...]
}

위와 같은 Cusor-based 기반의 페이징은 가장 많이 사용되는 기법이기 때문에 페이스북, 트위터, 슬랙, 레딧과 같은 개발자 문서에서 이런 형태의 REST API를 어렵지 않게 찾아볼 수 있습니다.

ItemKeyedDataSource는 현재 리스트의 마지막 요소를 이용해서 다음 페이지를 불러오는 페이징 방식에서 사용할 수 있습니다.

-> GET /api/comments.json?since=1364849754&limit=20
...
<- Status: 200 OK
"data": {
"comments": [
...
{
"id": 19,
"name": "Larry",
"comment": "It's no laughing matter.",
"timestamp: "1364859243"
}
]
}

서버 혹은 클라이언트가 위와 같이 Time-based 기반으로 페이징을 제공한다면 ItemKeyedDataSource를 사용해 페이지를 요청할 수 있습니다. 다음 페이지를 요청하는 Key가 반드시 Timestamp일 필요는 없습니다. ItemKeyedDataSource는 추상 함수를 통해 어떤 Key를 사용해 페이징을 할지에 대한 부분을 개발자가 정의할 수 있습니다.

PositionalDataSource는 페이지 번호 또는 오프셋을 이용하는 페이징 방식에서 사용할 수 있습니다.

-> GET /api/feeds.json?offset=50&limit=20
...
<- Status: 200 OK
"data": {
"count": 50,
"offset": 50,
"limit": 20,

"total": 890,
"feeds": [...]
}

다음 페이지 요청을 위해 현재 페이지의 특정 정보가 필요했던 PageKeyedDataSource, ItemKeyedDataSource와 달리 PositionalDataSource는 페이지 번호 또는 오프셋을 통해 특정 페이지를 바로 요청할 수 있는 이점이 있습니다.

PositionalDataSource는 패스트 스크롤을 지원하는 주소록과 같은 앱을 만들 때 유용하게 사용할 수 있습니다. 왜냐하면 PageKeyedDataSource, ItemKeyedDataSource는 순차적으로 페이지를 요청할 수밖에 없는 구조인 반면 PositionalDataSource는 다음 페이지 호출을 위해 이전 페이지의 정보를 몰라도 되는 오프셋(또는 페이지 번호)을 사용함으로써 병렬적으로 페이지를 요청할 수 있기 때문입니다. 만일 PositionalDataSource에서 사용 가능한 백그라운드 스레드를 10개라고 지정을 한 상태에서 사용자가 패스트 스크롤을 통해 “ㄱ"에서 “ㅎ"으로 스크롤을 내렸다면 PositionalDataSource는 사용 가능한 모든 백그라운드 스레드에서 페이지를 동시에 요청합니다.

PositionalDataSouce가 여러 스레드에서 실행되는 모습

추가로 페이징 라이브러리와 Room을 함께 사용하는 경우, Room은 쿼리 결과로 PositionalDataSource를 기본적으로 제공하기 때문에 큰 노력 없이 페이징을 지원할 수 있습니다.

DataSource의 무효화

RecyclerView에 보이는 데이터의 특성에 따라 해당 리스트는 오직 읽기만 가능하거나 수정, 삭제, 위치 변경이 가능한 두 가지 케이스로 나눌 수 있습니다. PagedList와 DataSource는 Pair로 동작을 하는데, 읽기만 가능한 리스트의 경우라면 단일 PagedList와 DataSource만으로 페이징을 동작시킬 수 있습니다. 예를 들면, 공지사항 목록을 가져오는 REST API가 GET 메서드만을 지원하는 경우가 이에 해당할 수 있습니다.

이제 피드를 호출하는 REST API가 GET, PUT 메서드를 지원한다고 가정해보겠습니다. 사용자가 특정 피드의 좋아요 버튼을 클릭했을 때, 최종적으로 RecyclerView의 화면을 어떻게 업데이트할 수 있을까요? 페이징 라이브러리에서는 일반적인 RecyclerView.Adapter처럼 업데이트가 필요한 요소에 직접 접근해서 데이터를 수정하고, notifyItemChanged()를 호출하는 방식을 사용할 수 없습니다. PagedList는 아이템을 불변으로(immutable) 다루기 때문입니다. 페이징 라이브러리에서 업데이트가 발생한 경우에는 반드시 새로운 PagedList와 DataSource를 생성해서 PagedListAdapter로 전달되어야 합니다. 이때 이전의 DataSource는 invalidate() 함수를 호출해 더 이상 새로운 데이터를 요청되지 않도록 무효화 시켜야 합니다. invalidate() 함수가 호출되면 이전의 PagedList와 DataSource는 최종적으로 detach 상태가 됩니다. 이는 스크롤이 리스트의 하단에 도달하거나 혹은 백그라운드에서 DataSource를 통해 데이터를 로딩 중인 상태였다고 하더라도 이전의 PagedList가 업데이트 되지 않음을 의미합니다.

LivePagedListBuilder와 RxPagedListBuilder

이 두 빌더 클래스는 위에서 설명한 리스트의 수정, 삭제가 발생하는 상황에서 DataSource의 invalidate() 함수가 호출되면 새로운 PagedList와 DataSource를 자동으로 생성하는 처리를 합니다. 이 빌더는 각각 LiveData<PagedList>와 Flowable<PagedList> 또는 Observable<PagedList>를 생성할 수 있기 때문에 이를 구독해 새로운 PagedList를 PagedListAdapter로 넘기는 것으로 RecyclerView를 업데이트할 수 있습니다. (참고, RxJava2를 지원하기 위한 RxPagedListBuilder는 1.0.0-beta1에 추가됐습니다.)

또한 invalidate() 발생 시, 업데이트가 발생한 페이지의 요청 Key(params.requestedInitialKey)를 사용해 새 PagedList의 초기 데이터 로드에 사용할 수 있습니다. 따라서 업데이트가 발생한 페이지가 5번째 페이지라면 새로운 PagedList는 초기화 시 5번째 페이지를 먼저 요청합니다. 이 상태에서 스크롤이 위쪽으로 이동할 경우, 4번째 페이지를 요청하고, 스크롤이 하단으로 이동할 경우에는, 6번째 페이지를 요청하는 방식으로 동작합니다. 이런 이유로 ItemKeyedDataSource를 사용한다면 REST API의 페이징이 다음 페이지뿐만 아니라 이전 페이지 호출을 지원하는지 확인이 필요합니다.(PageKeyedDataSource는 어떤 이유에서인지 params.requestedInitialKey를 제공하지 않기 때문에 현재 1.0.0-rc1 버전에서는 이 부분을 자연스럽게 처리할 수 없습니다.) PositionalDataSource는 오프셋과, 페이지 사이즈를 기반으로 이전 페이지와 다음 페이지를 수치적으로 계산할 수 있기 때문에 일반적으로 특별한 처리를 하지 않아도 양방향(이전/다음)으로 페이지를 호출할 수 있습니다.

참고, ItemKeyedDataSource의 LoadInitialParams에 전달되는 params.requestedInitialKey 는 사용자가 마지막으로 본 리스트의 중간쯤이라고 여겨지는 아이템 Key를 전달합니다. 따라서 해당 Key의 주변에 해당하는 아이템을 함께 로드해야지만 기대와 같이 동작합니다. (이를 위한 API를 서버에서 추가 지원해야 할수도 있음을 의미) 이와 관련한 서버와 클라이언트 예제는 샘플 코드를 통해 확인할 수 있습니다.

PagedListAdapter

PagedListAdapter는 PagedList의데이터를 RecyclerView에 표현하기 위한 어댑터 클래스입니다. 일반적인 RecyclerView 어댑터와의 차이점은 새로운 리스트가 어댑터로 전달됐을 때, DiffUtil을 이용해 이전 리스트와 비교해 변경된 요소들만을 업데이트하는 기능을 갖추고 있습니다. 두 리스트를 비교하는 처리는 백그라운드 스레드에서 실행되고, 비교가 완료되면 메인 스레드에서 notifyItem*() 함수를 호출해 UI를 업데이트합니다. 이 모든 처리는 새로운 리스트를 설정하는 PagedListAdapter.submitList(newList)를 호출했을 때 자동으로 수행됩니다.

샘플 코드

위 샘플 코드는 600여 개의 치즈 정보를 제공하는 Flask 기반의 REST API 서버와 PositionalDataSource를 이용해 페이징을 하는 클라이언트 코드로 구성되어 있습니다. 실제 사례에서는 단일 PagedList와 DataSource로 구성되는 경우 보다 게시글(혹은 피드)에 ‘좋아요’와 같은 업데이트 액션을 하는 사례가 많기 때문에 이를 살펴볼 수 있도록 예제를 만들었습니다. 샘플 앱에서는 아이템을 클릭하면 REST API를 호출해 ‘좋아요’ 숫자가 증가하는 것을 볼 수 있고, Observable<PageList>를 통해 새로 생성된 PagedList, DataSource가 어떤 식으로 동작하는지 살펴볼 수 있습니다.

안드로이드 나이츠 2018

안드로이드 나이츠 2018에서 발표한 슬라이드입니다. 행사 당일 사정상 질문을 못하셨거나 혹은 이 글을 읽고 궁금한 부분이 있다면 댓글 남겨주세요. 👨‍💻

맺으며

카카오 플레이스의 페이징

2016년에 출시된 카카오 플레이스의 페이징 영상입니다. 당시 같은 팀의 동료 개발자인 오웬이 PagedList와 유사한 Lazy List 구현체를 만드는 것을 지켜봤기 때문에 GDGEurope ’17의 Architecture Components 영상을 관심 있게 봤습니다. 페이징 라이브러리를 기준으로 카카오 플레이스의 페이징을 설명하면 플레이스홀더를 지원하는 CoutableList로 정리할 수 있습니다. 카카오 플레이스의 구현을 한 문장으로 설명할 수 있을 만큼 페이징 라이브러리를 사용하면 몇줄의 코드만으로 적지 않은 시간과 노력을 한번에 건너뛸 수 있습니다. 그런 면에서 드로이드 나이츠 2018 발표를 준비하면서 알게 된 페이징 라이브러리의 내부 구현은 개인적으로 꽤 인상적이었습니다. 다만 라이브러리가 초기 단계이기 때문에(이 글을 쓰는 시점에 1.0.0-rc1) 프로덕션 환경에서 페이징 라이브러리를 적용하는 경우에는 Issue Tracker에 올라온 이슈들을 꼭 한번 살펴보시기 바랍니다.

이 글이 페이징 라이브러리에 대한 이해에 도움이 됐으면 하는 마음으로 글을 마칩니다.

참조 및 읽어볼 만한 글

--

--

한로니

컴퓨터 앞에 앉아있는 시간이 많지만, 어째서인지 자전거를 타고 세계 곳곳을 여행하는 상상을 종종하는 괴발자 🤞