프로젝트 생성

Copy
yarn create react-app chat
cd chat
yarn start

Router

React-router-dom을 이용한다. 👉 공식문서

Copy
// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import './index.css';

const router = createBrowserRouter([
  {
    path: '/',
    element: <div>Hello world!</div>,
  },
]);

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
);

각 컴포넌트별 route는 다음과 같이 컴포넌트를 불러오고, 해당 컴포넌트의 path를 설정해줄 수 있다.

Copy
import Root from './routes/root';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
  },
]);

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
);

애플리케이션 구조

structure-of-app

실시간으로 반영되는 Real time 애플리케이션의 경우 주로 socket.io 모듈을 이용한다.

Firebase도 websocket을 이용하여 실시간 데이터를 반영하여 보여줄 수 있다.

REST vs. Websockets
Rest 방식의 경우 A가 B에게 메시지를 보낼 경우 refresh가 되어야 데이터를 받을 수 있다.
하지만 Websocket을 이용하는 경우 refresh가 되지 않아도 B는 즉각적으로 메시지를 확인할 수 있다.


Firebase

Google에서 개발된 것으로 데이터베이스, 스토리지, 인증, 푸시 알림, 배포 등 대부분의 앱을 만들 때 필요한 기능들을 자동으로 만들어주는 플랫폼이다.

Firebase에서 사용하는 데이터베이스는 Mysql이나 오라클 같은 관계형 데이터베이스가 아닌, MongoDB 같은 NoSQL 기반의 Document 형식의 빠르고 간편한 데이터베이스이다.

그리고 실시간으로 데이터를 전송해주는 RTSP(Real Time Stream Protocol) 방식을 지원한다.

이 방식 덕분에 실시간 기능을 요하는 채팅이나 택시앱 같은 것을 쉽게 구현할 수 있다.


Firebase에서 프로젝트를 생성한 뒤, 웹으로 시작을 눌러준다. 그러면 웹 앱에 Firebase SDK 추가하는 코드가 나온다.

우리는 리액트를 사용하므로 명령어로 프로젝트에 firebase를 설치한 뒤, firebase에 대한 설정 코드를 붙여 넣어준다. 그리고 추가적으로 이용할 인증과 데이터베이스, 스토리지에 대한 코드도 import 해온다.

웹에서 사용 가능한 Firebase 서비스에 대한 문서는 링크에서 자세하게 확인할 수 있다.

Copy
yarn add firebase

firebaseConfig 안에 들어가는 값들은 보안을 위해서 .env 파일을 만들고 환경변수로 따로 저장해서 바꿔주었다.

Copy
// src/firebase.js

// Import the functions you need from the SDKs you need
import { initializeApp } from 'firebase/app';
import 'firebase/auth';
import 'firebase/database';
import 'firebase/storage';

// import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
  apiKey: process.env.REACT_APP_apiKey,
  authDomain: process.env.REACT_APP_authDomain,
  projectId: process.env.REACT_APP_projectId,
  storageBucket: process.env.REACT_APP_storageBucket,
  messagingSenderId: process.env.REACT_APP_messagingSenderId,
  appId: process.env.REACT_APP_appId,
  measurementId: process.env.REACT_APP_measurementId,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
// const analytics = getAnalytics(app);

회원가입 유효성 체크

React-hook-form을 이용해서 회원가입 시에 유효성 체크를 할 수 있다. 📄공식문서

Copy
yarn add react-hook-form
Copy
import {useForm} = 'react-hook-form';
const { register, watch, formState: {errors}, handleSubmit } = useForm();

console.log(watch('email')); // register에 등록된 "email" 이라는 컴포넌트를 관찰한다.

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    // 위에 처럼 관찰을 할 때  register로 등록해주어야 한다.
    // 해당 input에 대해 등록할 이름과, 유효성 체크 조건을 넣어줄 수 있다.
    <input type="email" {...register('email', { required: true, pattern: /^\S+@\S+$/i })} />
    <input type="id" {...register('id', { required: true, maxLength: 10 })} />

    // 유효성 체크에 걸렸을 때 에러문구 설정
    {errors.email && errors.email.type === 'required' <p>This field is required</p>}
  </form>
);

