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
};
});
'Vue 3 + Firebase 기반 실시간 채팅 앱 개발' 카테고리의 다른 글
13. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅 상대의 온라인 상태 (0) | 2025.05.12 |
---|---|
12. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅룸 (0) | 2025.05.11 |
10. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 새채팅 (0) | 2025.05.08 |
9. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 구글 계정으로 로그인 (0) | 2025.05.08 |
8. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 비밀번호 변경, 비밀번호 재설정 (0) | 2025.05.08 |