[글마디 프로젝트] Realtime Database를 활용한 글마디 CRUD 및 좋아요 기능 구현 회고

Minsu Han·2023년 4월 25일
0

Side Projects

목록 보기
3/10
post-thumbnail

시작하며

글마디 프로젝트의 주 기능 중 하나인 '글마디 추가(C)/로드(R)/수정(U)/삭제(D)' 및 좋아요 기능을 Firebase Realtime Database를 활용하여 구현한 회고입니다.

Firebase Realtime Database를 사용한 이유

  1. 지난번 Firebase Auth를 사용한 이유와 비슷합니다. Firebase에서 DB 서비스를 제공하기 때문에 별도의 백엔드 구축 과정을 생략할 수 있고, Firebase가 제공하는 API를 통해 클라이언트에서 CRUD 작업을 요청하면 알아서 DB에서 처리해줍니다. 따라서 빠르게 프로토타입을 제작하여 서비스를 시험해보고자 하는 경우 시간을 절약할 수 있어서 좋습니다.

  2. 글마디 프로젝트의 DB 구조는 그리 복잡하지 않습니다. 만약 데이터 간 관계성 유지가 중요하고 SQL을 사용한 복잡한 쿼리가 요구된다면 행과 열로 구성된 테이블 형태의 관계형 데이터베이스(RDB) 사용이 적합합니다. 하지만 기본적인 쿼리들만 사용하고 데이터 간 관계도 복잡하지 않다면, 간단한 JSON 트리 형태로 관리하는 것을 고려해볼 수 있는데, Firebase는 NoSQL 기반 DB를 제공하기 때문에 이와 같은 환경에 적합합니다. Firebase는 Realtime DB, Firestore의 두 가지 NoSQL 기반 DB를 제공합니다.

    데이터베이스 선택: Cloud Firestore 또는 실시간 데이터베이스
    https://firebase.google.com/docs/firestore/rtdb-vs-firestore?hl=ko

    저는 하나의 JSON 트리 형태로 데이터를 관리하면서 클라이언트에서는 REST API로 CRUD 작업을 요청하도록 하고 싶어서 Realtime DB를 선택했습니다. 사실 Cloud Firestore가 신버전이고 Realtime DB는 구버전이라고 할 수 있기 때문에 만약 Firebase에서 제공하는 API를 사용하고자 한다면 Firestore를 사용하는 것도 좋아보입니다.

  3. Realtime DB에서 제공하는 콘솔 기능을 사용하면 간단하게 보안 관련 작업을 수행할 수 있습니다. 예컨대 콘솔에서 간단한 rule 작성만 하면 로그인하여 인증 토큰을 가진 유저만 데이터 쓰기 작업을 수행하게끔 할 수 있습니다.


Realtime DB 생성 및 보안 설정

데이터베이스를 생성하는 방법은 아래 공식 문서에 설명되어 있습니다.

데이터베이스 만들기
https://firebase.google.com/docs/database/web/start?hl=ko#create_a_database

글마디 하나하나를 posts 디렉터리에 저장하고, 유저별로 좋아한 글마디의 id들을 저장한 디렉터리를 likes라고 하였습니다.

구체적인 데이터 구조는 아래와 같습니다.

posts: {
	id: {		// 글마디 id (POST 요청으로 글마디 추가 시 자동으로 생성됩니다)
		type,	// 종류 (책 구절 또는 노래 가사)
		author,	// 작가 이름
		body,	// 내용
		reference,	// 책 또는 노래 제목
		timestamp,	// 글마디 생성시각
		uid,		// 유저 id
		likesNum,	// 좋아요 수
		tags, 	// 태그 목록
	}
},

likes: {
	uid: {
		favoritePosts,	// 유저가 좋아요 표시한 글마디 id 목록
	}
}

로그인하지 않은 유저는 글마디를 읽을 수만 있고 추가하거나 수정/삭제하는 등의 쓰기 작업과 좋아요 기능을 사용하지 못하게 하기 위해, 콘솔에서 다음과 같이 전체 DB에 대해 auth 토큰을 가진 유저에게만 쓰기 작업을 허용하도록 규칙을 작성했습니다.

REST 요청을 위한 helper 메서드 구현

