나갔다 들어와야 업데이트 되는 채팅방? (Feat. Firestore Listener)

Taeyoung Won·2023년 2월 1일
2

SwiftUI

목록 보기
2/7
post-thumbnail

지금 진행하고 있는 iOS 앱스쿨의 최종 프로젝트에서 채팅 기능과 View를 담당하게 되었다.
Firestore의 Listener를 활용해서 채팅 로직을 구현한 과정을 알아보자.



개요

시작하기 전 목표

이전까지 만든 게시판, 커뮤니티와 같은 기능과 가장 다른점은 데이터 CUD가 이루어졌을 때, 영향을 받는 사용자에게 실시간으로 반영이 되어야한다는 점이었다.

새로운 메세지를 수신했는데 채팅방을 나갔다 들어와야 볼 수 있으면 너무 이상하니까.

그래서 실시간 양방향 데이터 업데이트가 이번 구현의 가장 큰 목표였다.





왜 Firestore Listener?

백엔드 관련해서는 지식이 거의 없지만, 일반적으로 자체 서버가 없다면 채팅 로직을 구현할 때는 Socket.io 혹은 Vapor를 활용해서 실시간으로 양방향 통신을 구현한다고한다.

이에 대해서 프로젝트 초기 기획 단계에서 팀원들과 논의해본 결과, 다음의 이유들로 백엔드 기능 제공 솔루션인 Firebase를 이용하기로 하였다.

  1. 서버 개발자 없이 iOS 개발자로만 이루어진 팀
  2. 한정된 프로젝트 기간
  3. Socket.io or Vapor 학습에 들어가는 리소스 < 클라이언트 개발시간 확보





Firestore Listener?

Firestore는 Firebase에서 제공하는 기능 중의 하나로, NoSQL 기반의 문서형 DB를 제공한다.

여기에 더해 세부기능으로 Listener를 사용할 수 있는데, DB의 수정사항(생성, 변경, 삭제)을 감지하고 이를 트리거로 로직을 수행할 수 있는 실시간 통신 기능이다.

Firestore의 문서형 DB, Chat 컬렉션의 문서 안에 다수의 Message 문서로 되어있는 구조





코드 구조

이번 프로젝트는 MVVM 아키텍처로 구현했기 때문에 다음과 같은 구조로 되어있다.

  • 채팅과 메세지에 해당하는 Model
  • 채팅방 리스트와 메세지들이 표시되는 채팅방 내부 View
  • Model과 View 사이에서 데이터를 처리하고 DB와 상호작용하는
    ViewModel

실제 코드에는 더 많은 View Components와 로직이 있지만, 가독성을 위해 이번 주제에 관련한 코드만 명시합니다.





Models

struct Chat: Identifiable, Codable {
    let id: String // 채팅방 ID
    let date: Date // 생성 날짜
    let joinUserIDs: [String] // 채팅방에 참여한 유저 ID 리스트
    let lastDate: Date // 마지막 메세지 날짜
    let lastContent: String // 마지막 메세지 내용
    
    ...
    
}
struct Message: Identifiable, Codable {
    let id: String // 메세지 ID
    let userID: String // 작성한 유저 ID
    let content: String // 메세지 내용
    let date: Date // 메세지 작성일자
    
    ...
    
}   

위에 있는 Chat이 채팅방에 해당하는 모델, 아래의 Message가 메세지 버블에 해당하는 모델이다.

하나의 채팅방(Chat) 안에 두 유저가 나눈 다수의 채팅 메세지 버블(Message)로 이루어진 구조이다.





ViewModel

final class MessageStore: ObservableObject {

    @Published var messages: [Message]
    
    ...
    
    // MARK: -Method : Firestore에 요청해서 Message 컬렉션에서 문서들을 반환받는 함수
    private func getMessageDocuments(_ chatID: String, unreadMessageCount: Int) async -> QuerySnapshot? {
        do {
            let snapshot = try await db
                .collection(const.COLLECTION_CHAT)
                .document(chatID)
                .collection(const.COLLECTION_MESSAGE)
                .order(by: const.FIELD_SENT_DATE)
                .getDocuments()
            return snapshot
        } catch {
            print("Error-\(#file)-\(#function) : \(error.localizedDescription)")
        }
        return nil
    }
    
    @MainActor
    private func writeMessages(messages: [Message]) {
        self.messages = messages
        isFetchMessagesDone = true
    }
    
    // MARK: Method : 채팅 ID를 받아서 메세지들을 불러오는 함수
    func requestMessages(chatID: String, unreadMessageCount: Int) async {
        
        let snapshot = await getMessageDocuments(chatID, unreadMessageCount: unreadMessageCount)
        var newMessages: [Message] = []
        
        if let snapshot {
            for document in snapshot.documents {
                do {
                    let message: Message = try document.data(as: Message.self)
                    newMessages.append(message)
                } catch {
                    print("Error-\(#file)-\(#function) : \(error.localizedDescription)")
                }
            }
        }
        await writeMessages(messages: newMessages)
    }
    
    ...
    
}

Message에 대한 데이터 로직을 처리하고 View와 연동해주는 ViewModel이다.

