[Firebase] Firestore 데이터 페이징 (Swift)

숑이·2024년 1월 1일
0

프로젝트

목록 보기
1/1

해피뉴이어~ ^_^
새해부터 공부하고 있는 나... 대견하다
올해는 꼭 취업하자!!!!

문제 상황

이전에 했던 프로젝트 리팩토링을 하던 중 채팅 기능을 구현해야했고, 별도의 서버를 두지 않고 Firestore를 사용해서 구현했습니다.

Firestore는 문서에 대한 스냅샷 리스너를 등록해두면, 해당 문서가 변경되었을 때 실시간으로 변경사항을 가져올 수 있습니다. 이 리스너를 통해 간단하게 채팅앱을 구성했습니다.

하지만, 단순히 리스너를 등록했을 때 비용적인 측면에서 문제점이 존재합니다.
예를들어, 유저가 참여중인 채팅방에 수백 ~ 수천개의 메세지 데이터가 저장되어있다고 가정했을 때 유저가 해당 채팅방에 들어가면, 모든 메세지를 한번에 읽어오기 때문에 비용 측면에서 낭비가 발생합니다.

이러한 문제를 해결하기 위해서 페이징(Paging) 기법을 통해 한 번에 데이터를 읽어오는 갯수를 제한하여 비용을 절약할 수 있습니다.

그래서 저는 Firestore 데이터를 페이징하여 효율적으로 데이터를 읽어오는 것에 대한 고민을 했습니다.

메세지 페이징 아이디어

정적인 데이터 리스트를 페이징 처리하는 것은 쉽게 할 수 있지만, 이전 채팅 기록을 페이징하는 것과 동시에 새로운 메세지에 대한 처리도 해줘야 했기 때문에 까다롭다고 생각을 했습니다. 그래서 수많은 고민을 했고, 최선의 방법을 도출해냈습니다.

  1. 유저가 채팅방에 들어갔을 때, 가장 최근 메세지부터 20개를 읽어온다.
  2. 읽어온 데이터의 첫번째 Document(startDoc)와 마지막 Document(lastDoc)를 캐싱한다.
  3. 다음 페이지 요청시 startDoc(이전 페이지의 첫번째 Document) 이전까지의 메세지를 20개 읽어오고 다시 2번으로
  4. 새 메세지에 대한 리스너 등록은 lastDoc(최초에 읽어온 메세지의 마지막 Doucument) 이후의 메세지에 대해 등록한다.

이렇게 구현하면, 채팅방에 메세지 데이터가 수백 ~ 수천개 쌓여 있다고 하더라도 사용자가 요청한 페이지만큼의 데이터만을 읽기 때문에 효율적이고, 또한 새 메세지를 구독하기 때문에 실시간 채팅 기능도 구현할 수 있습니다.

코드 구현

	private var startDoc: DocumentSnapshot?
    private var lastDoc: DocumentSnapshot?
    private var endPaging: Bool = false
    private var limit: Int = 5

우선 필요한 프로퍼티를 선언해줍니다.
endPaging은 모든 페이지를 읽어왔는지 확인하기 위한 Flag 변수이고, limit는 한 페이지 당 읽어올 데이터 수 입니다.

    func fetchMessages(
        roomId: String
    ) async -> [WrappedMessage] {
        if endPaging { return [] }
        guard let uid = auth.currentUser?.uid else { return [] }
        
        let commonQuery = db.collection("Rooms")
            .document(roomId)
            .collection("ChatLogs")
            .order(by: "timestamp") // timestamp 필드를 기준으로 오름차순 정렬
            .limit(toLast: limit) // 마지막에서 20개
        
        let requestQuery: Query
        
        /// 이전 페이지의 첫번재 Document가 있는지 확인
        if let startDoc = startDoc {
            /// 다음 페이지 = 이전 페이지의 첫번째 Document 이전까지의 20개
            requestQuery = commonQuery
                .end(beforeDocument: startDoc)
        } else {
            requestQuery = commonQuery
        }
        
        do {
            let snapshot = try await requestQuery.getDocuments()
            
            /// document가 비어있다면, 더 이상 다음 페이지는 존재하지 않음.
            /// 이후 쿼리 요청을 막기 위해서 Bool 타입 flag 변수 사용
            if snapshot.documents.isEmpty {
                endPaging = true
                return []
            }
            
            /// 현재 페이지의 첫번째 Document와 마지막 Document를 기록
            /// startDoc은 다음 페이지의 마지막 Document를 지정하기 위해서 사용
            /// lastDoc은 새로운 메세지를 구독하는 시작 Document를 지정하기 위해서 사용
            startDoc = snapshot.documents.first
            lastDoc = snapshot.documents.last
            
			/// 스냅샷 데이터 처리...
            
			
        } catch {
            print("DEBUG: Fail to subscribeNewMessages with error: \(error.localizedDescription)")
            return []
        }
    }

