Language/React.js

[React] 감정일기장 프로젝트 : firebase 연결 (추가 업그레이드)

JJcoding 2024. 11. 4. 10:59

firebase 연결

최종 완성된 감정일기장은 웹 스토리지 DB를 사용하다보니 다른 사람과 일기 데이터를 공유할 수 없어 아쉬웠다.

그래서 firebase 를 사용하여 원격 DB를 연결해보았다. Chatgpt의 엄청난 도움을 받았다..^-^

처음에는 몽고DB에 연결해보려 하였으나, 기존코드를 많이 수정해야할 것 같아서 패스.. 하고 새로운 것을 찾아보았다. 이 때 firebase 라는게 있는 것도 처음 알았고, 사용법도 당연히 몰랐기 때문에 공식문서, 지피티, 블로그에 의존하며 하나씩 해보았다.

  1. 먼저 파이어베이스 가입하고 프로젝트를 생성한 뒤, SDK라는 것을 복사한다. SDK란 내 프로젝트의 config 정보를 말하는 것인데 밑에 나온다.
  2. 터미널에 npm install firebase 명령어를 통해 firebase를 설치하고, 내 프로젝트 src 폴더 아래에 firebase.js라는 초기화 모듈을 만든다.
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: process.env.REACT_APP_APIKEY,
  authDomain: process.env.REACT_APP_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_PROJECT_ID,
  storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_MESSAGIN_ID,
  appId: process.env.REACT_APP_APP_ID,
  measurementId: process.env.REACT_APP_MEASUREMENT_ID,
};

// Initialize Firebase
const firebase = initializeApp(firebaseConfig);
const firestore = getFirestore(firebase);

export { firestore };
  • initializeApp(firebaseConfig); 이 부분은 config 값을 넣어서 firebase를 초기화 해주는 것이고, getFirestore(firebase); 이 부분은 firebase firestore 데이터베이스에 연결하고, 해당 인스턴스를 생성하는 역할을 한다. 이를 통해 firestore의 데이터를 읽거나 쓰는 등의 작업을 수행할 수 있는 firestore 데이터베이스 객체를 반환한다.
  • firestore 객체를 통해 데이터베이스 CRUD를 진행할 수 있으므로 다른 컴포넌트에서 사용하기 위해 export 문으로 내보내준다.
  • firebaseConfig 부분에 나의 SDK를 넣으면 된다. 키값을 그대로 넣어도 되지만, 나만의 프로젝트의 민감한 정보를 그대로 github등의 장소에 올렸다가는 위험한 상황이 될 수도 있다. 따라서 일반적으로는 .env라는 환경변수 파일을 따로 만들어서 config를 관리한다.
  • 이때 .env 환경변수 파일은 root 경로에 넣어주어야 인식한다. 따라서 내 프로젝트의 루트는 index.html 이므로 이와 같은 장소에 .env 파일을 만든다.
# firebase config - SDK
REACT_APP_APIKEY=AIzaSyDVW~~~~ 내꺼
REACT_APP_AUTH_DOMAIN=emotion~~~ 내꺼
REACT_APP_PROJECT_ID=emotion~~~ 내꺼
REACT_APP_STORAGE_BUCKET=emotion~~~ 내꺼
REACT_APP_MESSAGIN_ID=9971~~~ 내꺼
REACT_APP_APP_ID=1:997128~~~ 내꺼
REACT_APP_MEASUREMENT_ID=G-LT~~~ 내꺼
  • 이렇게 하면 다 되었겠지? 하고 생각했는데, 아무리해도 firebase.js에서 내 환경변수 파일을 인식하지 못했다. 'process' is not defined 거리며 process가 뭔지 모르겠다고 계속 에러를 뱉어냄.. 여기서 좀 헤맸는데 어떤 용자분이 해결책을 블로그에 적어놓으셨다.
  • 다른 사람들처럼 내 앱도 React 기반 프로젝트니 당연히 react_app이라고 생각했는데, 사실 내 프로젝트는 vite 번들러를 사용한 것이다..!! 따라서 환경변수도 vite를 접두사로 써주어야 한다.

변경 후

firebase.js

