Overview


브랜디에서 서비스하는 앱들에는 e-commerce의 특성상 데이터화 된 많은 양의 상품정보를 보여주고 있습니다. 때문에 퍼포먼스 이슈를 피하기 위해 데이터 로드 시 Paging 적용이 필수 사항입니다.

작년 6월경 Alpha로 출시했던 Paging3가 올해 2월 Beta를 거쳐 5월에 정식 출시 되었습니다. Paging3는 로딩, 에러 등의 상태관리가 용이하도록 변경되었고, 데이터의 Refresh, 캐싱 처리 등의 기능을 지원하는데요. Migration 해보고자 학습했던 내용 및 작업 내용에 대해 소개하겠습니다.

Paging3 Summary


athena

Paging3는 PagingSource나 RemoteMediator와 PagingConfig의 정보를 토대로 Pager를 통해PagingData를 생성한 뒤, 해당 인스턴스를 PagingDataAdapter가 활용하여 UI를 그리는 아키텍쳐로 설계되어 있습니다.

1. Paging Source

Paging3에서 가장 크게 달라진 점은, 기존 Paging2에서 여러가지 방법으로 DataSource 처리를 하던 부분이 PagingSource 하나로 통합 되었다는 점입니다.

PagingSource는 데이터 소스를 정의하기 위한 클래스로 Key타입과, 반환할 데이터 형을 Generic으로 받습니다.

class TestPagingSource: PagingSource<Int, TestPagingData>() {

    override fun getRefreshKey(state: PagingState<Int, TestPagingData>): Int? {
        TODO("Not yet implemented")
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TestPagingData> {
        TODO("Not yet implemented")
    }
}

load 함수는 실제 데이터를 가져오는 로직구현을 위한 함수이고, getRefreshKey 함수는 초기 key값이나 데이터 로드 중단 후 재 로드시 이전 position에서 중단된 key값을 가져오는 등 load에서 사용할 key 값을 가져오는 로직구현을 위한 함수입니다.

2. RemoteMediator

athena

RemoteMediator는 Network에서 데이터 로드와, 로드된 데이터를 내부 DB로 저장하는 역할을 수행합니다. 즉, Paging3에서 지원하는 내부DB캐싱에 관련된 역할을 수행하는 클래스입니다.

RemoteMediator를 사용하게 되면 PagingSource는 캐시된 데이터만을 사용하여 UI처리용 데이터를 처리합니다.

@OptIn(ExperimentalPagingApi::class)
class TestRemoteMediator: RemoteMediator<Int, TestPagingData>(){

    override suspend fun initialize(): InitializeAction {
        return InitializeAction.SKIP_INITIAL_REFRESH
    }

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, TestPagingData>
    ): MediatorResult {
        MediatorResult.Success(endOfPaginationReached = true)
    }
}

initialize 함수는 캐시된 데이터를 갱신시킬 것인지에 대한 로직구현을 위한 함수이고, load함수는 데이터를 가져오기 위한 로직구현을 위한 함수입니다.

RemoteMediator 사용 시 특이점은 Key를 내부에서 제공해주거나 사용하지 않는 점입니다.

load 함수에서 제공되는 LoadType을 활용하여 데이터 추가로드 여부에 대한 로직을 구현한 뒤, 결과를 load함수의 반환형인 MediatorResult에 endOfPaginationReached 파라미터에 넘겨주어 데이터 로드를 끝마칠지 판단합니다.

(상황에 따라 Key가 필요하다면 내부 DB를 사용하여 직접 생성 및 관리 하도록 합니다.)

3. Pager

// Paging Source만 사용하는 형태의 Pager생성 
val pagingData: Flow<PagingData<TestPagingData>> = Pager(
        config = PagingConfig(pageSize = 30)
    ) {
        TestPagingSource()
    }.flow.cachedIn(viewModelScope)

// RemoteMediator를 사용하는 형태의 Pager생성
val pagingDataFlow: Flow<PagingData<TestPagingData>> = Pager(
        config = PagingConfig(pageSize = 30),
        remoteMediator = TestRemoteMediator()
    ) {
        TestPagingSource()
    }.flow

PagingSource 나 RemoteMediator와 PageConfig의 정보를 토대로 PagingData를 생성한 뒤 스트림화 해주는 클래스입니다.