register()에 조건을 넣어줌으로써 조건과 맞지 않으면 에러 문구를 보여주도록 설정할 수 있다.

  • required: true - 반드시 입력되어있어야 하는 값
  • maxLength: 10 - 최대 10글자까지만 입력이 가능하다.
  • pattern: /^\S+@\S+$/i - 이메일 유효성 체크 정규식

비밀번호 확인 입력의 경우 입력한 비밀번호와 일치하는지 확인을 해주어야 한다.

비밀번호를 입력하는 input에 ref를 주어서 현재 비밀번호의 ref.current 값이 비밀번호 확인 input에 입력된 값과 일치하는지 비교한다.

Copy
const pwRef = useRef(null);
pwRef.current = watch("pw");

return (
  // 비밀번호 입력
  <input ref={pwRef} {...register("pw", {required: true, minLength: 6})}/>

  // 비밀번호 확인 입력
  <input {...register('pwConfirm', {required: true, validate: value => value === pwRef.current})}/>
)

유저 생성

1. 코드

회원가입 버튼을 눌렀을 때 onSubmit 이벤트에 따른 함수 처리를 해주어야 한다.

📄Firebase 인증 문서

Copy
// firebase.js
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  // ...
};

const app = initializeApp(firebaseConfig);

export const auth = getAuth(app);
Copy
// 회원가입 페이지
import { auth } from '../../firebase';
import { useForm } from 'react-hook-form';

const RegisterPage = () => {
  const {
    register,
    watch,
    formState: { errors },
    handleSubmit,
  } = useForm();

  const [errorFromSubmit, setErrorFromSubmit] = useState('');
  const passwordRef = useRef(null);
  passwordRef.current = watch('password');

  const onSubmit = async (data) => {
    try {
      let createdUser = await createUserWithEmailAndPassword(auth, data.email, data.password);
      console.log(createdUser);
    } catch (erorr) {
      setErrorFromSubmit(error.message);
      setTimeout(() => {
        setErrorFromSubmit('');
      }, 5000);
    }
  };

  return (
    <form>
      <input type="email" placeholder="이메일" {...register()}/>
      {errors.email && erros.email.type === "required" && (<span>이메일을 입력해주세요</span>)}

      <input type="password" placeholder="비밀번호" ref={passwordRef} {...register('password', { required: true, minLength: 6})}/>
      {errors.password && erros.password.type === "required" && (<span>비밀번호를 입력해주세요</span>)}

      <input type="pasword" placeholder="비밀번호 확인" {...register('passwordConfirm',{ required: true, validate: value => value === passwordRef.current})}>
      {errors.passwordConfirm && erros.passwordConfirm.type === "required" && (<span>비밀번호를 입력해주세요</span>)}
      {errors.passwordConfirm && erros.passwordConfirm.type === "validate" && (<span>비밀번호가 일치하지 않습니다</span>)}


      // 회원가입 시 에러가 발생할 경우 에러메세지 출력
      {errorFromSubmit && <span>{errorFromSubmit}</span>}
      <button onSubmit={handleSubmit(onSubmit)}>가입</button>
    </form>
  );
};

export default RegisterPage;

이메일, 비밀번호 입력값에 대한 유효성 체크를 한 뒤, 조건에 맞지 않으면 안내 문구를 보여주도록 설정했다.

🚨 여기까지 하고 회원가입 정보를 입력하고 가입을 누르면 아래와 같은 에러가 뜬다.

post-error

에러가 나는 이유는 아직 Firebase에서 인증할 방식에 대한 설정을 해주지 않았기 때문이다.

인증 방식 설정은 Firebase의 Authentication에서 설정해줄 수 있다.


2. 이메일/비밀번호 인증 설정

Firebase - Authentication - Sign-in method 에 로그인 인증할 방식을 선택하는 부분이 나온다.

sign-in-method

이메일/비밀번호 를 선택한 뒤, 다시 입력폼에서 정보를 입력하고 가입을 누르면 Authentication - Users 탭에서 유저 정보가 생성된 것을 확인할 수 있다.

userList


3. 추가 설정

Submit 프로세스 처리 중 버튼 막기

Submit 버튼을 눌렀을 때 Firebase에서 유저 생성 진행되는 동안 가입 버튼이 더 눌리지 않도록 처리를 해주어야 한다.

Copy
const [loading, setLoading] = useState(false);

