2️React 기본 hook으로 만든 커피 주문 앱

1. 프로젝트 주제

2. 사용한 기술

  • client

    • React

    • 비동기 관리: useEffect, useState 기본 hook을 활용해 custom hook 제작

    • 라우터: React Router v6.4

    • 상태 관리: Context API

    • CSS: Styled-Components

  • backend

    • DB: Firebase

3. pages, components flow chart

4. 주요 구현 기능

  • 카카오페이 mock 결제

  • 카카오지도 매장 정보 위치 확인, 현재 위치 확인

  • firebase 사용

    • 물건 구매, 메뉴, 매장 정보 불러오기

  • calendar 필터링으로 주문내역 선택

  • 장바구니

    • Context API, useReducer로 전역 상태 관리

  • 결제 페이지

    • 주문매장, 장소선택, 포장선택, 픽업 예정시간, 주문 내역 등 state관리

  • 메뉴 이름 검색

    • useDebounce 커스텀 훅 사용으로 api 사용 최소화

  • 모달 창

    • createPortal 사용

프로젝트 회고:

  • 학원에서 리액트 진도가 처음 들어가고 스스로 만들면서 배우는 게 중요하다고 생각했다.

    • 안전한 환경을 벗어나자.

    • 다양한 에러를 직면하고 해결하자.

프로젝트 중 겪었던 기술적 어려움과 느낀점

1. kakao map api 적용

kakao pay와 마찬가지로 api 명세서를 읽고 적용을 했다. 다행히, 많은 예시코드와 설명이 잘돼있어서 kakao pay api 보다는 쉽게 적용할 수 있었다.

10개의 실제 매장의 좌표와 정보를 구글 맵에서 가져와 아래와 같이 Firebase에 저장했다. 그리고 axios로 데이터를 불러와 ReactDOM.createPortal 을 사용해 모달로 매장전체 지도를 렌더링 했다.

FieldValue

address

서울시 중구 을지로2가 203

company_owned

true

delivery_available

true

distance

3.7km

id

파인에비뉴점

image

kakao_map

name

파인에비뉴점

open_time

07:00 ~ 23:00

2. 방어코드

개발환경에서는 발생하지 않았던 ReferenceError: data is not defined 오류가 많았다. 원인은 데이터를 fetching 해오기 전에 하위 컴포넌트가 먼저 렌더링이 되면서 데이터를 찾을 수 없다는 오류였다.

그래서 아래와 같이 데이터가 있을 때만 컴포넌트를 렌더링하거나 옵셔널 체이닝을 넣어서 방어코드를 작성했다.

const { datas, loading, error, refetchData } = useGetOrderedMenu('/pay');

// datas가 있을 때만 컴포넌트 렌더링
{datas && <component />}

// 옵셔널 체이닝
datas?.map((data) => {
	...
})

3. ContextAPI, useReducer 사용

useState, useEffect는 사용법이 간단했는데 ContextAPI, useReducer는 비교적 어려웠다. 또 두 개의 훅을 결합해서 사용해야했기 때문에 헷갈리는 부분이 많았다.

총 4개의 ContextAPI를 사용해 전역 상태로 관리를 했다. 1. CartCotextProvider(일반 장바구니), 2. EasyOrderContextProvider(간편주문 장바구니), 3. LoginContextProvider(로그인), 4. SelectedStoreContextProvider(선택한 매장)

아쉬웠던 점: 아래 CartContextProvider.jsx와 같이 확장성을 고려하지 못하고 total객체에 Qty, prices가 구분없이 다 들어가 있다. 그러다보니, localStorage에 저장하는 객체들도 너무 더러워졌다.

// CartContextProvider.jsx

export const CartContext = createContext({
  title: 'CART',
  items: [],
  total: {
    total: 0,
    totalQty: 0,
    finalPrice: 0,
    originalPrices: [],
    discountPrices: [],
    discountedPrices: [],
  },
  addItem: (item) => {},
  removeItem: (id) => {},
  removeCheckedItem: (id) => {},
  clearCart: (item) => {},
});

const defaultCartState = {
  title: 'CART',
  items: [],
  total: {
    total: 0,
    totalQty: 0,
    finalPrice: 0,
    originalPrices: [],
    discountPrices: [],
    discountedPrices: [],
  },
};

4. Firebase적용