limit(toLast:)를 사용해서 읽어올 데이터 수를 제한하고, end(beforeDocument:)를 사용해서 startDoc 이전까지의 데이터를 읽어옵니다.

결과적으로 해당 쿼리를 요청하면, 이전 페이지의 첫번째 Document 이전까지 데이터를 읽을건데, 그 중에서 마지막에서 20개를 읽어오는 것입니다.

    func subscribeNewMessages(
        roomId: String,
        completion: @escaping([WrappedMessage]) -> Void
    ) {
        guard let uid = auth.currentUser?.uid else { return }
        
        let commonQuery = db.collection("Rooms")
            .document(roomId)
            .collection("ChatLogs")
            .order(by: "timestamp") // timestamp를 기준으로 오름차순 정렬
        
        let requestQuery: Query
        
        /// 첫번째 페이지의 마지막 Document(구독의 시작 지점)를 가져옴
        if let lastDoc = lastDoc {
            /// lastDoc 이후의  Query
            requestQuery = commonQuery
                .start(afterDocument: lastDoc)
        } else {
            requestQuery = commonQuery
        }
        
        requestQuery.addSnapshotListener { snapshot, error in
            guard let document = snapshot?.documents else {
                print("DEBUG: Fail to subscribeNewMessages with error document is nil")
                return
            }
            
            // 스냅샷 데이터 처리...
        }
    }

새로운 메세지에 대한 리스너를 등록하는 메서드입니다.
lastDoc 이후의 메세지를 읽어오도록 start(afterDocument:)를 사용했습니다.

final class ChatLogViewModel: ObservableObject {
    @Published var prevMessages: [WrappedMessage] = []
    @Published var newMessages: [WrappedMessage] = []
    
    var messages: [WrappedMessage] {
        return prevMessages + newMessages
    }
    
    var messageListenerExist: Bool = false
    
    private let carPoolManager: CarPoolManagerType
    
    init(carPoolManager: CarPoolManagerType) {
        self.carPoolManager = carPoolManager
    }
    
    func fetchMessages() {
        Task {
            let prev = await carPoolManager.fetchMessages(roomId: "채팅방 식별값")
            
            if !messageListenerExist {
                subscribeNewMessage()
            }
            await MainActor.run {
                prevMessages.insert(contentsOf: prev, at: 0)
            }
        }
    }
    
    private func subscribeNewMessage() {
        messageListenerExist = true
        
        carPoolManager.subscribeNewMessages(roomId: "채팅방 식별값") { [weak self] newMessages in
            self?.newMessages = newMessages
        }
    }
}

ViewModel은 위와 같이 정의했습니다.
prevMessages는 페이징을 통해 가져올 메세지를 저장할 배열, newMessages는 새 메세지를 저장할 배열입니다.

그리고 결과적으로 View에 표시할 데이터는 computed property인 messages 입니다.

새 메세지에 대한 구독 요청은 최초에 1번만 해야하기 때문에 messageListenerExist 변수를 사용했습니다.

fetchMessages() 메서드는 View의 onAppear() 에서 호출할 것이고, 이 후 다음 페이지 요청시 다시 호출합니다.

결과 화면

추가적으로 채팅방 입장 시점을 기준으로 입장 이후의 채팅 기록만을 가져오는 것을 구현해야겠다.

profile
iOS앱 개발자가 될테야

0개의 댓글