Vue 3 + Firebase 기반 실시간 채팅 앱 개발

16. Vue 3 + Firebase 기반 채팅 앱 개발 - 읽지 않은 메시지 수 배지 표시

그랜파 개발자 2025. 5. 16. 20:40

읽지 않은 메시지 수 배지 표시

읽지 않은 메시지 수를 사용자에게 표시하는 것은 실시간 채팅 앱에서 매우 중요한 UX 요소입니다.

 

사용자는 어떤 채팅방에 읽지 않은 메시지가 있는지 즉시 알 수 있고,
1:1 또는 업무용 대화에서 즉각 대응할 수 있는 계기를 제공합니다.

 

모바일 앱/웹에서 상단 또는 아이콘에 표시된 뱃지 숫자는 사용자에게 시각적 자극을 줍니다.
이 숫자는 "뭔가 내가 놓친 게 있다" 는 느낌을 주어 앱을 다시 열게 만드는 강력한 동기입니다.

 

읽지 않은 메시지 수 를 처리하는 로직

실시간 채팅 앱에서 읽지 않은 메시지 수 를 처리하는 로직은.
unreadCountByUser라는 필드를 사용하여
메시지 전송 시 읽지 않은 메시지 수 증가 시키고
채팅방 열람 시 메시지 읽음 처리 + 읽지 않은 메시지 수 초기화하고
채팅방 목록에서 뱃지 표시합니다.

 

chatRooms (컬렉션)

chatRooms (컬렉션)
  └─ {roomId} (문서)
      └─ userIds: [user1, user2]
      └─ lastMessage: {...}
      └─ unreadCountByUser: {
           user1: 0,
           user2: 3
         }

messages (서브컬렉션: chatRooms/{roomId}/messages)
  └─ {messageId}
      └─ senderId: user1
      └─ readBy: [user1]
      └─ createdAt, content

1. 읽지 않은 메시지 수 증가

{ unreadCountByUser.${receiverId}]: increment(1) }

이 표현은 Firebase Firestore에서 특정 필드 값을 원자적으로 증가시키는 코드입니다.

