식메추 1.0 ➡️ 2.0, 어떻게 바뀌었을까?

식메추 1.0 ➡️ 2.0, 어떻게 바뀌었을까?

·

9 min read

한 달 만에 식메추 2.0를 마쳤다. 그동안 리액트로 프로젝트를 진행하다가 오랜만에 바닐라 자바스크립트를 사용하니 어색했고, 리액트에서 간단히 구현할 수 있는 기능을 자바스크립트에서는 더 복잡하게 구현해야 한다는 것을 깨닫게 되었다.

오늘은 식메추 1.0에서 2.0로 업그레이드하면서 어떤 변화가 일어났는지 정리해보았다.

식메추 > https://sikmechu.vercel.app/

GitHub > https://github.com/YeonsuBaek/sikmechu

🍽️ 식메추 1.0에 대하여

2022년 10월부터 본격적으로 자바스크립트를 공부하기 시작했다. 우아한테크코스 프리코스를 진행하면서 ES6문법을 익히게 되었다. 이런 내용을 복습하기 위해 시작한 프로젝트가 식메추이다.

당시 나는 HTML, CSS 그리고 자바스크립트 기초 메서드를 활용한 퍼블리싱 정도를 다룰 수 있었다. 따라서 서버나 외부 API를 어떻게 사용하는지 모르기 때문에 모든 기능이 하드코딩으로 이루어졌다.

메뉴를 선택하는 옵션 값 뿐만 아니라 선택에 따른 메뉴 결과까지 전부 하나하나 작성하였다.

이러한 작업들은 당연히 프론트엔드 개발자로서 역량을 보여주지 못하고, 더 많은 데이터를 추가하기에 한계를 보인다.

자바스크립트 외에도 Tailwind CSS를 처음 공부하며 적용하였다. 다만, 반복되는 코드를 리팩터링하지 않아 Tailwind CSS의 장점을 충분히 활용하지 못하였다.

2년이 지나 해당 프로젝트를 다시 돌아보니 고쳐야할 점이 너무 많아 2.0으로 업그레이드를 해서 처음부터 다시 작업을 해보았다.

📣 피드백을 수용하다

1.0을 배포한 후 여러 사용자로부터 직접적인 피드백을 받았다. 그 중 즉시 개선할 수 있는 부분을 식별하여 수정하였다.

1. 메뉴가 너무 적어요.

하드코딩 특성 상 많은 데이터를 집어 넣기엔 용량과 작성자의 노가다(?)에도 한계가 있다. 그래서 Firebase를 이용하여 메뉴 데이터를 저장해서 불러오는 CRUD 로직을 구현하였다.

// api/menu.js
async function getMenu() {
  try {
    showLoadingModal()
    const collectionRef = collection(db, 'sikmechu')
    const docRef = doc(collectionRef, 'menu')
    const response = await getDoc(docRef)
    const menu = response.data()['menu']
    return menu
  } catch (error) {
    return null
  } finally {
    hideModal()
  }
}

getMenu는 menu 데이터를 가져오는 함수이다. showLoadingModal()을 우선 호출하여 데이터를 패치하는 시간동안 사용자가 지루하지 않도록 한다. 패치를 성공하면 menu 데이터를, 실패하면 null 값을 반환한다. 모두 끝난 후에는 hideModal()을 호출해 로딩 모달을 삭제한다.

// pages/result.js  
const menu = await getMenu()
const result = menu.filter((item) => {
  return Object.keys(selectedOption).every((key) => {
    return !selectedOption[key].length || selectedOption[key].some((value) => item[key].includes(value))
  })
})
const resultElement = createResultElement(result)

getMenu()로 데이터를 불러와 선택한 옵션에 맞는 데이터를 걸러 createResultElement의 매개변수로 넣으면 결과 요소를 확인할 수 있다.

// api/menu.js
async function saveMenu(newMenu) {
  try {
    const collectionRef = collection(db, 'sikmechu')
    const docRef = doc(collectionRef, 'menu')
    await updateDoc(docRef, {
      menu: arrayUnion(newMenu),
    })
    showToast('메뉴를 추가하였습니다.', 'success')
    goto('/')
  } catch (error) {
    console.error(error)
    showToast('메뉴를 추가하는 데 실패하였습니다.', 'fail')
  }
}

saveMenu는 새로운 menu 데이터를 저장하는 함수이다. Firebase에서 제공하는 arrayUnion 메서드를 사용해 새로운 데이터를 menu 필드의 배열에 추가한다.

// pages/add-menu.js
saveButton.addEventListener('click', () => {
  const menuName = document.querySelector('#menu-name').value

  if (menuName.trim() === '') {
    showModal({
      message: '메뉴 이름을 입력해주세요!',
      buttonLabel: {
        cancel: '닫기',
      },
    })
  } else {
    const newMenu = {
      id: Math.round(Math.random() * 1000000),
      name: menuName,
      ...selectedOptions,
    }
    saveMenu(newMenu)
  }
})

