Repository: tutti-tutti/tutti-client
Pull request #142: [✨ feat] 비회원/회원에 따라 장바구니 추가, 조회 적용 (#141)
Author: login: bbjbc
Labels: name: ✨ feat
description: 새로운 기능을 구현하거나 기존 기능을 확장합니다
Created: 2025-03-24T05:56:54Z | Merged: 2025-03-25T06:01:07Z

# 🚀 풀 리퀘스트 제안

## 📋 작업 내용

비회원/회원에 따라 장바구니 추가 및 조회를 적용했어요.

## 🔧 변경 사항

- 비회원/회원에 따른 서비스 함수를 제작했어요.
- 서비스 함수 제작하면서 비회원일 경우에는 상품 상세 정보와 `productId`를 비교해서 가져오는 부분이 필요해서 그 부분까지 추가했어요.
- 상품 썸네일에서 액션을 위해서 `productId`가 필요해 추가했어요.
- 상품 썸네일 `dim`에 보이는 장바구니 아이콘을 누르면 `addCart`가 적용되도록 했어요.

## 리팩토링

1. 상품 탐색에서 hover 시 등장하는 아이콘에서 장바구니 아이콘 제거
2. 비회원일 경우 장바구니에 추가 및 조회
3. 옵션 하나에 조합의 경우의 수 삽입

## 📸 스크린샷

수정된 화면 또는 기능을 시연할 수 있는 스크린샷을 첨부할 수 있습니다.

## 📄 기타

현재 장바구니 조회 시 쿼리가 아닌 api 호출하는 로직으로 구성되어 있어요.
캐싱이 되지 않고 장바구니 페이지로 이동 시마다 로딩 메시지를 통해 상품이 렌더링돼요. 
쿼리를 사용하니 어디가 잘못됐는지 `로딩 → 비어있음 → 상품 렌더링`의 플로우로 진행되고 있어요. 이는 주석처리 해놨어요.

