- NextJS
- Firestore
실시간 채팅 애플리케이션 구현(3)
onSnapshot을 활용한 Firestore 실시간 데이터 바인딩
Intro
지난 글에 이어서 채팅방에 입장하기 위한 라우터 처리와
onSnapshot을 사용해서 채팅방 대화 내용을 실시간으로 가져와 보도록 하죠
구현전략
🤨 데스트탑 앱을 만들어야 하니 어떤 프로그램을 쓰면 좋을까 NextJS와 Electron을 결합한 Nextron이란 라이브러리로 환경을 구성해보자 😎 백엔드 서버를 따로 두지 않고 Firebase에서 지원하는 인증을 사용해보자 Firebase 콘솔에서 새로운 프로젝트를 생성한 후 config를 프로젝트에 세팅한다
😲 Firebase 인증을 적용하고 서버사이드에서 접근을 제한 해보자
- 1. 계정 생성 페이지를 생성하고 createUserWithEmailAndPassword 함수로 계정을 만든다
- 2. 로그인 페이지를 생성하고 signInWithEmailAndPassword 함수로 로그인을 한다
- 3. 토큰 관리를 위해 context를 생성한 후 onAuthStateChanged 함수로 유저의 로그인 여부를 판별한 뒤 쿠키에 저장한다
- 4. ServerSideProps를 사용하여 인증 상태를 판별한 뒤 성공 시 채팅리스트로 실패 시 로그인 페이지로 보낸다
😏 채팅리스트와 각 채팅방에 입장하기 위한 라우터 처리는 이렇게 해보자 채팅방 입장은 pages에 chat/[id].tsx로 즉, 채팅방의 라우터 id로 접근하도록 한다(/chat/8796) 🙄 각 채팅방에 입장 시 입력된 메시지를 어떻게 보여주면 될까
- 1. useFirestoreQuery로 데이터베이스에 추가된 컬렉션의 메시지를 orderBy로 내림순으로 가져온다
- 2. 채팅 내용을 입력하고 firestore의 collection과 add 함수를 이용하여 메시지를 조회하고 추가한다
- 3. onSnapshot을 사용하여 채팅을 입력하기 전 쿼리와 입력 후 쿼리를 비교하여 채팅 내용을 업데이트 한다
채팅방 입장
getServerSideProps를 통해 서버에서 채팅방 구성에 필요한 ID와 유저 정보를 미리 렌더링 하자
context 객체에서 params 혹은 query를 통해 접근한 페이지의 ID를 받을 수 있다
// renderer/pages/chat/[id].tsx
import { GetServerSidePropsContext } from 'next'
import { useAuthContext } from '../../context/AuthProvider'
import Channel from '../../components/Channel'
interface ChatPageProps {
chatId: string
}
const ChatPage = ({ chatId }: ChatPageProps) => {
const { user } = useAuthContext()
return <Channel id={chatId} currentUser={user} />
}
export const getServerSideProps = async (
ctx: GetServerSidePropsContext,
): Promise<{ props: {} }> => {
try {
const paramsId = ctx.params.id
return {
props: {
chatId: paramsId,
},
}
} catch {
ctx.res.writeHead(302, { Location: '/' })
ctx.res.end()
return {
props: {},
}
}
}
export default ChatPage
채팅창
Channel 컴포넌트를 하나 생성해 봅니다
입력한 채팅 내용이 DB에 쌓일 수 있도록 작성폼을 하나 만듭니다
// renderer/components/Channel.tsx
import { collection, addDoc, serverTimestamp } from 'firebase/firestore'
import { db } from '../firebase/firebaseClient'
function Channel({ id }) {
const [newMessage, setNewMessage] = useState<string>('')
const docRef = collection(db, `collection-${id}`)
const handleOnChange = (e) => {
setNewMessage(e.target.value)
}
const handleOnSubmit = (e) => {
e.preventDefault()
addDoc(docRef, {
uid: currentUser?.uid,
displayName: currentUser?.displayName,
photoURL: currentUser?.photoURL,
message: newMessage,
createdAt: serverTimestamp(),
})
}
return (
<div>
{ ...채팅 메시지가 노출됨 }
<form onSubmit={handleOnSubmit}>
<input
value={newMessage}
onChange={handleOnChange}
type="text"
placeholder="Write a message..."
/>
<button type="submit">전송</button>
</form>
</div>
)
}
export default Channel
collection 함수로 DB에 접근한 뒤 ChatPage에서 전달한 id 값을 이용하여 해당 컬렉션을 지목해 줍니다
addDoc 함수는 지정한 컬렉션에 Document를 하나 생성하는데 이때 채팅 입력창에 작성한 내용과 시간 등 정보가 등록된다
onSnapshot
작성한 내용이 DB에 제대로 저장이 되었다면
이제 저장했던 채팅들을 채팅창에 보여줘야 할텐데 이때, 쿼리의 변동사항을 감지하는
스냅샷 기능을 사용하여 등록된 채팅을 실시간으로 가져올 수 있다
// renderer/hook/useFirebaseQuery.ts
import React, { useState, useEffect, useRef } from 'react'
import { queryEqual, onSnapshot } from 'firebase/firestore'
export const useFirebaseQuery = (query) => {
const [docs, setDocs] = useState([])
const queryRef = useRef(query)
useEffect(() => {
if (!queryEqual(queryRef?.current, query)) {
queryRef.current = query
}
}, [])
useEffect(() => {
if (!queryRef.current) {
return null
}
const unsubscribe = onSnapshot(queryRef.current, (querySnapshot) => {
const data = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}))
setDocs(data)
})
return unsubscribe
}, [queryRef])
return docs
}
onSnapshot 함수는 컬렉션 내의 문서를 스냅샷을 생성한 뒤 내용 변경 시 콜백을 호출하여 계속해서 문서 스냅샷을 만든다
적용하기
// renderer/components/Channel.tsx
import React, { useState } from 'react'
import { db } from '../firebase/firebaseClient'
import { collection, addDoc, serverTimestamp, orderBy, limit, query } from 'firebase/firestore'
import { User } from 'firebase/auth'
import { useFirebaseQuery } from '../hook/useFirebaseQuery'
import { formatDate } from '../utils/date'
import Message from './Message'
interface ChannelProps {
id: string
currentUser: Pick<User, 'displayName' | 'photoURL' | 'uid'>
}
function Channel({ id, currentUser }: ChannelProps) {
const [newMessage, setNewMessage] = useState<string>('')
const docRef = collection(db, `collection-${id}`)
const handleOnChange = (e) => {
setNewMessage(e.target.value)
}
const handleOnSubmit = (e) => {
{ ...문서 저장 }
}
const chats = useFirebaseQuery(query(docRef, orderBy('createdAt'), limit(100)))
return (
<div>
<div>
<ul>
{chats &&
chats.map((chat) => {
return (
<li key={chat.message}>
<Message
id={chat.uid}
nick={chat.displayName}
message={chat.message}
thumb={chat.photoURL}
createdAt={formatDate(chat.createdAt)}
/>
</li>
)
})}
</ul>
</div>
{ ...채팅 작성폼}
</div>
)
}
export default Channel
채팅 메시지
마지막으로 컬렉션 내의 저장된 문서 내용을 보여주는 Message 컴포넌트를 만든다
내가 작성한 메시지와 상대가 작성한 메시지를 uid로 구분하고 스타일을 다르게 적용하면 끝이다.
// renderer/components/Message.tsx
import { useAuthContext } from '../context/AuthProvider'
import Avatar from '../components/Avatar'
interface MessageProps {
id: string
nick: string
thumb: string
message: string
createdAt: string
}
function Message({ id, nick, thumb, message, createdAt }: MessageProps) {
const { user } = useAuthContext()
if (!message) return null
return (
<div>
{/* Document ID와 현재 유저의 uid로 구분 */}
{id == user.uid ? (
<div>
<Avatar src={thumb} alt={nick} />
<div>{nick}</div>
<div>{message}</div>
<time>{createdAt}</time>
</div>
) : (
<div>
<div>{nick}</div>
<div>{message}</div>
<time>{createdAt}</time>
</div>
)}
</div>
)
}
export default Message
Github 주소: nextron-desktop-chat에서 전체 소스를 확인할 수 있다.
정리
😟 작업중에 아쉬웠던 부분 혹은 개선해야 할 점
- 1. 현재는 useFirebaseQuery 훅을 사용해서 스냅샷의 변경 사항 업데이트 하고 있는데 문제는 setDocs로 컴포넌트의 상태가 변경되므로 리렌더링이 발생된다
- 2. 퍼포먼스가 생각보다 더 안나오는 듯 하다. 이유가 무엇일까 생각해보니 아무래도 Firebase에 요청을 주고 받는 것이 느린게 원인이지 않을까 생각된다
- 3. 채팅 리스트가 이번 애플리케이션 구현에 있어 가장 난해한 부분이라 생각되는데 이 부분을 해결하지 못한 상태여서 아쉬움이 제일 컷다
😂 채팅리스트를 왜 구현할 수 없었나
처음 설계는 채팅 작성 시 자신의 채팅리스트를 담을 컬렉션을 따로 만들어 각각의 Document에 채팅방의 정보(참여명단, 최근 메시지, 마지막 채팅시간 등)을 저장하고
채팅리스트로 뽑아내어 보여줄 생각이었다
const docRef = await getDocs(collection(db, uid))
그러나 이 계획의 가장 큰 문제점은 자신이 작성한 채팅 외에 상대방이 작성한 내용도 해당 컬렉션에 업데이트 되어야 했는데 그럴려면 자신과 상대방을 연결해 줄 무언가가 필요하였다