스트림화 시에는 Flow, LiveData, RxJava와 같은 Flowable 유형과 Observable유형 모두를 지원합니다.

현재 저희는 Flow를 사용하므로 Flow로 스트림화 된 데이터를 만들었습니다.

Core Module에 Generic 활용하여 Paging3 적용하기


현재 브랜디에서는 서비스 중인 앱들에서 공통적으로 사용가능한 부분을 Core Project로 분리해서 각 서비스의 Project마다 AAR형태로 사용하고 있습니다.

e-commerce의 앱에 필수적이라고 할 수 있는 Paging도 Core Project에서 제공해주어야 한다고 생각했기에, Paging3를 Generic하게 Core Project에 적용해 보았습니다.

athena

처음 구조를 잡을 때 화면별로 캐시 사용여부가 나뉘어지므로 내부 DB캐싱 사용여부에 따라 다른 Class를 사용하도록 구조를 잡았습니다.

1. GenericPagingSource & GenericPagingSourceFactory

class GenericPagingSource<Key : Any, Value : Any>(
    val getPagingDataInitial: suspend (params: LoadParams<Key>) -> LoadResult<Key, Value>,
    val getPagingDataAfter: suspend (params: LoadParams<Key>) -> LoadResult<Key, Value>
) : PagingSource<Key, Value>() {

    override fun getRefreshKey(state: PagingState<Key, Value>): Key? {
        return null
    }

    override suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value> {
        try {
            if (params.key == 0 || params.key == null) {
                return getPagingDataInitial(params)
            } else {
                return getPagingDataAfter(params)
            }

        } catch (basePagingThrowable: BasePagingThrowable) {
            return LoadResult.Error(basePagingThrowable)
        }
    }
}

abstract class GenericPagingSourceFactory<Key: Any, Value: Any> {

    abstract fun createDataSource(): GenericPagingSource<Key, Value>

    fun getGenericPagingFlow(pageSize: Int = DEFAULT_PAGING_SIZE): Flow<PagingData<Value>> {
        return Pager(
            config = getPagingConfig(pageSize)
        ) {
            createDataSource()
        }.flow
    }

    private fun getPagingConfig(pageSize: Int = DEFAULT_PAGING_SIZE): PagingConfig {
        return PagingConfig(
            pageSize = pageSize
        )
    }
}

GenericPagingSource는 PagingSource에 전달해줄 Key와 Data의 형을 Generic으로 명시해주도록 하였습니다. 또한 화면에 따라 최초 ApiCall과 이후 ApiCall이 분리 되어야 하는 케이스에 매번 키로 분기 처리해야 하는 불편함을 덜고자 파마리터로 람다를 전달해주고, 분기처리를 내부에서 하도록 구성하였습니다.

PagingData생성 및 스트림화는 FactoryPattern을 사용하였고, 작업자의 실수를 방지하고자 PagingSource를 생성하는 createDataSource함수를 abstract로 처리하였습니다. 추가로 PagingConfig에 설정해 줄 PageSize는 파라미터로 변경 가능하도록 구성하였습니다.

val pagingDataFlow: Flow<PagingData<TestPagingData>>

val dataSourceFactory = object : GenericPagingSourceFactory<String, TestPagingData>(){
    override fun createDataSource(): GenericPagingSource<String, TestPagingData> {
        return GenericPagingSource(
            ::requestApiCallFirst,
            ::requestApiCallAfter
        )
    }
}
pagingData = dataSourceFactory.getGenericPagingFlow().cachedIn(viewModelScope)

2. GenericRemoteMediator & GenericRemoteMediatorFactory

