읽지 않은 메시지 수 배지 표시
읽지 않은 메시지 수를 사용자에게 표시하는 것은 실시간 채팅 앱에서 매우 중요한 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 타임스탬프 추가 등 메시지를 읽음 상태로 업데이트합니다.
✅ 예시 시나리오
- 현재 사용자 ID: "user123"
- 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>
'Vue 3 + Firebase 기반 실시간 채팅 앱 개발' 카테고리의 다른 글
17. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 마지막 메시지 미리 보기 (0) | 2025.05.20 |
---|---|
15. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 메시지 읽은 시간 표시 (0) | 2025.05.15 |
14. Vue 3 + Firebase 기반 실시간 채팅 앱 개발 - 메시지 읽은 시간 표시 (0) | 2025.05.13 |
13. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅 상대의 온라인 상태 (0) | 2025.05.12 |
12. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅룸 (0) | 2025.05.11 |