관리 메뉴

거니의 velog

(46) 리액트 장바구니 구성 1 본문

SpringBoot_React 풀스택 프로젝트

(46) 리액트 장바구니 구성 1

Unlimited00 2024. 3. 8. 18:23

* 이번에는 이전에 구성했던 API 서버에 장바구니 관련 기능을 리액트를 이용해서 실제 화면을 구성해 본다. 리덕스 툴킷을 이용해서 로그인 상황에 따라 장바구니에 상품을 추가하고 변경하는 작업을 처리해 본다.

* 이번 장의 학습 목표는 다음과 같다.

1. 리덕스 툴킷을 이용한 장바구니 상태 관리

2. API 서버와 장바구니 상태 동기화 처리

1. API 서버와 통신

* 가장 먼저 할 일은 프로젝트 내 api 폴더에 cartApi.js를 추가하고 장바구니 관련 기능을 정리해 두는 것이다.

import jwtAxios from "../util/jwtUtil";
import { API_SERVER_HOST } from "./todoApi";

const host = `${API_SERVER_HOST}/api/cart`;

export const getCartItems = async () => {
  const res = await jwtAxios.get(`${host}/items`);
  return res.data;
};

export const postChangeCart = async (cartItem) => {
  const res = await jwtAxios.post(`${host}/change`, cartItem);
  return res.data;
};

* cartApi.js에는 현재 사용자의 로그인 정보를 이용하기 때문에 jwtAxios를 이용해서 API 서버를 호출해야 한다. getCartItems()는 현재 사용자의 장바구니에 담겨 있는 장바구니 아이템들을 조회하기 위해서 사용하고 장바구니 아이템을 추가하거나 수량을 변경하는 기능은 postChangeCart()를 이용할 것이다.


(1) cartSlice의 작성

* cartApi.js의 기능들은 리덕스 툴킷에서 createAsyncThunk()를 작성해서 사용할 것이므로 cartSlice.js를 구성하고 store.js에 이를 추가해 준다.

* 프로젝트 내 slices 폴더 내에 cartSlice.js를 추가한다.

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { getCartItems, postChangeCart } from "../api/cartApi";

export const getCartItemsAsync = createAsyncThunk("getCartItemsAsync", () => {
  return getCartItems();
});

export const postChangeCartAsync = createAsyncThunk(
  "postCartItemsAsync",
  (param) => {
    return postChangeCart(param);
  }
);

const initState = [];

const cartSlice = createSlice({
  name: "cartSlice",
  initialState: initState,

  extraReducers: (builder) => {
    builder
      .addCase(getCartItemsAsync.fulfilled, (state, action) => {
        console.log("getCartItemsAsync fulfilled");

        return action.payload;
      })
      .addCase(postChangeCartAsync.fulfilled, (state, action) => {
        console.log("postCartItemsAsync fulfilled");

        return action.payload;
      });
  },
});

export default cartSlice.reducer;

* cartSlice에는 우선 cartApi에 정의된 기능들을 createAsyncThunk로 구성하고 이를 이용해서 비동기 호출의 상태에 따른 결과를 처리할 수 있도록 구성한다. 장바구니의 경우 초기 상태는 빈 배열을 이용하고 API 서버의 호출 결과는 모두 장바구니의 아이템들의 배열이므로 이를 상태 데이터로 보관한다.

* 장바구니 상태는 로그인과 달리 모든 데이터는 API 서버에 의존한다. 로그인을 한 순간 API 서버로부터 현재 사용자의 장바구니 아이템들을 가져오고 사용자가 리액트 화면에서 변경하면 즉각적으로 서버와 동기화될 필요가 있다. 만일 유지하려는 상태 데이터가 상대적으로 덜 중요하다고 판단된다면 반드시 데이터베이스로 보관하지 않아도 괜찮다. 예를 들어 대부분의 쇼핑몰들은 고객이 최근에 본 상품목록의 경우 쿠키나 로컬 스토리지를 이용해서 보관하지만, 해당 상태 데이터의 중요성이 높다고 판단되면 반드시 데이터베이스로 보관한다.

* store.js에는 cartSlice를 추가한다.

import { configureStore } from "@reduxjs/toolkit";
import loginSlice from "./slices/loginSlice";
import cartSlice from "./slices/cartSlice";

export default configureStore({
  reducer: {
    loginSlice: loginSlice,
    cartSlice: cartSlice,
  },
});

2. 장바구니용 컴포넌트

* 장바구니를 사용하기 위한 기능들이 완성되었다면 컴포넌트를 이용해서 이를 확인하도록 한다. 장바구니는 화면에서 Sidebar 라고 출력되는 화면에 추가하도록 하고, 이를 위한 컴포넌트를 작성한다.

* 프로젝트 내 components/menus 폴더에 CartComponent.js 파일을 추가한다.

import React from "react";

const CartComponent = () => {
  return (
    <div className="w-full">
      <div>Cart</div>
    </div>
  );
};

export default CartComponent;

* CartComponent는 BasicLayout 내부에서 기본 레이아웃의 일부로 사용되도록 수정한다.

import React from "react";
import BasicMenu from "../components/menus/BasicMenu";
import CartComponent from "../components/menus/CartComponent";

const BasicLayout = ({ children }) => {
  return (
    <>
      {/* 기존 헤더 대신 BasicMenu */}
      <BasicMenu />

      {/* space-y-1 변경, md:space-x-1 변경 */}
      <div className="bg-white my-5 w-full flex flex-col space-y-1 md:flex-row md:space-x-1 md:space-y-0">
        {/* md:w-4/5 변경, py-5 변경 */}
        <main className="bg-sky-300 md:w-4/5 lg:w-3/4 px-5 py-5">
          {children}
        </main>
        {/* md:w-1/5 변경, flex 추가, py-5 변경 */}
        <aside className="bg-green-300 md:w-1/5 lg:w-1/4 px-5 flex py-5">
          <CartComponent />
        </aside>
      </div>
    </>
  );
};