실시간 데이터베이스 REST 데이터 저장
https://firebase.google.com/docs/database/rest/save-data?hl=ko

생성한 Realtime DB의 url에 .json을 추가한 url을 REST 엔드포인트로 사용하여, HTTPS 클라이언트에서 POST/GET/PUT/PATCH/DELETE 요청을 할 수 있습니다. 따라서 엔드포인트 url과 요청 type이 주어지면 해당 엔드포인트에 REST 요청을 전달하는 메서드를 작성했습니다. 만약 POST/PUT/PATCH 작업을 요청할 경우 uploadData도 함께 전달합니다.

/* helpers.js */

/**
 * @description 주어진 url에 주어진 type의 REST 요청을 전달
 * @param { String } url
 * @param { String } type : AJAX 요청 타입 (GET | POST | PUT | PATCH | DELETE)
 * @param { String | Object} uploadData
 * @returns response data
 */
export const AJAX = async function (url, type, uploadData = undefined) {
  try {
    // uploadData가 json raw string이면 그대로 전달
    // 객체이면 json string으로 변환 후 전달
    const fetchPro = fetch(url, {
      method: type,
      headers: { "Content-Type": "application/json" },
      body: uploadData
        ? typeof uploadData === "string"
          ? uploadData
          : JSON.stringify(uploadData)
        : null,
    });

    const res = await Promise.race([timeout(TIMEOUT_SEC), fetchPro]);

    if (!res.ok) {
      throw new Error(`AJAX 오류 발생 (${res.status})`);
    }

    const data = await res.json();
    return data;
  } catch (err) {
    throw err;
  }
};

그리고 config 파일에 posts, likes 디렉토리에 해당하는 엔드포인트를 정의해 놓았습니다.

/* config.js */
export const API_URL_POSTS =
  "https://geulmadi-default-rtdb.firebaseio.com/posts";
export const API_URL_LIKES =
  "https://geulmadi-default-rtdb.firebaseio.com/likes";

글마디 추가(Create) 구현


글마디 추가는 아래 과정으로 진행됩니다.

  1. 유저가 글마디 추가 창에서 form을 제출하면 uploadView.js에서 form data를 수집하여 controller.js에 전달
  2. controller.js는 전달받은 form data와 auth 토큰(Firebase Auth에서 제공하는 user idToken)을 model.js에게 전달하여 글마디 추가 작업을 요청
  3. model.js는 form data를 POST 요청에 전달할 json 객체로 변환한 다음, helpers.js의 AJAX 함수에 auth 토큰을 매개변수로 추가한 엔드포인트와 json 객체를 전달하여 POST 요청을 함.
/* uploadView.js */
  /**
   * @param { function } handler Controller의 글마디 등록 진행 함수
   * @description 제출 이벤트 발생 시 Controller가 실행하는 handler 등록
   */
  addHandlerUpload(handler) {
    this.#form.addEventListener('submit', function (e) {
      e.preventDefault();
      // 폼 데이터 수집 객체
      const formData = [...new FormData(this)].map(data => data[1]);

      handler(formData);
    });
  }
import { AJAX } from "./helpers.js";

/**
 * @description 글마디 업로드
 * @param { Object } formData
 * @param { string } uid
 * @param { string } token
 * @returns { Object } response data
 */
export const uploadPost = async function (formData, uid, token) {
  try {
    // 업로드할 data
    const uploadData = {
      type: formData[0],
      reference: formData[1],
      author: formData[2],
      body: formData[3].replaceAll("\n", "<br>"),
      tags: formData[4].split(","),
      timestamp: new Date().getTime(),
      likesNum: 0,
      uid: uid,
    };

    // 추가(POST) 요청
    const ret = await AJAX(
      `${API_URL_POSTS}.json?auth=${token}`,
      "POST",
      uploadData
    );
    return ret;
  } catch (err) {
    throw err;
  }
};
/* controller.js */

/**
 * @param { Object } uploadView로부터 받은 formData
 * @description 업로드 폼 제출버튼 클릭 감지 시 업로드 작업을 실행할 핸들러
 */