로딩 상태값을 만든뒤, onSubmit 함수 내에서 요청을 시작할 때 로딩값을 true로, 작업을 끝낸 뒤에 로딩값을 false로 바꿔준다. 그리고 버튼에는 loading중일 때는 disabled 되도록 설정해주면 된다.

Copy
const onSubmit = async (data) => {
  try {
    setLoading(true);
    let createdUser = await createUserWithEmailAndPassword(auth, data.email, data.password);
    console.log(createdUser);
    setLoading(false);
  } catch (erorr) {
    setErrorFromSubmit(error.message);
    setLoading(false);
    setTimeout(() => {
      setErrorFromSubmit('');
    }, 5000);
  }
};

<button type="submit" disabled={loading}>
  가입
</button>;

유저 상세 정보 추가하기

유저를 생성할 때 이메일과 비밀번호 정보만 이용해서 생성되고 있다. 닉네임이나, 이름, 번호, 사진 등의 추가 정보들을 넣고 싶을 때 updateProfile() 메서드를 이용해서 유저 정보를 업데이트 해줄 수 있다.

Copy
let createdUser = await createUserWithEmailAndPassword(auth, data.email, data.password);

await updateProfile(createdUser.user, {
  displayName: data.name,
  photoURL: `이미지URL`,
});
console.log(createdUser);

업데이트 할 수 있는 항목은 콘솔창에서 createdUser에 user에 담긴 항목들을 확인하면 된다.

createdUser


유니크 값 제공 모듈

유저별 유니크한 값을 주기 위해 MD5 라는 모듈을 사용할 수 있다.

Copy
yarn add md5
Copy
import md5 from 'md5';

let md5 = requrie('md5');
console.log(md5('message'));
// 78e731027d8fd50ed642340b7c9a63b3

이를 활용해서 유저별 사진 URL을 만드는 경우 유저의 고유값인 이메일 값을 md5의 값으로 넣어서 고유한 프로필 사진을 만들어낼 수 있다.

Copy
photoURL: `http://gravatar.com/avatar/${md5(createdUser.user.email)}?d=identicon`;

gravatar 이외에도 랜덤으로 아바타 프로필을 만들어내는 API들을 이용해보면 좋을 것 같다!

DiceBear에 보면 여러가지 랜덤 프로필을 API로 제공한다. 나는 깃허브에서 사용하는 dicebear의 identicon을 사용해보았다.

Copy
photoURL: `https://api.dicebear.com/6.x/identicon/svg?seed=${사용자 고유값}`;

유저 DB 저장

DB별 저장 방법 비교

MYSQL

Copy
INSERT INTO users (email, displayName, photoURL)
VALUES (johnahn@naver.com, johnahn, gravatar...)

MongoDB

Copy
UserModel.create({ email: "johnahn@naver.com", displayName: "johnahn" photoURL: "gravatar" })

Firebase

Copy
const db = getDatabase();
// 유저 Firebase DB 저장
await set(ref(database, 'users/' + createdUser.user.uid), {
  name: createdUser.user.displayName,
  avatar: createdUser.user.photoURL,
});

Firebase에서 Realtime Database(실시간 데이터베이스)를 만들고, 생성된 유저 정보를 DB에 저장할 것이다.

DB의 users 라는 테이블에 접근해서 해당 테이블 아래에 유저 정보를 넣어준다.

이렇게 작성해준 후 유저를 생성하면 실시간 데이터베이스의 /users/유저고유uid 아래에 유저정보가 들어온 것을 확인할 수 있다.


유저 로그인

유저 생성 방식과 비슷한데, 로그인 하는 경우에는 signInWithEmailAndPassword 를 이용해서 작성해주면 된다. 📄 공식문서

로그인도 회원가입과 동일하게 error 상황별 안내문구들을 추가해줄 수 있다.

Copy
import { auth } from '../../firebase';
import { useForm } from "react-hook-form";
import { signInWithEmailAndPassword } from "firebase/auth";

const LoginPage = () => {
  const { handleSubmit } = useForm();

  const onSubmit = async (data) -> {
    await signInWithEmailAndPassword(auth, data.email, data.password);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register("email", {required: true})} />
      <input type="password" {...register("password", {required: true})} />
      <button type="submit">로그인</button>
    </form>
  )
}

로그인 인증

현재 페이지는 로그인, 회원가입, 채팅방 세가지의 페이지가 있다. 이 중 로그인과 회원가입은 로그인이 되지 않은 유저가 사용 가능하다.