@OptIn(ExperimentalPagingApi::class)
class GenericRemoteMediator<Key: Any, Value: Any>(
    val getPagingDataInitial: suspend (loadType: LoadType, state: PagingState<Key, Value>) -> MediatorResult,
    val getPagingDataAfter: suspend (loadType: LoadType, state: PagingState<Key, Value>) -> MediatorResult,
    val getKey: suspend (data: Value) -> RemoteKeys?
): RemoteMediator<Key, Value>() {

    override suspend fun initialize(): InitializeAction {
        return InitializeAction.SKIP_INITIAL_REFRESH
    }

    override suspend fun load(loadType: LoadType, state: PagingState<Key, Value>): MediatorResult {
        return try {

            val key = getPageKey(loadType, state)

            if (key == null) {
                getPagingDataInitial(loadType, state)
            } else if (key == -1) {
                MediatorResult.Success(endOfPaginationReached = true)
            } else {
                getPagingDataAfter(loadType, state)
            }

        } catch (basePagingPagingThrowable: BasePagingThrowable) {
            MediatorResult.Error(basePagingPagingThrowable)
        }
    }

    suspend fun getPageKey(loadType: LoadType, state: PagingState<Key, Value>): Int? {

        return when(loadType) {
            LoadType.REFRESH -> {
                null
            }
            LoadType.PREPEND -> {
                val remoteKeys = getFirstRemoteKey(state)
                remoteKeys?.prevKey
            }
            LoadType.APPEND -> {
                val remoteKeys = getLastRemoteKey(state)
                remoteKeys?.nextKey
            }
        }
    }

    private suspend fun getFirstRemoteKey(state: PagingState<Key, Value>): RemoteKeys? {
        return state.pages
            .firstOrNull { it.data.isNotEmpty() }
            ?.data?.firstOrNull()
            ?.let {
                getKey(it)
            }
    }

    private suspend fun getLastRemoteKey(state: PagingState<Key, Value>): RemoteKeys? {
        return state.pages
            .lastOrNull { it.data.isNotEmpty() }
            ?.data?.lastOrNull()
            ?.let {
                getKey(it)
            }
    }
}

abstract class GenericRemoteMediatorFactory<Key: Any, Value: Any> {

    abstract suspend fun getRemoteKey(data: Value): RemoteKeys?

    abstract fun createRemoteMediator(): GenericRemoteMediator<Key, Value>

    abstract fun getPagingSource(): PagingSource<Key, Value>

    @OptIn(ExperimentalPagingApi::class)
    fun getGenericPagingFlow(pageSize: Int = DEFAULT_PAGING_SIZE): Flow<PagingData<Value>> {
        return Pager(
            config = getPagingConfig(pageSize),
            remoteMediator = createRemoteMediator()
        ) {
            getPagingSource()
        }.flow
    }

    private fun getPagingConfig(pageSize: Int = DEFAULT_PAGING_SIZE): PagingConfig {
        return PagingConfig(
            pageSize = pageSize
        )
    }
}

GenericPagingSource와 구현은 유사합니다.

다만 차이점은 Key를 DB에 구현해서 사용하도록 하므로 Key를 가져올 수 있는 getRemoteKey함수, 캐싱된 DB데이터에서 PagingSource를 가져올 수 있는 getPagingSource함수가 추가되었다는 점입니다.

val pagingDataFlow: Flow<PagingData<TestPagingData>>

val remoteMediatorFactory = object : GenericRemoteMediatorFactory<Int, TestPagingData>() {
    override fun createRemoteMediator(): GenericRemoteMediator<Int, TestPagingData> {
        remoteMediator = GenericRemoteMediator(
            ::requestGetEventWithTotalCount,
            ::requestGetEvent,
            ::getRemoteKey
        )
        return remoteMediator
    }

    override fun getPagingSource(): PagingSource<Int, TestPagingData> {
        return repository.getPagingSource()
    }

    override suspend fun getRemoteKey(data: TestPagingData): RemoteKeys? {
        return repository.getEventRemoteKey(data.id)
    }
}

pagingData = remoteMediatorFactory.getGenericPagingFlow().cachedIn(viewModelScope)

Conclusion


페이징3를 학습하면서 추가된 기능과 비례하게 학습에 대한 허들이 높아졌다고는 생각되지만, 그만큼 조금 더 강력한 라이브러리로 개선되었다고 느꼈습니다.

추가로, 신기술을 도입할 뿐만 아니라 Core Module에 적용하기 위한 작업을 진행하면서 여러 시행착오를 겪었습니다. 시행착오를 통해 많은 케이스를 경험하고 여러 케이스에 대한 고려의 필요성을 더욱 더 느끼게 된 계기가 되었던 것 같습니다.

참고자료

https://developer.android.com/topic/libraries/architecture/paging/v3-overview


박금성 | APPS실 MA팀
브랜디, 오직 예쁜 옷만