const controlUpload = async function (formData) {
  try {
    // 글마디 추가 요청
    await model.uploadPost(formData, uid, token);
    uploadView.renderSuccessMessage("글마디가 등록되었어요!");
  } catch (err) {
    uploadView.renderError("등록 중 오류가 발생했습니다.");
  }
};

uploadView.addHandlerUpload(controlUpload);

글마디 로드(Read) 구현


글마디 로드는 아래 과정으로 진행됩니다.

  1. controller.js가 model.js에게 글마디 리스트와 현재 유저가 좋아요 표시한 글마디 리스트 로드를 요청
  2. model.js는 helpers.js의 AJAX 함수에 posts 엔드포인트와 likes/(현재 유저id) 디렉토리 엔드포인트를 전달하여 GET 요청을 하고 결과를 controller.js에 전달
  3. controller.js는 recentPostsView.js에게 글마디 리스트와 현재 유저가 좋아요 표시한 글마디 리스트를 전달하여 화면에 글마디 렌더링을 요청
  4. recentPostView.js는 각 글마디 컴포넌트를 렌더링. 이 과정에서 현재 유저 자신이 등록했던 글마디인 경우 삭제/수정 버튼을 렌더링하고 좋아요 표시했던 글마디인 경우 좋아요 아이콘도 빨간색으로 채움
/* model.js */

/**
 * @description 글마디 리스트 불러오기
 * @returns response data
 */
export const loadRecentPost = async function () {
  try {
    const ret = await AJAX(`${API_URL_POSTS}.json`, "GET");
    return ret;
  } catch (err) {
    throw err;
  }
};


/**
 * @description 유저가 좋아요한 글마디 id 불러오기
 * @param { String }  uid : 유저 id
 * @returns 글마디 id 배열
 */
export const loadUserFavorites = async function (uid) {
  try {
    const ret = await AJAX(`${API_URL_LIKES}/${uid}/favorites.json`, "GET");
    return ret ? [...Object.keys(ret)] : null;
  } catch (err) {
    throw err;
  }
};
/* recentPostsView.js */

  /**
   * @description 주어진 글마디 데이터를 리스트 컨테이너에 렌더링
   * @param { Object[] } data : 렌더링한 글마디 데이터 배열
   * @param { String[] } userFavorites : 유저가 좋아요한 글마디 id 배열
   * @param { String } uid : 유저 id
   */
  render(data, userFavorites, uid) {
    let markup = '';

    // ID of posts
    const ids = Object.keys(data);
    ids.reverse(); // 최신순 정렬

    ids.forEach(id => {
      const post = data[id];

      // 좋아요 표시한 글마디인지 여부
      const liked = userFavorites
        ? userFavorites.includes(id)
          ? true
          : false
        : false;

      // 자신이 업로드한 글마디인지 여부
      const mine = post.uid === uid;

      // 글마디 컴포넌트 렌더링
      markup += `
        <div class="blockquote__list__child" data-id="${id}">
          <blockquote class="blockquote__${post.type}">
            ${post.body}
            <span>
              <div class="reference">
                &ndash; ${post.author}, 《${post.reference}》
              </div>
              ${
                mine
                  ? `<div class="icons">
              <span class="material-icons edit" title="수정">
                edit
              </span>
              <span class="material-icons delete" title="삭제">
                delete
              </span>
            </div>`
                  : ''
              }              
            </span>
          </blockquote>
          <div class="blockquote__list__child__like__area">
            <span class="material-icons icon__favorite__${
              liked ? 'on' : 'off'
            }">
              favorite${liked ? '' : '_border'}
            </span>
            <div class="blockquote__like__num ${liked ? 'favorite' : ''}">
              ${post.likesNum}
            </div>
          </div>
        </div>
      `;
    });

    // 글마디 리스트 컴포넌트에 추가
    this.#container.insertAdjacentHTML('afterbegin', markup);
  }
/* controller.js */

/**
 * @description 글마디 데이터를 불러와서 렌더링
 */