saveButton을 클릭하고 새로운 메뉴 이름이 빈 값이 아닌 경우에 saveMenu()를 통해 저장한다.

2. 결과 화면을 단독으로 보여주세요.

이전에는 HTML 파일을 분리하여 페이지 라우팅하는 방법만 알고 있었다. 정적인 페이지에서는 괜찮은 방법이라고 생각한다. 하지만 필터를 선택하여 해당하는 결과를 도출하는 페이지를 구현할 때는 데이터를 받아와야 하는데, HTML은 마크업 언어이기 때문에 데이터를 받아와 동적으로 변화시킬 수 없다.

이럴 때 History API를 사용하여 원하는 라우팅 기능을 구현할 수 있다.

// lib/routes.js
import { renderIndex } from '../pages'
import { renderResult } from '../pages/result'
import { renderAddMenu } from '../pages/add-menu'
import { renderError } from '../pages/error'

const routes = {
  '/': renderIndex,
  '/result': renderResult,
  '/add-menu': renderAddMenu,
  '/404': renderError,
}

페이지를 파일로 분리하여 routes 객체에 정리하였다. 키 값은 url에 들어갈 pathname이다.

식메추는 홈, 결과, 메뉴 추가, 404로 총 4개 페이지로 나누게 되었다.

// lib/router.js

/** @type {Object.<string, Function>} 현재 경로의 라우트 */
let routes

window.addEventListener('popstate', () => {
  const { pathname, search } = location
  const params = Object.fromEntries(new URLSearchParams(search))
  if (routes[pathname]) {
    routes[pathname]({ searchParams: params })
  } else {
    goto('/404', false)
  }
})

/**
 * 주어진 URL로 이동하고, 이동한 후에는 해당 경로에 대응하는 함수를 실행한다.
 *
 * @param {string} url 이동할 경로
 * @param {boolean} push 브라우저 히스토리 항목 추가 여부
 */
const goto = (url, push = true) => {
  const [pathname, search] = url.split('?')
  const params = Object.fromEntries(new URLSearchParams(search))
  if (routes[pathname]) {
    if (push) {
      history.pushState({ params }, '', url)
    }
    routes[pathname]({ searchParams: params })
  } else {
    goto('/404', false)
  }
}

/**
 *  초기에 브라우저가 로드될 때 호출되며, 현재 URL에 해당하는 경로로 이동한다.
 *
 * @param {Object.<string, Function>} params 라우트와 각 라우트에 대응하는 처리 함수로 구성된 객체
 */
const start = (params) => {
  routes = params
  goto(location.pathname + location.search, false)
}

export { start, goto }

브라우저의 주소 히스토리가 변경될 때마다 실행되는 이벤트 리스너를 추가한다. pathname이 routes 객체에 존재하는 경우 해당하는 함수를 실행한다. 이때 선택한 옵션 객체를 문자열로 저장한 search를 새로운 객체로 생성하여 searchParams에 넣어 query로 넘겨준다. 만약 pathname이 routes 객체에 존재하지 않으면 goto() 함수를 통해 404 페이지로 이동한다.

goto() 함수도 popstate 이벤트 리스너와 비슷하게 동작한다. 차이점은 push 여부를 받아 push인 경우 pushState를 통해 브라우저 히스토리 항목에 해당 페이지를 추가한다.

프로젝트를 시작할 때 사용할 start() 함수에서는 만들어진 페이지 함수가 객체로 저장된 params를 routes 값에 대입하고 현재 location의 pathname과 search 값에 따라 페이지를 이동한다.

// main.js
import { start } from './lib/router'
import { routes } from './lib/routes'

start(routes)

main.js 파일에선 start()를 호출하는 것으로 끝났다.

3.알림창을 띄워주세요.

사실, 이 피드백은 식메추가 아닌 OGSM 프로젝트에서 받은 것이다. 데이터를 추가, 수정, 삭제할 때 성공 또는 실패 여부를 사용자에게 더 잘 전달하기 위해 Toast를 띄우고, 사용자에게 한 번 더 확인을 받아야 할 때 Modal을 띄우는 것이 좋다.

3-1. Toast

// components/block/toast.js

/**
 * Toast를 띄운다.
 *
 * @param {string} text 알림 문구
 * @param {'success' | 'fail' | 'alert' | null} state 알림 상태
 */