로그인 성공 이후에는 바로 채팅방 페이지로 넘어갈 수 있도록 하고, 채팅방 페이지는 로그인 된 유저만 이용 가능하도록 만들 것이다.

Copy
useEffect(() => {
  onAuthStateChanged(auth, (user) => {
    // 로그인된 유저
    if (user) {
      navigate('/chat');
      dispatch(setUser(user));
    } else {
      navigate('/');
    }
  });
}, [navigate, dispatch]);

navigate 를 이용해서 유저별 라우터 처리를 해주었고, redux를 이용해서 유저 상태값을 관리해주었다.


Firebase Storage 이미지 업로드

Firebase의 Storage를 생성한다. 이 때 클라우드의 위치는 asia-northeast3(서울)로 하면 된다.

storage-region

Copy
/** 프로필 이미지를 변경할 함수 */
const handleUploadImg = async (event) => {
  // 파일 선택창에서 파일 선택 시 event.target.file 에 선택한 이미지 파일이 담긴다.
  const file = event.target.files[0];
  console.log('file', file);

  if (!file) return;
  const metadata = { contentType: mime.lookup(file.name) };

  try {
    // 1. 스토리지에 업로드한 이미지 파일 저장하기 + 저장된 파일의 URL 가져오기
    const storageRef = ref(storage, 'user_image/' + user.uid);
    let uploadTaskSnapshot = await uploadBytes(storageRef, file, metadata);
    const downloadURL = getDownloadURL(ref(storage, uploadTaskSnapshot.ref));
  } catch (error) {
    // 에러 처리
  }
};
  • 파일의 metadata 가져오기

사진 파일의 타입을 지정할 때 많이 사용되는 mime-types 모듈을 사용해서 mime.lookup() 이라는 메서드를 이용해서 파일명을 넣어주면 해당 파일의 콘텐츠 타입을 출력한다.

Copy
yarn add mime-types

프로필 업데이트

Storage에 변경할 프로필을 업로드 했으니, 이제 Firebase auth의 유저정보에 담긴 프로필 정보도 업데이트 해주어야 한다.

Copy
// 프로필 수정
updateProfile(currentUser, {
  photoURL: downloadURL,
});
Copy
// 리덕스에서 바뀐 이미지로 교체
dispatch(setPhotoURL(downloadURL));
Copy
// 데이터베이스에 이미지 URL 저장
// Web8.ver
firebase.database().ref('users').child(user.uid).update({ image: downloadURL });
// Web9.ver
await update(ref(database, 'users/' + user.uid), { image: downloadURL });

채팅방 생성

📄 공식문서

Copy
import { ref, push, child, update } from 'firebase/database';
const {
  register,
  formState: { errors },
  handleSubmit,
  reset,
} = useForm();

const onSubmit = (data) => {
  addChatRoom(data);
  reset((values) => ({ ...values, title: '', desc: '' }));
};

const addChatRoom = async (data) => {
  let key = push(child(ref(database), 'chatRooms')).key; // 키 생성

  // DB에 생성할 채팅방 데이터
  const newChatRoom = {
    id: key,
    title: data.title,
    description: data.desc,
    createdBy: {
      name: user.displayName,
      image: user.photoURL,
    },
  };

  try {
    await update(ref(database, 'chatRooms/' + key), newChatRoom);
    setShowModal(false);
  } catch (error) {
    alert(error);
  }
};

실시간 데이터 받기

채팅처럼 실시간으로 들어오는 데이터를 즉각적으로 보여주기 위해서는 데이터가 들어오면 event가 발생하는 것을 감지하는 event listener로 데이터를 받을 수 있다.

Copy
import { ref, onChildAdded, off } from 'firebase/database';

useEffect(() => {
  // 채팅방 추가 이벤트 리스너
  const chatRoomsRef = ref(database, 'chatRooms');
  let chatRoomsArray = [];
  onChildAdded(chatRoomsRef, (snapshot) => {
    chatRoomsArray = [...chatRoomsArray, snapshot.val()];
    setChatRooms(chatRoomsArray);
  });
  return () => off(chatRoomsRef, onChildAdded);
}, []);

메시지 생성/저장하기

이 때 set 과 혼동 주의! set 은 해당 위치의 정보를 바꿔주는 것이고, push 는 정보를 추가해준다.

Copy
import { serverTimestamp } from 'firebase/database';

