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

11. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 실시간 채팅

그랜파 개발자 2025. 5. 9. 22:10

1:1 채팅

1:1 채팅 기능을 구현합니다.
채팅방에 들어갈 때 상대의 userId와 이름, 그리고 chatRoomId를 Url의 query로 전달합니다.
채팅방이 열릴 때 지난 메시지를 가져와 나타냅니다.
메시지를 전송하면 자동으로 실시간으로 UI가 업데이트 대화의 내용을 볼 수 있습니다.

실시간 채팅

메시지를 입력하고 전송하면
메시지는 Firestore의 chatRooms 컬렉션에 저장됩니다.

chatRooms 컬렉션의 구조

chatRooms/
  {chatRoomId}/
    userIds: [user1Id, user2Id]
    createdAt: timestamp
  messages/
    {messageId}/

 

Firestore의 snapshot은 실시간 데이터 변화 감지 기능을 제공하는 객체입니다.
onSnapshot()을 사용하면 Firestore에서 데이터를 읽을 때,
데이터를 단순히 1회 가져오는 게 아니라, 변화가 생길 때마다 자동으로 알림을 받게 됩니다.
이때 전달되는 객체가 snapshot입니다.
Firestore는 클라이언트에 변경이 생기면 자동으로 해당 snapshot을 다시 보내줍니다.
이를 통해 Vue 등에서 UI를 실시간 자동으로 갱신할 수 있습니다.

composables와 store

Vue 3 앱에서 composables와 store는 상태 관리와 코드 재사용을 위한 핵심 개념입니다.
하지만 역할과 목적이 다릅니다. 아래에서 차이점과 예제를 설명드릴게요.

✅ Composables란?

Composition API의 로직 재사용 함수입니다.

  • useXXX 형식의 함수를 만들어 로직을 분리
  • 주로 비즈니스 로직, Firebase 연결, 라이프사이클 처리 등
  • Vue 컴포넌트에 종속되지 않은 재사용 가능한 함수

✅ Store란?

앱 전체에서 공유 가능한 전역 상태 저장소입니다.

  • Vuex 또는 Pinia로 구성
  • 사용자 정보, 로그인 상태, 채팅방 목록 등 앱 전반에서 필요한 상태를 유지
  • 여러 컴포넌트 간 상태를 일관되게 공유하려면 Store 사용

대부분의 실전 프로젝트에서는 Composables로 로직을 작성하고, Store로 상태를 관리합니다.

채팅방 들어가기

ChatRoom.vue의 script

sendMessage - 메시지 전송
subscribeToChatRoom - 채팅 메시지 실시간 가져오기 (Composables)

const { messages, subscribeToChatRoom } = useChat(auth.user.uid);

const submitMessage = async () => {
  if (!text.value.trim()) return;
  await chat.sendMessage(chatRoomId, auth.user.uid, otherUserId, text.value);
  text.value = '';

  nextTick(() => {
    if (chatContainer.value) {
      chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
    }
  });
};

onMounted(async () => {
  subscribeToChatRoom(chatRoomId);

  nextTick(() => {
    if (chatContainer.value) {
      chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
    }
  });  
});

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

      messages.value = newMessages;
    });
  };

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

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

  return { messages, subscribeToChatRoom, stopListening };
};

chatStore - sendMessage

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

    const messagesRef = collection(db, 'chatRooms', chatRoomId, 'messages');

    // 메시지 객체
    const messageData = {
      text,
      senderId,
      createdAt: serverTimestamp(),
      readAt: null
    };

    await addDoc(messagesRef, messageData);
  };

ChatRoom.vue

<!-- src/views/ChatRoom.vue -->
<template>
  <v-container fluid>
    <v-row>
      <v-col cols="12" sm="10" md="8" class="mx-auto">
        <h2 class="text-h6 my-4">
          {{ otherUserName }}님과의 채팅
        </h2>

        <v-sheet height="60vh" class="overflow-y-auto">
          <div ref="chatContainer"
            class="chat-scroll-area"
            style="height: 100%; overflow-y: auto;">
            <div
              v-for="msg in messages"
              :key="msg.id"
              :class="{
                'justify-end': msg.senderId === auth.user.uid,
                'justify-start': msg.senderId !== auth.user.uid,}"
              class="d-flex">            

              <template v-if="msg.type === 'audio'">
                <audio :src="msg.audioUrl" controls  class="ma-1" />
              </template>
              <template v-else>
                <v-sheet
                  max-width="60%"
                  :color="msg.senderId === auth.user.uid ? 'primary' : '#e1e1e1'"
                  :class="msg.senderId === auth.user.uid ? 'text-white' : 'text-black'"
                  class="pa-3 rounded-lg ma-1"
                  style="white-space: pre-line;"
                  elevation="1"
                >
                  <div class="text-body-2">{{ msg.text }}</div>
              </v-sheet>    
              </template>

            </div>
          </div>
        </v-sheet>

        <v-form @submit.prevent="submitMessage" class="mt-4">
          <v-textarea
            v-model="text"
            label="메시지를 입력하세요"
            auto-grow
            rows="1"
            max-rows="5"
            outlined
            dense
            append-inner-icon="mdi-send"
            @click:append-inner="submitMessage"            
          />
        </v-form>
      </v-col>
    </v-row>
  </v-container>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';
import { useChatStore } from '@/stores/chatStore';
import { useChat } from '@/composables/useChat';

const auth = useAuthStore();
const route = useRoute();
const chat = useChatStore();

const chatRoomId = route.query.roomId;
const otherUserName = route.query.otherUserName || '상대방';
const otherUserId = route.query.otherUserId

const text = ref('');
const chatContainer = ref(null);

const { messages, subscribeToChatRoom } = useChat(auth.user.uid);

const submitMessage = async () => {
  if (!text.value.trim()) return;
  await chat.sendMessage(chatRoomId, auth.user.uid, otherUserId, text.value);
  text.value = '';

  nextTick(() => {
    if (chatContainer.value) {
      chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
    }
  });
};

onMounted(async () => {
  subscribeToChatRoom(chatRoomId);

  nextTick(() => {
    if (chatContainer.value) {
      chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
    }
  });  
});
</script>

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 messagesRef = collection(db, 'chatRooms', chatRoomId, 'messages');

    // 메시지 객체
    const messageData = {
      text,
      senderId,
      createdAt: serverTimestamp(),
      readAt: null
    };

    await addDoc(messagesRef, messageData);
  };

  return { 
    findOrCreateChatRoom,
    sendMessage
  };
});