JinjerLog

©김민호 All rights reserved.
  • NextJS
  • Firestore

실시간 채팅 애플리케이션 구현(3)

onSnapshot을 활용한 Firestore 실시간 데이터 바인딩

Intro

지난 글에 이어서 채팅방에 입장하기 위한 라우터 처리와
onSnapshot을 사용해서 채팅방 대화 내용을 실시간으로 가져와 보도록 하죠

구현전략

🤨 데스트탑 앱을 만들어야 하니 어떤 프로그램을 쓰면 좋을까 NextJS와 Electron을 결합한 Nextron이란 라이브러리로 환경을 구성해보자 😎 백엔드 서버를 따로 두지 않고 Firebase에서 지원하는 인증을 사용해보자 Firebase 콘솔에서 새로운 프로젝트를 생성한 후 config를 프로젝트에 세팅한다

😲 Firebase 인증을 적용하고 서버사이드에서 접근을 제한 해보자

  1. 1. 계정 생성 페이지를 생성하고 createUserWithEmailAndPassword 함수로 계정을 만든다
  2. 2. 로그인 페이지를 생성하고 signInWithEmailAndPassword 함수로 로그인을 한다
  3. 3. 토큰 관리를 위해 context를 생성한 후 onAuthStateChanged 함수로 유저의 로그인 여부를 판별한 뒤 쿠키에 저장한다
  4. 4. ServerSideProps를 사용하여 인증 상태를 판별한 뒤 성공 시 채팅리스트로 실패 시 로그인 페이지로 보낸다

😏 채팅리스트와 각 채팅방에 입장하기 위한 라우터 처리는 이렇게 해보자 채팅방 입장은 pages에 chat/[id].tsx로 즉, 채팅방의 라우터 id로 접근하도록 한다(/chat/8796) 🙄 각 채팅방에 입장 시 입력된 메시지를 어떻게 보여주면 될까

  1. 1. useFirestoreQuery로 데이터베이스에 추가된 컬렉션의 메시지를 orderBy로 내림순으로 가져온다
  2. 2. 채팅 내용을 입력하고 firestore의 collection과 add 함수를 이용하여 메시지를 조회하고 추가한다
  3. 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에서 전체 소스를 확인할 수 있다.

Screenshot

정리

😟 작업중에 아쉬웠던 부분 혹은 개선해야 할 점

  1. 1. 현재는 useFirebaseQuery 훅을 사용해서 스냅샷의 변경 사항 업데이트 하고 있는데 문제는 setDocs로 컴포넌트의 상태가 변경되므로 리렌더링이 발생된다
  2. 2. 퍼포먼스가 생각보다 더 안나오는 듯 하다. 이유가 무엇일까 생각해보니 아무래도 Firebase에 요청을 주고 받는 것이 느린게 원인이지 않을까 생각된다
  3. 3. 채팅 리스트가 이번 애플리케이션 구현에 있어 가장 난해한 부분이라 생각되는데 이 부분을 해결하지 못한 상태여서 아쉬움이 제일 컷다

😂 채팅리스트를 왜 구현할 수 없었나

처음 설계는 채팅 작성 시 자신의 채팅리스트를 담을 컬렉션을 따로 만들어 각각의 Document에 채팅방의 정보(참여명단, 최근 메시지, 마지막 채팅시간 등)을 저장하고
채팅리스트로 뽑아내어 보여줄 생각이었다

const docRef = await getDocs(collection(db, uid))

그러나 이 계획의 가장 큰 문제점은 자신이 작성한 채팅 외에 상대방이 작성한 내용도 해당 컬렉션에 업데이트 되어야 했는데 그럴려면 자신과 상대방을 연결해 줄 무언가가 필요하였다