express, sequelize 등 백엔드 라이브러리 혹은 프레임워크를 배우지 않아 Firebase의 realtime database를 사용헀다. db를 생성하면 https:// … /menu.json 자동으로 생성되는 주소 뒤에 menu.json을 붙이면 api 요청주소를 get, post, put, patch, delete할 수 있어서 쉽게 통신할 수 있었다.

리액트 첫 프로젝트이어서 useEffect, custom hook api를 만들어 백엔드와 통신하는데 친해질 수 있었다.

5. 1일 이상 나를 괴롭혔던 문제

useGetMenu에서 비동기로 이미 처리를 하고 있는데 const datas = data.find((menu) => menu.id === id);는 왜 undefined가 나올까

useGetMenu.jsx

import React, { useEffect, useState } from 'react';
import { axiosFirebase } from '../constants/axios';

function useGetMenu() {
  const [data, setData] = useState([]);
  const [error, setError] = useState(false);
  const [loading, setLoading] = useState(false);

  const fetchMenu = async () => {
    try {
      setLoading(true);
      const res = await axiosFirebase.get('/menu.json');
      const menuArr = Object.values(res.data);

      setData(menuArr);
    } catch (err) {
      console.log(err);
      setError(true);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchMenu();
  }, []);

  return { data, loading, error };
}

export default useGetMenu;

MenuDetail.jsx

import React, { useContext, useEffect, useRef, useState } from 'react';
import * as S from './MenuDetail.style';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { menuDatas } from '../../../constants/data/menuDatas';
import RecommendList from '../../../components/RecommendList/RecommendList';
import { A11y } from 'swiper';
import 'swiper/css';
import CartButton from '../../../components/CartButton/CartButton';
import CartContext from '../../../store/CartContext';
import useGetMenu from '../../../hooks/useGetMenu';

function MenuDetail() {
  const navigate = useNavigate();
  const cartCtx = useContext(CartContext);
  const [menuCount, setMenuCount] = useState(1);
  const { data, loading, error } = useGetMenu();
  console.log('data', data);
  const { id } = useParams();
  console.log(id);

  const datas = data.find((menu) => menu.id === id);

  const coffeeMenu = data.filter((menu) => menu.tags.includes('coffee'));
  const bestMenu = data.filter((menu) => menu.isBest === true);
  const newMenu = data.filter((menu) => menu.isNew === true);

  console.log('datas', datas);
  // console.log('menuData', menuData);

  const {
    title,
    ENTitle,
    desc,
    discountRate,
    isBest,
    isNew,
    isChecked,
    isSoldOut,
    price,
    tags,
    thumbnail,
  } = datas;

  // ... 
  );
}

export default MenuDetail;
  • useGetMenu 커스텀 훅에서 데이터를 비동기로 가져오기 때문에 data 상태는 초기값인 빈 배열([])로 초기화된다.

  • 그리고 useEffect 훅에서 fetchMenu() 함수를 호출하여 비동기적으로 데이터를 가져오고, setData() 함수를 호출하여 data 상태를 업데이트함. 이 과정에서 data 상태가 업데이트되기 전에 MenuDetail 컴포넌트가 렌더링될 수 있음.

  • 그래서 datas 변수가 data.find((menu) => menu.id === id)로 초기화되는 시점에는 data 상태가 아직 업데이트되지 않은 상태일 수 있기 때문에 undefined를 반환할 수 있다.

해결:

해결 방법으로는 datas 변수를 data 상태를 직접 사용하는 것이 아니라, data.find() 함수를 MenuDetail 컴포넌트 내부에서 사용하여 datas 변수를 업데이트하도록 변경할 수 있음. 이렇게 하면 datas 변수가 undefined를 반환하는 문제를 해결할 수 있다.

6. 카카오페이 api(단건 결제) 적용

React + kakao pay API를 적용한 다른 프로젝트를 구글링 혹은 블로그를 찾아봤지만 프론트단의 로직 설명만 있거나 설명이 부족했다. 그래서 kakao developers api 명세서를 직접 보면서 해결해나갔다. 덕분에, 비록 한국어로 설명된 명세서였지만, api 명세서를 읽고 적용하는 자신감이 생겼다.

http1.1의 kakao pay

지금까지 접해왔던 형식과 다른 이전 버전의 형식의 http 요청을 해야했다. 하지만 기본 용어들은 같았기 때문에 처음엔 헤맸지만 쉽게 적용할 수 있었다.

