이번 포스트에서는 React 채팅앱에 Firestore를 적용하는 방법을 공유드리고자 합니다.
기존에 polling 방식으로 채팅을 구현했을 때 속도도 너무 느리고, 싱크가 맞지 않아 실시간으로 데이터가 반영될 수 있는 Firestore를 적용했습니다.
구글에서 제공하는 플랫폼 서비스로 인증(authentication), 데이터베이스(firestore, realtime database), 스토리지, 호스팅, Function 등 여러 기능을 제공합니다. 백엔드 기능을 클라우드 서비스 형태로 제공하기 때문에 간단한 조작으로 서버리스 애플리케이션 개발이 가능하며, 개인적으로 이전에 사용했던 aws 보다 훨씬 간단하게 느껴졌습니다.
Firestore는 구글(firebase)에서 지원하는NoSQL 데이터베이스 서비스로 실시간 리스너를 통해 사용자와 기기간 데이터의 실시간 동기화가 가능합니다. 또한, Cloud Firestore는 앱에서 많이 사용되는 데이터를 캐시하기 때문에 기기가 오프라인 상태가 되더라도 앱에서 데이터를 쓰고 읽고 수신 대기하고 쿼리할 수 있습니다.
구글 설명은 아래와 같습니다.
- Cloud Firestore는 모바일 앱 개발을 위한 Firebase의 최신 데이터베이스로서 실시간 데이터베이스의 성공을 바탕으로 더욱 직관적인 새로운 데이터 모델을 선보입니다. 또한 실시간 데이터베이스보다 풍부하고 빠른 쿼리와 원활한 확장성을 제공합니다.
작업하기에 앞서, Firestore의 기본 구조를 간단하게 훑고 가겠습니다.
SQL 데이터베이스와 달리 테이블이나 행이 없으며, 컬렉션으로 정리되는 문서에 데이터를 저장합니다. 그리고 각 문서에는 키-값 쌍이 들어 있습니다.
출처: Cloud Firestore 데이터 모델
Cloud Firestore은 컬렉션(collection)과 도큐먼트(document)로 구성된 트리구조로 이뤄져 있습니다. 컬렉션은 도큐먼트를 저장하는 공간이고, 도큐먼트는 딕셔너리 형태로 자료를 저장하는 공간입니다. SQL 테이블로 비교하자면 도큐먼트는 테이블의 한 행, 즉 데이터이고 데이터별로 그룹화해서 컬렉션에 저장하는 구조입니다.
이미지 출처: Understanding Collection Group Queries in Cloud Firestore
아래는 Firestore 대시보드 예시입니다. 위에 설명했듯이 컬렉션을 누르면 해당 컬렉션에 속한 도큐먼트가 보이며, 가장 우측에서 도큐먼트 구조를 볼 수 있습니다.
컬렉션과 도큐먼트는 아래와 같은 규칙을 따릅니다.
공식문서 참고: Cloud Firestore 시작하기
Firebase 설정이 되어있다는 가정하에 진행하겠습니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
실서버에서는 본인이 참여한 데이터만 열람/수정 가능하게 제한을 두는것이 권장됩니다.
참고로, 위 사진처럼 Firestore는 NoSQL 데이터베이스 특성에 따라 스키마를 정의하지 않고, 유동적으로 필드를 추가 / 제거할 수 있습니다.
React app에 Firestore를 적용해 실시간 채팅 앱을 구현해보도록 하겠습니다. 코드는 아래 깃헙을 참고했습니다.
크게 세 가지 컴포넌트를 사용했습니다:
1) firestore의 도큐먼트를 가져오는 useFirestoreQuery
함수
2) 채팅 데이터를 가져오고 생성하는 Channel
컴포넌
3) 그리고 가져온 채팅 내용을 보여주는 Message
컴포넌트.
하단 코드는 해당 깃헙 코드를 참고했습니다.
export function useFirestoreQuery(query) {
const [docs, setDocs] = useState([]);
// Store current query in ref
const queryRef = useRef(query);
// Compare current query with the previous one
useEffect(() => {
// Use Firestore built-in 'isEqual' method
// to compare queries
if (!queryRef?.current?.isEqual(query)) {
queryRef.current = query;
}
});
// Re-run data listener only if query has changed
useEffect(() => {
if (!queryRef.current) {
return null;
}
// Subscribe to query with onSnapshot
const unsubscribe = queryRef.current.onSnapshot((querySnapshot) => {
// Get all documents from collection - with IDs
const data = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}));
// Update state
setDocs(data);
});
// Detach listener
return unsubscribe;
}, [queryRef]);
return docs;
}
// firebase 8 이하로 다운그레이드 해서 import 하거나, firebase 9 이상은 compatability 옵션 사용
import React, { useEffect, useState, useRef } from "react";
import firebase from "firebase/compat/app";
import "firebase/compat/firestore";
import { firebaseConfig } from "@lib/firebase";
import { useFirestoreQuery } from "@frontend"
const Channel = ({ id = null }) => {
// firebase initialize
firebase.initializeApp(firebaseConfig);
// get firestore from my firebase app
const db = firebase.firestore();
// firestore 에서 해당 채널 id의 컬렉션 가져옴. 없으면 새로 생성됨. (여기서 채널은 채팅방을 의미)
const messagesRef = db.collection(`messages-${id}`);
// 0. 에서 작성한 useFirestoreQuery 로 도큐먼트 가져옴
const messages = useFirestoreQuery(
messagesRef.orderBy("createdAt", "desc").limit(1000)
);
// 채팅 메세지 생성시 useState로 새로운 메세지 저장
const [newMessage, setNewMessage] = useState("");
// input 필드 포커싱과 하단 스크롤을 위한 useRef
const inputRef = useRef();
const bottomListRef = useRef();
// 채팅 작성했을 때 onChanghandler, onSubmitHandler
const handleOnChange = (e) => {
// 추후에 내용 작성
};
const handleOnSubmit = async (e) => {
// 추후에 내용 작성
}
}
return(
<div className="flex flex-col h-full">
<div className="overflow-auto h-full">
<div className="py-4 max-w-screen-lg mx-auto">
<ul>
{messages
?.sort((first, second) =>
first?.createdAt?.seconds <= second?.createdAt?.seconds
? -1
: 1
)
?.map((message) => (
<li key={message.id}>
{/* 추후 Message 컴포넌트 생성해서 채팅 내용 표시 */}
<Message {...message} />
</li>
))}
</ul>
<div ref={bottomListRef} className="mb-16" />
</div>
</div>
</div>
{/* 채팅 입력 폼 생성 */}
<div className="w-full z-20 pb-safe bottom-0 fixed md:max-w-xl p-4 bg-gray-50">
<form onSubmit={handleOnSubmit} className="flex">
<input
ref={inputRef}
type="text"
value={newMessage}
onChange={handleOnChange}
placeholder="메세지를 입력하세요"
className="border rounded-full px-4 h-10 flex-1 mr-1 ml-1"
/>
<button
type="submit"
disabled={!(newMessage)}
className="rounded-full bg-red-400 h-10 w-10"
>
<BiSend className="text-white text-xl w-10" />
</button>
</form>
</div>
)
const Channel = ({ id = null }) => {
...
// 포커싱과 하단 스크롤을 위한 useRef
const inputRef = useRef();
const bottomListRef = useRef();
// 채팅 작성했을 때 onChanghandler, onSubmitHandler
const handleOnChange = (e) => {
setNewMessage(e.target.value);
};
const handleOnSubmit = async (e) => {
e.preventDefault();
// 입력한 채팅 공백 제거
const trimmedMessage = newMessage.trim();
if (trimmedMessage) {
// Add new message in Firestore
messagesRef.add({
text: trimmedMessage,
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
uid: currentUser?.id,
displayName: currentUser?.name,
photoURL: currentUser?.image,
isRead: false,
});
// Clear input field
setNewMessage("");
// Scroll down to the bottom of the list
bottomListRef.current.scrollIntoView({ behavior: "smooth" });
}
// 그 외, useRef 활용한 모션들
useEffect(() => {
if (inputRef.current) {
// 인풋 포커싱
inputRef.current.focus();
}
}, [inputRef]);
// 첫 화면 하단 스크롤
useEffect(() => {
if (bottomListRef.current) {
bottomListRef.current.scrollIntoView({ behavior: "smooth" });
}
// messagesRef 업데이트가 될 때 마다 읽음/안읽음 표시 업데이트를 할 수도 있습니다.
}, [messagesRef]);
}
import React from "react";
import PropTypes from "prop-types";
import { formatRelative } from "date-fns";
import { imageUrl, useCurrentUser, timeFormat } from "@lib/frontend";
import Image from "next/image";
const Message = ({
createdAt = null,
uid = "",
text = "",
displayName = "",
photoURL = "",
isRead = false,
}) => {
const { currentUser } = useCurrentUser();
// 채팅 내용 없으면 보여주지 않음
if (!(text)) return null;
return (
<>
<div
className={`flex items-start flex-wrap p-4 ${
uid === currentUser?.id && "flex-row-reverse"
}`}
>
{currentUser?.id !== uid && (
<>
{/* 상대방 프로필 사진 */}
<div className={`w-10 ${uid === currentUser.id ? "" : "mr-2"}`}>
{" "}
<img
src={photoURL ? imageUrl(photoURL) : "/gray.png"}
alt="Avatar"
className="rounded-full mr-4 h-10 w-10"
width={45}
height={45}
/>
</div>
</>
)}
{/* 채팅 내용. 사용자 별로 색깔 구분 */}
<div
className={`p-2 rounded-lg ${
uid === currentUser.id ? "bg-red-400 text-white " : "bg-gray-100"
}`}
>
{text}
</div>
<div className="text-gray-400 text-xs mx-2 flex flex-col">
{createdAt?.seconds ? (
<span
className={`text-gray-500 text-xs ${
uid === currentUser?.id && "flex-row-reverse"
}`}
>
{/* 읽음 & 안읽음 표시, 시간 표 */}
{isRead === false && uid === currentUser.id && (
<div className="text-right text-xs text-red-400">1</div>
)}
{timeFormat(new Date(createdAt.seconds * 1000))}
</span>
) : null}
</div>
</div>
</>
);
};
Message.propTypes = {
text: PropTypes.string,
createdAt: PropTypes.shape({
seconds: PropTypes.number,
}),
displayName: PropTypes.string,
photoURL: PropTypes.string,
};
export default Message;
import Channel from "@components/channel";
const ChatPage = () => {
const router = useRouter();
const { id } = router.query;
const { currentUser } = useCurrentUser();
return <>{currentUser && <Channel id={id} />}</>;
};
export default ChatPage;
비슷한 방법으로 ChatList 컴포넌트를 만들어 채팅 목록 기능을 구현할 수 있습니다. 또한, 심화 기능으로 메세지 생성시 이미지 업로드 기능을 추가하거나, 읽음/안 읽음 표시를 구현할 수도 있습니다.
아 이번 프로젝트 소켓으로 했는데;;
감사합니다. 다음에 해볼게요.