현재 `access_token`이 존재하지 않아 비회원의 형태로 확인 가능해요. 상품썸네일에서 호버하고 장바구니에서 추가하거나, 상세페이지에서 `장바구니 담기` 하면 로컬스토리지에 추가되고 `장바구니` 페이지에서 확인할 수 있습니다.
인터랙션 이후 토스트 메시지가 표시되면 좋을 것 같은데 토스트 라이브러리 [sonner](https://sonner.emilkowal.ski/)를 사용하는 것은 어떨까요? 간단하게 사용할 수 있고 예쁩니다.


Diff:
diff --git a/src/app/(root)/cart/page.tsx b/src/app/(root)/cart/page.tsx
index 731cd4f6..0713bc79 100644
--- a/src/app/(root)/cart/page.tsx
+++ b/src/app/(root)/cart/page.tsx
@@ -1,12 +1,17 @@
-import { fetchCart } from '@/services';
+import { HydrationBoundary } from '@tanstack/react-query';
+
 import { CartInfo, RecommendProductList } from '@/components';
+import { getDehydratedState } from '@/utils';
+import { cartQueryOptions } from '@/queries';
 
-const CartPage = async () => {
-  const cartItems = await fetchCart();
+const CartPage = () => {
+  const dehydratedState = getDehydratedState(cartQueryOptions);
 
   return (
     <div className="gap-3xl flex flex-col">
-      <CartInfo initialCartItems={cartItems} />
+      <HydrationBoundary state={dehydratedState}>
+        <CartInfo />
+      </HydrationBoundary>
       <div className="mb-7xl">
         <RecommendProductList categoryName="식료품" />
       </div>
diff --git a/src/app/api/cart/route.ts b/src/app/api/cart/route.ts
new file mode 100644
index 00000000..8fd407f0
--- /dev/null
+++ b/src/app/api/cart/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from 'next/server';
+
+import { addCart } from '@/services';
+
+export const POST = async (request: Request) => {
+  try {
+    const body = await request.json();
+
+    if (
+      !body.cartItems ||
+      !Array.isArray(body.cartItems) ||
+      body.cartItems.length === 0
+    ) {
+      return NextResponse.json(
+        { message: 'cartItems가 필요합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const cartItem = body.cartItems[0];
+
+    if (!cartItem.productItemId || !cartItem.quantity) {
+      return NextResponse.json(
+        { message: 'productItemId와 quantity가 필요합니다.' },
+        { status: 400 },
+      );
+    }
+
+    if (!cartItem.productId) {
+      return NextResponse.json(
+        { message: 'productId가 필요합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const products = await addCart(
+      cartItem.productId,
+      cartItem.productItemId,
+      cartItem.quantity,
+    );
+
+    return NextResponse.json(products);
+  } catch (error) {
+    return NextResponse.json(
+      { message: (error as Error).message },
+      { status: 500 },
+    );
+  }
+};
diff --git a/src/app/api/products/[productId]/route.ts b/src/app/api/products/[productId]/route.ts
new file mode 100644
index 00000000..3f60a7ed
--- /dev/null
+++ b/src/app/api/products/[productId]/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from 'next/server';
+
+import { fetchProductById } from '@/services';
+
+interface Params {
+  params: Promise<{ productId: number }>;
+}
+
+export const GET = async (_request: Request, { params }: Params) => {
+  const { productId } = await params;
+
+  try {
+    const product = await fetchProductById(String(productId));
+
+    if (!product) {
+      return NextResponse.json(
+        { message: '상품을 찾을 수 없습니다.' },
+        { status: 404 },
+      );
+    }
+
+    return NextResponse.json(product);
+  } catch (error) {
+    return NextResponse.json(
+      { message: (error as Error).message },
+      { status: 500 },
+    );
+  }
+};
diff --git a/src/components/cart/CartInfo.tsx b/src/components/cart/CartInfo.tsx
index 20b72f9f..76eeafe0 100644
--- a/src/components/cart/CartInfo.tsx
+++ b/src/components/cart/CartInfo.tsx
@@ -1,8 +1,5 @@
 'use client';
 
-import { useEffect } from 'react';
-
-import { useCartStore } from '@/stores';
 import {
   Checkbox,
   ExtraButton,
@@ -10,40 +7,32 @@ import {
   CartPaymentInfo,
   Button,
 } from '@/components';
-import type { CartProductItem } from '@/types';
 import { cn } from '@/utils';
+import { useCart } from '@/hooks';
 import CartHeader from './CartHeader';
 
-interface CartInfoProps {
-  initialCartItems: CartProductItem[];
-}
-
-const CartInfo = ({ initialCartItems }: CartInfoProps) => {
+const CartInfo = () => {
   const {
-    setCartItems,
-    toggleAllCheckbox,
-    removeSelectedItems,
-    getCheckedItemsCount,
-    getTotalItemsCount,
+    error,
+    checkedCount,
+    totalCount,
     isAllChecked,
-    getPaymentInfo,
-  } = useCartStore();
-
-  useEffect(() => {
-    setCartItems(initialCartItems);
-  }, [initialCartItems, setCartItems]);
-
-  const checkedCount = getCheckedItemsCount();
-  const totalCount = getTotalItemsCount();
+    paymentInfo,
+    toggleAllCheckbox,
+    handleDeleteSelected,
+  } = useCart();
 
-  const { totalPrice, discountPrice, deliveryPrice, finalPrice } =
-    getPaymentInfo();
+  if (error) {
+    return (
+      <div className="py-xl text-center">
+        <p className="font-style-heading text-text-danger">
+          {error?.message || String(error)}
+        </p>
+      </div>
+    );
+  }
 
-  const handleDelete = () => {
-    if (checkedCount > 0 && window.confirm('선택한 상품을 삭제하시겠습니까?')) {
-      removeSelectedItems();
-    }
-  };
+  const { totalPrice, discountPrice, deliveryPrice, finalPrice } = paymentInfo;
 
   return (
     <>
@@ -55,7 +44,7 @@ const CartInfo = ({ initialCartItems }: CartInfoProps) => {
             <div className="py-md flex w-full items-center justify-between">
               <Checkbox
                 label={`전체선택하기 (${checkedCount}/${totalCount})`}
-                checked={isAllChecked()}
+                checked={isAllChecked}
                 onChange={checked => toggleAllCheckbox(checked)}
                 disabled={totalCount === 0}
               />
@@ -64,7 +53,7 @@ const CartInfo = ({ initialCartItems }: CartInfoProps) => {
                   '!px-xs !py-2xs md:px-md md:py-xs',
                   checkedCount === 0 && 'cursor-not-allowed',
                 )}
-                onClick={handleDelete}
+                onClick={handleDeleteSelected}
               >
                 선택삭제
               </ExtraButton>
diff --git a/src/components/cart/CartItem.tsx b/src/components/cart/CartItem.tsx
index 0b0d66c8..ed28d9e4 100644
--- a/src/components/cart/CartItem.tsx
+++ b/src/components/cart/CartItem.tsx
@@ -12,7 +12,7 @@ import CartItemPrice from './CartItemPrice';
 import CartItemQuantity from './CartItemQuantity';
 
 const CartItem = ({
-  productId,
+  productItemId,
   productItemName,
   storeName,
   productImgUrl,
@@ -29,11 +29,11 @@ const CartItem = ({
   const { toggleItemCheckbox, updateQuantity, removeItem, checkedItems } =
     useCartStore();
 
-  const isChecked = checkedItems[productId] || false;
+  const isChecked = checkedItems[productItemId] || false;
 
   useEffect(() => {
-    updateQuantity(productId, productQuantity);
-  }, [productId, productQuantity, updateQuantity]);
+    updateQuantity(productItemId, productQuantity);
+  }, [productItemId, productQuantity, updateQuantity]);
 
   const discountRate = calculateDiscountRate(originalPrice, sellingPrice);
   const hasOptions = !!firstOptionName || !!secondOptionName;
@@ -52,14 +52,14 @@ const CartItem = ({
 
   const handleDelete = () => {
     if (window.confirm('해당 상품을 삭제하시겠습니까?')) {
-      removeItem(productId);
+      removeItem(productItemId);
     }
   };
 
   return (
     <li className="py-lg md:py-2xl border-border-secondary gap-sm flex w-full border-t">
       <CartItemImage
-        productId={productId}
+        productId={productItemId}
         productImgUrl={productImgUrl}
         productItemName={productItemName}
         isChecked={isChecked}
diff --git a/src/components/cart/CartItemQuantity.tsx b/src/components/cart/CartItemQuantity.tsx
index 8fe7e508..0e898931 100644
--- a/src/components/cart/CartItemQuantity.tsx
+++ b/src/components/cart/CartItemQuantity.tsx
@@ -16,7 +16,7 @@ const CartItemQuantity = ({
   handleDecrease,
 }: CartItemQuantityProps) => {
   return (
-    <div className="flex flex-col">
+    <div className="flex h-full flex-col">
       <div className="mt-auto flex items-center justify-start md:justify-end">
         <span className="mr-md font-style-paragraph md:font-style-heading text-text-secondary">
           수량
diff --git a/src/components/cart/CartList.tsx b/src/components/cart/CartList.tsx
index 8ce40c50..4dd59634 100644
--- a/src/components/cart/CartList.tsx
+++ b/src/components/cart/CartList.tsx
@@ -1,9 +1,22 @@
-import { useCartStore } from '@/stores';
+'use client';
+
 import CartItem from './CartItem';
 import { MoreViewButton } from '../common';
+import { useCart } from '@/hooks';
 
 const CartList = () => {
-  const { items } = useCartStore();
+  const { items, isLoading } = useCart();
+
+  if (isLoading) {
+    return (
+      <div className="py-xl text-center">
+        <p className="text-text-secondary font-style-heading">
+          {/* 추후 스켈레톤으로 구현 */}
+          장바구니를 불러오는 중...
+        </p>
+      </div>
+    );
+  }
 
   if (items.length === 0) {
     return (
@@ -19,7 +32,7 @@ const CartList = () => {
     <section>
       <ul className="flex flex-col">
         {items.map(item => (
-          <CartItem key={item.productId} {...item} />
+          <CartItem key={item.productItemId} {...item} />
         ))}
       </ul>
       {items.length > 3 && <MoreViewButton />}
diff --git a/src/components/products/ProductThumbnailAction.tsx b/src/components/products/ProductThumbnailAction.tsx
index a52e63d0..e0165911 100644
--- a/src/components/products/ProductThumbnailAction.tsx
+++ b/src/components/products/ProductThumbnailAction.tsx
@@ -3,22 +3,20 @@
 import { IconButton } from '../common';
 
 const ProductThumbnailAction = () => {
-  const handleCartClick = () => {
-    console.log('장바구니에 담기'); // 추후 구현
-  };
-
   const handleHeartClick = () => {
     console.log('이 상품을 좋아해요'); // 추후 구현
   };
 
+  const handleContainerClick = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    e.preventDefault();
+  };
+
   return (
-    <div className="gap-sm absolute right-4 bottom-4 flex items-center">
-      <IconButton
-        icon="cart"
-        variant="primaryShadow"
-        iconProps={{ width: '24', height: '24' }}
-        onClick={handleCartClick}
-      />
+    <div
+      className="gap-sm absolute right-4 bottom-4 flex items-center"
+      onClick={handleContainerClick}
+    >
       <IconButton
         icon="heart"
         variant="primaryShadow"
diff --git a/src/components/products/product-detail/ProductActions.tsx b/src/components/products/product-detail/ProductActions.tsx
index 564d20f3..22eb5bc3 100644
--- a/src/components/products/product-detail/ProductActions.tsx
+++ b/src/components/products/product-detail/ProductActions.tsx
@@ -1,14 +1,39 @@
 import { Button } from '@/components';
+import { addCart } from '@/services';
+
+interface ProductActionsProps {
+  productId: number;
+  productItemId: number;
+  quantity: number;
+  disabled?: boolean;
+}
+
+const ProductActions = ({
+  productId,
+  productItemId,
+  quantity,
+  disabled,
+}: ProductActionsProps) => {
+  const handleCartClick = async () => {
+    await addCart(productId, productItemId, quantity);
+  };
 
-const ProductActions = () => {
   return (
     <div className="gap-sm grid grid-cols-2">
-      <Button
-        variant="secondaryOutline"
-        className="font-style-subHeading flex-auto"
-      >
-        장바구니 담기
-      </Button>
+      {disabled ? (
+        <Button variant="disabled" className="font-style-subHeading flex-auto">
+          장바구니 담기
+        </Button>
+      ) : (
+        <Button
+          variant="secondaryOutline"
+          className="font-style-subHeading flex-auto"
+          onClick={handleCartClick}
+        >
+          장바구니 담기
+        </Button>
+      )}
+
       <Button className="font-style-subHeading flex-auto">구매하기</Button>
     </div>
   );
diff --git a/src/components/products/product-detail/ProductDetailItem.tsx b/src/components/products/product-detail/ProductDetailItem.tsx
index 318c40e9..b39c729d 100644
--- a/src/components/products/product-detail/ProductDetailItem.tsx
+++ b/src/components/products/product-detail/ProductDetailItem.tsx
@@ -3,7 +3,7 @@
 import { useState } from 'react';
 
 import { calculateDiscountRate } from '@/utils';
-import type { Product, OptionValue } from '@/types';
+import type { Product, ProductOption } from '@/types';
 import ProductHeader from './ProductHeader';
 import ProductOptions from './ProductOptions';
 import ProductPrice from './ProductPrice';
@@ -12,6 +12,7 @@ import ProductActions from './ProductActions';
 import ProductThumbnail from '../ProductThumbnail';
 
 const ProductDetailItem = ({
+  productId,
   titleUrl,
   name,
   storeName,
@@ -20,34 +21,24 @@ const ProductDetailItem = ({
   likes,
   originalPrice,
   sellingPrice,
-  options,
+  productItems,
   maxPurchaseQuantity,
 }: Product) => {
   const discountRate = calculateDiscountRate(originalPrice, sellingPrice);
 
-  const [selectedOptions, setSelectedOptions] = useState<
-    Record<string, OptionValue>
-  >({});
   const [finalPrice, setFinalPrice] = useState<number>(sellingPrice);
   const [quantity, setQuantity] = useState<number>(1);
+  const [selectedProductItemId, setSelectedProductItemId] = useState<
+    number | null
+  >(null);
 
-  const handleOptionChange = (
-    optionName: string,
-    selectedValue: OptionValue,
-  ) => {
-    const newSelectedOptions = {
-      ...selectedOptions,
-      [optionName]: selectedValue,
-    };
-
-    setSelectedOptions(newSelectedOptions);
+  const handleOptionChange = (selectedOption: ProductOption) => {
+    setSelectedProductItemId(selectedOption.productItemId);
 
     let newPrice = sellingPrice;
-    Object.values(newSelectedOptions).forEach(value => {
-      if (value && value.additionalPrice) {
-        newPrice += value.additionalPrice;
-      }
-    });
+    if (selectedOption && selectedOption.additionalPrice) {
+      newPrice += selectedOption.additionalPrice;
+    }
 
     setFinalPrice(newPrice);
   };
@@ -86,7 +77,7 @@ const ProductDetailItem = ({
           likes={likes}
         />
         <ProductOptions
-          options={options}
+          productItems={productItems}
           handleOptionChange={handleOptionChange}
         />
         <ProductPrice
@@ -100,8 +91,14 @@ const ProductDetailItem = ({
           maxPurchaseQuantity={maxPurchaseQuantity}
           handleIncrease={handleIncrease}
           handleDecrease={handleDecrease}
+          disabled={selectedProductItemId === null}
+        />
+        <ProductActions
+          productItemId={selectedProductItemId ?? 0}
+          quantity={quantity}
+          disabled={selectedProductItemId === null}
+          productId={productId}
         />
-        <ProductActions />
       </section>
     </div>
   );
diff --git a/src/components/products/product-detail/ProductOptions.tsx b/src/components/products/product-detail/ProductOptions.tsx
index 07b1a86d..178020e7 100644
--- a/src/components/products/product-detail/ProductOptions.tsx
+++ b/src/components/products/product-detail/ProductOptions.tsx
@@ -1,49 +1,71 @@
 'use client';
 
-import { Dropdown } from '@/components/common';
-import type { OptionValue, ProductOption } from '@/types';
+import { useEffect } from 'react';
+
+import { Dropdown } from '@/components';
+import type { ProductOption } from '@/types';
 
 interface ProductOptionsProps {
-  options: ProductOption[];
-  handleOptionChange: (optionName: string, selectedValue: OptionValue) => void;
+  productItems: ProductOption[];
+  handleOptionChange: (selectedOption: ProductOption) => void;
 }
 
 const ProductOptions = ({
-  options,
+  productItems,
   handleOptionChange,
 }: ProductOptionsProps) => {
-  const formatDropdownOptions = (option: ProductOption) => {
-    return option.values.map(value => ({
-      value: value.name,
-      label: value.additionalPrice
-        ? `${value.name} (+${value.additionalPrice.toLocaleString()}원)`
-        : value.name,
-      additionalPrice: value.additionalPrice || 0,
+  const hasOptions = productItems.some(
+    item => item.firstOptionName !== null || item.secondOptionName !== null,
+  );
+
+  useEffect(() => {
+    if (!hasOptions && productItems.length > 0) {
+      handleOptionChange(productItems[0]);
+    }
+  }, [productItems, hasOptions, handleOptionChange]);
+
+  const formatDropdownOptions = (options: ProductOption[]) => {
+    return options.map(option => ({
+      value: option.productItemId.toString(),
+      label: `${option.firstOptionValue || ''}${
+        option.secondOptionValue
+          ? (option.firstOptionValue ? ', ' : '') + option.secondOptionValue
+          : ''
+      }${
+        option.additionalPrice && option.additionalPrice > 0
+          ? `(+${option.additionalPrice.toLocaleString()}원)`
+          : option.additionalPrice && option.additionalPrice < 0
+            ? `(${option.additionalPrice.toLocaleString()}원)`
+            : ''
+      }`,
+      additionalPrice: option.additionalPrice || 0,
     }));
   };
 
+  if (!hasOptions) {
+    return null;
+  }
+
   return (
     <article className="gap-lg pb-lg md:py-xl flex flex-col">
-      {options &&
-        options.map((option, index) => (
-          <div key={index} className="gap-md flex items-center">
-            <label className="font-style-subHeading text-text-secondary min-w-[100px]">
-              {option.name}
-            </label>
-            <Dropdown
-              options={formatDropdownOptions(option)}
-              placeholder={`${option.name} 선택`}
-              onChange={selectedOption => {
-                const selectedValue = option.values.find(
-                  v => v.name === selectedOption.value,
-                );
-                if (selectedValue) {
-                  handleOptionChange(option.name, selectedValue);
-                }
-              }}
-            />
-          </div>
-        ))}
+      <div className="gap-md flex items-center">
+        <label className="font-style-subHeading text-text-secondary min-w-[60px]">
+          옵션
+        </label>
+        <Dropdown
+          options={formatDropdownOptions(productItems)}
+          placeholder="옵션 선택"
+          onChange={selectedOption => {
+            const selectedValue = productItems.find(
+              option =>
+                option.productItemId.toString() === selectedOption.value,
+            );
+            if (selectedValue) {
+              handleOptionChange(selectedValue);
+            }
+          }}
+        />
+      </div>
     </article>
   );
 };
diff --git a/src/components/products/product-detail/ProductQuantity.tsx b/src/components/products/product-detail/ProductQuantity.tsx
index 4e704f77..4f83be46 100644
--- a/src/components/products/product-detail/ProductQuantity.tsx
+++ b/src/components/products/product-detail/ProductQuantity.tsx
@@ -7,6 +7,7 @@ interface ProductQuantityProps {
   maxPurchaseQuantity: number;
   handleIncrease: () => void;
   handleDecrease: () => void;
+  disabled?: boolean;
 }
 
 const ProductQuantity = ({
@@ -14,6 +15,7 @@ const ProductQuantity = ({
   maxPurchaseQuantity,
   handleIncrease,
   handleDecrease,
+  disabled,
 }: ProductQuantityProps) => {
   return (
     <article className="pb-lg">
@@ -26,7 +28,7 @@ const ProductQuantity = ({
               <IconButton
                 icon="minus"
                 iconProps={{ width: 18, height: 18 }}
-                variant={quantity <= 1 ? 'disabled' : undefined}
+                variant={disabled || quantity <= 1 ? 'disabled' : undefined}
                 onClick={handleDecrease}
               />
               <span className="font-style-heading text-text-primary">
@@ -36,7 +38,9 @@ const ProductQuantity = ({
                 icon="plus"
                 iconProps={{ width: 18, height: 18 }}
                 variant={
-                  quantity >= maxPurchaseQuantity ? 'disabled' : undefined
+                  disabled || quantity >= maxPurchaseQuantity
+                    ? 'disabled'
+                    : undefined
                 }
                 onClick={handleIncrease}
               />
diff --git a/src/constants/queries.ts b/src/constants/queries.ts
index 461c6d7e..81f5bc70 100644
--- a/src/constants/queries.ts
+++ b/src/constants/queries.ts
@@ -17,3 +17,7 @@ export const PRODUCTS_QUERY_KEY = {
   LATEST: 'latest',
   RECOMMEND: 'recommend',
 };
+
+export const CART_QUERY_KEY = {
+  CART: 'cart',
+};
diff --git a/src/hooks/common/index.ts b/src/hooks/common/index.ts
index c5fd8f6b..51178413 100644
--- a/src/hooks/common/index.ts
+++ b/src/hooks/common/index.ts
@@ -1 +1,2 @@
 export * from './useDebounce';
+export * from './useCart';
diff --git a/src/hooks/common/useCart.ts b/src/hooks/common/useCart.ts
new file mode 100644
index 00000000..d07794bd
--- /dev/null
+++ b/src/hooks/common/useCart.ts
@@ -0,0 +1,117 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+import { useCartStore } from '@/stores';
+import { fetchCart } from '@/services';
+
+const useCart = () => {
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  const {
+    items,
+    setCartItems,
+    toggleAllCheckbox,
+    removeSelectedItems,
+    getCheckedItemsCount,
+    getTotalItemsCount,
+    isAllChecked,
+    getPaymentInfo,
+  } = useCartStore();
+
+  useEffect(() => {
+    const loadData = async () => {
+      try {
+        setIsLoading(true);
+        const result = await fetchCart();
+        setCartItems(result || []);
+      } catch (err) {
+        console.error('장바구니 로딩 중 오류가 발생했습니다.', err);
+        setError(err as Error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    loadData();
+  }, [setCartItems]);
+
+  const handleDeleteSelected = () => {
+    const checkedCount = getCheckedItemsCount();
+    if (checkedCount > 0 && window.confirm('선택한 상품을 삭제하시겠습니까?')) {
+      removeSelectedItems();
+    }
+  };
+
+  return {
+    isLoading,
+    error,
+    items,
+    checkedCount: getCheckedItemsCount(),
+    totalCount: getTotalItemsCount(),
+    isAllChecked: isAllChecked(),
+    paymentInfo: getPaymentInfo(),
+    toggleAllCheckbox,
+    handleDeleteSelected,
+  };
+};
+
+export { useCart };
+
+// 'use client';
+
+// import { useQuery } from '@tanstack/react-query';
+
+// import { useCartStore } from '@/stores';
+// import { fetchCart } from '@/services';
+// import { CART_QUERY_KEY } from '@/constants';
+
+// const useCart = () => {
+//   const {
+//     items,
+//     setCartItems,
+//     toggleAllCheckbox,
+//     removeSelectedItems,
+//     getCheckedItemsCount,
+//     getTotalItemsCount,
+//     isAllChecked,
+//     getPaymentInfo,
+//   } = useCartStore();
+
+//   const { isLoading, error } = useQuery(cartQueryOptions); -> 이렇게 사용하려 했는데 이러면 캐싱이 안됨.
+//   const { isLoading, error } = useQuery({
+//     queryKey: [CART_QUERY_KEY.CART],
+//     queryFn: async () => {
+//       try {
+//         const result = await fetchCart();
+//         setCartItems(result || []);
+//         return result;
+//       } catch (err) {
+//         console.error('장바구니 로딩 중 오류가 발생했습니다.', err);
+//         throw err;
+//       }
+//     },
+//   });
+
+//   const handleDeleteSelected = () => {
+//     const checkedCount = getCheckedItemsCount();
+//     if (checkedCount > 0 && window.confirm('선택한 상품을 삭제하시겠습니까?')) {
+//       removeSelectedItems();
+//     }
+//   };
+
+//   return {
+//     isLoading,
+//     error,
+//     items,
+//     checkedCount: getCheckedItemsCount(),
+//     totalCount: getTotalItemsCount(),
+//     isAllChecked: isAllChecked(),
+//     paymentInfo: getPaymentInfo(),
+//     toggleAllCheckbox,
+//     handleDeleteSelected,
+//   };
+// };
+
+// export { useCart };
diff --git a/src/mocks/cart.ts b/src/mocks/cart.ts
index 7bf98a41..e5fea124 100644
--- a/src/mocks/cart.ts
+++ b/src/mocks/cart.ts
@@ -2,7 +2,7 @@ const cart = [
   {
     cartItemId: 78901,
     storeName: '깔끔이몰',
-    productId: 1,
+    productItemId: 1,
     productImgUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Ad36bb01de72146b4a6f6f1748402bb1fY.jpg_960x960q75.jpg_.avif',
     productItemName:
@@ -20,7 +20,7 @@ const cart = [
   {
     cartItemId: 78902,
     storeName: 'Shop9876543210',
-    productId: 2,
+    productItemId: 2,
     productImgUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/S3bb61fcf7de648b19170f4c6b6ab7bc1r.jpg_480x480q75.jpg_.avif',
     productItemName: '삼성 갤럭시 북 프로 360 NT950QFG-K71A',
@@ -37,7 +37,7 @@ const cart = [
   {
     cartItemId: 78903,
     storeName: 'Shop2468101214',
-    productId: 3,
+    productItemId: 3,
     productImgUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Sc10b21f82eb94c1c8eb0ac96faafe9f3G.jpg_480x480q75.jpg_.avif',
     productItemName: '캐논 EOS R6 Mark II 미러리스 카메라',
diff --git a/src/mocks/products.ts b/src/mocks/products.ts
index cdb93e53..bf9b99f4 100644
--- a/src/mocks/products.ts
+++ b/src/mocks/products.ts
@@ -1,351 +1,562 @@
 const products = [
   {
-    productId: 1,
-    storeName: 'Shop1104069354',
-    name: '아이리버 전자사전 딕플 D100 (인터넷 / 게임안됨) 글씨를 더욱 더 써야해요 글씨를 더욱 더 써야해요 글씨를 더욱 더 써야해요 글씨를 더욱 더 써야해요',
+    productId: 3,
+    storeName: 'Shop2468101214',
+    name: '캐논 EOS R6 Mark II 미러리스 카메라',
     titleUrl:
-      'https://ae-pic-a1.aliexpress-media.com/kf/Ad36bb01de72146b4a6f6f1748402bb1fY.jpg_960x960q75.jpg_.avif',
-    description: 'LCD 디스플레이 전자사전',
-    originalPrice: 15000,
-    sellingPrice: 10000,
+      'https://ae-pic-a1.aliexpress-media.com/kf/Sc10b21f82eb94c1c8eb0ac96faafe9f3G.jpg_480x480q75.jpg_.avif',
+    description: '풀프레임 센서 24.2MP 미러리스 카메라',
+    originalPrice: 3200000,
+    sellingPrice: 2950000,
     adultOnly: 0,
-    likes: 21,
-    createdAt: '2025-02-28 14:30:15',
+    likes: 87,
+    createdAt: '2025-03-02 16:15:40',
     updatedAt: null,
     freeDelivery: true,
-    almostOutOfStock: true,
-    options: [
+    almostOutOfStock: false,
+    productItems: [
+      {
+        productItemId: 1,
+        firstOptionName: '색상',
+        firstOptionValue: '용달블루',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'S',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 2,
+        firstOptionName: '색상',
+        firstOptionValue: '용달블루',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'M',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 3,
+        firstOptionName: '색상',
+        firstOptionValue: '용달블루',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'L',
+        additionalPrice: 100,
+      },
+      {
+        productItemId: 4,
+        firstOptionName: '색상',
+        firstOptionValue: '블랙',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'S',
+        additionalPrice: 0,
+      },
       {
-        name: '색상',
-        values: [
-          { name: '용달블루', additionalPrice: 0 },
-          {
-            name: '스페이스그레이',
-            additionalPrice: 500,
-          },
-        ],
+        productItemId: 5,
+        firstOptionName: '색상',
+        firstOptionValue: '블랙',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'M',
+        additionalPrice: 0,
       },
       {
-        name: '사이즈',
-        values: [
-          { name: 'S', additionalPrice: 0 },
-          { name: 'M', additionalPrice: 100 },
-          { name: 'L', additionalPrice: 200 },
-        ],
+        productItemId: 6,
+        firstOptionName: '색상',
+        firstOptionValue: '블랙',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'L',
+        additionalPrice: 100,
       },
     ],
-    maxPurchaseQuantity: 5,
+    maxPurchaseQuantity: 3,
   },
   {
-    productId: 2,
-    storeName: 'Shop9876543210',
-    name: '삼성 갤럭시 북 프로 360 NT950QFG-K71A',
+    productId: 4,
+    storeName: 'TechGadgetKR',
+    name: '삼성 갤럭시 S25 울트라',
     titleUrl:
-      'https://ae-pic-a1.aliexpress-media.com/kf/S3bb61fcf7de648b19170f4c6b6ab7bc1r.jpg_480x480q75.jpg_.avif',
-    description: '15.6인치 2-in-1 터치스크린 노트북',
+      'https://ae-pic-a1.aliexpress-media.com/kf/Ad36bb01de72146b4a6f6f1748402bb1fY.jpg_960x960q75.jpg_.avif',
+    description: '최신 5G 스마트폰 (12GB RAM, 512GB 저장공간)',
     originalPrice: 1850000,
-    sellingPrice: 1650000,
+    sellingPrice: 1750000,
     adultOnly: 0,
-    likes: 153,
-    createdAt: '2025-03-01 09:45:22',
-    updatedAt: '2025-03-05 11:20:33',
+    likes: 152,
+    createdAt: '2025-03-05 09:22:35',
+    updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: false,
-    options: [
+    productItems: [
       {
-        name: '색상',
-        values: [
-          { name: '미스틱 블랙', additionalPrice: 0 },
-          { name: '미스틱 실버', additionalPrice: 50000 },
-        ],
+        productItemId: 7,
+        firstOptionName: '색상',
+        firstOptionValue: '팬텀 블랙',
+        secondOptionName: '저장용량',
+        secondOptionValue: '256GB',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 8,
+        firstOptionName: '색상',
+        firstOptionValue: '팬텀 블랙',
+        secondOptionName: '저장용량',
+        secondOptionValue: '512GB',
+        additionalPrice: 150000,
+      },
+      {
+        productItemId: 9,
+        firstOptionName: '색상',
+        firstOptionValue: '팬텀 화이트',
+        secondOptionName: '저장용량',
+        secondOptionValue: '256GB',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 10,
+        firstOptionName: '색상',
+        firstOptionValue: '팬텀 화이트',
+        secondOptionName: '저장용량',
+        secondOptionValue: '512GB',
+        additionalPrice: 150000,
       },
     ],
-    maxPurchaseQuantity: 3,
+    maxPurchaseQuantity: 2,
   },
   {
-    productId: 3,
-    storeName: 'Shop2468101214',
-    name: '캐논 EOS R6 Mark II 미러리스 카메라',
+    productId: 5,
+    storeName: 'HomeEssentials',
+    name: '다이슨 V15 무선 청소기',
     titleUrl:
-      'https://ae-pic-a1.aliexpress-media.com/kf/Sc10b21f82eb94c1c8eb0ac96faafe9f3G.jpg_480x480q75.jpg_.avif',
-    description: '풀프레임 센서 24.2MP 미러리스 카메라',
-    originalPrice: 3200000,
-    sellingPrice: 2950000,
+      'https://ae-pic-a1.aliexpress-media.com/kf/S3bb61fcf7de648b19170f4c6b6ab7bc1r.jpg_480x480q75.jpg_.avif',
+    description: '강력한 흡입력과 최대 60분 사용 가능한 무선 청소기',
+    originalPrice: 1200000,
+    sellingPrice: 980000,
     adultOnly: 0,
-    likes: 87,
-    createdAt: '2025-03-02 16:15:40',
+    likes: 94,
+    createdAt: '2025-02-28 14:18:55',
     updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: false,
-    options: [
+    productItems: [
       {
-        name: '렌즈 키트',
-        values: [
-          { name: '바디 온리', additionalPrice: 0 },
-          {
-            name: 'RF 24-105mm F4-7.1 IS STM 키트',
-            additionalPrice: 450000,
-          },
-        ],
+        productItemId: 11,
+        firstOptionName: '모델',
+        firstOptionValue: '스탠다드',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
       },
       {
-        name: '추가 옵션',
-        values: [
-          { name: '추가 배터리', additionalPrice: 120000 },
-          { name: '삼각대', additionalPrice: 150000 },
-        ],
+        productItemId: 12,
+        firstOptionName: '모델',
+        firstOptionValue: '프리미엄',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 200000,
       },
     ],
-    maxPurchaseQuantity: 3,
+    maxPurchaseQuantity: 2,
   },
   {
-    productId: 4,
-    storeName: 'Shop1357924680',
-    name: '애플 에어팟 프로 2세대 (MQD83KH/A)',
+    productId: 6,
+    storeName: 'BookLoversKR',
+    name: '2025 베스트셀러 소설 세트',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Ac03df95f71ca472493713ba485fdd1a9G.jpg_480x480q75.jpg_.avif',
-    description: '액티브 노이즈 캔슬링 무선 이어폰',
-    originalPrice: 359000,
-    sellingPrice: 279000,
+    description: '2025년 상반기 베스트셀러 소설 10권 컬렉션',
+    originalPrice: 250000,
+    sellingPrice: 189000,
     adultOnly: 0,
-    likes: 432,
-    createdAt: '2025-03-03 10:30:55',
-    updatedAt: '2025-03-06 14:22:18',
+    likes: 78,
+    createdAt: '2025-03-10 11:45:22',
+    updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: true,
-    options: [
+    productItems: [
       {
-        name: '모델',
-        values: [
-          { name: '에어팟 프로 2세대', additionalPrice: 0 },
-          { name: '에어팟 프로 맥스', additionalPrice: 350000 },
-        ],
+        productItemId: 13,
+        firstOptionName: null,
+        firstOptionValue: null,
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: null,
       },
     ],
     maxPurchaseQuantity: 3,
   },
   {
-    productId: 5,
-    storeName: 'Shop9753186420',
-    name: '다이슨 에어랩 컴플리트 헤어 스타일러',
+    productId: 7,
+    storeName: 'GamerZone',
+    name: 'PlayStation 6 콘솔',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Sca6c2b427b404ac7bc30a256f879c24c2.jpg_480x480q75.jpg_.avif',
-    description: '다양한 헤어 스타일링 액세서리 포함',
-    originalPrice: 699000,
-    sellingPrice: 599000,
+    description: '최신 게임 콘솔 (2TB SSD, 듀얼 컨트롤러 포함)',
+    originalPrice: 880000,
+    sellingPrice: 849000,
     adultOnly: 0,
-    likes: 278,
-    createdAt: '2025-03-04 13:20:10',
+    likes: 213,
+    createdAt: '2025-03-01 17:30:45',
     updatedAt: null,
-    freeDelivery: false,
-    almostOutOfStock: false,
-    options: [],
-    maxPurchaseQuantity: 5,
+    freeDelivery: true,
+    almostOutOfStock: true,
+    productItems: [
+      {
+        productItemId: 14,
+        firstOptionName: '번들',
+        firstOptionValue: '스탠다드',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 15,
+        firstOptionName: '번들',
+        firstOptionValue: '디지털 에디션',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: -50000,
+      },
+      {
+        productItemId: 16,
+        firstOptionName: '번들',
+        firstOptionValue: '프리미엄 팩',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 150000,
+      },
+    ],
+    maxPurchaseQuantity: 1,
   },
   {
-    productId: 6,
-    storeName: 'Shop0246813579',
-    name: '소니 플레이스테이션 5 디지털 에디션',
+    productId: 8,
+    storeName: 'FitStyleKR',
+    name: '애플 워치 시리즈 10',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/S4733375821df45f083e07a9a111bc679b.jpg_960x960q75.jpg_.avif',
-    description: '차세대 게임 콘솔',
-    originalPrice: 548000,
-    sellingPrice: 498000,
+    description: '향상된 건강 모니터링 기능과 배터리 수명',
+    originalPrice: 650000,
+    sellingPrice: 599000,
     adultOnly: 0,
-    likes: 512,
-    createdAt: '2025-03-05 08:45:30',
-    updatedAt: '2025-03-07 09:15:45',
+    likes: 165,
+    createdAt: '2025-03-08 10:11:33',
+    updatedAt: null,
     freeDelivery: true,
-    almostOutOfStock: true,
-    options: [
+    almostOutOfStock: false,
+    productItems: [
+      {
+        productItemId: 17,
+        firstOptionName: '사이즈',
+        firstOptionValue: '41mm',
+        secondOptionName: '밴드',
+        secondOptionValue: '스포츠',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 18,
+        firstOptionName: '사이즈',
+        firstOptionValue: '41mm',
+        secondOptionName: '밴드',
+        secondOptionValue: '밀라노 루프',
+        additionalPrice: 100000,
+      },
       {
-        name: '에디션',
-        values: [
-          { name: '디지털 에디션', additionalPrice: 0 },
-          { name: '디스크 드라이브 에디션', additionalPrice: 100000 },
-        ],
+        productItemId: 19,
+        firstOptionName: '사이즈',
+        firstOptionValue: '45mm',
+        secondOptionName: '밴드',
+        secondOptionValue: '스포츠',
+        additionalPrice: 50000,
       },
       {
-        name: '번들',
-        values: [
-          { name: '본품만', additionalPrice: 0 },
-          { name: '추가 컨트롤러 포함', additionalPrice: 75000 },
-          { name: '게임 3종 포함', additionalPrice: 150000 },
-        ],
+        productItemId: 20,
+        firstOptionName: '사이즈',
+        firstOptionValue: '45mm',
+        secondOptionName: '밴드',
+        secondOptionValue: '밀라노 루프',
+        additionalPrice: 150000,
       },
     ],
-    maxPurchaseQuantity: 3,
+    maxPurchaseQuantity: 2,
   },
   {
-    productId: 7,
-    storeName: 'TechPrime',
-    name: '삼성 갤럭시 북3 프로 16인치 코어 i7 16GB 512GB',
+    productId: 9,
+    storeName: 'KitchenPro',
+    name: '필립스 에어프라이어 XXL',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/A1cb9e2b499e8456dad44fc078ffcfd27W.jpg_960x960q75.jpg_.avif',
-    description: '강력한 성능의 프리미엄 노트북',
-    originalPrice: 2190000,
-    sellingPrice: 1890000,
+    description: '대용량 7.3L 디지털 에어프라이어',
+    originalPrice: 320000,
+    sellingPrice: 249000,
     adultOnly: 0,
-    likes: 328,
-    createdAt: '2025-02-15 14:30:22',
-    updatedAt: '2025-03-02 11:25:10',
+    likes: 118,
+    createdAt: '2025-02-25 09:55:17',
+    updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: false,
-    options: [
+    productItems: [
       {
-        name: '저장공간',
-        values: [
-          { name: '512GB', additionalPrice: 0 },
-          { name: '1TB', additionalPrice: 200000 },
-        ],
+        productItemId: 21,
+        firstOptionName: '색상',
+        firstOptionValue: '블랙',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
       },
       {
-        name: '메모리',
-        values: [
-          { name: '16GB', additionalPrice: 0 },
-          { name: '32GB', additionalPrice: 300000 },
-        ],
+        productItemId: 22,
+        firstOptionName: '색상',
+        firstOptionValue: '화이트',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
       },
     ],
-    maxPurchaseQuantity: 5,
+    maxPurchaseQuantity: 3,
   },
   {
-    productId: 8,
-    storeName: 'HomeComfort',
-    name: '다이슨 에어랩 컴플리트 헤어 스타일러 HS05',
+    productId: 10,
+    storeName: 'AudioElite',
+    name: '소니 WH-1000XM6 노이즈 캔슬링 헤드폰',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Afbccee627628485fa36aa5b5e2b2d6eej.jpg_960x960q75.jpg_.avif',
-    description: '다양한 스타일링이 가능한 프리미엄 헤어드라이어',
-    originalPrice: 699000,
-    sellingPrice: 599000,
+    description: '업계 최고의 노이즈 캔슬링과 50시간 배터리 수명',
+    originalPrice: 450000,
+    sellingPrice: 399000,
     adultOnly: 0,
-    likes: 1024,
-    createdAt: '2025-01-25 09:12:45',
-    updatedAt: '2025-03-01 10:35:20',
+    likes: 97,
+    createdAt: '2025-03-07 15:40:28',
+    updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: false,
-    options: [],
-    maxPurchaseQuantity: 3,
+    productItems: [
+      {
+        productItemId: 23,
+        firstOptionName: '색상',
+        firstOptionValue: '블랙',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 24,
+        firstOptionName: '색상',
+        firstOptionValue: '실버',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 25,
+        firstOptionName: '색상',
+        firstOptionValue: '미드나이트 블루',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 10000,
+      },
+    ],
+    maxPurchaseQuantity: 2,
   },
   {
-    productId: 9,
-    storeName: 'KitchenMaster',
-    name: '필립스 에어프라이어 HD9270 디지털 에디션',
+    productId: 11,
+    storeName: 'BeautySecrets',
+    name: '디올 향수 컬렉션 세트',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Sd593c17d1b084b5eacfd9d9f70cc5b38N.png_960x960.png_.avif',
-    description: '건강한 요리를 위한 대용량 에어프라이어',
-    originalPrice: 189000,
+    description: '5가지 인기 향수 미니어처 세트',
+    originalPrice: 180000,
     sellingPrice: 149000,
     adultOnly: 0,
-    likes: 756,
-    createdAt: '2025-02-10 16:45:30',
-    updatedAt: '2025-03-05 14:20:15',
+    likes: 188,
+    createdAt: '2025-03-12 13:25:19',
+    updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: false,
-    options: [
+    productItems: [
       {
-        name: '용량',
-        values: [
-          { name: '5.5L', additionalPrice: 0 },
-          { name: '7.2L', additionalPrice: 30000 },
-        ],
+        productItemId: 26,
+        firstOptionName: '타입',
+        firstOptionValue: '여성용',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 27,
+        firstOptionName: '타입',
+        firstOptionValue: '남성용',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 28,
+        firstOptionName: '타입',
+        firstOptionValue: '유니섹스',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 10000,
       },
     ],
-    maxPurchaseQuantity: 3,
+    maxPurchaseQuantity: 5,
   },
   {
-    productId: 10,
-    storeName: 'MusicLovers',
-    name: 'Apple 에어팟 프로 2세대 노이즈 캔슬링',
+    productId: 12,
+    storeName: 'OutdoorLife',
+    name: '노스페이스 등산 배낭 45L',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/Aa366bf703ea942b48712ef3b1a3d8358I.jpg_960x960q75.jpg_.avif',
-    description: '향상된 액티브 노이즈 캔슬링 기능의 무선 이어폰',
-    originalPrice: 359000,
-    sellingPrice: 299000,
+    description: '방수 하이킹 배낭 (등받이 지지 시스템 포함)',
+    originalPrice: 280000,
+    sellingPrice: 229000,
     adultOnly: 0,
-    likes: 892,
-    createdAt: '2025-02-28 13:10:45',
-    updatedAt: '2025-03-08 15:30:25',
+    likes: 76,
+    createdAt: '2025-03-03 18:50:42',
+    updatedAt: null,
     freeDelivery: true,
-    almostOutOfStock: true,
-    options: [
+    almostOutOfStock: false,
+    productItems: [
       {
-        name: '컬러',
-        values: [{ name: '화이트', additionalPrice: 0 }],
+        productItemId: 29,
+        firstOptionName: null,
+        firstOptionValue: null,
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: null,
       },
     ],
     maxPurchaseQuantity: 3,
   },
   {
-    productId: 11,
-    storeName: 'SportLife',
-    name: '나이키 에어 줌 페가수스 39 런닝화',
+    productId: 13,
+    storeName: 'SmartHomeKR',
+    name: '구글 네스트 허브 맥스',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/S3e385db932c64060ae3e7f226457739ae.jpg_960x960q75.jpg_.avif',
-    description: '편안한 착화감의 프리미엄 러닝화',
-    originalPrice: 169000,
-    sellingPrice: 129000,
+    description: '10인치 스마트 디스플레이 및 스마트홈 컨트롤러',
+    originalPrice: 320000,
+    sellingPrice: 279000,
     adultOnly: 0,
-    likes: 345,
-    createdAt: '2025-03-01 11:25:40',
-    updatedAt: '2025-03-09 09:45:15',
-    freeDelivery: false,
+    likes: 85,
+    createdAt: '2025-03-15 12:05:38',
+    updatedAt: null,
+    freeDelivery: true,
     almostOutOfStock: false,
-    options: [
+    productItems: [
       {
-        name: '사이즈',
-        values: [
-          { name: '250mm', additionalPrice: 0 },
-          { name: '255mm', additionalPrice: 0 },
-          { name: '260mm', additionalPrice: 0 },
-          { name: '265mm', additionalPrice: 0 },
-          { name: '270mm', additionalPrice: 0 },
-        ],
+        productItemId: 30,
+        firstOptionName: '색상',
+        firstOptionValue: '차콜',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
       },
       {
-        name: '컬러',
-        values: [
-          { name: '블랙/화이트', additionalPrice: 0 },
-          { name: '그레이/블루', additionalPrice: 5000 },
-          { name: '레드/블랙', additionalPrice: 10000 },
-        ],
+        productItemId: 31,
+        firstOptionName: '색상',
+        firstOptionValue: '초크',
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: 0,
       },
     ],
-    maxPurchaseQuantity: 3,
+    maxPurchaseQuantity: 2,
   },
   {
-    productId: 12,
-    storeName: 'CameraWorld',
-    name: '소니 알파 a7 IV 미러리스 카메라 바디',
+    productId: 14,
+    storeName: 'LaptopDirect',
+    name: '맥북 프로 16인치 M3 맥스',
+    titleUrl:
+      'https://ae-pic-a1.aliexpress-media.com/kf/Abe422e9ffcd14842a6fdbcb2af70c74a7.jpg_960x960q75.jpg_.avif',
+    description: '최신 M3 맥스 칩, 32GB RAM, 1TB SSD',
+    originalPrice: 4200000,
+    sellingPrice: 3999000,
+    adultOnly: 0,
+    likes: 134,
+    createdAt: '2025-03-18 09:30:55',
+    updatedAt: null,
+    freeDelivery: true,
+    almostOutOfStock: false,
+    productItems: [
+      {
+        productItemId: 32,
+        firstOptionName: '색상',
+        firstOptionValue: '스페이스 그레이',
+        secondOptionName: '저장용량',
+        secondOptionValue: '1TB',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 33,
+        firstOptionName: '색상',
+        firstOptionValue: '스페이스 그레이',
+        secondOptionName: '저장용량',
+        secondOptionValue: '2TB',
+        additionalPrice: 400000,
+      },
+      {
+        productItemId: 34,
+        firstOptionName: '색상',
+        firstOptionValue: '실버',
+        secondOptionName: '저장용량',
+        secondOptionValue: '1TB',
+        additionalPrice: 0,
+      },
+      {
+        productItemId: 35,
+        firstOptionName: '색상',
+        firstOptionValue: '실버',
+        secondOptionName: '저장용량',
+        secondOptionValue: '2TB',
+        additionalPrice: 400000,
+      },
+    ],
+    maxPurchaseQuantity: 1,
+  },
+  {
+    productId: 15,
+    storeName: 'GiftHaven',
+    name: '스와로브스키 크리스탈 팔찌',
     titleUrl:
       'https://ae-pic-a1.aliexpress-media.com/kf/A353f473bfc034d3d8a6ec3cc34013659h.jpg_960x960q75.jpg_.avif',
-    description: '전문가급 풀프레임 미러리스 카메라',
-    originalPrice: 2890000,
-    sellingPrice: 2690000,
+    description: '우아한 디자인의 고급 크리스탈 팔찌 (선물용 케이스 포함)',
+    originalPrice: 150000,
+    sellingPrice: 129000,
     adultOnly: 0,
-    likes: 421,
-    createdAt: '2025-02-20 10:15:30',
-    updatedAt: '2025-03-06 16:40:20',
+    likes: 156,
+    createdAt: '2025-03-14 14:20:37',
+    updatedAt: null,
     freeDelivery: true,
     almostOutOfStock: false,
-    options: [
+    productItems: [
+      {
+        productItemId: 36,
+        firstOptionName: '색상',
+        firstOptionValue: '실버',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'S',
+        additionalPrice: 0,
+      },
       {
-        name: '구성',
-        values: [
-          { name: '바디 온리', additionalPrice: 0 },
-          { name: '28-70mm 렌즈 키트', additionalPrice: 500000 },
-        ],
+        productItemId: 37,
+        firstOptionName: '색상',
+        firstOptionValue: '실버',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'M',
+        additionalPrice: 0,
       },
       {
-        name: '보증기간',
-        values: [
-          { name: '1년', additionalPrice: 0 },
-          { name: '2년', additionalPrice: 150000 },
-          { name: '3년', additionalPrice: 250000 },
-        ],
+        productItemId: 38,
+        firstOptionName: '색상',
+        firstOptionValue: '로즈골드',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'S',
+        additionalPrice: 10000,
+      },
+      {
+        productItemId: 39,
+        firstOptionName: '색상',
+        firstOptionValue: '로즈골드',
+        secondOptionName: '사이즈',
+        secondOptionValue: 'M',
+        additionalPrice: 10000,
       },
     ],
-    maxPurchaseQuantity: 3,
+    maxPurchaseQuantity: 5,
   },
 ];
 
diff --git a/src/queries/cartQueries.ts b/src/queries/cartQueries.ts
new file mode 100644
index 00000000..bbb17d11
--- /dev/null
+++ b/src/queries/cartQueries.ts
@@ -0,0 +1,9 @@
+import { queryOptions } from '@tanstack/react-query';
+
+import { fetchCart } from '@/services';
+import { CART_QUERY_KEY } from '@/constants';
+
+export const cartQueryOptions = queryOptions({
+  queryKey: [CART_QUERY_KEY.CART],
+  queryFn: async () => await fetchCart(),
+});
diff --git a/src/queries/index.ts b/src/queries/index.ts
index d4b27ea5..c9d1a9a5 100644
--- a/src/queries/index.ts
+++ b/src/queries/index.ts
@@ -1 +1,2 @@
 export * from './productsQueries';
+export * from './cartQueries';
diff --git a/src/services/cartService.ts b/src/services/cartService.ts
index 26aa0194..3c0b8b12 100644
--- a/src/services/cartService.ts
+++ b/src/services/cartService.ts
@@ -1,10 +1,192 @@
 import { axiosInstance } from '@/lib';
 import { CART_ENDPOINTS } from '@/constants';
+import type {
+  Product,
+  ProductOption,
+  CartProductItem,
+  LocalCartItem,
+  CartAddRequestSchema,
+} from '@/types';
+import { getAccessToken } from './tokenService';
+import { fetchProductById } from './productService';
 
-export const fetchCart = async () => {
-  const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+export const mapProductToCartItem = (
+  product: Product,
+  selectedOption: ProductOption,
+  quantity: number,
+): CartProductItem => {
+  return {
+    cartItemId: -1,
+    storeName: product.storeName,
+    productItemId: selectedOption.productItemId,
+    productImgUrl: product.titleUrl as string,
+    productItemName: product.name,
+    originalPrice: product.originalPrice,
+    sellingPrice: product.sellingPrice + (selectedOption.additionalPrice || 0),
+    quantity,
+    firstOptionName: selectedOption.firstOptionName || undefined,
+    firstOptionValue: selectedOption.firstOptionValue || undefined,
+    secondOptionName: selectedOption.secondOptionName || undefined,
+    secondOptionValue: selectedOption.secondOptionValue || undefined,
+    soldOut: false,
+    maxQuantity: product.maxPurchaseQuantity || 10,
+  };
+};
 
-  return response.data;
+export const fetchLocalCart = (): LocalCartItem[] => {
+  if (typeof window === 'undefined') return [];
+
+  const localCart = localStorage.getItem('cart');
+  return localCart ? JSON.parse(localCart) : [];
+};
+
+export const fetchCart = async (): Promise<CartProductItem[]> => {
+  const accessToken = await getAccessToken();
+
+  if (accessToken) {
+    try {
+      const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+      return response.data;
+    } catch (error) {
+      console.error('장바구니를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  } else {
+    const localCart = fetchLocalCart();
+
+    if (localCart.length === 0) {
+      return [];
+    }
+
+    try {
+      const cartItemsWithProducts = await Promise.all(
+        localCart.map(async item => {
+          try {
+            const product = await fetchProductById(String(item.productId));
+            if (!product) return null;
+
+            const selectedOption = product.productItems.find(
+              option => option.productItemId === item.productItemId,
+            );
+
+            if (!selectedOption) return null;
+
+            return mapProductToCartItem(product, selectedOption, item.quantity);
+          } catch (error) {
+            console.error('상품 정보를 불러오는 데 실패했습니다.', error);
+            return null;
+          }
+        }),
+      );
+
+      return cartItemsWithProducts.filter(Boolean) as CartProductItem[];
+    } catch (error) {
+      console.error('장바구니 정보를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  }
+};
+
+export const addToLocalCart = async (
+  productId: number,
+  productItemId: number,
+  quantity: number,
+) => {
+  const currentCart = fetchLocalCart();
+
+  const existingItemById = currentCart.findIndex(
+    cartItem => cartItem.productItemId === productItemId,
+  );
+
+  let maxQuantity = 10;
+  try {
+    const product = await fetchProductById(String(productId));
+    if (product) {
+      maxQuantity = product.maxPurchaseQuantity || 10;
+
+      const optionExists = product.productItems.some(
+        option => option.productItemId === productItemId,
+      );
+
+      if (!optionExists) {
+        return {
+          success: false,
+          message: '유효하지 않은 상품 옵션입니다.',
+          cart: currentCart,
+        };
+      }
+    }
+  } catch (error) {
+    console.error('상품 정보를 불러오는 데 실패했습니다.', error);
+  }
+
+  if (existingItemById >= 0) {
+    const existingItem = currentCart[existingItemById];
+    const newQuantity = existingItem.quantity + quantity;
+
+    if (newQuantity > maxQuantity) {
+      return {
+        success: false,
+        message: `최대 구매 수량은 ${maxQuantity}개입니다.`,
+        cart: currentCart,
+      };
+    }
+
+    currentCart[existingItemById].quantity = newQuantity;
+  } else {
+    if (quantity > maxQuantity) {
+      return {
+        success: false,
+        message: `최대 구매 수량은 ${maxQuantity}개입니다.`,
+        cart: currentCart,
+      };
+    }
+
+    currentCart.push({
+      productId,
+      productItemId,
+      quantity,
+    });
+  }
+
+  localStorage.setItem('cart', JSON.stringify(currentCart));
+
+  return {
+    success: true,
+    message: '장바구니에 추가되었습니다.',
+    cart: currentCart,
+  };
+};
+
+export const addCart = async (
+  productId: number,
+  productItemId: number,
+  quantity: number,
+) => {
+  const accessToken = await getAccessToken();
+
+  if (accessToken) {
+    try {
+      const apiPayload: CartAddRequestSchema = {
+        cartItems: [
+          {
+            productItemId,
+            quantity,
+          },
+        ],
+      };
+      const { data } = await axiosInstance.post(
+        CART_ENDPOINTS.LIST,
+        apiPayload,
+      );
+      return data;
+    } catch (error) {
+      console.error('장바구니에 추가하는 데 실패했습니다.', error);
+      throw error;
+    }
+  } else {
+    return addToLocalCart(productId, productItemId, quantity);
+  }
 };
 
 export const patchCartById = async (cartItemId: string) => {
diff --git a/src/stores/cartStore.ts b/src/stores/cartStore.ts
index c1d40d51..d403d986 100644
--- a/src/stores/cartStore.ts
+++ b/src/stores/cartStore.ts
@@ -38,7 +38,7 @@ const useCartStore = create<CartState>((set, get) => ({
     set(state => ({
       checkedItems: state.items.reduce(
         (acc, item) => {
-          acc[item.productId] = isChecked;
+          acc[item.productItemId] = isChecked;
           return acc;
         },
         {} as Record<string, boolean>,
@@ -49,7 +49,7 @@ const useCartStore = create<CartState>((set, get) => ({
   updateQuantity: (productId, quantity) => {
     set(state => ({
       items: state.items.map(item =>
-        item.productId === productId ? { ...item, quantity } : item,
+        item.productItemId === productId ? { ...item, quantity } : item,
       ),
     }));
   },
@@ -60,7 +60,7 @@ const useCartStore = create<CartState>((set, get) => ({
       delete newCheckedItems[productId];
 
       return {
-        items: state.items.filter(item => item.productId !== productId),
+        items: state.items.filter(item => item.productItemId !== productId),
         checkedItems: newCheckedItems,
       };
     });
@@ -69,12 +69,12 @@ const useCartStore = create<CartState>((set, get) => ({
   removeSelectedItems: () => {
     set(state => {
       const remainingItems = state.items.filter(
-        item => !state.checkedItems[item.productId],
+        item => !state.checkedItems[item.productItemId],
       );
 
       const newCheckedItems = {} as Record<string, boolean>;
       remainingItems.forEach(item => {
-        newCheckedItems[item.productId] = false;
+        newCheckedItems[item.productItemId] = false;
       });
 
       return {
@@ -90,7 +90,7 @@ const useCartStore = create<CartState>((set, get) => ({
     let discountPrice = 0;
 
     state.items.forEach(item => {
-      if (state.checkedItems[item.productId]) {
+      if (state.checkedItems[item.productItemId]) {
         totalPrice += item.originalPrice * item.quantity;
         discountPrice +=
           (item.originalPrice - item.sellingPrice) * item.quantity;
diff --git a/src/stores/createCartMocks.ts b/src/stores/createCartMocks.ts
index 4670f142..912c0d60 100644
--- a/src/stores/createCartMocks.ts
+++ b/src/stores/createCartMocks.ts
@@ -64,16 +64,17 @@ export const restoreCartStore = () => {
 
 export const createMockCartItems = (): CartProductItem[] => {
   return cart.map(item => ({
-    productId: item.productId,
+    productItemId: item.productItemId,
     productItemName: item.productItemName,
     productImgUrl: item.productImgUrl,
     originalPrice: item.originalPrice,
     sellingPrice: item.sellingPrice,
     quantity: item.quantity,
-    firstOptionName: item.firstOptionName || undefined,
-    firstOptionValue: item.firstOptionValue || undefined,
-    secondOptionName: item.secondOptionName || undefined,
-    secondOptionValue: item.secondOptionValue || undefined,
+    firstOptionName: item.firstOptionName ?? undefined,
+    firstOptionValue: item.firstOptionValue ?? undefined,
+    secondOptionName: item.secondOptionName ?? undefined,
+    secondOptionValue: item.secondOptionValue ?? undefined,
+    soldOut: item.soldOut,
     maxQuantity: item.maxQuantity,
     cartItemId: item.cartItemId,
     storeName: item.storeName,
diff --git a/src/stories/product/ProductDetailItem.stories.tsx b/src/stories/product/ProductDetailItem.stories.tsx
index 4dc88a0e..145fb2e3 100644
--- a/src/stories/product/ProductDetailItem.stories.tsx
+++ b/src/stories/product/ProductDetailItem.stories.tsx
@@ -32,30 +32,78 @@ export const Default: Story = {
     originalPrice: 38000,
     sellingPrice: 28500,
     maxPurchaseQuantity: 5,
-    options: [
+    productItems: [
       {
-        name: '크기',
-        values: [
-          { name: '1호 (15cm)', additionalPrice: 0 },
-          { name: '2호 (18cm)', additionalPrice: 5000 },
-          { name: '3호 (21cm)', additionalPrice: 10000 },
-        ],
+        productItemId: 1,
+        firstOptionName: '크기',
+        firstOptionValue: '1호 (15cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '초콜릿',
+        additionalPrice: 0,
       },
       {
-        name: '맛',
-        values: [
-          { name: '초콜릿', additionalPrice: 0 },
-          { name: '바닐라', additionalPrice: 0 },
-          { name: '딸기', additionalPrice: 2000 },
-        ],
+        productItemId: 2,
+        firstOptionName: '크기',
+        firstOptionValue: '1호 (15cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '바닐라',
+        additionalPrice: 0,
       },
       {
-        name: '메시지 카드',
-        values: [
-          { name: '미포함', additionalPrice: 0 },
-          { name: '생일 축하 메시지 (+3,000원)', additionalPrice: 3000 },
-          { name: '기념일 축하 메시지 (+3,000원)', additionalPrice: 3000 },
-        ],
+        productItemId: 3,
+        firstOptionName: '크기',
+        firstOptionValue: '1호 (15cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '딸기',
+        additionalPrice: 2000,
+      },
+      {
+        productItemId: 4,
+        firstOptionName: '크기',
+        firstOptionValue: '2호 (18cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '초콜릿',
+        additionalPrice: 5000,
+      },
+      {
+        productItemId: 5,
+        firstOptionName: '크기',
+        firstOptionValue: '2호 (18cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '바닐라',
+        additionalPrice: 5000,
+      },
+      {
+        productItemId: 6,
+        firstOptionName: '크기',
+        firstOptionValue: '2호 (18cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '딸기',
+        additionalPrice: 7000,
+      },
+      {
+        productItemId: 7,
+        firstOptionName: '크기',
+        firstOptionValue: '3호 (21cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '초콜릿',
+        additionalPrice: 10000,
+      },
+      {
+        productItemId: 8,
+        firstOptionName: '크기',
+        firstOptionValue: '3호 (21cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '바닐라',
+        additionalPrice: 10000,
+      },
+      {
+        productItemId: 9,
+        firstOptionName: '크기',
+        firstOptionValue: '3호 (21cm)',
+        secondOptionName: '맛',
+        secondOptionValue: '딸기',
+        additionalPrice: 12000,
       },
     ],
   },
@@ -64,7 +112,16 @@ export const Default: Story = {
 export const NoOptions: Story = {
   args: {
     ...Default.args,
-    options: [],
+    productItems: [
+      {
+        productItemId: 10,
+        firstOptionName: null,
+        firstOptionValue: null,
+        secondOptionName: null,
+        secondOptionValue: null,
+        additionalPrice: null,
+      },
+    ],
   },
 };
 
diff --git a/src/stories/product/ProductItem.stories.tsx b/src/stories/product/ProductItem.stories.tsx
index 0f1802b8..77e58b2f 100644
--- a/src/stories/product/ProductItem.stories.tsx
+++ b/src/stories/product/ProductItem.stories.tsx
@@ -36,7 +36,7 @@ const defaultProduct: Product = {
   createdAt: new Date(),
   freeDelivery: false,
   almostOutOfStock: false,
-  options: [],
+  productItems: [],
   maxPurchaseQuantity: 1,
 };
 
diff --git a/src/stories/product/ProductList.stories.tsx b/src/stories/product/ProductList.stories.tsx
index 38175fc2..88b8d00c 100644
--- a/src/stories/product/ProductList.stories.tsx
+++ b/src/stories/product/ProductList.stories.tsx
@@ -29,7 +29,7 @@ const createProduct = (id: number): Product => ({
   createdAt: new Date(),
   freeDelivery: true,
   almostOutOfStock: true,
-  options: [],
+  productItems: [],
   maxPurchaseQuantity: 1,
 });
 
diff --git a/src/stories/product/RecommendProductItem.stories.tsx b/src/stories/product/RecommendProductItem.stories.tsx
index fcffc201..e362a220 100644
--- a/src/stories/product/RecommendProductItem.stories.tsx
+++ b/src/stories/product/RecommendProductItem.stories.tsx
@@ -36,7 +36,7 @@ const defaultProduct: Product = {
   createdAt: new Date(),
   freeDelivery: false,
   almostOutOfStock: false,
-  options: [],
+  productItems: [],
   maxPurchaseQuantity: 1,
 };
 
diff --git a/src/stories/product/RecommendProductList.stories.tsx b/src/stories/product/RecommendProductList.stories.tsx
index e1f76d91..50993e38 100644
--- a/src/stories/product/RecommendProductList.stories.tsx
+++ b/src/stories/product/RecommendProductList.stories.tsx
@@ -1,6 +1,8 @@
 import type { Meta, StoryObj } from '@storybook/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
 import { RecommendCarousel } from '@/components';
+import { recommededProductsQueryOptions } from '@/queries';
 import type { Product } from '@/types';
 
 const sampleImage =
@@ -23,11 +25,35 @@ const generateProducts = (count: number): Product[] => {
       createdAt: new Date(),
       freeDelivery: index % 3 === 0,
       almostOutOfStock: index % 4 === 0,
-      options: [],
+      productItems: [
+        {
+          productItemId: index + 1,
+          firstOptionName: index % 2 === 0 ? '색상' : null,
+          firstOptionValue: index % 2 === 0 ? '블랙' : null,
+          secondOptionName: index % 3 === 0 ? '사이즈' : null,
+          secondOptionValue: index % 3 === 0 ? 'M' : null,
+          additionalPrice: index % 4 === 0 ? 5000 : null,
+        },
+      ],
       maxPurchaseQuantity: 1,
     }));
 };
 
+const createQueryClient = (products: Product[]) => {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+      },
+    },
+  });
+
+  const queryKey = recommededProductsQueryOptions.queryKey;
+  queryClient.setQueryData(queryKey, products);
+
+  return queryClient;
+};
+
 const meta: Meta<typeof RecommendCarousel> = {
   title: 'Product/RecommendCarousel',
   component: RecommendCarousel,
@@ -35,20 +61,24 @@ const meta: Meta<typeof RecommendCarousel> = {
     layout: 'fullscreen',
   },
   tags: ['autodocs'],
-  decorators: [
-    Story => (
-      <div className="mx-auto max-w-7xl p-6">
-        <Story />
-      </div>
-    ),
-  ],
 };
 
 export default meta;
 type Story = StoryObj<typeof RecommendCarousel>;
 
 export const Default: Story = {
-  args: {
-    products: generateProducts(12),
-  },
+  decorators: [
+    Story => {
+      const products = generateProducts(12);
+      const queryClient = createQueryClient(products);
+
+      return (
+        <QueryClientProvider client={queryClient}>
+          <div className="mx-auto max-w-7xl p-6">
+            <Story />
+          </div>
+        </QueryClientProvider>
+      );
+    },
+  ],
 };
diff --git a/src/types/cartType.ts b/src/types/cartType.ts
index 2845b5ee..ad718c9b 100644
--- a/src/types/cartType.ts
+++ b/src/types/cartType.ts
@@ -1,7 +1,20 @@
+export interface CartItem {
+  productItemId: number;
+  quantity: number;
+}
+
+export interface LocalCartItem extends CartItem {
+  productId: number;
+}
+
+export interface CartAddRequestSchema {
+  cartItems: CartItem[];
+}
+
 export interface CartProductItem {
   cartItemId: number;
   storeName: string;
-  productId: number;
+  productItemId: number;
   productImgUrl: string;
   productItemName: string;
   originalPrice: number;
@@ -14,7 +27,3 @@ export interface CartProductItem {
   soldOut?: boolean;
   maxQuantity: number;
 }
-
-export interface Cart {
-  items: CartProductItem[];
-}
diff --git a/src/types/productType.ts b/src/types/productType.ts
index b85b43a9..617abe1a 100644
--- a/src/types/productType.ts
+++ b/src/types/productType.ts
@@ -1,14 +1,14 @@
 import { StaticImageData } from 'next/image';
 
-export interface OptionValue {
-  name: string;
-  additionalPrice?: number;
-}
-
 export interface ProductOption {
-  name: string;
-  values: OptionValue[];
+  productItemId: number;
+  firstOptionName: string | null;
+  firstOptionValue: string | null;
+  secondOptionName: string | null;
+  secondOptionValue: string | null;
+  additionalPrice: number | null;
 }
+
 export interface Product {
   productId: number;
   storeName: string;
@@ -23,7 +23,7 @@ export interface Product {
   updatedAt?: Date;
   freeDelivery?: boolean;
   almostOutOfStock?: boolean;
-  options: ProductOption[];
+  productItems: ProductOption[];
   maxPurchaseQuantity: number;
 }
 


Reviews:
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T14:44:19Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:10:17Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:11:56Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:17:09Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:35:28Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:43:05Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:54:29Z
user: login: bbjbc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:12:57Z
user: login: bbjbc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:15:41Z
user: login: goldegg127
body: 고생하셨어요! 👍🏻
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T05:56:01Z

Review comments:
diff_hunk: @@ -1,11 +1,17 @@
 import { Button } from '@/components';
+import { addCart } from '@/services';
+
+const ProductActions = ({ productId }: { productId: number }) => {
+  const handleCartClick = async () => {
+    await addCart({ productItemId: productId, quantity: 1 });
+  };
path: src/components/products/product-detail/ProductActions.tsx
user: login: goldegg127
body: 이 부분이요! `productItemId` 그리고 데이터를 전달하는 형태는 request 요청값 형태가 배열이어서 배열에 담아서 수정해주시면 될거 같아요!

```typescript
const cartItems = [ { productItemId, quantity } ]
const handleCartClick = async () => await addCart(cartItems);
``` 
created_at: 2025-03-24T14:44:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,10 +1,138 @@
 import { axiosInstance } from '@/lib';
 import { CART_ENDPOINTS } from '@/constants';
+import type { Product } from '@/types';
+import { getAccessToken } from './tokenService';
+import { fetchProductById } from './productService';
+
+interface CartItem {
+  productItemId: number;
+  quantity: number;
+}
path: src/services/cartService.ts
user: login: goldegg127
body: CartItem 타입을 cartType.ts 파일에서 export 해서 관리하면 좋겠습니다~
그리고 장바구니 상품을 추가하기 위한 스키마 인터페이스도 추가하는 것이 좋을 거 같습니다!

```typescript
// cartType.ts

export interface CartItem {
  productItemId: number;
  quantity: number;
}

// cart 상품 추가 API Schema
export type CartAddRequestAPISchema = CartItem[]
``` 
타입명을 CartAddRequestAPISchema 라고 생각한 이유는.. 

장바구니에서 추가와 조회할때 엔드포인트가 /cart 로 동일해서 명확하게 Add 를, 요청할때와 응답할 때 둘다 body 에 들어갈 interface를 둘다 정의해야하는 경우도 있어서 구분하기 위해 Request와 Response로 구분해봤습니다!

created_at: 2025-03-24T15:10:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,10 +1,138 @@
 import { axiosInstance } from '@/lib';
 import { CART_ENDPOINTS } from '@/constants';
+import type { Product } from '@/types';
+import { getAccessToken } from './tokenService';
+import { fetchProductById } from './productService';
+
+interface CartItem {
+  productItemId: number;
+  quantity: number;
+}
+
+export const mapProductToCartItem = (product: Product, quantity: number) => {
+  return {
+    ...product,
+    productItemId: product.productId,
+    productImgUrl: product.titleUrl,
+    productItemName: product.name,
+    maxQuantity: product.maxPurchaseQuantity || 10,
+    quantity,
+  };
+};
+
+export const fetchLocalCart = (): CartItem[] => {
+  if (typeof window === 'undefined') return [];
+
+  const localCart = localStorage.getItem('cart');
+  return localCart ? JSON.parse(localCart) : [];
+};
 
 export const fetchCart = async () => {
-  const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+  const accessToken = await getAccessToken();
 
-  return response.data;
+  if (accessToken) {
+    try {
+      const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+      return response.data;
+    } catch (error) {
+      console.error('장바구니를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  } else {
+    const localCart = fetchLocalCart();
+
+    if (localCart.length === 0) {
+      return [];
+    }
+
+    try {
+      const cartItemsWithProducts = await Promise.all(
+        localCart.map(async item => {
+          try {
+            const product = await fetchProductById(String(item.productItemId));
+            if (!product) return null;
+
+            return mapProductToCartItem(product, item.quantity);
+          } catch (error) {
+            console.error('상품 정보를 불러오는 데 실패했습니다.', error);
+            return null;
+          }
+        }),
+      );
+
+      return cartItemsWithProducts.filter(Boolean);
+    } catch (error) {
+      console.error('장바구니 정보를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  }
+};
+
+export const addToLocalCart = async (item: CartItem) => {
+  const currentCart = fetchLocalCart();
+
+  const existingItemById = currentCart.findIndex(
+    cartItem => cartItem.productItemId === item.productItemId,
+  );
+
+  let maxQuantity = 10;
+  try {
+    const product = await fetchProductById(String(item.productItemId));
+    if (product) {
+      maxQuantity = product.maxPurchaseQuantity || 10;
+    }
+  } catch (error) {
+    console.error('상품 정보를 불러오는 데 실패했습니다.', error);
+  }
+
+  if (existingItemById >= 0) {
+    const existingItem = currentCart[existingItemById];
+    const newQuantity = existingItem.quantity + item.quantity;
+
+    if (newQuantity > maxQuantity) {
+      return {
+        success: false,
+        message: `최대 구매 수량은 ${maxQuantity}개입니다.`,
+        cart: currentCart,
+      };
+    }
+
+    currentCart[existingItemById].quantity = newQuantity;
+  } else {
+    if (item.quantity > maxQuantity) {
+      return {
+        success: false,
+        message: `최대 구매 수량은 ${maxQuantity}개입니다.`,
+        cart: currentCart,
+      };
+    }
+
+    currentCart.push(item);
+  }
+
+  localStorage.setItem('cart', JSON.stringify(currentCart));
+
+  return {
+    success: true,
+    message: '장바구니에 추가되었습니다.',
+    cart: currentCart,
+  };
+};
+
+export const addCart = async (payload: CartItem) => {
path: src/services/cartService.ts
user: login: goldegg127
body: `CartAddRequestAPISchema` 와 같이 지정한 스키마 타입으로 적용해주세요!
created_at: 2025-03-24T15:11:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,6 +32,7 @@ const CartItemImage = ({
         imageUrl={productImgUrl}
         name={productItemName}
         className="aspect-square"
+        productId={productId}
path: src/components/cart/CartItemImage.tsx
user: login: goldegg127
body: `productItemId`로 바껴야하나요? 

`상품 썸네일에서 액션을 위해서 productId가 필요해 추가했어요. ` 부분과 관련 있어 보이는데 여기서 액션은 썸네일 호버시 나타나는 장바구니 추가 아이콘 때문인가요? 그렇다면 썸네일을 위해 전달한 id 모두 `productItemId`로 바꾸면 될 거 같아요
created_at: 2025-03-24T15:17:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,117 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+import { useCartStore } from '@/stores';
+import { fetchCart } from '@/services';
+
+const useCart = () => {
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  const {
+    items,
+    setCartItems,
+    toggleAllCheckbox,
+    removeSelectedItems,
+    getCheckedItemsCount,
+    getTotalItemsCount,
+    isAllChecked,
+    getPaymentInfo,
+  } = useCartStore();
+
+  useEffect(() => {
+    const loadData = async () => {
+      try {
+        setIsLoading(true);
+        const result = await fetchCart();
+        setCartItems(result || []);
+      } catch (err) {
+        console.error('장바구니 로딩 중 오류가 발생했습니다.', err);
+        setError(err as Error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    loadData();
+  }, [setCartItems]);
+
+  const handleDeleteSelected = () => {
+    const checkedCount = getCheckedItemsCount();
+    if (checkedCount > 0 && window.confirm('선택한 상품을 삭제하시겠습니까?')) {
+      removeSelectedItems();
+    }
+  };
+
+  return {
+    isLoading,
+    error,
+    items,
+    checkedCount: getCheckedItemsCount(),
+    totalCount: getTotalItemsCount(),
+    isAllChecked: isAllChecked(),
+    paymentInfo: getPaymentInfo(),
+    toggleAllCheckbox,
+    handleDeleteSelected,
+  };
+};
+
+export { useCart };
+
+// 'use client';
+
+// import { useQuery } from '@tanstack/react-query';
+
+// import { useCartStore } from '@/stores';
+// import { fetchCart } from '@/services';
+// import { CART_QUERY_KEY } from '@/constants';
+
+// const useCart = () => {
+//   const {
+//     items,
+//     setCartItems,
+//     toggleAllCheckbox,
+//     removeSelectedItems,
+//     getCheckedItemsCount,
+//     getTotalItemsCount,
+//     isAllChecked,
+//     getPaymentInfo,
+//   } = useCartStore();
+
+//   const { isLoading, error } = useQuery(cartQueryOptions); -> 이렇게 사용하려 했는데 이러면 캐싱이 안됨.
path: src/hooks/common/useCart.ts
user: login: goldegg127
body: tanstack querry 버전5 에서는 `isLoading` 을 사용하지않고 `isPending`을 사용한다고 해요! ([참고 글](https://velog.io/@houndhollis/%EC%99%9C-%EA%B3%B5%EC%8B%9D%EB%AC%B8%EC%84%9C-%EC%97%90%EC%84%9C%EB%8A%94-isLoading-%EB%8C%80%EC%8B%A0-isPending%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%9D%BC%EA%B3%A0-%ED%95%A0%EA%B9%8C-Tanstack-gitisLoading-vs-isPending))

그리고 제가 예시로 넣어둔 서버 컴포넌트 ProductListWrapTemp 와 클라이언트 컴포넌트 ProductListClientTemp 를 살펴보시면 
`isPending` 을 사용하지 않고 클라이언트 컴포넌트를 `Suspense` 컴포넌트가 감싸고 있어요. 

그리고 클라이언트 컴포넌트는 `useQuery`가 아닌 `useSuspenseQuery` 를 사용하고 있어요. 

`useSuspenseQuery`를 사용해야 `Suspense` 컴포넌트가 제대로 동작해요!

```tsx
const ProductListWrapTemp = async () => {
  const dehydratedState = await getDehydratedState(productsQueryOptions);

  return (
    <HydrationBoundary state={dehydratedState}>
      <Suspense fallback={<ProductListSkeleton />}>      // Suspense 사용
        <ProductListClientTemp />
      </Suspense>
    </HydrationBoundary>
  );
};

export default ProductListWrapTemp;
``` 

```tsx
'use client';

import { useSuspenseQuery } from '@tanstack/react-query';
// 생략

const ProductListClientTemp = () => {
  const {
    data: productItems,
    isError,
    error,
  } = useSuspenseQuery(productsQueryOptions);     // useSuspenseQuery 사용

  if (isError && error) return 'An error has occurred: ' + error.message;

  return (
    <section>
      <ul className="gap-x-md gap-y-7xl py-7xl grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
        {productItems.map((item: Product) => (
          <ProductItem key={item.productId} {...item} />
        ))}
      </ul>
    </section>
  );
};

export default ProductListClientTemp;
``` 
created_at: 2025-03-24T15:35:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,10 +1,138 @@
 import { axiosInstance } from '@/lib';
 import { CART_ENDPOINTS } from '@/constants';
+import type { Product } from '@/types';
+import { getAccessToken } from './tokenService';
+import { fetchProductById } from './productService';
+
+interface CartItem {
+  productItemId: number;
+  quantity: number;
+}
+
+export const mapProductToCartItem = (product: Product, quantity: number) => {
+  return {
+    ...product,
+    productItemId: product.productId,
+    productImgUrl: product.titleUrl,
+    productItemName: product.name,
+    maxQuantity: product.maxPurchaseQuantity || 10,
+    quantity,
+  };
+};
+
+export const fetchLocalCart = (): CartItem[] => {
+  if (typeof window === 'undefined') return [];
+
+  const localCart = localStorage.getItem('cart');
+  return localCart ? JSON.parse(localCart) : [];
+};
path: src/services/cartService.ts
user: login: goldegg127
body: 비회원을 위한 서비스 함수군요!
created_at: 2025-03-24T15:43:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,10 +1,138 @@
 import { axiosInstance } from '@/lib';
 import { CART_ENDPOINTS } from '@/constants';
+import type { Product } from '@/types';
+import { getAccessToken } from './tokenService';
+import { fetchProductById } from './productService';
+
+interface CartItem {
+  productItemId: number;
+  quantity: number;
+}
+
+export const mapProductToCartItem = (product: Product, quantity: number) => {
+  return {
+    ...product,
+    productItemId: product.productId,
+    productImgUrl: product.titleUrl,
+    productItemName: product.name,
+    maxQuantity: product.maxPurchaseQuantity || 10,
+    quantity,
+  };
+};
+
+export const fetchLocalCart = (): CartItem[] => {
+  if (typeof window === 'undefined') return [];
+
+  const localCart = localStorage.getItem('cart');
+  return localCart ? JSON.parse(localCart) : [];
+};
 
 export const fetchCart = async () => {
-  const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+  const accessToken = await getAccessToken();
 
-  return response.data;
+  if (accessToken) {
+    try {
+      const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+      return response.data;
+    } catch (error) {
+      console.error('장바구니를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  } else {
+    const localCart = fetchLocalCart();
+
+    if (localCart.length === 0) {
+      return [];
+    }
+
+    try {
+      const cartItemsWithProducts = await Promise.all(
+        localCart.map(async item => {
+          try {
+            const product = await fetchProductById(String(item.productItemId));
+            if (!product) return null;
+
+            return mapProductToCartItem(product, item.quantity);
+          } catch (error) {
+            console.error('상품 정보를 불러오는 데 실패했습니다.', error);
+            return null;
+          }
+        }),
+      );
+
+      return cartItemsWithProducts.filter(Boolean);
+    } catch (error) {
+      console.error('장바구니 정보를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  }
+};
path: src/services/cartService.ts
user: login: goldegg127
body: `서비스 함수 제작하면서 비회원일 경우에는 상품 상세 정보와 productId를 비교해서 가져오는 부분이 필요해서 그 부분까지 추가했어요.`  라는 말을 이해했습니다. 

`/cart` 엔드포인트에서 조회를 할 수 없기 때문에 직접 api를 호출해서 상품정보를 가져오는 거였군요! 
아 그럼 여기서도 장바구니 100개 상품을 저장하면 렌더링 할때 상품 api 호출을 100번 하겠군요 😂
created_at: 2025-03-24T15:54:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,10 +1,138 @@
 import { axiosInstance } from '@/lib';
 import { CART_ENDPOINTS } from '@/constants';
+import type { Product } from '@/types';
+import { getAccessToken } from './tokenService';
+import { fetchProductById } from './productService';
+
+interface CartItem {
+  productItemId: number;
+  quantity: number;
+}
+
+export const mapProductToCartItem = (product: Product, quantity: number) => {
+  return {
+    ...product,
+    productItemId: product.productId,
+    productImgUrl: product.titleUrl,
+    productItemName: product.name,
+    maxQuantity: product.maxPurchaseQuantity || 10,
+    quantity,
+  };
+};
+
+export const fetchLocalCart = (): CartItem[] => {
+  if (typeof window === 'undefined') return [];
+
+  const localCart = localStorage.getItem('cart');
+  return localCart ? JSON.parse(localCart) : [];
+};
 
 export const fetchCart = async () => {
-  const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+  const accessToken = await getAccessToken();
 
-  return response.data;
+  if (accessToken) {
+    try {
+      const response = await axiosInstance.get(CART_ENDPOINTS.LIST);
+      return response.data;
+    } catch (error) {
+      console.error('장바구니를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  } else {
+    const localCart = fetchLocalCart();
+
+    if (localCart.length === 0) {
+      return [];
+    }
+
+    try {
+      const cartItemsWithProducts = await Promise.all(
+        localCart.map(async item => {
+          try {
+            const product = await fetchProductById(String(item.productItemId));
+            if (!product) return null;
+
+            return mapProductToCartItem(product, item.quantity);
+          } catch (error) {
+            console.error('상품 정보를 불러오는 데 실패했습니다.', error);
+            return null;
+          }
+        }),
+      );
+
+      return cartItemsWithProducts.filter(Boolean);
+    } catch (error) {
+      console.error('장바구니 정보를 불러오는 데 실패했습니다.', error);
+      throw error;
+    }
+  }
+};
path: src/services/cartService.ts
user: login: bbjbc
body: 진짜 이거야말로 허거덩그러니마트네여 ~
created_at: 2025-03-24T16:12:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,6 +32,7 @@ const CartItemImage = ({
         imageUrl={productImgUrl}
         name={productItemName}
         className="aspect-square"
+        productId={productId}
path: src/components/cart/CartItemImage.tsx
user: login: bbjbc
body: 네 장바구니 추가 아이콘 때문인데요 ! 
일단 지금으로서는 `productItemId`의 정체를 알게 되었기 때문에 hover하고 바로 장바구니에 추가하기는 어려울 것 같아요. 장바구니를 눌러서 옵션이 나오게 하기 위해서는 또 ui를 만들어야 하기도 하고, 생각해보니 그렇게 할거면 상세 페이지로 들어가서 옵션 선택하는 게 나을 것 같긴 해요.
created_at: 2025-03-24T16:15:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: goldegg127
created_at: 2025-03-24T14:37:46Z
body: 병찬님 고생많으셨어요!

`productId`가 사용된 곳을 살펴보니까 동적 route와 패스 파라미터, 썸네일 이미지 쪽에 주로 사용하신 거 같네요!
ProductActions 컴포넌트에서 props로 `productId`를 넘기셨는데 `productItemId` 으로 수정해서 넘기시면 될거 같아요!
author_association: CONTRIBUTOR
user: login: goldegg127
created_at: 2025-03-24T15:56:48Z
body: 말씀하신 것 처럼 토스트 라이브러리 [sonner](https://sonner.emilkowal.ski/) 라이브러리 사용하면 매우 좋을 거 같습니다~!
author_association: CONTRIBUTOR
user: login: goldegg127
created_at: 2025-03-24T15:57:58Z
body: 혹시 페이지도 저희가 해왔던 컴포넌트 처럼 스토리북에 표시가 될 수 있을까요?
author_association: CONTRIBUTOR
user: login: bbjbc
created_at: 2025-03-24T16:13:50Z
body: > 혹시 페이지도 저희가 해왔던 컴포넌트 처럼 스토리북에 표시가 될 수 있을까요?

페이지 컴포넌트를 불러올 수 있지만, 컴포넌트를 불러와서 사용하면 로딩, 빌드 시간이 오래 걸리는 것 같아요 ! 
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-25T03:28:21Z
body: 🎵Tutti의 storybook 확인: https://67c074b44451fef93aa0663d-hvxsogrzjd.chromatic.com/
author_association: NONE