기능HTTP 1.1HTTP/2

멀티플렉싱

하나의 연결에 하나의 요청을 처리하므로, 동시에 여러 요청을 처리하려면 여러 연결이 필요합니다.

하나의 연결에 여러 요청 및 응답을 동시에 처리할 수 있습니다. 이를 통해 네트워크 리소스를 효율적으로 사용할 수 있습니다.

헤더 압축

HTTP 1.1에서는 헤더를 압축하지 않습니다. 이로 인해 불필요한 네트워크 대역폭 사용이 증가할 수 있습니다.

HTTP/2에서는 HPACK라는 표준을 사용하여 헤더를 압축하여 네트워크 리소스를 절약할 수 있습니다.

서버 푸시

HTTP 1.1에서는 서버가 클라이언트의 명시적인 요청 없이 데이터를 보내는 것이 불가능합니다.

HTTP/2에서는 서버 푸시 기능을 통해 클라이언트가 요청하지 않아도 미리 예상되는 필요한 리소스를 클라이언트에게 보낼 수 있습니다.

이진 프로토콜

HTTP 1.1은 텍스트 기반 프로토콜입니다. 따라서 오류 발생 시 디버깅이 쉽습니다. 하지만 이로 인해 효율성이 떨어질 수 있습니다.

HTTP/2는 이진 프로토콜로, 통신 효율성과 정확성을 높이는데 기여합니다. 이진 프로토콜은 디버깅이 어려울 수 있지만, 고급 도구를 통해 분석이 가능합니다.

보안

HTTP 1.1은 자체적인 보안 기능을 제공하지 않습니다. 일반적으로 SSL/TLS와 함께 사용하여 HTTPS를 형성하며, 이를 통해 보안을 제공합니다.

HTTP/2는 기본적으로 SSL/TLS와 함께 작동하도록 설계되어 있어 HTTPS가 기본적으로 사용됩니다. 그러나 자체적으로 보안 기능을 가지고 있지는 않습니다.

// ConfirmOrder.jsx

// 카카오페이 데이터
const kakaoPayData = {
    cid: 'TC0ONETIME',
    partner_order_id: 'partner_order_id',
    partner_user_id: 'partner_user_id',
    item_name: `애플프레소 ${cartCtx?.items[0]?.title}${cartCtx?.items?.length}개`,
    quantity: cartCtx.total.totalQty,
    total_amount: cartCtx.total.finalPrice,
    vat_amount: cartCtx.total.finalPrice / 10,
    tax_free_amount: 0,
    approval_url: `${import.meta.env.VITE_KAKAO_PAY_APPROVAL_URL}`,
    fail_url: `${import.meta.env.VITE_KAKAO_PAY_FAIL_URL}`,
    cancel_url: `${import.meta.env.VITE_KAKAO_PAY_CANCEL_URL}`,
  };

// 결제 함수
const handleFinalPayment = () => {
    postKakaoPay(kakaoPayData);
    postMenu({
      user: currentUser?.user,
      orderDetail: cartCtx.items,
      orderDate,
      orderType: cartCtx.title === 'EASYORDER' ? 'EASY_ORDER' : 'REGULAR_ORDER',
      orderRequest,
      orderShop: {
        name: currentStore.name,
        address: currentStore.address,
        company_owned: currentStore.company_owned,
      },
    });
    cartCtx.clearCart();
  };
// useKakaoPay.jsx

import React, { useCallback, useContext, useState } from 'react';
import axios from 'axios';
import { LoginContext } from '../contexts/LoginContextProvider';

export const kakaoPayConfig = {
  headers: {
    Authorization: `KakaoAK ${import.meta.env.VITE_KAKAO_PAY_ADMIN_KEY}`,
    'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
  },
};

function useKakaoPay() {
  const { currentUser } = useContext(LoginContext);
  const [kakaoPayErr, setKakaoPayErr] = useState(false);
  const [kakaoPayLoading, setKakaoPayLoading] = useState(false);
  const [kakaoPaySuccess, setKakaoPaySuccess] = useState(false);
  const [kakaopayment, setKakaoPayment] = useState(null);

  const postKakaoPay = useCallback(async (data) => {
    const params = new URLSearchParams(data);
    try {
      setKakaoPaySuccess(false);
      setKakaoPayLoading(true);
      const res = await axios.post(
        '<https://kapi.kakao.com/v1/payment/ready>',
        params.toString(),
        kakaoPayConfig
      );
      setKakaoPayment(res.data);
      localStorage.setItem(
        `${currentUser?.user.email}-tid`,
        JSON.stringify(res.data.tid)
      );
      window.location.href = res.data.next_redirect_pc_url;
      console.log(res);
    } catch (err) {
      console.log(err);
      setKakaoPayErr(err);
    } finally {
      setKakaoPayLoading(false);
      setKakaoPaySuccess(true);
    }
  }, []);
  return { kakaopayment, kakaoPayLoading, kakaoPayErr, postKakaoPay, kakaoPaySuccess };
}