import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: import.meta.env.VITE_APIKEY,
  authDomain: import.meta.env.VITE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_MESSAGIN_ID,
  appId: import.meta.env.VITE_APP_ID,
  measurementId: import.meta.env.VITE_MEASUREMENT_ID,
};

// Initialize Firebase
const firebase = initializeApp(firebaseConfig);
const firestore = getFirestore(firebase);

export { firestore };

.env

# firebase config - SDK
VITE_APIKEY=AIzaSyDVW~~~~ 내꺼
VITE_AUTH_DOMAIN=emotion~~~ 내꺼
VITE_PROJECT_ID=emotion~~~ 내꺼
VITE_STORAGE_BUCKET=emotion~~~ 내꺼
VITE_MESSAGIN_ID=9971~~~ 내꺼
VITE_APP_ID=1:997128~~~ 내꺼
VITE_MEASUREMENT_ID=G-LT~~~ 내꺼
  • 이렇게 변경하였더니 인식이 잘 되었다. next.js를 사용한 프로젝트는 접두사를 next에 맞게 바꿔야 한다고 한다. 아직 next.js는 공부해본적이 없어서 일단 알아만 두기!

여기까지 했을 때, 이제 연결을 했으니 데이터베이스를 생성해야겠지? 하고 firebase에 들어가서 빌드 탭 > Firestore Database 에서 데이터 베이스 생성을 눌렀다. 그런데 컬렉션..? 문서..? 생전 처음 보는 단어들이 나왔다. RDBMS만 써봐서 그런것인가.. 이게 무엇인가..? 그래서 gpt 한테 물어봤다.

컬렉션 (Collection)

  • 컬렉션은 일종의 문서의 그룹이다.
  • 데이터베이스에서 테이블과 유사하지만, 엄밀히는 완전히 다르다고 볼 수 있다.
    • 컬렉션에는 같은 형식의 문서들이 모여 있는 것이 아니라, 단지 여러 문서가 모여있다는 점만 같을 뿐이다.
    • 각 문서는 서로 다른 구조와 필드를 가질 수 있어, 강제적인 스키마(schema)가 없다는 특징이 있다.

문서 (Document)

  • 문서는 Firestore의 데이터 저장 단위이며, JSON 객체와 비슷한 구조를 가진다.
  • 문서는 여러 필드를 가지고 있으며, 각 필드는 다양한 데이터 타입을 가질 수 있다. (예: 문자열, 숫자, 배열, 객체 등).
  • 문서는 고유한 ID로 구분된다.
  • 문서 내부에 또 다른 하위 컬렉션을 가질 수 있다. 이로 인해 중첩 구조가 가능하다.

Firestore의 특징

  • Firestore에서는 스키마리스(schema-less) 방식으로 데이터가 저장된다. 즉, 각 문서가 같은 필드를 가질 필요가 없고, 같은 컬렉션에 속한 문서들이 서로 다른 구조를 가질 수 있다.
  • 데이터는 필요한 만큼 중첩시킬 수 있다. 예를 들어, 한 문서 안에 또 다른 컬렉션을 포함할 수 있고, 그 컬렉션 안에도 또 문서를 넣을 수 있다. 이 중첩된 구조는 Firestore의 강력한 유연성의 원천이다.

정확히는 이해가 되지 않지만 쉽게 말해, 데이터를 저장하는 단위가 ‘문서’ 이고, 그 문서를 묶은 그룹을 ‘컬렉션’ 이라고 하는구나. → 단순히 이렇게까지만 이해했다. 아직 낯서니 천천히 알아가보자.. rdbms와 다른 점도 정리해두었다.

Firestore(NoSQL)와 RDBMS의 주요 차이점

특징 Firestore RDBMS

