Firebase로 채팅 구현하기

eltese·2023년 3월 19일
0
post-thumbnail

국비 교육 과정을 진행하며 가장 처음 배우고 활용한 DBMS는 MySQL이었다. 기성 어플을 뜯어보며 ERD를 작성해보고, 프로젝트를 위한 데이터 구조도 직접 구성해보았기 때문에 MySQL 같은 RDBMS가 나에게는 가장 익숙했다. (그래도 어렵지만)

그래서 이번 프로젝트에 제대로 헤딩한 firebase, 즉 NoSQL과 RDBMS의 차이점을 피부로 느낄 수 있었기에 firebase로 채팅 앱을 구성해보며 겪은 시행착오와 함께 깨달은 점을 기록해보고자 한다.

📝 목표

  • 채팅방은 두 명의 유저가 이용한다.
  • 내가 보낸 채팅은 오른쪽, 상대가 보낸 채팅은 왼쪽 정렬로 배치된다.
  • 채팅 목록은 내가 포함된 채팅방만 불러와야 한다.
  • 채팅 목록에는 상대방의 닉네임, 가장 최근 채팅, 읽음 확인 배지가 표시된다.
  • 채팅하기 버튼 클릭시 이미 채팅방이 있는 상대라면 기존 채팅 기록을 보여주고 아니라면 채팅을 보낼 때 새로운 컬렉션을 만든다.

📛 발생한 문제들

  • and 조건이 불가능하다.
  • array 타입에 조건을 두 번 걸어줄 수 없다.
  • join이 없기 때문에 중복 데이터를 저장해야 하는 상황이 생긴다.

목표와 발생했던 문제를 정리했으니 본격적으로 해결 과정을 살펴보자.


우선 UI부터 살펴보자!


채팅을 위한 페이지는 채팅 목록과 채팅방, 둘로 나뉜다.


💬 채팅 구현

문제1. 마지막 채팅 띄우기

처음 데이터 구조를 짤 때는 RDBMS를 생각하며... 채팅방 컬렉션에 필드를 chatRoomState만 줬다. (읽음 표시용)

가장 최근 채팅 같은 데이터는 채팅 컬렉션에 있는 정보를 불러오면 된다고 생각했다. (예를 들면 MySQL의 경우, JOIN을 통해 데이터를 불러올 수 있을 것이다.)

그리하여 처음 작성한 채팅 데이터 구조 ↓↓↓

Flutter에서 채팅 목록을 출력하기 위해서는 StreamBuilder를 사용해 chatroom 컬렉션 안의 필드를 불러와 화면에 ListView를 띄운다.

하지만 마지막 채팅의 경우 chatroom 컬렉션 안의 chat 컬렉션의 field이기 때문에

🚫 각각의 chatroom 컬렉션마다 chat 컬렉션을 한 번 더 호출해야 하는 문제가 생겼다.

어떻게든 해보려고 시도했으나 아직 나의 구글링 실력이 부족한 건지 방법이 없는 건지 성공해내지 못했고...
그때 선생님이 늘 하시는 말씀이 생각났다. 쉽게 생각하고 쉽게 해라!

SQLD를 준비하며 공부한 내용도 떠올랐다.

정규화와 반정규화.

데이터 구조를 짤 때 절대적인 것은 없어 row수가 늘어나면 테이블을 나누기도 하고, JOIN이 복잡해지는 경우 편한 SELECT를 위해 attribute로 FK를 저장하기도 한다.

💡 그래서 chatroom 컬렉션에 field를 추가하기로 결정했다!

한층 발전된 데이터 구조 ↓↓↓

  • userIds: 나(=로그인한 유저)의 채팅 목록만 불러와야 한다. where 조건으로 id를 넣어 select하기 위해 추가했다.
  • lastChat: 채팅 전송 버튼을 누를 때마다 해당 채팅 내용으로 update된다.
  • sendUserId: 채팅을 보낸 userId를 저장한다. -> 이 field를 이용해 현재 로그인한 사용자 id와 같으면 오른쪽 정렬, 아니면 왼쪽 정렬했다.

문제2. 채팅방이 중복되어 만들어지면 안 된다.

예를 들어, 몽이라는 유저와 망이라는 유저의 채팅방이 이미 있다면 몽이가 망이와 채팅하기 버튼을 누르더라도 새로운 채팅방이 만들어지면 안 되고, 기존 채팅방을 출력해 줘야 한다.

로직의 순서는
1. 몽이가 망이와 채팅하기 버튼을 누르면 userIds 필드에 몽이와 망이의 id가 모두 담겨 있는 chatroom이 있는지 확인한다.
2. 있다면 기존 채팅방을 보여주고
3. 없다면 새로운 채팅방을 만든다.