export default useKakaoPay;

7. custom api 사용

자주 사용하는 React Query, Redux Toolkit의 데이터 fetching 방식을 모방해 커스텀 api 훅을 만들어 사용했습니다.

  • 재사용이 가능하도록 Firebase에서 데이터를 가져오는 로직을 캡슐화했습니다.

  • loading, error, data, refetchData를 return 값으로 내뱉고 데이터가 필요한 컴포넌트에서 import해서 사용할 수 있습니다.

  • useCallback, useEffect 훅을 통해 불필요한 네트워크 요청 최소화 했습니다.

  • 에러 바운더리가 필요한 컴포넌트에서 refetchData 함수를 사용할 수 있습니다.

// useGetMenu.jsx

import React, { useCallback, useEffect, useState } from 'react';
import { axiosFirebase } from '../constants/axios';

function useGetMenu(url = '') {
  const [data, setData] = useState([]);
  const [error, setError] = useState(false);
  const [loading, setLoading] = useState(false);

  const fetchMenu = useCallback(async () => {
    try {
      setError(false);
      setLoading(true);
      const res = await axiosFirebase.get(`${url}.json`);
      const menuArr = Object.values(res.data);

      setData(menuArr);
    } catch (err) {
      console.log(err);
      setError(true);
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchMenu();
  }, [url]);

  const refetchData = useCallback(() => {
    fetchMenu();
  }, [url, fetchMenu]);

  return { data, loading, error, refetchData };
}

export default useGetMenu;
// CoffeeMenu.jsx

import React from 'react';
import * as S from './CoffeeMenu.style';
import MenuList from '../../../components/MenuList/MenuList';
import useGetMenu from '../../../hooks/useGetMenu';

function CoffeeMenu() {
  const { data, loading, error, refetchData } = useGetMenu('/menu');
  const coffeeData = data.filter((menu) => menu.tags.includes('coffee'));

  return (
    <S.Container>
      <MenuList 
        menus={coffeeData} 
        loading={loading} 
        error={error} 
        refetchData={refetchData} 
      />				
    </S.Container>
  );
}

export default CoffeeMenu;

8. useDebounce 훅을 활용한 검색 기능

  • 입력된 값과 지연 시간을 받아 지연된 값을 반환하는 함수인 useDebounce 훅을 만들고

  • useState를 사용하여 debouncedValue 상태를 관리, useEffect를 활용하여 입력 값이 변경될 때마다 지연 시간 이후에 값을 업데이트

  • clearTimeout을 이용하여 이전에 설정된 타이머를 취소하여 중복된 타이머가 발생하는 것을 방지

  • 검색어 입력이 변경될 때마다 fetchSearchMenuName 함수를 호출하여 지연된 검색어로 검색 결과를 가져오고, 이를 searchResults 상태에 업데이트

// useDebounce.jsx

import { useState, useEffect } from 'react';

export const useDebounce = (value, delay) => {
  const [debounceValue, setDebounceValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebounceValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debounceValue;
};
// SearchMenuModal.jsx

import { useDebounce } from '../../../hooks/useDebounce';

function SearchMenuModal({ isOpenSearchMenuModal, setIsOpenSearchMenuModal }) {
  const [searchResults, setSearchResults] = useState([]);

  const debouncedSearchValue = useDebounce(searchValue, 500);

  useEffect(() => {
    if (debouncedSearchValue) {
      fetchSearchMenuName(debouncedSearchValue);
    }
  }, [debouncedSearchValue]);

  const fetchSearchMenuName = async (searchValue) => {
    const results = data?.filter((item) => item.title.includes(searchValue));
    setSearchResults(results);
  };

  return (
    <S.SearchResultContainer>
      <MenuList menus={searchResults} />
    </S.SearchResultContainer>
  );
}

Last updated