const controlLoadRecentPosts = async function () {
  try {
    // 현재 글마디 리스트 컨테이너 초기화
    recentPostsView.clearList();
    // 글마디 데이터 불러오기 (MODEL)
    recentPostsView.toggleSpinner();
    const data = await model.loadRecentPost();

    // 글마디 렌더링 (VIEW)
    const userFavorites = await model.loadUserFavorites(uid);
    if (data) {
      recentPostsView.render(data, userFavorites, uid);
    }
    recentPostsView.toggleSpinner();
  } catch (err) {
    recentPostsView.toggleSpinner();
    recentPostsView.renderError(err);
  }
};

글마디 수정(Update) 구현


글마디 수정은 글마디 추가와 비슷한 로직으로 동작합니다.

수정 버튼을 누르면 동일한 업로드 폼 화면에 수정할 글마디의 원래 내용을 미리 표시해놓고, 유저가 폼을 제출하면 controller.js가 업로드 메서드에 수정할 글마디 id를 추가적으로 전달하여 POST 요청 대신 PATCH 요청을 수행하도록 기존의 업로드 메서드를 확장하였습니다.

PATCH 요청을 사용하면 기존 데이터를 덮어쓰지 않고 지정한 특정 하위 항목들만 업데이트할 수 있습니다. 따라서 수정 시 지정하지 않은 항목들(등록한 유저id, 좋아요 개수 등)은 그대로 남아 있습니다.

글마디 수정 과정을 정리하면 아래와 같습니다.

  1. 유저가 글마디 수정 버튼을 클릭하면 recentPostsView.js에서는 해당 글마디 id를 controller.js에 전달하고, 유저가 글마디 수정 창에서 form을 제출하면 uploadView.js에서는 유저가 수정한 form data를 controller.js에 전달
  2. controller.js는 전달받은 form data와 auth 토큰(Firebase Auth에서 제공하는 user idToken), 수정할 글마디 id를 model.js에게 전달하여 글마디 수정 작업을 요청
  3. model.js는 form data를 PATCH 요청에 전달할 json 객체로 변환한 다음, helpers.js의 AJAX 함수에 글마디 id, auth 토큰을 매개변수로 추가한 엔드포인트와 json 객체를 전달하여 PATCH 요청을 함.
/* recentPostsView.js */

  /**
   * @description 수정 아이콘 클릭 시 handler 실행
   * @param { Function } handler : 수정 기능 핸들러
   */
  addHandlerBtnEdit(handler) {
    this.#container.addEventListener('click', e => {
      // 수정 버튼이 아니면 btnEdit === null
      const btnEdit = e.target.closest('.edit');

      if (btnEdit) {
        // 글마디 id
        const postId = btnEdit.closest('.blockquote__list__child').dataset.id;
		
        // controller.js에 수정 버튼이 클릭된 글마디id를 알려줌
        handler(postId);
      }
    });
  }
/* uploadView.js */

  /**
   * @param { function } handler Controller의 글마디 등록 진행 함수
   * @description 제출 이벤트 발생 시 Controller가 실행하는 handler 등록
   */
  addHandlerUpload(handler) {
    this.#form.addEventListener('submit', function (e) {
      e.preventDefault();
      // 폼 데이터 수집 객체
      const formData = [...new FormData(this)].map(data => data[1]);

      handler(formData);
    });
  }
/* model.js */

/**
 * @description 글마디 업로드
 * @param { Object } formData
 * @param { string } uid
 * @param { string } token
 * @param { string } postId : 글마디 수정 요청 시 전달하는 글마디 id. null일 경우는 글마디 추가 요청임
 * @returns { Object } response data
 */
export const uploadPost = async function (formData, uid, token, postId = null) {
  try {
    // postId가 존재하면 수정 요청
    if (postId) {
      // 수정할 data
      const uploadData = {
        type: formData[0],
        reference: formData[1],
        author: formData[2],
        body: formData[3].replaceAll("\n", "<br>"),
        tags: formData[4].split(","),
      };

      // 수정(PATCH) 요청
      const ret = await AJAX(
        `${API_URL_POSTS}/${postId}.json?auth=${token}`,
        "PATCH",
        uploadData
      );
      return ret;
    } else {
      // 업로드할 data
      const uploadData = {
        type: formData[0],
        reference: formData[1],
        author: formData[2],
        body: formData[3].replaceAll("\n", "<br>"),
        tags: formData[4].split(","),
        timestamp: new Date().getTime(),
        likesNum: 0,
        uid: uid,
      };

      // 추가(POST) 요청
      const ret = await AJAX(
        `${API_URL_POSTS}.json?auth=${token}`,
        "POST",
        uploadData
      );
      return ret;
    }
  } catch (err) {
    throw err;
  }
};
/* controller.js */

