해피뉴이어~ ^_^
새해부터 공부하고 있는 나... 대견하다
올해는 꼭 취업하자!!!!
이전에 했던 프로젝트 리팩토링을 하던 중 채팅 기능을 구현해야했고, 별도의 서버를 두지 않고 Firestore를 사용해서 구현했습니다.
Firestore는 문서에 대한 스냅샷 리스너를 등록해두면, 해당 문서가 변경되었을 때 실시간으로 변경사항을 가져올 수 있습니다. 이 리스너를 통해 간단하게 채팅앱을 구성했습니다.
하지만, 단순히 리스너를 등록했을 때 비용적인 측면에서 문제점이 존재합니다.
예를들어, 유저가 참여중인 채팅방에 수백 ~ 수천개의 메세지 데이터가 저장되어있다고 가정했을 때 유저가 해당 채팅방에 들어가면, 모든 메세지를 한번에 읽어오기 때문에 비용 측면에서 낭비가 발생합니다.
이러한 문제를 해결하기 위해서 페이징(Paging) 기법을 통해 한 번에 데이터를 읽어오는 갯수를 제한하여 비용을 절약할 수 있습니다.
그래서 저는 Firestore 데이터를 페이징하여 효율적으로 데이터를 읽어오는 것에 대한 고민을 했습니다.
정적인 데이터 리스트를 페이징 처리하는 것은 쉽게 할 수 있지만, 이전 채팅 기록을 페이징하는 것과 동시에 새로운 메세지에 대한 처리도 해줘야 했기 때문에 까다롭다고 생각을 했습니다. 그래서 수많은 고민을 했고, 최선의 방법을 도출해냈습니다.
- 유저가 채팅방에 들어갔을 때, 가장 최근 메세지부터 20개를 읽어온다.
- 읽어온 데이터의 첫번째 Document(startDoc)와 마지막 Document(lastDoc)를 캐싱한다.
- 다음 페이지 요청시 startDoc(이전 페이지의 첫번째 Document) 이전까지의 메세지를 20개 읽어오고 다시 2번으로
- 새 메세지에 대한 리스너 등록은 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() 에서 호출할 것이고, 이 후 다음 페이지 요청시 다시 호출합니다.
추가적으로 채팅방 입장 시점을 기준으로 입장 이후의 채팅 기록만을 가져오는 것을 구현해야겠다.