✨ 기억보다 기록을/프로젝트

20241126(화) ▶️ 굿즈샵 상세보기 페이지 - 장바구니 버튼 클릭시 로컬스토리지에 저장하기

서카츄 2024. 11. 26. 19:32

장바구니 버튼을 클릭할 때 로컬스토리지에 저장해서 비회원도 장바구니에 담을 수 있도록 구현해 보았다.

 

장바구니 담을 시 고려할 사항

  1. 같은 상품 담을 시 최대 수량 5개로 제한, 더 담으려고 할때 toast 창으로 더이상 담을 수 없다고 알림창 띄워줌
  2. 장바구니 아이콘에 총 수량 표시(header)

 

 

먼저 로컬스토리지에 담을 유틸리티 함수를 만든다.

//utils/cartStorage.ts

import type { CartItem } from "@/types";

export const cartStorage = {
  getCart: (): CartItem[] => {
    if (typeof window === "undefined") return [];
    const cart = localStorage.getItem("shopping_cart");
    return cart ? JSON.parse(cart) : [];
  },

  //현재 장바구니에 담긴 특정 상품의 수량을 확인하는 함수
  getItemQuantity: (itemId: string): number => {
    const currentCart = cartStorage.getCart();
    const existingItem = currentCart.find((item) => item.id === itemId);
    return existingItem?.quantity || 0;
  },

  addItem: (item: CartItem) => {
    const currentCart = cartStorage.getCart();
    const existingItem = currentCart.find(
      (cartItem) => cartItem.id === item.id
    );

    //현재 장바구니에 담긴 수량 + 새로 담을 수량이 5개를 초과하는지 체크
    const currentQuantity = existingItem?.quantity || 0;
    const newTotalQuantity = currentQuantity + item.quantity;

    if (newTotalQuantity > 5) {
      throw new Error("최대 구매 가능 수량은 5개입니다.");
    }

    let newCart;
    if (existingItem) {
      newCart = currentCart.map((cartItem) =>
        cartItem.id === item.id
          ? { ...cartItem, quantity: newTotalQuantity }
          : cartItem
      );
    } else {
      newCart = [...currentCart, item];
    }

    localStorage.setItem("shopping_cart", JSON.stringify(newCart));
    return newCart;
  },
};

 

 

 

코드 설명

`cartStorage`를 객체로 만들어서, 장바구니 관련 기능들을 묶어서 관리한다.

 

 

장바구니 조회

getCart: (): CartItem[] => {
  // 서버사이드에서 실행될 경우 빈 배열 반환
  if (typeof window === "undefined") return [];
  
  // localStorage에서 장바구니 데이터 가져오기
  const cart = localStorage.getItem("shopping_cart");
  return cart ? JSON.parse(cart) : [];
}

 

로컬스토리지는 CSR일 때 실행되기 때문에 if문으로 SSR에서 실행되면 빈 배열을 반환하도록 체크한다. (하이드레이션 에러 방지)

로컬스토리지에서 `getItem`으로 장바구니에 있는 데이터를 가져온다.

데이터가 있으면 `cart`변수에 담은 내용을 가져오고, 없으면 빈 배열을 반환한다.

로컬스토리지에서 가져온 문자열을 `JSON.parse`로 CartItem[] 배열로 반환한다.

(로컬스토리지는 문자열만 저장할 수 있기 때문에!)

 

 

 

 

특정 상품의 수량 확인 체크

getItemQuantity: (itemId: string): number => {
  const currentCart = cartStorage.getCart(); //CartItem[] 배열을 반환한다!
  
  //특정 상품을 찾는다.
  const existingItem = currentCart.find((item) => item.id === itemId);
  
  //반환
  return existingItem?.quantity || 0;
}

 

매개변수로 특정 상품의 id를 받아와서 장바구니 안에 있는 수량을 반환한다.

`currentCart` : cartStorage에 있는 getCart 메서드를 호출해서 현재 장바구니의 데이터를 가져온다.

`existingItem` : find 메서드로 조건에 맞는 아이템을 찾아서 반환하고, 매개변수로 받은 itemId와 같은 상품을 찾는다. 없으면 undefined.

반환하는 return에는 `existingItem`안에 있는 quantity가 있으면 보여주고, 상품이 없으면 0을 반환한다.

 

 

 

 

 

상품 추가

addItem: (item: CartItem) => {
  const currentCart = cartStorage.getCart();
  const existingItem = currentCart.find(
    (cartItem) => cartItem.id === item.id
  );

  //현재 장바구니에 담긴 수량 + 새로 담을 수량이 5개를 초과하는지 체크
  const currentQuantity = existingItem?.quantity || 0;
  const newTotalQuantity = currentQuantity + item.quantity;

  if (newTotalQuantity > 5) {
    throw new Error("최대 구매 가능 수량은 5개입니다.");
  }

  let newCart;
  if (existingItem) {
    newCart = currentCart.map((cartItem) =>
      cartItem.id === item.id
        ? { ...cartItem, quantity: newTotalQuantity }
        : cartItem
    );
  } else {
    newCart = [...currentCart, item];
  }

  localStorage.setItem("shopping_cart", JSON.stringify(newCart));
  return newCart;
},

 