🔹 1. 객체의 동적 키 생성

  • 백틱(`) + ${} 문법을 사용하여 키 이름을 동적으로 생성하고 있음.
  • 예를 들어 receiverId가 "abc123"라면:
[ `unreadCountByUser.${receiverId}` ] // → "unreadCountByUser.abc123"

즉, 이 키는 Firestore 문서의 중첩 필드를 의미합니다.
예시 구조:

{
  unreadCountByUser: {
    abc123: 5,
    xyz789: 0
  }
}

🔹 2. increment(1)

  • Firestore의 FieldValue.increment()는 숫자 필드를 원자적으로 증가시키는 함수입니다.
  • increment(1) → 현재 값에 +1, increment(-1) → 현재 값에 -1을 더합니다.

이 함수는 Firestore가 내부적으로 트랜잭션 처리하기 때문에 동시성 문제가 없음.

🔹 3. 전체 맥락

이 코드는 Firestore 문서를 업데이트하면서,
unreadCountByUser라는 필드 안에서 receiverId에 해당하는 유저의 안 읽은 메시지 수를 +1 증가시키는 역할을 합니다.

import { doc, updateDoc, increment } from 'firebase/firestore';

await updateDoc(doc(db, 'chatRooms', roomId), {
  [`unreadCountByUser.${receiverId}`]: increment(1)
});

✅ 예시 시나리오

상황:
- A가 B에게 메시지를 보냄.
- receiverId = B의 uid

 

Firestore 문서 구조:

{
  "unreadCountByUser": {
    "uid_of_B": 2
  }
}

 

코드 실행 후 결과:

{
  "unreadCountByUser": {
    "uid_of_B": 3  // +1 증가
  }
}

chatStore.js

// src/stores/chatStore.js
import { defineStore } from 'pinia';
import { db } from '@/firebase';
import {
  collection,
  doc,
  addDoc,
  writeBatch,
  getDoc,
  query,
  where, 
  getDocs,
  increment,
  serverTimestamp,
  Timestamp,
} from 'firebase/firestore';

export const useChatStore = defineStore('chat', () => {

  const findOrCreateChatRoom = async (currentUserId, otherUserId) => {

    const chatRoomsRef = collection(db, 'chatRooms');
    const q = query(chatRoomsRef, where('userIds', 'array-contains', currentUserId));
    const snapshot = await getDocs(q);

    // 같은 유저 조합이 있는지 확인
    for (const doc of snapshot.docs) {
      const userIds = doc.data().userIds;
      if (userIds.includes(otherUserId)) {
        return doc.id; // 기존 채팅방 ID 반환
      }
    }

    // 없으면 새로 생성
    const newRoomRef = await addDoc(chatRoomsRef, {
      userIds: [currentUserId, otherUserId],
      createdAt: Timestamp.now(),
    });

    return newRoomRef.id;
  };

  const sendMessage = async (chatRoomId, senderId, receiverId, text) => {

    const batch = writeBatch(db);

    const messagesRef = collection(db, 'chatRooms', chatRoomId, 'messages');
    const newMessageRef = doc(messagesRef); // 수동으로 새 메시지 문서 생성

    const timestamp = serverTimestamp();

    // 메시지 객체
    const messageData = {
      text,
      senderId, 
      readBy: [senderId],    // 보낸 사람은 읽은 것으로 처리
      createdAt: timestamp,
      readAt: null
    };

    // 참조
    const chatRoomRef = doc(db, 'chatRooms', chatRoomId);  
    // 1. 메시지 추가
    batch.set(newMessageRef, messageData);

    // 2. 수신자의 안 읽은 메시지 수 증가
    batch.update(chatRoomRef, {
      [`unreadCountByUser.${receiverId}`]: increment(1)
    });

    // 4. 배치 커밋
    await batch.commit();
  };

  return { 
    findOrCreateChatRoom,
    sendMessage
  };
})

현재 사용자의 안 읽은 메시지 수 가져오기

unreadCount: roomData.unreadCountByUser?.[currentUserId] || 0

🧠 각 부분 설명

 

1. roomData.unreadCountByUser?.[currentUserId]
- roomData는 하나의 채팅방 데이터입니다. 예: Firestore에서 가져온 chatRoom 문서.
- unreadCountByUser는 Firestore에서 다음과 같은 구조를 가질 수 있는 객체입니다:

{
  unreadCountByUser: {
    uid1: 3,
    uid2: 0
  }
}
  • roomData.unreadCountByUser?.[currentUserId]는:
    • unreadCountByUser 객체가 존재한다면, 현재 로그인한 사용자의 uid (currentUserId)에 해당하는 값을 가져옵니다.
    • ?.는 Optional Chaining 연산자입니다. unreadCountByUser가 null이거나 undefined인 경우 오류 없이 undefined 반환.

2. || 0
- 위의 값이 undefined, null, 0, false 등 falsy 값일 경우 대신 0을 사용합니다.
- 즉, 해당 유저의 unreadCount가 없으면 기본값으로 0을 사용.

✅ 전체 의미

unreadCount: roomData.unreadCountByUser?.[currentUserId] || 0

이 코드는 다음을 의미합니다:
"현재 채팅방의 unreadCountByUser에서 현재 사용자(currentUserId) 의 안 읽은 메시지 수를 가져오고,
값이 없으면 0으로 간주하라."

💡 예제

const roomData = {
  unreadCountByUser: {
    abc123: 2,
    def456: 0
  }
};

const currentUserId = 'abc123';

const unreadCount = roomData.unreadCountByUser?.[currentUserId] || 0;
// 결과: 2

 

다른 경우:

const currentUserId = 'ghi789';

const unreadCount = roomData.unreadCountByUser?.[currentUserId] || 0;
// 결과: 0 (해당 userId 없음)

composables - useChatRooms.js

// src/composables/useChatRooms.js
import { collection, query, where, onSnapshot } from 'firebase/firestore';
import { useAuthStore } from '@/stores/authStore'; // ✅ authStore import
import { db } from '@/firebase';
import { ref } from 'vue';


let unsubscribe = null;

export function useChatRooms() {

  const auth = useAuthStore(); // ✅ authStore 인스턴스
  const chatRooms = ref([]);

  const fetchChatRooms = (currentUserId) => {
    if (!currentUserId)
      return;

    const q = query(
      collection(db, 'chatRooms'),
      where('userIds', 'array-contains', currentUserId)
    );

    unsubscribe = onSnapshot(q, async (snapshot) => {
      const rooms = [];

      for (const docSnap of snapshot.docs) {
        const roomData = docSnap.data();
        const otherUserId = roomData.userIds.find(uid => uid !== currentUserId);
        const otherProfile = auth.profiles.find(p => p.id === otherUserId);
        if(otherProfile) {
          const otherUserName = otherProfile.name;
          // 1:1 채팅방에서 서로 상대의 이름을 가져간다.
          // 채팅방 리스트에 나타낸다.
          rooms.push({
            id: docSnap.id,
            ...roomData,
            otherUserId,
            otherUserName,
            unreadCount:  roomData.unreadCountByUser?.[currentUserId] || 0
          });
        }        
      }

      chatRooms.value = rooms;
    });   
  };

  const stopListening = () => {
    if (unsubscribe) {
      unsubscribe();
      unsubscribe = null;
    }
  };

  return {
    chatRooms,
    fetchChatRooms,
    stopListening
  };
}

읽지 않은 메시지를 읽음 처리

📌 전체 코드

if (!data.readBy?.includes(currentUserId)) {
  markMessageAsRead(chatRoomId, docSnap.id);
}

🧠 각 부분 설명

🔹 data.readBy?.includes(currentUserId)
- data는 메시지 문서의 데이터입니다. 예: Firestore에서 messages 문서 하나를 snapshot으로 가져온 것.
- readBy는 이 메시지를 읽은 사용자들의 uid 배열입니다. 예:

readBy: ["uid1", "uid2"]
  • readBy?.includes(currentUserId)는 다음을 의미합니다:
    • readBy가 존재하면 → 배열에 현재 사용자의 uid (currentUserId)가 포함되어 있는지 확인
    • ?.는 Optional Chaining으로, readBy가 undefined일 때도 에러 없이 처리됨

🔹 ! (not)
- !는 부정 연산자입니다.
- 즉, 현재 사용자가 아직 이 메시지를 읽지 않았다면 true가 됩니다.

🔹 전체 조건 의미

if (!data.readBy?.includes(currentUserId))

 

“현재 메시지를 읽은 사람 목록에 현재 사용자가 없다면…”

 

🔹 markMessageAsRead(chatRoomId, docSnap.id);

이 조건이 참이면, markMessageAsRead 함수를 실행해서:
- 메시지의 readBy 배열에 현재 사용자 uid를 추가
- readAt 타임스탬프 추가 등 메시지를 읽음 상태로 업데이트합니다.

✅ 예시 시나리오

  1. 현재 사용자 ID: "user123"
  2. Firestore 메시지 데이터:
{
  text: "안녕하세요",
  readBy: ["user456"],
  createdAt: ...
}

→ 현재 사용자가 아직 readBy에 없음 → 조건문 통과 → 읽음 처리 함수 실행

composables - useChat.js

// -- src/composables/useChat.js
import { ref, onUnmounted } from 'vue';
import { db } from '@/firebase';
import { collection, doc, updateDoc, query, 
  orderBy, onSnapshot,arrayUnion } from 'firebase/firestore';

export const useChat = (currentUserId) => {
  const messages = ref([]);  
  let unsubscribe = null;

  const markMessageAsRead = async (chatRoomId, messageId) => {
    const chatRoomRef = doc(db, 'chatRooms', chatRoomId);

    // 안 읽은 메시지수 초기화 - 읽음
    await updateDoc(chatRoomRef, {
      [`unreadCountByUser.${currentUserId}`]: 0
    });

    // readBy에 읽은 사용자 userId 추가
    const msgRef = doc(db, `chatRooms/${chatRoomId}/messages/${messageId}`);
    try {
      await updateDoc(msgRef, {
        readBy: arrayUnion(currentUserId),
      });
    } catch (err) {
      console.error('읽음 표시 실패:', err.message);
    }
  };

  const subscribeToChatRoom = (chatRoomId) => {
    const messagesRef = collection(db, 'chatRooms', chatRoomId, 'messages');
    const q = query(messagesRef, orderBy('createdAt'));

    unsubscribe = onSnapshot(q, (snapshot) => {
      const newMessages = [];
      for (const docSnap of snapshot.docs) {

        const data = docSnap.data();
        const msg = { id: docSnap.id, ...data };
        newMessages.push(msg);

        if (!data.readBy?.includes(currentUserId)) {
          markMessageAsRead(chatRoomId, docSnap.id);
        }
      }

      messages.value = newMessages;
    });
  };

  const stopListening = () => {
    if (unsubscribe) {
      unsubscribe();
    }
  };

  onUnmounted(() => {
    stopListening();
  });

  return { messages, subscribeToChatRoom, stopListening };
};

ChatList.vue

<!-- src/views/ChatList.vue -->
<template>
  <v-container>
    <h2>채팅룸</h2>
    <v-list v-if="chatRooms && chatRooms.length">
      <v-list-item
        v-for="room in chatRooms"
        :key="room.id"
        @click="goToChat(room.id, room.otherUserId, room.otherUserName)"
      >
        <div class="d-flex align-center">
          <v-list-item-title>
            {{ room.otherUserName }}님
          </v-list-item-title>      

          <!-- 안읽은 메시지 수 -->
          <v-badge
              v-if="room.unreadCount > 0"
              :content="room.unreadCount"
              color="red"
              overlap
              class="ms-4"
              content-class="mini-badge"
            />  
          </div> 

      </v-list-item>              
    </v-list>

    <!-- 없을 때 안내 메시지 -->
    <v-list v-else>
      <v-list-item>
        <v-list-item-title>채팅방이 없습니다.</v-list-item-title>
      </v-list-item>
    </v-list>
  </v-container>
</template>

<script setup>
import { useAuthStore } from '@/stores/authStore';
import { useChatRooms } from '@/composables/useChatRooms';
import { watch } from 'vue';
import { useRouter } from 'vue-router'; 

const router = useRouter();
const auth = useAuthStore();
const { chatRooms, fetchChatRooms } = useChatRooms();

const goToChat = (chatRoomId, otherUserId, otherUserName) => { 
  if(!auth.user)
    return

  // 채팅방으로 이동
  router.push({ path: '/chat', query: { 
    otherUserId, 
    otherUserName, 
    roomId: chatRoomId
  }});
};

// auth.user가 나중에 바뀔 수도 있으니까 watch로 감지!
// auth.profile이 로드 되어야 회원 정보가 모두 사용가능하다.
watch(
  () => auth.profile,
  (newUser) => {
    if (newUser && newUser.userId ) {
      fetchChatRooms(newUser.userId);
    }
  },
  { immediate: true }
);
</script>

<style scoped>
::v-deep(.mini-badge) {
  font-size: 8px !important;
  min-width: 16px !important;
  height: 16px !important;
  line-height: 16px !important;
  padding: 0 4px !important;
  background-color: red !important;
  color: white !important;
}
</style>