function showToast(text, state) {
  const color = getColor(state)
  document.querySelector('#toast').innerHTML = `
          <aside
            id="toast-container"
            class="popover popover-removing fixed top-6 right-6 z-50 flex items-center justify-between w-[312px] min-h-[64px] bg-white border rounded shadow-lg"
            style="border-color:${color};"
          >
            <p class="pl-4">${text}</p>
            <button id="close-button" type="button" class="pr-2 w-8 h-8 dark-text-button">
              <i class="ph-x"></i>
            </button>
          </aside>
        `

  const toastElement = document.querySelector('#toast-container')
  setTimeout(() => {
    toastElement.classList.remove('popover-removing')
  })
  setTimeout(() => {
    hideToast()
  }, 5000)

  const closeButton = document.querySelector('#close-button')
  closeButton.addEventListener('click', () => {
    hideToast()
  })
}

/**
 * Toast를 닫는다.
 */
function hideToast() {
  const toastElement = document.querySelector('#toast-container')
  toastElement.classList.add('popover-removing')
  setTimeout(() => {
    toastElement.remove()
  }, 300)
}

/**
 * 상태에 맞는 Popover 색상을 반환한다.
 *
 * @param {'success' | 'fail' | 'alert' | null} state
 * @returns {string}
 */
function getColor(state) {
  if (state === 'success') {
    return '#95de64'
  }
  if (state === 'fail') {
    return '#ff7875'
  }
  if (state === 'alert') {
    return '#ffc069'
  }
  return '#f0f0f0'
}

Toast 기능은 다음과 같다.

  1. showToast()를 호출한다.

  2. getColor()를 통해 state에 맞는 color를 얻는다.

  3. color 테마를 가진 Toast에 text 값을 넣어 #toast 영역에 삽입한다.

  4. successElement가 생성되면 .popover-removing 클래스를 지워서 스르륵 보이도록 한다.

  5. closeButton을 클릭하거나 5초 뒤에 hideToast()를 통해 Toast를 없앤다.

  6. hideToast()가 호출되면 toastElement.popover-removing 클래스를 추가하고 애니메이션이 끝나는 시점인 0.3초 후에 toastElement#toast 영역에서 삭제한다.

아래와 같이 재사용할 수 있도록 Toast 로직을 하나의 컴포넌트처럼 분리하였다.

// api/menu.js
async function saveMenu(newMenu) {
  try {
    const collectionRef = collection(db, 'sikmechu')
    const docRef = doc(collectionRef, 'menu')
    await updateDoc(docRef, {
      menu: arrayUnion(newMenu),
    })
    showToast('메뉴를 추가하였습니다.', 'success')
    goto('/')
  } catch (error) {
    console.error(error)
    showToast('메뉴를 추가하는 데 실패하였습니다.', 'fail')
  }
}

3-2. Modal

// components/block/modal.js

/**
 * Modal을 보여준다.
 *
 * @param {Object} options Modal에 대한 옵션들
 * @param {string} options.message 표시할 메시지
 * @param {{ cancel: string | null, save: string | null }} options.buttonLabel 버튼 라벨
 * @param {() => void} options.onSave 저장할 때 호출할 함수
 */
function showModal({ message, buttonLabel, onSave }) {
  document.querySelector('#modal').innerHTML = `
        <aside id="modal-container" class="fixed z-50 flex-col w-[90%] max-w-[572px] bg-white rounded pos-center">
          <main class="p-6">${message}</main>
          <footer id="modal-footer" class="flex-center md:justify-end w-full h-[64px] px-4 gap-2"></footer>
        </aside>
        <aside id="modal-backdrop" class="fixed z-40 bg-black pos-full opacity-20"></aside>
      `

  const footer = document.querySelector('#modal-footer')
  const { cancel, save } = buttonLabel
  if (cancel) {
    footer.innerHTML += `
      <button type="button" id="cancel-button" class="gray-button w-full md:w-auto py-2 px-3">${cancel}</button>
    `
  }
  if (save) {
    footer.innerHTML += `
      <button type="button" id="save-button" class="blue-button w-full md:w-auto py-2 px-3">${save}</button>
    `
  }

  const cancelButton = footer.querySelector('#cancel-button')
  if (cancelButton) {
    cancelButton.addEventListener('click', () => {
      hideModal()
    })
  }

  const saveButton = footer.querySelector('#save-button')
  if (saveButton) {
    saveButton.addEventListener('click', () => {
      hideModal()
      onSave()
    })
  }
}

/**
 * 로딩 모달을 숨긴다.
 */
function hideModal() {
  const modalElement = document.querySelector('#modal-container')
  const backdropElement = document.querySelector('#modal-backdrop')
  if (modalElement) {
    modalElement.remove()
    backdropElement.remove()
  }
}

