Paging3 적절히 판단해서 사용하기
채팅 로그 서비스 개발에 사용했던 Paging3에 대한 경험을 기록합니다.
Room 기반으로 채팅 데이터를 기록하고, 이 데이터를 Paging3 를 활용하여 보여줍니다.
하지만 원하는 요구사항에 항상 Paging3가 맞아 떨어지진 않았습니다.
Paging3를 사용할지 직접 구현할지를 결정하고, 그 경험을 기록합니다.
1. Gallery 뷰에 Paging3 활용 사례
우선, Paging3를 활용했던 경우에 대해서 먼저 이야기하겠습니다.
Gallery 뷰의 진입점은 채팅방 -> 갤러리로 이뤄지고 있습니다.
휴대폰의 기본 갤러리 앱 처럼, N x 3의 그리드 형식으로 사진들을 보여줘야 하고
날짜에 따라 날짜 헤더 구분자 또한 필요합니다.
우선 이 요구사항들을 기반으로, Paging3를 구현하게 된다면 두 가지 방법이 있습니다.
A. Room의 Paging3지원 활용
Room에서 리턴형으로 PagingSource<T>를 지원하는 기능이 있습니다.
이를 활용하기 위해선 우선 build.gradle에 Dependency 를 추가해줘야 합니다.
// $room_version 은 최신 버전을 참고하세요.
def paging_version = "3.1.1"
androidx.room:room-paging:$room_version
dependency 추가를 완료했으면 아래처럼 활용할 수 있습니다.
fun getAllChatImages(): PagingSource<Int, ChatImage>
B. PagingSource 직접 구현
Room에서 제공하는 PagingSource 리턴형을 사용하지 않고, PagingSource를 따로 구현하기 때문에, Room에서는 List 형태로 페이징한 데이터를 가져옵니다.
@Query("SELECT * FROM ChatImage WHERE group = :group ORDER BY date DESC LIMIT :loadSize OFFSET :index * :loadSize")
fun getChatImagesByName(group: String, index: Int, loadSize: Int): PagingSource<Int, ChatImage>
첫 번째 방법의 경우엔, Room의 자체 리턴형으로 PagingSource를 지원한다는 점이었습니다.
두 번째 방법은 PagingSource를 직접 구현하는 것인데, 두 방법은 단순히 편의적인 면모의 차이만 있는 것은 아닙니다.
Room의 리턴형으로 PagingSource를 갖게 된다면, 기본적으로 DB 변화에 반응하여 Flow가 재방출되게 됩니다.
더군다나 Room의 특성상,
WHERE 문으로 Filter된 데이터에 대해 데이터를 가져온다 하더라도, 같은 테이블에 변화가 생기면 무조건 Flow가 재방출됩니다.
Paging3을 사용하지 않는다면 distinctUtilChanged를 통해 재방출에 대한 오버헤드를 줄일 수 있겠지만, Paging3가 다루는 PagingData는 내부를 들여다 볼 수 없기 때문에 이것이 불가능하다는 점이 있습니다.
이는 개발 요구사항에 따라 매우 중요히 인지하고 있어야 할 내용입니다.
다시 말해,
@Query("SELECT * FROM Chat WHERE group = :group")
fun getChatImagesByName(group: String): PagingSource<Int, Chat>
class GetChatByNameUseCase @Inject constructor(private val repository: Repository) {
operator fun invoke(group: String): Flow<PagingData<Chat>> {
return Pager(config = PagingConfig(pageSize = 100, enablePlaceholders = false, initialLoadSize = 300,), pagingSourceFactory = {
repository.getChatImagesByName(group)
}).flow
}
}
이렇게 코드가 구성되어 사용한다면, id가 'hevton'이 아닌 데이터가 삽입/제거 되어도 flow가 재방출되게끔
Room 내부적으로 설계되어 있습니다. 또한 PagingData는 내부를 다룰 수 없도록 설계되어 있어서 isEmpty()같은 함수는 물론, 리스트의 변경이나 확인조차 불가능하기 때문에 distinctUtilChanged()와 같은 중복 판별도 불가능합니다.
이러한 전제가 있기 때문에, 데이터를 다루기 어렵다는 단점이 있고
직접적으로 관련없는 데이터가 삽입되더라도 flow가 매번 재방출된다는 상황이 오버헤드가 될 수 있습니다.
이것이 초래하는 결과에 극단적인 상황을 예로 들자면, 현재 A와 관련된 데이터를 A화면에서 보고 있는데
B에 대한 데이터가 실시간으로 추가되더라도 현재 A화면이 계속해서 리인덱싱되고 있는 상황입니다.
더군다나 PagingSize를 적절히 크게 주지 않으면 리인덱싱 될 때 스크롤 위치가 튕겨버리기 때문에 PagingSize를 기본 100, InitialLoadSize를 3배 정도인 300정도로 주게 되는데, 이렇게 되면 리인덱싱되는 오버헤드가 더 커지게 되기 마련입니다.
다만 그러한 이유로 인해서 테이블의 변경이 즉시 반영된다는 장점 또한 존재하겠습니다.
갤러리 뷰의 경우에는 사용자 플로우를 고려했을 때,
갤러리 데이터의 변경점에 대한 즉시 반영이 굳이 필요치 않기 때문에
PagingSource를 직접 구현하는 방식으로 구현을 진행하였습니다.
코드 예시를 간단하게 남기겠습니다.
@Query("SELECT * FROM ChatImage WHERE group = :group ORDER BY date DESC LIMIT :loadSize OFFSET :index * :loadSize")
fun getChatImagesByName(group: String, index: Int, loadSize: Int): PagingSource<Int, ChatImage>
이런식으로 Dao를 구성하게 될 것입니다.
그리고 PagingSource를 따로 구현하게 될 것입니다.
class ImagePagingSource @Inject constructor(
private val repository: Repository,
private val group: String
) : PagingSource<Int, ChatImage>() {
private companion object {
const val INIT_PAGE_INDEX = 0
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ChatWithIcon> {
val pageNumber = params.key ?: INIT_PAGE_INDEX
return try {
val chat = repository.getChatImagesByGroup(group, pageIndex, params.loadSize)
val prevKey = if (pageIndex == INIT_PAGE_INDEX) null else pageIndex -1
val nextKey = if (chatsWithIcon.isNullOrEmpty()) null else pageIndex + 1
LoadResult.Page(
data = chatsWithIcon,
prevKey = prevKey,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
그리고 이를 기반으로 UseCase를 작성하고
class GetChatByNameUseCase @Inject constructor(private val repository: Repository) {
operator fun invoke(group: String): Flow<PagingData<Chat>> {
return Pager(config = PagingConfig(pageSize = 100, enablePlaceholders = false, initialLoadSize = 300,), pagingSourceFactory = {
repository.getChatImagesByName(group)
}).flow
}
}
뷰모델에서 이를 활용해주면 됩니다.
그러면 다음과 같은 결과가 나오게 됩니다.
갤러리 구현에 대해서 자세히 궁금하다면 제가 작성한 글을 확인해보시면 좋을 것 같습니다.
2. ChatRoomList 뷰에 Paging3 활용 사례
이번엔 채팅방 리스트의 채팅 데이터들에 대한 Paging3 활용 사례를 말씀드립니다.
채팅방 리스트는 우리가 흔히 아는 것 처럼 채팅 룸들에 대한 가장 최근 채팅 하나를 순서대로 보여줍니다.
최근 채팅에 따라 채팅방들의 시간 순 위치가 실시간으로 유동적으로 변할 수 있고, 페이징 역시 필요하기 때문에
위에서 얘기했던 1 - A 방식을 사용하면 요구사항에 딱 맞게 paging3를 활용할 수 있습니다.
3. ChatRoom 뷰에 Paging3 활용 사례
이번엔 채팅방 내에서 채팅 데이터들에 대한 Paging3 활용 사례를 말씀드립니다.
채팅 리스트들은 순서가 유동적이게 변경되지 않으며, 새로운 데이터가 오게 되면 맨 아래에 데이터를 추가해줘야 하고,
동시에 위로 스크롤링하여 이전 대화들을 보는 페이징 기능 역시 필요합니다.
여기서 1 - A 방식을 사용하게 된다면, 다른 채팅방의 데이터가 삽입/삭제/변경 되더라도 매번 Paging Size만큼 현재 채팅방 데이터를 다시 불러일으키는 과정이 오버헤드로 작용하게 됩니다.
따라서 1 - B 방식을 고려할 수 있으나, PagingSource로 데이터를 구상하는 방식은 위에서 말씀드렸다시피 리스트 데이터에 직접적으로 접근할 수 없기 때문에, 새로운 채팅이 왔을 때 리스트의 맨 앞에 삽입해주는 커스텀 과정이 아예 불가능하게 됩니다.
따라서 이 경우에는 Paging3를 사용하지 않고 직접 구현하게 됩니다.
간단하게 예시코드로, ChatRoom 뷰에서 사용하는 UiModel인 ChatRoomUiState인 다음과 같이 구상했습니다.
data class ChatRoomUiState(
val chats: List<Chat> = emptyList(),
val pageFirstCursorDate: Long = 0,
val pageLastCursorDate: Long = 0,
val isPageEnd: Boolean = false,
val isLoading: Boolean = false,
val isNew: Boolean = false
)
두 개의 PageCursor를 토대로, 전/후 페이징을 구현함과 동시에 isNew 태그를 통해 만약 새로운 채팅 데이터가 추가된 것이라면
리스트의 맨 앞에 데이터를 추가하게끔 진행해주었습니다. 그리고 이 태그를 활용하여 플로팅 버튼을 표기할 수 있습니다.
그러면 다음과 같은 결과가 나오게 됩니다.
정리
이를 고려해서 Paging3를 적절히 사용하면서, 필요에 따라 Paging3를 사용하는 대신 직접 구현하여 사용하면 되겠습니다.
Room의 PagingSource 고려사항
해당 테이블의 데이터 변경이 감지될 때마다 flow 방출되므로, 다른 채팅방의 데이터가 추가되더라도 현재 채팅방이 recollect될 수 있다.
-> 이 때 PagingData는 내부를 볼 수 없기에 distinctUtilChanged 기준도 작용할 수 없다.
-> 또한 현재 채팅방이 recollect 될 때, PagingSize를 InitialLoadSize의 3배수(보통 100과 300) 만큼 할당하지 않으면 인덱싱 될 때마다 스크롤 위치가 튕길 수 있으며 페이징 사이즈를 크게 잡으면 그만큼의 오버헤드로 작용하게 된다.
PagingSource 자체의 고려사항
위에서 얘기했듯, PagingData는 내부를 볼 수 없기에 isEmpty나, 맨 앞/맨 뒤에 데이터 추가를 분기하는 작업이 불가능하고,
아래와 같이 채팅 데이터의 맨 앞/맨 뒤로 페이지를 이동하는 구현이 불가능하다.
이 점들을 고려해서, Paging3를 기준에 따라 활용하며, 커스텀 활용 면에서 직접 구현하는 방법도 적절히 활용하면 되겠습니다.