이것도 꽤 골치 아픈 문제였다. ㅜㅜ

userIds field에 몽이와 망이의 id가 모두 담겨 있는지 확인하려면 and 조건을 사용하면 되는데, 공식 문서의 문법을 살펴보니 and 조건이 없었다. 🫣

그래서 userIds에 array-contains 조건을 두 번(망이와 몽이) 걸어 확인하려고 했더니,

쿼리당 array-contains 절을 최대 1개 사용할 수 있습니다. array-contains와 array-contains-any를 결합할 수 없습니다.

🫣...

그래서 내가 선택한 방법은 chatroom 필드에 sendUserId와 receiveUserId를 추가하는 것이다.
무한정 늘어나는 필드

FirebaseFirestore fs = FirebaseFirestore.instance;
	
    // sendUserId가 "mang"이고 receiveUserId가 "mong"인 경우
    final Query query1 = fs
        .collection('chatroom')
        .where("sendUserId", isEqualTo: "mang")
        .where("receiveUserId", isEqualTo: "mong");
	
    // sendUserId가 "mong"이고 receiveUserId가 "mang"인 경우
    final Query query2 = fs
        .collection("chatroom")
        .where("sendUserId", isEqualTo: "mong")
        .where("receiveUserId", isEqualTo: "mang");

이 둘의 size(개수)를 더해 0이면 채팅방이 없는 것이므로 빈 페이지를 띄우고, 0이 아니면 채팅방이 있는 것이므로 기존 채팅방을 띄우도록 했다.

사실 그다지 효율적인 방법이 아닌 것 같아 추후에 좀 더 고민해보기로...

문제3. 읽음 확인 표시 추가

이것도 상당히 골치 아팠고, 합리적인 로직이라고 생각하지 않지만 발전 가능성을 위해 적어보겠다.

계획한 로직

  1. 채팅을 보낸 사용자과 받은 사용자는 상태를 따로 가진다. (ex. 채팅을 보낸 순간 보낸 사용자는 읽음 상태 true, 받은 사용자는 false)
  2. 받은 사용자의 상태는 채팅 목록을 클릭하는 순간 true가 된다.
  3. 채팅을 받은 사용자와 보낸 사용자는 바뀔 수 있다.

구현 방법

chatroom 컬렉션의 필드 중 읽음 상태를 저장하는 건 chatRoomState의 역할이었다.

하지만! 보낸 사용자와 받은 사용자의 상태는 다르기 때문에 한 개의 필드를 공동으로 사용할 수 없었다.

💡 따라서, chatRoomState를 sendChatRoomState와 receiveChatRoomState로 분리했다.

읽음 확인 상태가 변하는 이벤트는 두 가지뿐인데, 첫 번째는 채팅을 전송할 때. 두 번째는 채팅을 받은 사용자가 해당 채팅 목록을 클릭할 때다.

  1. 채팅을 전송할 때는 두 가지 일이 발생한다. 첫째로 chat 컬렉션에 insert된다. 둘째로 chatroom 컬렉션의 sendUserId, receiveUserId, sendChatRoomState, receiveChatRoomState가 update된다.
FirebaseFirestore.instance
          .collection("chatroom")
          .doc(chatRoomId)
          .update({
        "lastChat": tfChatController.text,
        "sendUserId": "보낸 사용자 id",
        "receiveUserId": "받는 사용자 id",
        "sendChatRoomState": true,
        "receiveChatRoomState": false,
      })
  1. 채팅을 받은 사용자가 채팅 목록을 클릭했을 경우 receiveChatRoomState가 true로 update된다.
FirebaseFirestore.instance
        .collection("chatroom")
        .doc(chatRoomId)
        .update({"receiveChatRoomState": true});

👿 이 방법의 단점

  • 채팅 가능 인원을 둘에서 셋으로 늘릴 경우 읽음 확인 표시를 구현하기 어렵다. (확장이 어려움)

이번 채팅 기능 구현에 firebase를 선택한 이유는 실시간으로 데이터가 동기화되기 때문이다.

MySQL은 ERD를 짤 때 relation에서 애를 먹는 경우가 많았지만 데이터 중복을 최소한으로 줄일 수 있고 select query에 다양한 조건을 덧붙일 수 있다는 장점을 깨우치게 된 시간이었다.

💡 알아낸 해결법

  • firebase에 복합 쿼리를 위한 색인 기능이 있다.
    - array 타입에 조건 두 번 걸기 가능

( ♡ 부디 조언할 부분이 있다면 댓글 남겨 주세요 )

profile
el & altese

0개의 댓글