데이터 구조 스키마리스 (Schema-less), 유연한 문서 기반 구조 스키마 기반 (Schema-based), 고정된 테이블 구조
단위 컬렉션(문서들의 그룹), 문서(필드와 데이터) 테이블(행/레코드와 열), 행(레코드)
스키마 문서마다 다른 구조 가질 수 있음 모든 행이 고정된 스키마를 따름
데이터 중첩 문서 내에 컬렉션을 중첩해서 넣을 수 있음 테이블 내에서 데이터를 중첩하기 위해서는 외래키 등을 사용해야 함
확장성 분산형 NoSQL, 수평 확장에 적합 수직 확장이 주로 이루어짐
쿼리 방식 복잡한 쿼리는 제한적, 인덱싱된 필드를 기반으로 쿼리 복잡한 SQL 쿼리 및 JOIN 지원
관계 표현 명시적 관계 없음, 데이터를 중첩해 저장하거나 문서 ID로 참조 테이블 간의 명확한 관계를 외래키로 정의
유연성 매우 유연, 데이터 구조 변경 용이 구조 변경이 어렵고, 유연성이 상대적으로 떨어짐
데이터 일관성 기본적으로 eventual consistency (최종적 일관성) 강한 일관성 (strong consistency)

3. 그리고 일단 컬렉션은 반드시 필요한 것 같아 ‘diary’ 라는 이름으로 컬렉션을 생성한다. 안에 문서와 문서의 필드 값은 만들어놓지 않아도 된다. 그리고 내 프로젝트로 들어와서 코드를 수정한다.

수정 전

App.jsx

import "./App.css";
import { useReducer, useRef, createContext, useEffect, useState } from "react";
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Diary from "./pages/Diary";
import New from "./pages/New";
import Edit from "./pages/Edit";
import Notfound from "./pages/Notfound";

function reducer(state, action) {
  let nextState;
  switch (action.type) {
    case "INIT":
      return action.data;
    case "CREATE": {
      nextState = [action.data, ...state];
      break;
    }
    case "UPDATE": {
      nextState = state.map((item) =>
        String(item.id) === String(action.data.id) ? action.data : item
      );
      break;
    }
    case "DELETE": {
      nextState = state.filter((item) => String(item.id) !== String(action.id));
      break;
    }
    default:
      return state;
  }

  localStorage.setItem("diary", JSON.stringify(nextState));
  return nextState;
}

export const DiaryStateContext = createContext();
export const DiaryDispatchContext = createContext();

function App() {
  const [isLoding, setIsLoding] = useState(true);
  const [data, dispatch] = useReducer(reducer, []);
  const idRef = useRef(0);

  useEffect(() => {
    const storedData = localStorage.getItem("diary");
    if (!storedData) {
      setIsLoding(false);
      return;
    }
    const parsedData = JSON.parse(storedData);
    if (!Array.isArray(parsedData)) {
      setIsLoding(false);
      return;
    }

    let maxId = 0;
    parsedData.forEach((item) => {
      if (Number(item.id) > maxId) {
        maxId = Number(item.id);
      }
    });

    idRef.current = maxId + 1;

    dispatch({
      type: "INIT",
      data: parsedData,
    });
    setIsLoding(false);
  }, []);

  // 새로운 일기 추가
  const onCreate = (createdDate, emotionId, content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        createdDate,
        emotionId,
        content,
      },
    });
  };

  // 기존 일기 수정
  const onUpdate = (id, createdDate, emotionId, content) => {
    dispatch({
      type: "UPDATE",
      data: {
        id,
        createdDate,
        emotionId,
        content,
      },
    });
  };

  // 기존 일기 삭제
  const onDelete = (id) => {
    dispatch({
      type: "DELETE",
      id,
    });
  };

  if (isLoding) {
    return <div>데이터 로딩중입니다람쥐...</div>;
  }

  return (
    <>
      <DiaryStateContext.Provider value={data}>
        <DiaryDispatchContext.Provider value={{ onCreate, onUpdate, onDelete }}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/new" element={<New />} />
            <Route path="/diary/:id" element={<Diary />} />
            <Route path="edit/:id" element={<Edit />} />
            <Route path="*" element={<Notfound />} />
          </Routes>
        </DiaryDispatchContext.Provider>
      </DiaryStateContext.Provider>
    </>
  );
}

export default App;
  • localStorage.setItem, localStorage.getItem을 사용하여 데이터를 저장하고 불러온다.

수정 후

App.jsx

import "./App.css";
import { useReducer, useRef, createContext, useEffect, useState } from "react";
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Diary from "./pages/Diary";
import New from "./pages/New";
import Edit from "./pages/Edit";
import Notfound from "./pages/Notfound";
import {
  doc,
  addDoc,
  collection,
  getDocs,
  updateDoc,
  deleteDoc,
} from "firebase/firestore";
import { firestore } from "./firebase";