/**
 * @param { Object } uploadView로부터 받은 formData
 * @description 업로드 폼 제출버튼 클릭 감지 시 업로드 작업을 실행할 핸들러
 */
let editPostId;

const controlUpload = async function (formData) {
  try {
    // 업로드 요청(editPostId 존재여부에 따라 수정 또는 추가)
    if (editPostId) {
      // 글마디 수정 요청
      await model.uploadPost(formData, uid, token, editPostId);
    } else {
      // 글마디 추가 요청
      await model.uploadPost(formData, uid, token);
    }
    uploadView.renderSuccessMessage("글마디가 등록되었어요!");

    // 수정 후 editPostId 초기화
    editPostId = null;
    // 등록 창 닫고 최근 글마디 reload
    uploadView.closeModal();
    await controlLoadRecentPosts();
  } catch (err) {
    uploadView.renderError("등록 중 오류가 발생했습니다.");
  }
};

uploadView.addHandlerUpload(controlUpload);
recentPostsView.addHandlerBtnEdit(controlEdit);

글마디 삭제(Delete) 구현


글마디 삭제 과정은 다음과 같습니다.

  1. 유저가 글마디 삭제 버튼을 클릭하면 recentPostsView.js에서는 해당 글마디 id를 controller.js에 전달
  2. controller.js는 전달받은 글마디 id를 model.js에게 전달하여 글마디 삭제 작업을 요청
  3. model.js는 helpers.js의 AJAX 함수에 삭제할 글마디 id, auth 토큰을 매개변수로 추가한 엔드포인트에 DELETE 요청을 함
/* recentPostsView.js */

  /**
   * @description 삭제 아이콘 클릭 시 handler 실행
   * @param { Function } handler : 삭제 기능 핸들러
   */
  addHandlerBtnDelete(handler) {
    this.#container.addEventListener('click', e => {
      // 삭제 버튼이 아니면 btnDelete === null
      const btnDelete = e.target.closest('.delete');

      if (btnDelete) {
        // 글마디 id
        const postId = btnDelete.closest('.blockquote__list__child').dataset.id;

        handler(postId);
      }
    });
  }
/* model.js */

/**
 * @description 글마디 삭제하기
 * @param { string } postId : 글마디 id
 * @param { string } token : 유저 idToken
 * @returns response data
 */
export const deletePost = async function (postId, token) {
  try {
    const ret = await AJAX(
      `${API_URL_POSTS}/${postId}.json?auth=${token}`,
      "DELETE"
    );
    return ret;
  } catch (err) {
    throw err;
  }
};
/* controller.js */

/**
 * @description 삭제 버튼 클릭시 기능
 * @param 삭제 버튼이 클릭된 글마디 id
 */
const controlDelete = async function (postId) {
  if (confirm("선택한 글마디가 삭제됩니다")) {
    try {
      // 글마디 삭제 요청
      await model.deletePost(postId, token);
      // 최근 글마디 reload
      await controlLoadRecentPosts();
    } catch (err) {
      recentPostsView.renderError(err);
    }
  }
};

recentPostsView.addHandlerBtnDelete(controlDelete);

글마디 좋아요(LIKE) 기능 구현


로그인 상태의 유저는 글마디에 좋아요 표시를 할 수 있는 기능을 넣었습니다.

유저가 좋아요를 클릭/취소하면 DB의 posts 디렉토리에서 해당 글마디의 likesNum(좋아요 개수) 값을 증가/취소시키고, DB의 likes/(현재 유저 id)/favorites 디렉토리에서 해당 글마디의 id를 추가/제거합니다.

  1. 유저가 좋아요 아이콘을 클릭하면 recentPostsView.js는 좋아요 여부에 따라 아이콘 색과 좋아요 수를 렌더링하고, controller.js에 해당 글마디 id를 전달
  2. controller.js는 해당 글마디id와 auth 토큰을 model.js에 전달하여 좋아요 등록/취소 요청
  3. model.js는 helpers.js의 AJAX 메서드를 사용하여 posts/(postId)/likesNum 값 수정, likes/(현재 유저 id)/favorites 디렉토리에 글마디id 추가/제거 요청