const currentCart = cartStorage.getCart();

 

현재 장바구니를 가져온다.

 

 

 

const existingItem = currentCart.find(
  (cartItem) => cartItem.id === item.id
);

 

find 메서드로 현재 장바구니에 있는 item의 id를 찾는다.

 

 

 

 

const currentQuantity = existingItem?.quantity || 0;
const newTotalQuantity = currentQuantity + item.quantity;

 

수량을 체크하는 함수이다.

`currentQuantity` : existingItem에 quantity를 가져오고, 없으면 0을 담는다.

`newTotalQuantity` : 현재 로컬스토리지에 있는 수량과 매개변수로 받아온 item의 quantity를 더한 내용을 담는다.

 

 

 

 

if (newTotalQuantity > 5) {
  throw new Error("최대 구매 가능 수량은 5개입니다.");
}

 

if문으로 수량이 5개를 초과하면 에러를 던지고 함수실행을 중단한다.

 

 

 

 

// 장바구니 업데이트
let newCart;
if (existingItem) {
  // 기존 상품이 있으면 수량만 업데이트
  newCart = currentCart.map((cartItem) =>
    cartItem.id === item.id
      ? { ...cartItem, quantity: newTotalQuantity }
      : cartItem
  );
} else {
  // 새 상품이면 배열에 추가!
  newCart = [...currentCart, item];
}

 

새로운 장바구니를 업데이트 해야하기 때문에 `newCart`변수를 생성한다.

map으로 순회하면서 기존 상품이 있으면 `quantity`를 업데이트 하고 나머지는 그대로 유지한다.

만약에 새로운 상품이면 기존 배열에 새 상품을 추가한다.

 

 

 

 

localStorage.setItem("shopping_cart", JSON.stringify(newCart));
return newCart;

 

`newCart` 객체는 문자열로 변환해서 로컬스토리지에 저장하고, 업데이트 된 장바구니를 반환한다.

 

 


 

 

적용할 컴포넌트

//ProductActions.tsx

const ProductActions = ({ product, quantity }: ProductActionsProps) => {
  const setCartItems = useSetRecoilState(cartState);

  const onClickCart = () => {
    try {
      //현재 장바구니에 담긴 수량 확인
      const currentQuantity = cartStorage.getItemQuantity(product.id);

      //추가할 수량과 합쳐서 5개를 초과하는지 체크
      if (currentQuantity + quantity > 5) {
        toast({
          title: `현재 장바구니에 ${currentQuantity}개가 있어 ${quantity}개를 추가할 수 없습니다.`,
          description: "최대 구매 가능 수량은 5개입니다.",
        });
        return;
      }

      const cartItem: CartItem = {
        ...product,
        quantity,
      };

      const updatedCart = cartStorage.addItem(cartItem);
      setCartItems(updatedCart);
      setIsDrawerOpen(true);
    } catch (error) {
      if (error instanceof Error) {
        toast({
          title: error.message,
        });
      }
    }
  };

  return (
    <>
	    {/* 중략 */}
	     <ActionButton
            onClick={onClickCart}
            variant="outline"
            className="w-full py-3 text-center"
          >
          장바구니
	      </ActionButton>
    </>
  );
};

export default ProductActions;

 

부모 컴포넌트에서 상품과 현재 수량을 props로 받아와서 장바구니 버튼을 클릭을 할때 액션을 취한다.

 

 

 

 

 

 

 

const onClickCart = () => {
    try {
      //현재 장바구니에 담긴 수량 확인
      const currentQuantity = cartStorage.getItemQuantity(product.id);

      //추가할 수량과 합쳐서 5개를 초과하는지 체크
      if (currentQuantity + quantity > 5) {
        toast({
          title: `현재 장바구니에 ${currentQuantity}개가 있어 ${quantity}개를 추가할 수 없습니다.`,
          description: "최대 구매 가능 수량은 5개입니다.",
        });
        return;
      }

      const cartItem: CartItem = {
        ...product,
        quantity,
      };

      const updatedCart = cartStorage.addItem(cartItem);
      setCartItems(updatedCart);
      setIsDrawerOpen(true);
    } catch (error) {
      if (error instanceof Error) {
        toast({
          title: error.message,
        });
      }
    }
  };

 

const currentQuantity = cartStorage.getItemQuantity(product.id);

 

현재 장바구니에 담긴 수량을 확인하기 위해 `cartStorage`함수에 있는 특정 상품 수량을 확인하는 함수에 id를 보내줘서 체크하고, `currentQuantity` 변수에 담는다.

 

 

 

 