Modal 기능은 다음과 같다.

  1. showModal()을 호출한다.

  2. message가 포함된 modal 컨테이너와 아래 깔리는 backdrop을 #modal 영역에 삽입한다.

  3. cancel label이나 save label이 매개변수에 포함되어 있다면 footer에 버튼을 추가한다.

  4. cancel button이나 save button을 클릭하면 hideModal()을 호출하고, save button 클릭한 경우 저장 후 로직이 담긴 onSave()도 호출한다.

  5. hideModal()이 호출되면 modalElementbackdropElement#modal 영역에서 삭제한다.

Modal 컴포넌트는 아래와 같이 사용되었다.

// pages/add-menu.js
homeButton.addEventListener('click', () => {
  showModal({
    message: '지금 나가면 작성한 내용이 모두 사라져요! 그만 작성하고 나갈까요?',
    buttonLabel: {
      cancel: '이어서 할래요.',
      save: '네, 그만 할래요.',
    },
    onSave: () => goto('/'),
  })
})
// pages/add-menu.js
if (menuName.trim() === '') {
  showModal({
    message: '메뉴 이름을 입력해주세요!',
    buttonLabel: {
      cancel: '닫기',
    },
  })
}

✨ 프로젝트 개선을 위한 기술적인 변화

1.번들러를 도입하다

지금까지 자바스크립트를 공부하면서 프로젝트를 설정할 때 index.html, style.css, main.js 이 세 가지 파일만을 생성하는 방식으로 진행하였다. 이러한 방식은 자바스크립트를 배우는 초기에는 편리하게 느껴졌다. 그런데 최근에 시나브로 자바스크립트 강의를 통해 번들러의 존재를 알게 되었고, 그 중에서도 Vite를 사용하여 2.0에서도 적용하게 되었다. 번들러를 사용하며 어떤 이점을 얻을 수 있을까?

우선, 개발 속도가 상당히 향상되었다. Vite의 개발 서버는 HMR(핫 모듈 리로딩)을 지원하기 때문에 코드 변경 사항이 있을 때마다 새로고침을 하지 않아도 브라우저에서 변경사항을 즉시 볼 수 있다.

또한, Tailwind CSS를 프로젝트에 통합하는 과정에서 큰 변화를 겪었다. 처음에는 CDN을 통해 Tailwind CSS를 설치했는데, 이는 버전 관리가 어렵고 네트워크 속도에 따라 성능이 달라지는 문제가 있었다.

그래서 CLI를 사용하여 설치하게 되었다. 이 방식은 버전 관리가 용이하고 여러 환경에서 일관성을 유지하는 데 좋다. 또한, JIT(Just-In-Time) 모드를 사용하여 필요한 스타일 클래스를 동적으로 생성하여 번들 크기를 최적화하고 개발 시간을 단축할 수 있었다. 하지만 JIT 모드를 사용하더라도 초기 설치 시에는 모든 스타일을 포함하는 파일을 생성하기 때문에 번들 크기가 커지고 페이지 로딩 속도가 느려질 수 있다.

이러한 문제들을 Vite라는 번들러를 도입함으로써 해결할 수 있었다. Vite를 사용하면 필요한 스타일만을 동적으로 생성하여 번들 크기를 최적화할 수 있습니다. 따라서 프로젝트의 개발 속도와 성능을 크게 향상시킬 수 있었다.

2.Tailwind CSS 리팩터링하다

Tailwind CSS를 사용하면 클래스가 너무 많아지는 문제가 발생할 수 있다. 자주 사용되는 스타일을 리팩터링하여 반복되는 클래스를 줄이고 재사용성을 높일 수 있다.

더 자세한 리팩터링 방식은 이전에 작성한 Tailwind CSS 상수화 및 커스텀 스타일 정의 포스트에서 확인할 수 있다.

3.JSDoc을 적용하다

요즘 타입스크립트를 거의 모든 곳에서 사용하는 추세이다. 그러나 나는 자바스크립트로 작업을 진행 중이었고, 타입스크립트로의 마이그레이션을 고려하였다. 그러나 JSDoc을 도입하여 타입스크립트의 장점을 어느 정도 보완할 수 있었다.

이전에 작성한 JSDoc: 자바스크립트 함수 주석 문서화 포스트를 다시 복습하면서, 주요 메서드마다 수행하는 작업과 매개변수 타입을 지정하였다.

/**
 * Modal을 보여준다.
 *
 * @param {Object} options Modal에 대한 옵션들
 * @param {string} options.message 표시할 메시지
 * @param {{ cancel: string | null, save: string | null }} options.buttonLabel 버튼 라벨
 * @param {() => void} options.onSave 저장할 때 호출할 함수
 */
function showModal({ message, buttonLabel, onSave }) {
  ...

함수 위에 마우스를 올리면 작성된 문서가 표시되며, 이를 통해 함수를 찾고 그 용도와 타입을 추측하는 데 드는 시간을 단축하였다.