개인 프로젝트의 채팅 기능을 구현하던 도중 마주한 트러블 슈팅에 대해 다뤄볼까 합니다.
우선 fireStore을 사용했으며 오프라인 기능을 지원하기 위해 데이터를 Room에 저장한 뒤 Room 데이터를 활용해 채팅 화면을 뿌려주는 방법을 선택했습니다.
한번에 모든 데이터를 Flow로 가져오는 방법은 데이터가 대용량일 경우 매우 비효율적일 수 있다고 생각해 최신 채팅 데이터를 보여주고 유저가 스크롤을 올릴 시 이전 채팅 데이터들을 조회하는 페이징을 구현했습니다.
이를 구현하며 마주했던 트러블 슈팅, 삽질, 선택한 해결방안등에 대해 다뤄볼까 합니다.
Room Flow에서 리팩토링을 시도한 방법입니다.
@Dao
interface ChatDao {
@Query("SELECT * FROM Chat WHERE chatRoomId = :chatRoomId ORDER BY createAt DESC")
fun getChatsByChatRoomId(chatRoomId: String): PagingSource<Int, ChatEntity>
}
Room은 Paging 라이브러리와의 통합을 통해 PagingSource를 반환하는 DAO 메서드를 제공함으로써 Paging 라이브러리와 자연스럽게 연동됩니다.
또한 내부적으로 데이터베이스의 변경 사항을 감지하여 PagingSource를 무효화(invalidate) 합니다.
그렇기 때문에 직접 PagingSource를 작성하지 않아도 되는 장점이 있습니다.
오? 좋은데??
내부적으로 데이터베이스의 변경 사항을 감지하여 PagingSource를 무효화 합니다.
즉 테이블의 데이터가 변경될 때마다 Room이 PagingSource를 무효화하고 새롭게 인스턴스를 생성합니다.
데이터 변경이 자주 일어나는 채팅 같은 경우 INSERT, UPDATE 가 자주 발생합니다.
때문에 데이터의 변경사항을 감지하면 PagingSource를 계속해서 무효화 합니다.
이게 뭐가 문제일까요?
사용자가 오래된 데이터를 보기 위해 스크롤을 위해 올렸을 때 새로운 메시지가 왔다면 새로운 PagingSource가 만들어져 기존에 로드된 데이터가 다시 로드되어야 하는 문제
스크롤 위치가 초기화될 수 있는 문제
UI 갱신이 자주 발생하여 앱의 성능 저하 발생
이러한 문제들이 발생할 수 있기 때문에 다른 방법을 생각해내야 했습니다.
결국 돌고돌아 직접 PagingSource를 작성하는 방법을 선택했습니다.
@Dao
interface ChatDao {
// 키셋 페이징: createAt를 기준으로 쿼리하는 키셋 페이징 방식
@Query("SELECT * FROM Chat WHERE chatRoomId = :chatRoomId AND createAt < :date ORDER BY createAt DESC LIMIT :loadSize OFFSET (:index * :loadSize)")
fun getChatsByChatRoomId(chatRoomId: String, loadSize: Int = 20, index: Int, date: Long): List<ChatEntity>
// 페이징에서 사용된 date 이후에 오는 메시지들을 Flow로 관리
@Query("SELECT * FROM Chat WHERE chatRoomId = :chatRoomId AND createAt > :lastFetched ORDER BY createAt DESC")
fun getLatestMessageFlow(chatRoomId: String, lastFetched: Long): Flow<List<ChatEntity>>
}
직접 PagingSource를 작성했기 때문에 어떻게 어떤 데이터를 가져오는 페이징 함수를 작성했습니다.
여기서 createAt < :date 를 왜 달았을가요?
직접 PagingSource를 작성했기 때문에 이는 데이터의 변경사항을 반영하기 어렵습니다.
페이징이 불리고 난 뒤 내가 새롭게 메시지를 보냈거나, 상대방이 메시지를 보냈다면 Room의 전체 데이터 수에 영향이 가게 되고 중복된 데이터를 불러올 수 있습니다.
PagingSource에서 20개의 데이터를 가져왔고 서버에서 3개의 새로운 메시지가 추가되어 Room에 저장되었다고 가정해보겠습니다.
이 때 유저가 스크롤을 올려 다음 페이징을 요청할 때 20~40 번째의 데이터를 요청하게 됩니다.
그러나 새로운 데이터 3개가 들어왔기 때문에 데이터 크기가 변경되었고 위 그림과 같이 중복된 데이터를 불러오게 됩니다.
때문에 기준점이 필요하며 그 기준점을 createAt < : date 를 설정해 준 이유입니다.
:date 이전의 메시지는 페이징으로 처리하고 그 이후에 오는 메시지들은 Flow로 직접 관리하는 방법을 선택했습니다.
페이징을 요청한 뒤 페이징에서 첫번째 데이터의 createAt을 확인하고 최신메시지를 가져오는 Flow에 이 createAt의 Date.time을 전달합니다
페이징 데이터와 그 이후에 오는 최신 메시지들을 따로 관리합니다. 최신 메시지들을 받는 방식은 Flow이기 때문에 상대방의 읽음 처리가 되었다면 자동으로 반영된 데이터가 Flow를 통해 들어옵니다.
페이징 데이터들에 대해 읽음처리를 감지하는 Flow를 작성해 읽음 처리된 데이터들을 관리하는 Set을 만들고 페이징 데이터들에 한해 Set 집합에 같은 id가 존재하면 읽어진 데이터, 그렇지 않다면 읽혀지지 않은 데이터 처리
이렇게 처리했을 시 제가 의도한 새로운 메시지가 와도 새로운 PagingSource가 생성되지 않고 기존에 로드된 데이터들을 계속 확인할 수 있었습니다.
잘못되거나 더 효율적인 방법이 있다면 알려주시면 정말 감사하겠습니다~.
마지막으로 Room의 PagingSource 삽질을 하며 알게된 새로운 사실에 대해 다루고 마무리 하겠습니다.
Room + PagingSource를 사용하며 발생한 오류에 대한 트러블 입니다.
LazyColumn에서 Key를 사용하는 이유는 무엇일가요?
리스트의 요소를 효율적으로 재활용하기 위해서 입니다.
LazyColumn은 key를 기준으로 아이템을 추적하여 새로고침 시 동일한 항목을 재사용하려고 합니다.
즉 key를 사용하면 불필요한 재구성(recomposition)을 막을 수 있습니다.
이를 위해 key를 설정했고
items(chatList.itemCount, key = { chatList[it]?.id ?: it })
로 설정했지만 화면이 깜빡거리거나(이는 페이징 소스 인스턴스가 새롭게 생성될때 문제 가능성), 다음 페이징을 요청하지도 않았는데 전체 데이터를 가져오는 문제 등 의도하지 않은 동작이 일어났습니다.
삽질을 하며, 검색을 하다보니
StackOverFlow에 비슷한 문제가 있었고 참고하여 문제를 해결했습니다.
내용은 다음과 같습니다.
내부적으로 get(it)이 실행됨
LazyColumn은 리스트 아이템을 모두 탐색하면서 키를 미리 계산하려고 합니다. 즉, 아직 화면에 보이지 않는 아이템까지도 get(it)을 호출하면서, pagingSource가 불필요하게 추가 데이터를 로드하게 됨
public class LazyPagingItems<T : Any> internal constructor(
/**
* the [Flow] object which contains a stream of [PagingData] elements.
*/
private val flow: Flow<PagingData<T>>
) {
fun peek(index: Int): T? {
return itemSnapshotList[index]
}
}
우선 peek 함수에 대해 알아보겠습니다.
itemSnapshotList의 index 요소를 반환하고 있습니다.
itemSnapshotList는 현재 메모리에 있는 데이터만을 반환합니다.
즉, PagingSource에 추가 요청을 보내지 않고 LazyColumn에서 키를 미리 계산해도 추가 요청이 발생하지 않습니다.
일반 List의 경우 모든 데이터를 미리 가지고 있기 때문에 get(it)을 사용해도 문제가 없지만
LazyPagingItems는 페이징이 적용된 데이터므로, get(it)이 추가 데이터를 요청할 수 있기 때문에
이를 방지하기 위해 key = chatList.peek(it)을 사용합니다.
이 사항에 대해서는 다른 데이터의 변경사항이 없는 페이징이나 여러 사항들에서도 테스트 해본 뒤 자세히 공부한 뒤 관한 내용을 포스팅 하도록 하겠습니다.