const createMessage = (content, fileUrl = null) => {
  // 작성된 메시지 정보 - 작성시간, 작성한 유저정보
  const message = {
    timestamp: serverTimestamp(),
    user: {
      id: user.uid,
      name: user.displayName,
      image: user.photoURL,
    },
  };
  // 채팅창에 이미지를 첨부 했을 경우
  if (fileUrl != null) {
    message['image'] = fileUrl;
  } else {
    message['content'] = content;
  }
  return message;
};
Copy
const onSubmit = async (data) => {
  // Firebase에 메시지 저장
  if (!data.chatInput) {
    setErrors('빈칸');
    return;
  }
  try {
    await push(ref(database, 'messages/' + chatRoomInfo.id), createMessage(data));
    setErrors('');
    console.log('메시지 전송!');
  } catch (error) {
    setErrors(error.message);
    setTimeout(() => {
      setErrors('');
    }, 3000);
  }
  reset((values) => ({ ...values, chatInput: '' })); // 전송 후 Input 빈칸 만들기
};

메시지 추가 이벤트 리스너

이벤트 리스너의 경우 채팅방을 불러올 때와 방식은 동일하다!

Copy
useEffect(() => {
  // 메시지 추가 이벤트 리스너
  const addMessagesListener = (chatRoomId) => {
    const messagesRef = ref(database, 'messages/' + chatRoomId);
    let messagesArray = [];
    onChildAdded(messagesRef, (snapshot) => {
      messagesArray = [...messagesArray, snapshot.val()];
      setMessages({ messages: messagesArray, messageLoading: false });
    });
    return () => off(messagesRef, onChildAdded);
  };

  if (chatRoomInfo) {
    addMessagesListener(chatRoomInfo.id);
  }
}, [chatRoomInfo]);

n분전 메시지 표시하기

moment 라는 모듈을 이용해서 “30분전 메시지” 이런 식으로 표현할 수 있다.

Copy
yarn add moment
Copy
import moment from 'moment';

const timeFromNow = (timestamp) => moment(timestamp).fromNow();

const sendedTime = timeFromNow(message.timestamp); // 메시지 전송 시 메시지에 담겨있는 timestamp 값

여기서 timestamp는 메시지 정보(작성시간, 작성한 유저 정보)에 담긴 메시지가 작성된 시간을 의미한다.


채팅방 이미지 업로드하기

이미지를 업로드하는 방식은 위에 ‘Firebase 이미지 업로드’와 ‘프로필 업데이트’ 부분과 동일하다.

이 때 만약 이미지 파일이 전송되는 중일 때 진행률을 보여주고 싶다면 uploadBytes 대신 uploadBytesResumable을 사용해서 진행 상황에 따른 코드를 작성해줄 수 있다. 📄 공식문서

Copy
/** 선택한 이미지 스토리지에 업로드 */
const handleUploadChatImage = (event) => {
  const file = event.target.files[0];
  if (!file) return;

  const filePath = `message/public/${file.name}`;
  const metadata = { contentType: mime.lookup(file.name) };

  try {
    // 1. 파일을 먼저 스토리지에 저장하기
    const storageRef = strRef(storage, filePath);
    const uploadTask = uploadBytesResumable(storageRef, file, metadata);
    // 2. 진행률 확인
    uploadTask.on(
      'state_changed',
      (snapshot) => {
        const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
        setPercentage(progress);
      },
      (error) => {
        // 에러 핸들러
      },
      () => {
        // 작업 완료 후
        // 3. 메시지 저장된 이후에 파일 메시지 전송
        // 저장된 파일을 다운로드 받을 수 있는 URL 가져오기
        getDownloadURL(ref(storage, uploadTask.ref)).then((downloadURL) => {
          push(ref(database, 'messages/' + chatRoomInfo.id), createMessage(downloadURL));
          setLoading(false);
        });
      },
    );
  } catch (error) {
    alert('이미지 파일을 전송하는데 실패했습니다. 다시 시도해주세요.');
    console.error(error);
  }
};

// Progress bar 보여주기
{
  !(percentage === 0 || percentage === 100) && <ProgressBar />;
}

이 때 주의할 점은 uploadBytesResumable 에서 state_changed observer는 업로드하면서 진행상황을 지켜봐야하기 때문에 async-await을 빼주어야 작동한다!

Copy