export default BasicLayout;

* 브라우저를 통해서 Sidebar 영역에 CartComponent가 출력되는지를 확인한다.


(1) 로그인 상태 체크와 장바구니

* CartComponent는 현재 로그인한 상황에 따라서 API 서버의 장바구니 기능을 호출해야 한다. 따라서 CartComponent에서는 useCustomLogin 훅을 이용해서 로그인 여부를 확인한다.

import React from "react";
import useCustomLogin from "../../hooks/useCustomLogin";

const CartComponent = () => {
  const { isLogin, loginState } = useCustomLogin();

  return (
    <div className="w-full">
      {isLogin ? <div>{loginState.nickname}'s Cart</div> : <div></div>}
    </div>
  );
};

export default CartComponent;

* 브라우저에서는 로그인 여부에 따라서 사용자의 nickname이 출력되거나 아무것도 보이지 않게 된다.


[장바구니 아이템 가져오기]

* 현재 사용자가 로그인이 되었다면 장바구니 API를 호출하도록 CartComponent를 수정한다.

import React, { useEffect } from "react";
import useCustomLogin from "../../hooks/useCustomLogin";
import { getCartItemsAsync } from "../../slices/cartSlice";
import { useDispatch } from "react-redux";

const CartComponent = () => {
  const { isLogin, loginState } = useCustomLogin();
  const dispatch = useDispatch();

  useEffect(() => {
    if (isLogin) {
      dispatch(getCartItemsAsync());
    }
  }, [isLogin, dispatch]);

  return (
    <div className="w-full">
      {isLogin ? <div>{loginState.nickname}'s Cart</div> : <div></div>}
    </div>
  );
};

export default CartComponent;

* 코드에서는 useEffect()를 이용해서 로그인 상황이 되면 cartSlice 를 이용해서 장바구니 아이템들을 가져오도록 한다. 아직 브라우저 화면에서는 아무런 변화가 없지만, 로그인이 된 상황에서 API 서버를 호출하는 것을 확인할 수 있다.

* API 서버의 호출이 확인되었다면 화면에 현재 화면에 장바구니 아이템의 숫자를 출력해 보도록 한다. 예를 들어 아래와 같이 현재 사용자가 2개의 장바구니 아이템이 있다면 화면에 2를 출력하도록 구성해 본다.

import React, { useEffect } from "react";
import useCustomLogin from "../../hooks/useCustomLogin";
import { getCartItemsAsync } from "../../slices/cartSlice";
import { useDispatch, useSelector } from "react-redux";

const CartComponent = () => {
  const { isLogin, loginState } = useCustomLogin();
  const dispatch = useDispatch();
  const cartItems = useSelector((state) => state.cartSlice);

  useEffect(() => {
    if (isLogin) {
      dispatch(getCartItemsAsync());
    }
  }, [isLogin, dispatch]);

  return (
    <div className="w-full">
      {isLogin ? (
        <>
          <div className="flex">
            <div className="m-2 font-extrabold">
              {loginState.nickname}'s Cart
            </div>
          </div>
          <div className="bg-orange-600 w-9 text-center text-white font-bold rounded-full m-2">
            {cartItems.length}
          </div>
        </>
      ) : (
        <div></div>
      )}
    </div>
  );
};

export default CartComponent;


(2) 커스텀 훅으로 정리하기

* 장바구니와 관련된 기능들을 정리해 보면 useCustomLogin과도 밀접하게 관련이 있고 useDispatch, useSelector 등도 사용해야 할 일이 많다.

* 이러한 기능들을 hooks 폴더에 useCustomCart.js로 구성한다.

import { useDispatch, useSelector } from "react-redux";
import { getCartItemsAsync, postChangeCartAsync } from "../slices/cartSlice";

const useCustomCart = () => {
  const cartItems = useSelector((state) => state.cartSlice);

  const dispatch = useDispatch();

  const refreshCart = () => {
    dispatch(getCartItemsAsync());
  };

  const changeCart = (param) => {
    dispatch(postChangeCartAsync(param));
  };

  return { cartItems, refreshCart, changeCart };
};

export default useCustomCart;

* useCustomCart는 로그인 관련 데이터와 장바구니 관련 호출 기능을 refreshCart와 changeCart로 반환한다.

* CartComponent는 작성된 useCustomCart를 이용하도록 수정한다.

import React, { useEffect } from "react";
import useCustomLogin from "../../hooks/useCustomLogin";
import useCustomCart from "../../hooks/useCustomCart";

const CartComponent = () => {
  const { isLogin, loginState } = useCustomLogin();
  const { refreshCart, cartItems } = useCustomCart();

  useEffect(() => {
    if (isLogin) {
      refreshCart();
    }
  }, [isLogin]); // refreshCart 를 의존성 배열에 넣으면 무한루프에 걸리므로 뺀다.

  return (
    <div className="w-full">
      {isLogin ? (
        <>
          <div className="flex">
            <div className="m-2 font-extrabold">
              {loginState.nickname}'s Cart
            </div>
          </div>
          <div className="bg-orange-600 w-9 text-center text-white font-bold rounded-full m-2">
            {cartItems.length}
          </div>
        </>
      ) : (
        <div></div>
      )}
    </div>
  );
};

export default CartComponent;