//추가할 수량과 합쳐서 5개를 초과하는지 체크
if (currentQuantity + quantity > 5) {
  toast({
    title: `현재 장바구니에 ${currentQuantity}개가 있어 ${quantity}개를 추가할 수 없습니다.`,
    description: "최대 구매 가능 수량은 5개입니다.",
  });
  return;
}

 

로컬스토리지에 있는 수량과 추가할 수량을 체크해서 5개 이상이면 toast로 알림창을 띄운다.

 

 

 

 

const cartItem: CartItem = {
  ...product,
  quantity,
};

 

이제 장바구니에 담을 상품 객체를 만든다.

기존 상품 `내용` + `수량`을 합쳐야 하기 때문에

기존 상품 정보를 스프레드 연산자로 복사하고 선택된 수량을 추가 해준다.

`quantity`는 숏핸드 프로퍼티로 줄여주었다

 

 

 

 

const updatedCart = cartStorage.addItem(cartItem);
setCartItems(updatedCart); //Recoil state 업데이트(header)에 장바구니 업데이트
setIsDrawerOpen(true); //모달창 열기(서랍)

 

장바구니를 업데이트하기 위해서 로컬스토리지에 상품을 추가하고 업데이트 된 상품을 반환한다.

 

 

 

 


 

 

장바구니 수량 카운트(header)

장바구니에 담고 현재 담은 내용을 장바구니 아이콘에 카운팅 하기위해서 전역스테이트인 `Recoil`에 담았다.

export const cartState = atom({
  key: "cartState",
  default: cartStorage.getCart(),
});

 

`default`는 초기값으로 로컬스토리지의 장바구니 데이터를 가져온다.

 

 

 


 

 

 

적용할 컴포넌트

//CartIcon.tsx

const CartIcon = ({
  iconSize = 24,
  iconClassName = "",
  linkClassName = "", 
  className = "", 
}: CartIconProps): JSX.Element => {
  const [mounted, setMounted] = useState(false);
  const [cartItems] = useRecoilState(cartState);
  
  const totalQuantity = cartItems.reduce(
    (sum, item) => sum + (item.quantity || 0),
    0
  );

  useEffect(() => {
    setMounted(true);
  }, []);

  return (
    <Link href="/cart" className={linkClassName}>
      <div className="relative">
        <IoCartOutline
          className={`${iconClassName} cursor-pointer`}
          size={iconSize}
        />
        {mounted && totalQuantity > 0 && (
          <span
            className={`${className} absolute bottom-3 rounded-full bg-rose-500 text-xs text-white w-5 h-5 flex items-center justify-center`}
          >
            {totalQuantity}
          </span>
        )}
      </div>
    </Link>
  );
};

export default CartIcon;

 

 

const CartIcon = ({
  iconSize = 24, //기본 아이콘 크기
  iconClassName = "", //기본 아이콘 클래스
  linkClassName = "", //기본 링크 클래스
  className = "", //기본 클래스
}: CartIconProps): JSX.Element => {

 

PC와 모바일 버전에서 재사용되는 컴포넌트여서, props로 커스터마이징이 가능하도록 설계하였다.

 

 

JSX.Element 타입 지정이 필요한 이유

SSR(서버 사이드 렌더링)과정

  • 초기 프리렌더링때 로컬스토리지에 접근이 불가하기 때문이다.
  • 이 시점에서 컴포넌트가 무엇을 반환하지? 를 모르고 있었다.

CSR(클라이언트 사이드 렌더링)과정

  • 하이드레이션이 끝나고, 컴포넌트를 마운트 한다.
  • 마운트가 끝나야 로컬스토리지에 있는 데이터에 접근이 가능했다.
  • 그 뒤로 JSX를 렌더링한다.

→ 결국 프리렌더링 과정으로 타입에러가 발생하기 때문에 JSX를 반환할거야! 라고 명시해 주어야 한다.

타입스크립트에서 반환타입을 정확하게 알려주고, JSX 관련 타입 체크를 명시적으로 해주었다.

 

 

 

const [mounted, setMounted] = useState(false);

useEffect(() => {
  setMounted(true);
}, []);

 

로컬스토리지는 브라우저 기능이기 때문에 useEffect로 컴포넌트가 마운트 되면 내용을 불러올 수 있도록 상태관리를 추가하였다.

 

 

 

 

const totalQuantity = cartItems.reduce(
  (sum, item) => sum + (item.quantity || 0),
  0
);

 

`reduce` 메서드를 사용해서 배열을 순회하면서 누적값과 현재값을 더한다.

만약에 item에 수량이 없으면 `||` 연산자로 0을 반환한다.

 

 

 

 

{mounted && totalQuantity > 0 && (
    <span
      className={`${className} absolute bottom-3 rounded-full bg-rose-500 text-xs text-white w-5 h-5 flex items-center justify-center`}
      >
      {totalQuantity}
    </span>
)}

 

`mounted`가 true 고 `totalQuanity`가 1부터 시작하면(즉, 장바구니에 내용이 있으면) 카운팅한 갯수를 보여준다.

 

 

 

 

 

 

적용 결과