/* recentPostsView.js */

  /**
   * @description 좋아요 아이콘 클릭 시 handler 실행
   * @param { Function } handler : 좋아요 기능 핸들러
   */
  addHandlerBtnLike(handler) {
    this.#container.addEventListener('click', e => {
      // 클릭한 좋아요 버튼이 포함된 글마디 id 추출(data-id)
      // 좋아요 버튼이 아니면 btnLike === null
      const btnLike = e.target.closest('.material-icons');

      // 좋아요 여부에 따라 좋아요 버튼 및 숫자 렌더링
      if (btnLike) {
        // 로그인 안 한 경우
        if (
          [...document.querySelector('.nav__btn__logout').classList].includes(
            'hidden'
          )
        ) {
          this.renderInfoMessage('로그인이 필요합니다');
          return;
        }

        const likeNum = btnLike.nextElementSibling;
        const postId = e.target.closest('.blockquote__list__child').dataset.id;

        // 좋아요
        if (btnLike.classList.contains('icon__favorite__off')) {
          btnLike.classList.remove('icon__favorite__off');
          btnLike.classList.add('icon__favorite__on');
          btnLike.textContent = 'favorite';
          likeNum.classList.add('favorite');
          likeNum.textContent = +likeNum.textContent + 1;

          // 좋아요 여부 반영 (CONTROLLER)
          handler(postId, 'on');
        }

        // 좋아요 취소
        else if (btnLike.classList.contains('icon__favorite__on')) {
          btnLike.classList.remove('icon__favorite__on');
          btnLike.classList.add('icon__favorite__off');
          btnLike.textContent = 'favorite_border';
          likeNum.classList.remove('favorite');
          likeNum.textContent = +likeNum.textContent - 1;

          // 좋아요 여부 반영 (CONTROLLER)
          handler(postId, 'off');
        }
      }
    });
  }
/* model.js */

/**
 * @description 좋아요 여부 DB 반영
 * @param { string } postId : 글마디 id
 * @param { string } uid : 유저 id
 * @param { string } type : 좋아요 추가: "on", 취소: "off"
 * @param { string } token : 유저 idToken
 * @returns response data
 */
export const saveLike = async function (postId, uid, type, token) {
  try {
    // 좋아요
    if (type === "on") {
      // 좋아요한 글마디 ID 추가
      await AJAX(
        `${API_URL_LIKES}/${uid}/favorites.json?auth=${token}`,
        "PATCH",
        `{"${postId}": { "time": "${new Date().getTime()}" }}`
      );

      // 좋아요 개수 업데이트
      const likesNum = await AJAX(
        `${API_URL_POSTS}/${postId}/likesNum.json`,
        "GET"
      );
      await AJAX(`${API_URL_POSTS}/${postId}.json?auth=${token}`, "PATCH", {
        likesNum: +likesNum + 1,
      });
    }

    // 좋아요 취소
    if (type === "off") {
      // 좋아요한 글마디 ID 제거
      await AJAX(
        `${API_URL_LIKES}/${uid}/favorites/${postId}.json?auth=${token}`,
        "DELETE"
      );

      // 좋아요 개수 업데이트
      const likesNum = await AJAX(
        `${API_URL_POSTS}/${postId}/likesNum.json`,
        "GET"
      );
      await AJAX(`${API_URL_POSTS}/${postId}.json?auth=${token}`, "PATCH", {
        likesNum: +likesNum - 1,
      });
    }
  } catch (err) {
    throw err;
  }
};
/* controller.js */

/**
 * @description 좋아요 버튼 클릭시 기능
 * @param 좋아요 버튼이 클릭된 글마디 id
 */
const controlLike = async function (postId, type) {
  try {
    // model에 좋아요 여부 반영 요청
    await model.saveLike(postId, uid, type, token);
  } catch (err) {
    recentPostsView.renderError(err);
  }
};

recentPostsView.addHandlerBtnLike(controlLike);
profile
기록하기

0개의 댓글