function reducer(state, action) {
  let nextState;
  switch (action.type) {
    case "INIT":
      return action.data;
    case "CREATE": {
      nextState = [action.data, ...state];
      break;
    }
    case "UPDATE": {
      nextState = state.map((item) =>
        String(item.id) === String(action.data.id) ? action.data : item
      );
      break;
    }
    case "DELETE": {
      nextState = state.filter((item) => String(item.id) !== String(action.id));
      break;
    }
    default:
      return state;
  }
  return nextState;
}

export const DiaryStateContext = createContext();
export const DiaryDispatchContext = createContext();

function App() {
  const [isLoding, setIsLoding] = useState(true);
  const [data, dispatch] = useReducer(reducer, []);
  //const idRef = useRef(0);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const querySnapshot = await getDocs(collection(firestore, "diary"));
        const diaryData = querySnapshot.docs.map((doc) => ({
          id: doc.id,
          ...doc.data(),
        }));

        dispatch({
          type: "INIT",
          data: diaryData,
        });
      } catch (e) {
        console.error("Error fetching data: ", e);
      } finally {
        setIsLoding(false);
      }
    };
    fetchData();
  }, []);

  // 새로운 일기 추가
  const onCreate = async (createdDate, emotionId, content) => {
    const newData = {
      createdDate,
      emotionId,
      content,
    };

    try {
      const docRef = await addDoc(collection(firestore, "diary"), newData);
      dispatch({
        type: "CREATE",
        data: {
          id: docRef.id,
          ...newData,
        },
      });
    } catch (e) {
      console.error("Error adding document: ", e);
    }
  };

  // 기존 일기 수정
  const onUpdate = async (id, createdDate, emotionId, content) => {
    const updatedData = {
      createdDate,
      emotionId,
      content,
    };
    
    try {
      const docRef = doc(firestore, "diary", id);
      await updateDoc(docRef, updatedData);

      dispatch({
        type: "UPDATE",
        data: {
          id,
          ...updatedData,
        },
      });
    } catch (e) {
      console.error("Error updating document: ", e);
    }
  };

  // 기존 일기 삭제
  const onDelete = async (id) => {
    try {
      const docRef = doc(firestore, "diary", id);
      await deleteDoc(docRef);
      dispatch({
        type: "DELETE",
        id,
      });
    } catch (e) {
      console.error("Error deleting document: ", e);
    }
  };

  if (isLoding) {
    return <div>데이터 로딩중입니다람쥐...</div>;
  }

  return (
    <>
      <DiaryStateContext.Provider value={data}>
        <DiaryDispatchContext.Provider value={{ onCreate, onUpdate, onDelete }}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/new" element={<New />} />
            <Route path="/diary/:id" element={<Diary />} />
            <Route path="edit/:id" element={<Edit />} />
            <Route path="*" element={<Notfound />} />
          </Routes>
        </DiaryDispatchContext.Provider>
      </DiaryStateContext.Provider>
    </>
  );
}

