Android Paging Library 분석
안드로이드 페이징 라이브러리는 로컬(Local) 및 원격(Remote) 저장소 데이터를 페이지 단위로 UI 에 표현하는 것을 도와주는 라이브러리 입니다. 기존까지는 이러한 페이징 기능 구현을 위해서 개발자는 View 및 View Controller 에 그를 위한 구현을 해야 했습니다.
(이후로 View 는 편의 상 Activity, Fragment 등의 UI Component 를 지칭하며, View Controller 는 ViewModel, Presenter 등의 Model binder 를 지칭합니다.)
페이징 기능 구현을 위해서 기존에 우리가 해왔던 작업들을 한번 살펴보면,
View 레벨에서는 리스트 UI (ListView, RecyclerView, …)가 Top 혹은 Bottom 에 도달했는지를 판단하여 View Controller 에 이전 혹은 다음 페이지를 표현하기 위해 필요한 데이터 로딩을 요청하는 코드를 추가해야 합니다.
View controller 레벨에서는 최초 View 에 연결 되는 시점 또는 위에서 언급한 View 의 스크롤 상태와 같은 이벤트에 대응하여 데이터의 최초 혹은 추가 로딩을 수행해야 합니다.
이 때, 데이터의 출처가 되는 Repository 가 Position 기반으로 동작하면 현재 캐시된 데이터(직전에 로딩한)를 기반으로 추가로 로딩 할 Position 을 계산하여 요청해야 하고, Unique Key 기반으로 동작하면 최상위혹은 최하위 데이터의 Unique key를 이용하여 추가 데이터를 요청해야 합니다.
여기에 더하여 데이터 로딩이 진행되는 중에 다른 페이지 요청이 들어오는 경우, 네트워크 오류 등으로 페이지 요청이 실패하는 경우 등의 예외처리들과 페이지를 순차적으로 요청하는 것이 아닌 특정 위치로 점프하며 요청해야 한다거나, 오프라인 지원을 위한 로컬과 원격 저장소 동기화 등등 페이징 기능 구현 시 마다 공통적으로 고민해야 할 부분들이 아주 많았습니다.
Google은 보편적으로 사용되고 있는 이러한 페이징 기능을 Pagiging library 로 만들어 제공함으로써 개별 개발자들이 위에서 언급한 루틴에 가까운 작업들과 예외처리들을 반복하지 않도록 만들었으며 이 라이브러리는 Jetpack 에 포함되어 있습니다.
Paging library 를 프로젝트에 적용하고 사용하기 위해서는 아래 Android Official Guide 를 참고하여 gradle dependencies 를 추가하면 됩니다. 그리고 그에 대한 사용 방법 또한 SDK Guide 에 잘 설명되어 있으며, 수 많은 유용한 포스트들이 웹에 존재합니다. Paging Library 를 프로젝트에 설정하는 방법과 사용 방법에 대한 Best Practice 들은 위의 공식 가이드 및 샘플, 웹에 있는 좋은 포스트들을 참고해 보면 좋을 것 같습니다.
여기서 우리는 페이징 라이브러리의 설정과 그 사용법보다는 내부 구현 코드를 살펴 봄으로써 그 동작 원리를 조금 더 깊게 알아보고자 합니다.
시작합니다!
페이징 라이브러리의 주요 요소는 크게 PagedList, PagedListAdapter 그리고 DataSource 이렇게 세 가지로 생각해 볼 수 있는데, 그 관계를 간략히 도식화 해보자면 아래 정도가 될 것 같습니다.
우리는 리스트 형태의 데이터를 UI 에 표시할 때 RecyclerView 를 주로 사용합니다 (과거에는 ListView 를 사용). 이 때 각 항목을 화면에 표현하기 위한 View 생성 및 데이터 바인딩을 위해 RecyclerView.Adapter 를 상속하여 사용합니다. 지금 우리는 PagedList 라는 페이지 화 된 데이터를 RecyclerView 에 바인딩하고자 하고, 이를 위해서는 PagedListAdapter 사용하면 됩니다 (물론 RecyclerView.Adapter 를 사용하고 Differ 를 구현해 사용하는 방법도 있습니다). PagedListAdapter 는 내부적으로 AsyncPagedListDiffer 라는 객체를 가지고 있는데, 이는 바인딩 요청(submit) 되는 페이지와 현재 바인딩 된 페이지의 비교를 비동기로 수행한 후 UI 를 새로운 페이지로 업데이트 하기 위해 사용되는 PagedListAdapter 의 핵심요소 입니다. 이 때, 현재 페이지 상태를 SnapShot 상태로 저장하여 비교 대상이 되는 페이지의 변경을 방지하는 등의 기술이 녹아있습니다. PagedList 는 필요 시 마다 DataSource 에서 페이지 정보를 가져와서 내부적으로 Page들의 통합 저장소로 사용하는 PagedStorage 에 저장합니다. 이러한 일련의 동작들은 Config 오브젝트에 설정되어 있는 정보들을 기반으로 수행합니다. 이것이 페이징 라이브러리의 간략한 동작 방식입니다.
그럼 PagedList, PagedListAdapter, DataSource 순서로 조금 더 자세히 살펴보겠습니다.
PagedList
“PagedList는 최초 생성되면 초기 데이터를 가져오기 위한 DataSource로의 요청을 호출 스레드(PagedList 가 생성된 스레드)에서 수행하기 때문에 백그라운드 스레드에서 생성해야 합니다. 다만, `LivePagedListBuilder` 를 이용해 PagedList 생성하면 백그라운드 스레드(IO thread) 에서 초기화 동작을 수행하므로 별도의 스레딩 처리는 필요하지 않습니다.” (이후 로딩 과정을 위한 시퀀스 다이어그램에서 확인해 볼 수 있습니다.)
다이어그램에서 PagedList 왼쪽에 보면 Config 클래스가 있습니다. 이 클래스는 PagedList 생성 시 전달되며, 데이터를 어느 시점에 어느 정도 크기로 로딩할 지, PlaceHolder 를 사용하여 계속적으로 추가 데이터를 로딩하는 것을 허용 할 지와 같은 설정들을 가지고 있습니다. PagedList 클래스는 이렇게 설정된 Config 객체를 참조해서 그에 따른 페이징 동작을 수행합니다.
backgroundThreadExecutor는 데이터의 추가 로딩 시 사용되며 (최초 로딩 제외), mainThreadExecutor 는 데이터 로딩이 완료되면 콜백을 통해 UI (adapter) 로 결과를 전달할 때 사용됩니다.
boundaryCallback 은 PagedList 생성 시 선택사항(Optional)이며, 최초 로딩 시점에서는 데이터 로딩 요청의 결과 데이터가 비어있는지(onZeroItemsLoaded), 이미 일부 데이터가 로딩 된 상태에서는 최상단 데이터에 접근이 발생했는지(onItemAtFrontLoaded), 최하단 데이터 접근이 발생했는지(onItemAtEndLoaded)에 따라서 그에 따른 Callback 함수를 호출하여 줍니다. 이런 BoundaryCallback 이용해야 하는 대표적인 예로는 로컬 DB 와 원격 서버를 이용한 Repository 를 구성하는 케이스를 생각해 볼 수 있습니다. (공식 샘플)
그림을 보면 데이터 소스를 Repository 라는 형태로 추상화해 놓고, UI 에서 Repositofy 로 데이터를 요청하면 PagedList 형태로 반환 하고 있습니다. 이런 구현을 할 때 UI 는 보통 Local database 를 실제 화면에 표시할 데이터 소스로 사용하게 됩니다 (Single Source of Truth).
- 최초에 Local database 에는 데이터가 없으므로 Boundary callback 의 onZeroItemsLoaded callback 이 호출 됩니다. 그러면 최초 데이터 로딩을 Remote Server 에 요청하고 결과 데이터를 Local Database 에 저장하게 됩니다. (Remote-Local Sync)
- 사용자가 화면에 표시되어 있는 데이터 목록에서 스크롤 동작을 통해 최상단 혹은 최하단으로 이동 시 그에 상응하는 Callback (onItemAtFrontLoaded, onItemAtEndLoaded) 이 호출되며, 그에 따라 이전 혹은 다음 페이지를 요청하여 동기화 하는 작업이 수행됩니다.
다이어그램에서 boundaryCallback 속성 아래 있는 boundaryCallbackBeginDeferred, boundaryCallbackEndDeferred, lowestIndexAccessed, highestIndexAccessed 속성들은 모두 Boundary callback 이 적절한 시점에 불릴 수 있도록 관리하기 위한 상태 속성들입니다.
storage 는 PagedStorage 클래스의 객체로써 PagedStorage 또한 AbstractList 를 상속하고 있는 리스트 유형 객체입니다. storage 는 PagedList 에서 동적으로 로딩되는 Chunk (page) 단위의 데이터들의 집합을 단일 리스트 형태로 관리하기 위한 클래스입니다. 내부적으로는 Page가 List<T> 로 표현된다고 할 때, ArrayList<List<T>> 형태로 Page들의 목록을 관리하고 있습니다.
Storage 는 Contiguous, Non-Contiguous 타입으로 나뉘어 집니다.
Contiguous 타입의 경우 Page 들 사이에는 비어있는 공간이 없이 연속적으로 이어지는 Page (chunk) 를 다루는 타입이며 각 Page 별로 크기는 다를 수 있습니다. Non-Contiguous 타입의 경우 Page 들 사이에 비어있는 공간을 허용하며 Page 마다 크기가 일정해야 합니다. (단, 생각해보면 당연한 이야기지만 페이지가 하나밖에 없거나, 가장 마지막 페이지의 경우에는 기본 페이지 크기보다 작을 수 있습니다.) Non-Contiguous 타입이 필요한 케이스가 뭐가 있을까요? 주소록에서 특정 성으로 점프하며 목록을 확인한다거나, 갤러리에서 특정 연도로 점프하며 사진을 찾는 케이스를 생각해 볼 수 있겠네요.
storage 는 앞서 설명한 실제 로딩 된 데이터를 pages 속성으로 갖고 있으며, 추가적으로 leadingNullCount, trailingNullCount 속성을 갖고 있습니다. 이는 현재 로딩이 진행중이거나 로딩 예정인 데이터들의 개수를 나타내며 leadingNullCount는 현재 데이터의 앞쪽, trailingNullCount 는 뒤쪽을 나타냅니다. 즉, leadingNulls… pages… trailingNulls 와 같은 모습니다.
Storage 는 데이터가 추가될 때마다 이 leading 혹은 trailing null count 를 줄여가며 null 을 실제 데이터로 대체하고, null 이 없을 경우 데이터를 추가하며 pages 를 구성해 갑니다. 또한 storage 에 데이터를 추가하기 위한 함수들에 그 결과에 대한 Callback 을 제공합니다. 데이터 추가를 위한 함수로 Contiguous 타입은 prependPage(앞에 추가) 와 appendPage(뒤에 추가) 를 제공하고 있으며, Non-Contiguous 타입의 경우에는 initAndSplit(최초 페이지 목록 구성) 과 insertPage(특정 위치에 페이지 추가)를 제공합니다.
prependPage 함수를 통해 현재 데이터의 앞쪽에 페이지를 추가하고 나면 아래 Callback 을 호출합니다.
void onPagePrepended(int leadingNulls, int changed, int added);
leadingNulls 는 페이지 앞쪽에 null data 의 개수이며, changed 는 기존의 leadingNulls 에서 실제 데이터로 변경된 개수, added는 changed 이외에 추가된 데이터 개수를 나타냅니다.
appendPage 함수를 통해 현재 데이터 뒤쪽에 페이지를 추가하고 나면 아래 Callback 을 호출합니다.
void onPageAppended(int endPosition, int changed, int added);
endPosition 은 기존 데이터의 마지막 위치이며, changed 및 added 는 prependPage 와 동일합니다.
initAndSplit 은 임의 길이의 page 와 leadingNulls, trailingNulls, pageSize등을 파라미터로 받아 pageSize 크기로 page 를 분할하여 (tiling) Non-Contiguous 스토리지를 초기화하는 역할을 합니다.
insertPage 는 page 와 page가 삽입될 position 을 받아 해당 위치에 page 를 추가하는 역할을 수행합니다. 이 때, 해당 위치에 null page 가 존재하지 않으면 null page 를 먼저 추가하는 작업을 수행(allocatePageRange) 한 후에 추가된 공간에 요청된 page를 삽입합니다.
PagedList 에 정의된 Callback은 아래와 같습니다.
PagedList는 이후 설명할 PagedListAdapter 의 AsyncPagedListDiffer 에이 Callback을 통해 데이터의 변경을 통지하며 실제 UI 업데이트가 이루어지도록 합니다.
PagedListAdapter
PagedListAdapter 는 PagedList 를 RecyclerView 에 효율적으로 연결하기 위한Adapter 구현을 제공합니다.
PagedListAdapter 는 RecyclerView.Adapter 를 상속하며 내부적으로 AsyncPagedListDiffer 와 여기 정의된 listener 를 구현해서 PageList 가 업데이트 될 때마다 UI 업데이트가 적절히 이루어질 수 있도록 합니다.
AsyncPagedListDiffer 는 Adapter 에 PagedList 를 바인딩 하기위한 핵심 로직을 담고 있습니다. 페이징 기능 구현 시 우리는 Repository 로부터 PagedList 를 응답으로 받은 후 Adapter 에 submitList 함수를 이용하여 데이터를 바인딩합니다. submitList 를 호출하면서 pagedList 를 파라미터로 넘기면 AsyncListDiffer 는 기존에 바인딩 되어 있던 pagedList 가 존재하면 기존 pagedList를 snapshot 상태로 전환합니다. (snapshot 역시 PagedList 를 상속한 클래스로써 Page 데이터의 일종이지만, DataSource로부터 데이터의 추가 로딩 기능을 제공하지 않고 생성 당시 소스가 되는 Page 데이터만 가지고 있는 Immutable paged list 입니다.)
새로운 PagedList 가 submit 되는 코드를 일부 발췌해보면 아래와 같습니다.
기존 pagedList 에 대한 snapshot 은 oldSnapshot 이라는 변수에 저장하고, submit 이 요청 된 새로운 pagedList 또한 snapshot 상태로 전환하여 newSnapshot 변수에 저장합니다.
그리고 백그라운드 스레드에서 두 Snapshot 의 비교를 수행하여 비교 결과를 담은 DiffUtil.DiffResult 를 반환받습니다. 그리고 메인 스레드 (UI thread) 로 전환하여 신규 pagedList로의 교체 작업을 진행합니다. (latchPagedList)
코드상에서 generation 이라는 변수들이 보이는데 generation은 PagedList 비교 작업이 백그라운드 스레드에서 일어나기 때문에 여러번의 Diff 작업이 요청되면 가장 최근의 작업만을 UI 에 반영하고 예전 요청들에 대한 결과는 버려기 위해 사용됩니다.)
비교 결과를 UI 로 반영하기 위한 latchPagedList 함수의 코드는 아래와 같습니다.
이 함수는 AsyncPagedListDiffer 내부적으로 유지하는 pagedList 속성을 새로운 pagedList 로 교체하고 updateCallback 을 통해서 Adapter 로 onChanged, onInserted, onRemoved 등의 변경 사항을 통지하는 작업을 수행합니다. RecyclerView 에 ItemAnimation 등이 설정되어 있다면 애니메이션과 함께 아이템이 추가/삭제되는 모습이 UI 에 나타나게 됩니다.
만약 PagedListAdapter 를 사용할 수 없는 경우가 있다면 RecyclerView.Adapter 를 사용하고 AsyncPagedListDiffer 를 생성하거나 커스터마이즈하여 직접 호출해 사용하는 방법도 있습니다.
DataSource
DataSource는 PagedList가 Page(Chunk) 단위로 데이터를 공급 받을 대상이며 추상 클래스인 PagedList 의 구체적인 구현체들(Ex> ContiguousPagedList, TiledPagedList)은 그 특성에 맞는 DataSource 를 멤버로 갖고, 그것으로부터 데이터 로딩을 수행합니다.
DataSource 는 크게 ContiguousDataSource 와 PositionalDataSource 로 나뉘어 있습니다.
ContiguousDataSource 는 길이가 가변적이며 페이지 로딩이 순차적으로 이루어지 경우에 사용할 수 있는 DataSource 입니다. 대표적인 구현 클래스로는 ItemKeyedDataSource 와 PageKeyedDataSource 가 있습니다.
ItemKeyedDataSource 는 페이지 데이터의 각 아이템의 고유 키를 기반으로 페이징을 수행합니다. 예를 들어 최초 로딩 이후에 이전 페이지 데이터를 위해서는 제일 앞 아이템의 Key, 이후 페이지 데이터를 위해서는 제일 뒤 아이템의 Key 를 기반으로 지정된 크기 만큼의 데이터를 가져옵니다.
PageKeyedDataSource 는 페이지 고유 키를 기반으로 페이징을 수행하며, 그렇기 때문에 이전 페이지 키(previousKey), 다음 페이지 키(nextKey) 를 속성으로 가지고 있습니다.
PositionalDataSource 는 위치 기반의 DataSource 이며 길이가 정해져 있어 그 크기를 알 수 있고, 비 순차적으로 임의의 페이지에 대한 접근이 필요할 경우에 사용할 수 있는 DataSource 입니다. 대표적인 구현 클래스로는 TiledDataSource, ListDataSource, LimitOffsetDataSource 가 있습니다.
TiledDataSource 는 Position 기반으로 임의의 페이지에 대한 접근을 용이하게 해주기 위한 구현체입니다.
ListDataSource 는 메모리상에 캐시 된 특정 리스트를 DataSource 로 사용해야 할 경우 사용할 수 있습니다.
LimitOffsetDataSource 는 Room database 를 사용할 경우 쿼리에 Offset과 Limit 을 사용하여 일정 사이즈의 페이지 단위로 로딩할 경우 사용할 수 있습니다. (사실 Sqlite 에서는 key column 에 대해서 index 를 두어 페이징 시 성능 저하를 막을 수 있지만, Offset/Limit 기반으로 구현한 이유를 Class JavaDoc 에서 설명하고 있습니다. Sqlite 성능 이슈 참고)
지금까지 페이징 라이브러리의 3가지 요소에 대해서 살펴보았습니다.
이제 페이징 라이브러리를 통해서 데이터 로딩이 수행되는 과정을 최초 로딩과 추가 로딩으로 나누어 살펴보겠습니다. (여기서는 AAC의 ViewModel 을 사용한다고 가정하고, Paging type은 Contiguous 를 기준으로 살펴보겠습니다.)
최초 로딩
- Fragment 에서 RecyclerView 에 데이터를 표현하기 위해서 PagedListAdapter 를 생성하면 PagedListAdapter는 내부적으로 AsyncPagedListDiffer 를 생성해서 유지합니다.
- Fragment 에서 데이터 로딩을 위해 ViewModel 을 생성하면 ViewModel 에서 우리는 보통 PagedList 를 LiveData 형태로 사용하기 위해서 LivePagedListBuilder 를 통해서 LiveData<PagedList<Type>> 속성을 생성합니다. 이렇게 생성된 LiveData는 사실 ComputableLiveData 가 내부적으로 유지하는 LiveData 인데 ComputableLiveData 는 invalidate 호출을 통해 데이터 갱신이 가능한 LiveData 로써 실제 Active observer 가 있을 경우에만 데이터 갱신을 시도합니다.
- 이제 Fragment 에서 ViewModel 의 LiveData 를 구독하면 LiveData 는 Active observer 가 생성되었으므로 해당 콜백을 받게되고(onActive) RefreshRunnable 을 비동기로(IO Thread) 수행함으로써 데이터 갱신 수행합니다.
- RefreshRunnable 은 ComputableLiveData 의 compute 함수를 호출하는데 이함수는 DataSource 를 생성하고, 이 DataSource 를 이용하는 PagedList 를 생성하여 반환합니다. 이렇게 반환받은 데이터를 LiveData 에 저장합니다.
- 그러면 UI 에서는 LiveData 의 변경을 통지 받고 PagedListAdapter 에 변경된 PagedList 를 submit 합니다. 그러면 PagedListAdapter 의 AsyncPagedListDiffer 는 기존에 PagedList 가 존재하면 그 차이를 비교한 후 새로운 페이지로 갱신합니다. 이 때, AsyncPagedListDiffer 는 이 후 PagedList 에 대한 변경 사항을 통지 받지 위해 약한 참조로 Callback을 등록합니다. (addWeakCallback)
- (4) 의 과정에서 PagedList 는 생성 시점에서 다중 페이지를 관리할 PagedStorage 와 DataSource 로부터 결과를 수신할 PageResult.Receiver 를 생성합니다. 그리고 최초 데이터를 로딩하기 위해서 DataSource에 dispatchLoadInitial 를 호출합니다.
- dispatchLoadInitial 은 DataSource 의 실제 구현체의 loadInitial 함수를 호출하게 되고 우리는 이시 점에 우리가 추가한 코드로 (API call) 최초 데이터의 로딩을 수행하게 되며, 로딩이 완료되고 나면 loadInitial 함수에서 전달 받았던 callback 객체를 이용하여 onResult 로 결과를 전달하게 됩니다.
- 이 결과는 UI 스레드로 전환되어 PagedList 로 전달되게 되고, PagedList 는 이 초기 데이터를 기반으로 PagedStorage 를 초기화 합니다. (init)
- 초기화가 완료되면 PagedList 는 콜백을 통해 데이터 추가를 알리게 되고(notifyInserted) PagedListAdapter 의 AsyncPagedListDiffer 는 이 콜백을 받아 변경된 항목들에 대한 갱신 및 UI에 바인딩을 수행합니다.
추가로딩
- PagedListAdapter 에서 화면에 표시할 데이터를 가져오기 위해 AsyncPagedListDiffer 의 getItem 함수를 호출하면 해당 인덱스의 데이터를 반환하면서 동시에 요청된 인덱스 주변의 데이터 로딩을 위한 loadAround 함수를 호출합니다.
- loadAround 함수는 config 의 prefetchDistance (PagedList 의 최상단/최하단에 도달하기 얼마 전에 추가 로딩을 수행하지에 대한 설정) 를 참고하여 최상단 추가 로드가 필요하면 schedulePrepend, 최하단 추가 로드가 필요하면 scheduleAppend 함수를 호출합니다.
- PagedList 의 scheduleAppend 함수가 호출되면 (schedulePrepend 는 이후 과정이 비슷하여 생략) 현재 PagedList 의 이후 데이터를 로딩하기 위해서 비동기로(IO Thread) DataSource 의 dispatchLoadAfter 함수를 호출합니다.
- DataSource 는 loadAfter 함수를 호출하는데 우리는 이 시점에 loadAfter 함수에 전달 된 파라미터를 기반으로 Api 콜을 수행하여 결과를 loadAfter 함수에 전달 된 onResult 콜백으로 전달하게 됩니다.
- 전달한 결과 데이터는UI 스레드로 전환 후 콜백을 통해 PagedList 로 전달 되고 PagedList 는 내부 저장소인 PagedStorage 에 appendPage 함수 호출을 통해 새로 전달 된 페이지를 추가하게 됩니다.
- 추가가 완료되면 PagedStorage 는 추가가 완료되었다는 콜백(onPageAppended)을 PagedList 로 전달하게 되고, 이를 전달 받은 PagedList 는 이후 데이터가 더 필요한 경우에 scheduleAppend 를 다시 한번 호출하여 비동기로 (3) 의 과정부터 다시 한번 수행합니다.
- (6) 에서 append 과정을 다시 수행하는 것과는 무관하게 PagedList 는 추가된 데이터에 대한 결과를 콜백을 통해 AsyncPagedListDiffer 에게 전달하게 되고 이는 Adapter 의 데이터 갱신을 이루어지게 합니다.
페이징 라이브러리는 이러한 방식으로 PagedList 가 생성되어 Adapter 에 바인딩된 이후로 요청되는 항목의 인덱스에 따라서 이전/다음 데이터를 백그라운드에서 로드하여 그 결과를 반영하며 동작합니다.