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

17. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 마지막 메시지 미리 보기

그랜파 개발자 2025. 5. 20. 20:24

마지막 메시지 미리보기

채팅방의 마지막 메시지를 채팅방 리스트에서 보여 줍니다.
채팅방 목록에 마지막 메시지를 미리보기 형태로 보여주는 것은
사용자 경험(UX)을 향상시키는 데 매우 중요한 역할을 합니다.
사용자가 채팅방을 열지 않고도 대화의 흐름이나 최근 상황을 파악할 수 있고
어떤 채팅방에 먼저 들어가야 할지 판단할 수 있게 도와줍니다.

 

🔧 1. Firestore 데이터 구조

chatRooms (컬렉션)
  └─ roomId (문서)
       ├─ lastMessage: {
       │     text: "내일 보자!",
       │     senderId: "user123",
       │     createdAt: 2025-05-20T12:00:00Z
       │   }
       └─ ... (기타 필드)

messages (컬렉션)
  └─ roomId (서브컬렉션)
       └─ messageId (문서)
           ├─ text: "내일 보자!"
           ├─ senderId: "user123"
           ├─ createdAt: ...

✅ 2. 미리보기용 필드: lastMessage

성능상 이유로 채팅방 문서에 lastMessage 필드를 저장해 두는 것이 일반적입니다.
이 필드는 새 메시지가 전송될 때마다 업데이트됩니다.

 

chatStore

  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);

    batch.update(chatRoomRef, {
      // 2. 수신자의 안 읽은 메시지 수 증가
      [`unreadCountByUser.${receiverId}`]: increment(1),
      // 3. 마지막 메시지 정보 갱신
      lastMessage: {
        text,
        senderId,
        createdAt: timestamp
      }
    });

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

🔍 3. Vue에서 채팅방 목록 보여줄 때

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,
            lastMessage: roomData.lastMessage || null,
          });
        }        
      }

      chatRooms.value = rooms;
    });   
  };

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

  return {
    chatRooms,
    fetchChatRooms,
    stopListening
  };
}

🖼️ 4. UI에 보여줄 때

ChatList.vue의 template

        <div class="d-flex align-center">
          <v-list-item-title>
            <b>{{ room.otherUserName }}님</b>
          </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-subtitle v-if="room.lastMessage && room.lastMessage.text">
            {{ room.lastMessage?.text || '메시지가 없습니다' }}            
            ({{ formatTime(room.lastMessage.createdAt.toDate()) }})
          </v-list-item-subtitle>

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>
            <b>{{ room.otherUserName }}님</b>
          </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-subtitle v-if="room.lastMessage && room.lastMessage.text">
            {{ room.lastMessage?.text || '메시지가 없습니다' }}            
            ({{ formatTime(room.lastMessage.createdAt.toDate()) }})
          </v-list-item-subtitle>

      </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'; 
import { format } from 'date-fns'; 

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
  }});
};

const formatTime = (timestamp) => {
  if (!timestamp) return '';
  const date = timestamp;  
  // 24시간 형식 예: 14:05
  return format(date, 'HH:mm'); 
};

// 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>

chatStore

// 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);

    batch.update(chatRoomRef, {
      // 2. 수신자의 안 읽은 메시지 수 증가
      [`unreadCountByUser.${receiverId}`]: increment(1),
      // 3. 마지막 메시지 정보 갱신
      lastMessage: {
        text,
        senderId,
        createdAt: timestamp
      }
    });

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

  return { 
    findOrCreateChatRoom,
    sendMessage
  };
});

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,
            lastMessage: roomData.lastMessage || null,
          });
        }        
      }

      chatRooms.value = rooms;
    });   
  };

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

  return {
    chatRooms,
    fetchChatRooms,
    stopListening
  };
}