requestMessages() 메서드를 호출하면 DB의 해당 Chat 문서에 접근하여 하위 Message의 문서들을 반환받는다.

그리고 이 문서들을 Message 인스턴스로 변환해서 Published 래퍼로 게시된 변수인 messages에 추가해준다.





View

// MARK: -View : 채팅방 뷰
struct ChatRoomView: View {

	...
    
    @EnvironmentObject var messageStore: MessageStore
    
    var body: some View {
		
        ScrollViewReader { proxy in
        	ScrollView {
                ...
                ForEach(messageStore.messages) { message in
					MessageCell(message: message)              
                }
                ...
            }
        }
        .task {
        	await messageStore.requestMessages(chatID: chat.id)
        }
    }
}

채팅방 View에 진입하면 먼저 ViewModel의 requestMessages()를 호출해서 DB에 있는 채팅 메세지들을 배열에 추가한다.

그렇게 추가된 배열은 published로 구독된 View에서 말풍선 모양의 SubView로 메세지 내용을 표시해준다.





채팅 화면 시뮬레이터

채팅 화면이 잘 구성되었다.

메세지를 전송하면 Create 요청을 통해 DB에 새 메세지를 추가한다.
로컬에서도 messages 배열에 추가하면 프로퍼티 래퍼인 published에 의해 데이터 모델을 구독한 View에서 리렌더링이 이루어진다.

하지만 상대방 입장에서는 채팅방 입장 시점에서만 request 메서드가 호출되기 때문에, 새 메세지를 확인하려면 채팅방 화면에서 벗어났다가 다시 진입해야한다.

그렇게 해야 request 메서드가 재호출되면서 추가된 메세지까지 포함된 DB를 업데이트 받을 수 있다.

하지만 이래서는 실시간 채팅이라고 할 수 없다.

이 부분을 해결해보자.





Listener 적용

위에서 언급한것처럼 Listener는 DB의 변경사항을 감지하고 이를 트리거로 특정 로직을 수행할 수 있다.

Listener를 통해서 메세지가 추가되는 것을 감지하고, fetch 메서드를 호출해보자.





Listener 코드

	// MARK: -Method : 추가된 문서 필드에 접근하여 Message 인스턴스를 만들어 반환하는 함수
    func requestNewMessage(change: QueryDocumentSnapshot) -> Message? {
        do {
            let newMessage = try change.data(as: Message.self)
            return newMessage
        } catch {
            print("Fetch New Message in Message Listener Error : \(error)")
        }
        return nil
    }
    

    func addListener(chatID: String) {
        listener = db
            .collection(const.COLLECTION_CHAT)
            .document(chatID)
            .collection(const.COLLECTION_MESSAGE)
            .addSnapshotListener { snapshot, error in
                // snapshot이 비어있으면 에러 출력 후 리턴
                guard let snp = snapshot else {
                    print("Error fetching documents: \(error!.localizedDescription)")
                    return
                }
                // document 변경 사항에 대해 감지해서 작업 수행
                snp.documentChanges.forEach { diff in
                    switch diff.type {
                    case .added:
                        if let newMessage = self.requestNewMessage(change: diff.document) {
                            self.messages.append(newMessage)
                            // 메세지 추가 시 Chat Room View 스크롤을 최하단으로 내리기 위한 트리거
                            self.isMessageAdded.toggle()
                        }
                    case .modified:
                        let _ = 1
                    case .removed:
                        self.removeDeletedLocalMessage(change: diff.document)
                    }
                }
            }
    }
.task {
	messageStore.addListener(chatID: chat.id)
	await messageStore.requestMessages(chatID: chat.id)
}

Collection 경로에 접근해서 addSnapshtListener를 호출하면, Listener를 제거하기 전까지 해당 Collection에서 발생하는 변경사항을 감지할 수 있다.

감지된 변경사항은 생성, 변경, 삭제 중 하나에 해당하며, 열거형 값으로 들어오기 때문에 case별로 다른 로직을 수행할 수 있다.

우선 added case에 대한 로직을 구현해서, 추가된 문서를 반환 받고 Message 인스턴스로 변환해서 messages 배열에 추가하도록 했다.

그리고 View 진입 시, listener를 활성화시키는 코드를 추가해주었다.

이제 View에서 잘 작동하는지 확인해보자.





Listener 적용 후 채팅 화면 시뮬레이터

Listener를 통해서 채팅 메세지를 추가하면 상대방 View에서도 즉시 반영되는 것을 확인할 수 있다.

이처럼 Firestore Listener만 사용해도 간단한 실시간 채팅 로직을 구현할 수 있다.

실시간 통신을 위한 다른 대안들이 많이 있지만, 막상 Firestore로 구현해보니 결과가 나쁘지 않았다.

제공되는 서버 솔루션을 사용하고 클라이언트에 집중해서 퀄리티를 높일 수 있는 선택이었던 것 같다.

다음에는 ScrollView Reader를 활용해서 상용 채팅 앱처럼 작동하는 화면을 구현해봐야겠다.

profile
iOS Developer

0개의 댓글