export default App;
  • firestore를 통한 데이터 작업은 다음과 같다.
    • 데이터 읽기: getDocs(collection(firestore, "컬렉션이름"))
    • 데이터 추가: addDoc(collection(firestore, "컬렉션이름"), 데이터)
    • 데이터 수정: updateDoc(doc(firestore, "컬렉션이름", "문서ID"), 업데이트할 데이터)
    • 데이터 삭제: deleteDoc(doc(firestore, "컬렉션이름", "문서ID"))
  • 기존 useRef로 id를 넣어주었던 반면에, firebase로 옮기면서 DB에서 자체 생성되는 doc.id를 사용했다.
    • 이 doc.id는 문서 고유의 id로 내가 명시적으로 지정하지 않으면 임의의 값으로 자동 지정된다.
  • async, await 함수를 사용하여 비동기로 DB 관련 처리를 해주었다. 그 이유는?
    • 비동기 방식은 UI의 반응성을 유지한다.
      • Firestore 같은 원격 데이터베이스와 통신할 때, 네트워크 지연이나 서버 응답 속도에 따라 작업이 완료되기까지 수십초 이상 걸릴 수도 있다. 비동기 방식을 사용하면 이러한 지연이 발생해도 UI는 블로킹되지 않고 다른 작업을 계속 처리할 수 있다. (동기 방식으로 처리한다면 네트워크 요청이 끝날 때까지 UI가 멈춰서 사용자의 클릭, 스크롤 반응이 느려지거나 중단될 수 있다.)
    • 여러 작업을 동시에 처리할 수 있다.
      • DB 데이터를 읽어오는 동안 UI는 사용자 입력을 받거나 애니메이션을 실행하는 등 다른 작업을 할 수 있다. 멀티태스킹 지원이 비동기 프로그래밍의 핵심이다.
    • DB 작업이 실패했을 때 그에 맞는 에러처리를 할 수 있다.
      • 비동기 호출에서 try-catch나 then-catch를 통해 오류를 잡고, 사용자에게 오류 메세지를 보여주거나 재시도 로직을 구현할 수 있다. 동기 방식에서는 이러한 유연한 에러 처리와 사용자 대응이 더 어렵다.
    • 결론 : 원격 서버나 데이터베이스와 상호작용하는 경우는 비동기 처리가 기본이다.

이제 다 됐다고 생각하고 테스트를 해보았는데, 한번에 될리가 없지. 에러가 발생했다.

Error fetching data:  FirebaseError: Missing or insufficient permissions.

구글에 검색해보니 Firebase Firestore에서 데이터에 접근할 권한이 없거나, 읽기/쓰기 권한이 충분하지 않아서 발생하는 에러라고 한다. 이는 보통 Firestore 보안 규칙(Firestore Security Rules)과 관련이 있다고. 그래서 해결방법은?

  • Firebase → 빌드(Build) → Firestore Database → 규칙(Rules) 탭에 들어간다.
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false; // -> true로 변경
    }
  }
}
  • 그럼 위 코드를 볼 수 있는데, 마지막 줄 false를 true로 바꾸어준다. 그러면 빨간 배경으로 ‘보안 규칙이 공개로 정의되어 있어 누구나 데이터베이스의 데이터를 도용, 수정, 삭제할 수 있습니다.’ 라고 뜬다. 누구나 쓰도록 하는게 나의 목표다!

이렇게 하면 에러없이 앱의 모든 동작(CRUD)이 잘 작동하는 것을 확인할 수 있다.

코드를 완성하고 난 뒤, 이벤트 핸들러에서 너무 많은 작업을 하는 느낌이 들어 reducer 함수에서 then과 catch를 이용해 DB 처리를 하면 어떨까 생각해보고 시도하였는데, 결론적으로는 지금 이 코드가 가장 좋은 코드였다. 그 이유는?

  • 일단 reducer 함수는 일반적으로 순수 함수(pure function)이어야 한다. 순수 함수란 같은 입력이 주어 졌을 때 항상 같은 출력을 반환하고, 부수 효과(side effect)가 없는 함수를 의미한다. 예를 들어 외부 데이터베이스와 상호작용하지 않고, 비동기 작업도 하지 않아야 한다. reducer 는 순수 함수로 남아있고, Firestore와의 상호작용은 이벤트 핸들러에서 처리되므로 위 코드가 React의 상태 관리 패턴에 더 맞는다.
  • 가독성의 측면에서도 위 코드가 더 좋다. reducer 함수가 복잡하지 않고, Firestore와의 상호작용은 별도로 관리되므로 코드를 읽기가 쉽다.
  • 관리가 용이하다. 비동기 작업이 성공적으로 완료된 후에만 상태가 업데이트되므로, 데이터의 일관성이 보장된다.
  • 확장성이 좋다. 비동기 작업을 핸들러에서 처리하므로, 상태 관리와 비동기 작업을 유연하게 확장할 수 있다. 필요하면 redux-thunk나 redux-saga 같은 비동기 작업 관리 미들웨어로 확장하기 쉽다.
  • 결론 : 리액트 초보여서 할 수 있는 생각이었다. 이렇게 배워가는 거지!

완성 : https://emotion-diary-1myahayyv-leejinjus-projects.vercel.app/