[리팩토링] Next.js 쇼핑몰 프로젝트 - 상품 리스트 컴포넌트

YouGyoung·2024년 3월 31일
0

기존 코드

상품 리스트 컴포넌트는, 배포 중 오류가 생겨 주석처리한 코드들과 함께 리스트 목록도 표시가 안 되고 있는 상황입니다.

상품 리스트는 상품을 보여주는 컴포넌트인 List 그리고 페이지네이션 컴포넌트인 Pagenation으로 이루어져있습니다. List 내부에는 찜하기 및 장바구니 담기 버튼이 컴포넌트로 import되어있습니다.

src/app/product/[...slug]/page.tsx

'use client';
import React, { useLayoutEffect } from 'react';

import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { AppDispatch } from '@/types/reduxTypes';
import List from './components/List';
import Pagenation from './components/Pagenation';
interface ProductProps {
  params: {
    slug: string;
  };
}
const Product: React.FC<ProductProps> = ({ params }) => {
  const dispatch: AppDispatch = useAppDispatch();
  const prevCategory: string = useAppSelector(
    (state) => state.product.currentCategory
  );
  const curCategory: string = params.slug[0];
  const curPage: number = Number(params.slug[1]);

  useLayoutEffect(() => {
    //   if (prevCategory !== curCategory) dispatch(setCategory(curCategory));
    //  if (curPage === undefined || curPage === null) dispatch(setCurrentPage(1));
    //dispatch(setCurrentPage(curPage));
  }, [curCategory, curPage]);

  return (
    <>
      <div className="mt-14 flex flex-col justify-center w-full items-center">
        <h2>{curCategory.charAt(0).toUpperCase() + curCategory.slice(1)}</h2>
      </div>

      <div className="flex flex-col mb-44">
        <List />
      </div>
      <div className="mt-14">
        <Pagenation />
      </div>
    </>
  );
};
export default Product;

src/app/product/[...slug]/components/List.tsx

'use client';
import React from 'react';
import Image from 'next/image';
import { CartItems, Product, WishlistItems } from '@/types/globalTypes';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ITEMSPERPAGE } from '@/constants/product';
import CartButton from './CartButton';
import WishlistButton from './WishlistButton';
import Link from 'next/link';
import { useFilteredProductList } from '@/hooks/useFilteredProductList';

const List: React.FC = () => {
  const cartItems: CartItems = useAppSelector((state) => state.cart.cartItems);
  const wishlist: WishlistItems = useAppSelector(
    (state) => state.wishlist.wishlistItems
  );
  const currentPage: number = useAppSelector(
    (state) => state.product.currentProductListPage
  );

  const curProductList: Product[] = useFilteredProductList();

  const startIndex: number = ITEMSPERPAGE * (currentPage - 1);
  const endIndex: number = startIndex + ITEMSPERPAGE;
  const curProducts: Product[] = curProductList.slice(startIndex, endIndex);

  return (
    <ul className="grid grid-cols-1 gap-8 h-full sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
      {curProducts.map((product) => (
        <li key={product.id} className="h-full">
          <div className="h-4/5">
            <Link
              href={`/product/detail/${product.id}`}
              className="flex items-center w-full h-full relative"
            >
              <Image
                src={product.image}
                alt={product.title}
                width={0}
                height={0}
                sizes="100vw"
                style={{
                  width: '100%',
                  height: 'auto',
                  padding: '20%',
                }}
                priority
              />
            </Link>
          </div>
          <div>
            <Link href={`/product/detail/${product.id}`}>{product.title}</Link>
            <p className="my-2">${product.price}</p>
          </div>
          <div className="flex">
            <div className="mr-2">
              <WishlistButton product={product} wishlist={wishlist} />
            </div>
            <div>
              <CartButton product={product} cartItems={cartItems} />
            </div>
          </div>
        </li>
      ))}
    </ul>
  );
};

export default List;

src/app/product/[...slug]/components/Pagenation.tsx

'use client';
import React from 'react';
import { useFilteredProductList } from '@/hooks/useFilteredProductList';
import { useRouter } from 'next/navigation';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { ITEMSPERPAGE } from '@/constants/product';
import { useAppSelector } from '@/hooks/useAppSelector';
import { Product } from '@/types/globalTypes';

const Pagination: React.FC = () => {
  const curCategory = useAppSelector((state) => state.product.currentCategory);
  const curProductList: Product[] = useFilteredProductList();
  const currentPage: number = useAppSelector(
    (state) => state.product.currentProductListPage
  );
  const router = useRouter();

  const totalPages: number = Math.ceil(curProductList.length / ITEMSPERPAGE);

  const movePage = (page: number): void => {
    router.push(`/product/${curCategory}/${page}`);
  };

  return (
    <ul className="flex justify-center ">
      <li className="p-2.5">
        <button
          name="newer"
          className="flex items-center disabled:opacity-20 "
          onClick={() => movePage(currentPage - 1)}
          disabled={currentPage === 1}
          style={{ fontSize: '20px' }}
          aria-label="다음 페이지"
        >
          <FiChevronLeft />
        </button>
      </li>
      {Array.from({ length: totalPages }, (_, index) => index + 1).map(
        (page) => (
          <li key={page} className="p-2.5">
            <button
              className="disabled:text-zinc-300 text-black  text-base dark:text-white dark:disabled:text-zinc-300"
              onClick={() => movePage(page)}
              disabled={page === currentPage}
              aria-label={`${page} page`}
            >
              {page}
            </button>
          </li>
        )
      )}
      <li className="p-2.5">
        <button
          name="older"
          className="flex items-center disabled:opacity-20"
          onClick={() => movePage(currentPage + 1)}
          disabled={currentPage === totalPages}
          style={{ fontSize: '20px' }}
          aria-label="이전 페이지"
        >
          <FiChevronRight />
        </button>
      </li>
    </ul>
  );
};

export default Pagination;

src/app/product/[...slug]/components/CartButton.tsx

'use client';
import React, { useContext } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { Product, CartItems } from '@/types/globalTypes';
import { doc, updateDoc } from 'firebase/firestore';
import { db } from '@/app/firebaseConfig';
import { PiShoppingBagFill, PiShoppingBagLight } from 'react-icons/pi';
import {
  addCartItemsLocalStorage,
  deleteCartItemsLocalStorage,
} from '../../../../utils/localstorage';

interface CartButtonProps {
  product: Product;
  cartItems: CartItems;
}

const CartButton: React.FC<CartButtonProps> = ({ product, cartItems }) => {
  // const currentUser = null;
  // const dispatch = useAppDispatch();

  // // const toggleCartItem = async () => {
  // //   const productID = product.id.toString();
  // //   let newCartItems: CartItems = { ...cartItems };

  // //   if (currentUser && currentUser.email) {
  // //     if (newCartItems[productID]) {
  // //       delete newCartItems[productID];
  // //     } else {
  // //       newCartItems[productID] = { product, count: 1 };
  // //     }

  // //     const userRef = doc(db, 'users', currentUser.email);
  // //     await updateDoc(userRef, { cartItems: newCartItems });
  // //   } else {
  // //     if (newCartItems[productID]) {
  // //       delete newCartItems[productID];
  // //       deleteCartItemsLocalStorage([productID]);
  // //     } else {
  // //       newCartItems[productID] = { product, count: 1 };
  // //       addCartItemsLocalStorage(product);
  // //     }
  // //   }

  //   // dispatch(setCartItems(newCartItems));
  // };

  // return (
  //   <button
  //     onClick={toggleCartItem}
  //     aria-label="장바구니"
  //     //className="tooltip"
  //     // data-tip="장바구니 넣기"
  //   >
  //     {cartItems[product.id] ? (
  //       <PiShoppingBagFill style={{ fontSize: '28px' }} />
  //     ) : (
  //       <PiShoppingBagLight style={{ fontSize: '28px' }} />
  //     )}
  //   </button>
  // );
  return <></>;
};

export default CartButton;

src/app/product/[...slug]/components/WishlistButton.tsx

'use client';
import React, { useContext } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';

import { Product, WishlistItems } from '@/types/globalTypes';
import { doc, updateDoc } from 'firebase/firestore';
import { db } from '@/app/firebaseConfig';
import { PiHeartFill, PiHeartLight } from 'react-icons/pi';

interface WishlistButtonProps {
  product: Product;
  wishlist: WishlistItems;
}

const WishlistButton: React.FC<WishlistButtonProps> = ({
  product,
  wishlist,
}) => {
  const currentUser = null;
  const dispatch = useAppDispatch();

  const toggleWishlist = async () => {
    // if (!currentUser || !currentUser.email) {
    //   alert('로그인이 필요한 기능입니다.');
    //   return;
    // }

    const newWishlist = { ...wishlist };
    const productID = product.id.toString();

    if (wishlist[productID]) {
      delete newWishlist[productID];
    } else {
      newWishlist[productID] = product;
    }

    // const userRef = doc(db, 'users', currentUser.email);
    // await updateDoc(userRef, { wishlist: newWishlist });

    // dispatch(setWishlist(newWishlist));
  };

  return (
    <button
      onClick={toggleWishlist}
      aria-label="찜"
      // data-tip="찜"
      // className="tooltip"
    >
      {wishlist[product.id] ? (
        <PiHeartFill style={{ fontSize: '28px' }} />
      ) : (
        <PiHeartLight style={{ fontSize: '28px' }} />
      )}
    </button>
  );
};

export default WishlistButton;

리팩토링 후 코드

다음은 Product 컴포넌트의 리팩토링을 마친, 관련 파일 구조입니다.

src/
├── app/
│   ├── product/
│   │   └── [...slug]/
│   │       ├── components/
│   │       │   ├── Pagenation.tsx
│   │       │   ├── ProductList.tsx
│   │       │   └── productList/
│   │       │       ├── ProductListSkeleton.tsx
│   │       │       ├── Products.tsx
│   │       │       └── Products/
│   │       │           ├── CartButton.tsx
│   │       │           └── WishlistButton.tsx
│   │       └── page.tsx
├── hooks/
│   ├── useProduct.ts
│   └── useStore.ts
├── _utils/
│   ├── setCartItemsFireStore.ts
│   ├── setCartItemsLocalStorage.ts
│   └── setWishlistItemsFireStore.ts
├── slices/
│   └── productSlice.ts
└── utils/
    ├── checkItemExistsById.ts
    ├── filteredProductsByCategory.ts
    └── getProductsInPage.ts

src/app/product/[...slug]/page.tsx

Redux Store에서 categorypage를 제거한 점이 가장 크게 달라진 점이라고 할 수 있습니다.

기존에는 스토어에 저장된 category와 현재 카테고리를 비교해서 달라진 경우에만 스토어에 다시 저장하는 코드였는데요. 왜 그렇게 작성했었는지 모르겠습니다..ㅋㅋ

pagePagenation 컴포넌트에서만 필요해서 params로 받은 pageprops로 넘겨주는 방식으로 변경했습니다.

'use client';
import React from 'react';
import ProductList from './components/ProductList';
import Pagenation from './components/Pagenation';
import { CategoryKey } from '@/types/globalTypes';

interface ProductProps {
  params: {
    slug: string;
  };
}
const Product: React.FC<ProductProps> = ({ params }) => {
  const category: CategoryKey = params.slug[0] as CategoryKey;
  const page: number = Number(params.slug[1]);

  return (
    <>
      <div className="mt-14 flex flex-col justify-center w-full items-center">
        <h2>{category.charAt(0).toUpperCase() + category.slice(1)}</h2>
      </div>

      <div className="flex flex-col mb-44">
        <ProductList category={category} page={page} />
      </div>
      <div className="mt-14">
        <Pagenation category={category} page={page} />
      </div>
    </>
  );
};
export default Product;

src/slices/productSlice.ts

기존에는 pagecategory 상태와 해당 상태를 설정해 주는 리듀서가 있었습니다.
위에서 설명한 것과 같이 스토어에 저장할 필요성이 사라졌기 때문에 제거했습니다.
결과적으로는 외부 API를 통해 Fetch한 products 데이터만 저장하게 되었습니다.

import { Product } from '@/types/globalTypes';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface ProductState {
  products: Product[];
}

const initialProductState: ProductState = {
  products: [],
};

const productSlice = createSlice({
  name: 'product',
  initialState: initialProductState,
  reducers: {
    setProducts: (state, action: PayloadAction<Product[]>) => {
      state.products = action.payload;
    },
  },
});

export const { setProducts } = productSlice.actions;
export const productReducer = productSlice.reducer;

src/app/product/[...slug]/components/ProductList.tsx

기존에는 ul 요소 내부에서 map메서드를 사용해 제품 아이템에 대한 li 요소를 만들었습니다.
이 부분을 Products 컴포넌트로 따로 분리했습니다.

제품 아이템은 productsInPage 배열을 기준으로 렌더링이 되는데요.
productsInPage 배열의 길이가 0인 경우에는 아직 제품의 로딩이 완료되지 않을 것이라고 보고, 스켈레톤을 나타내는 ProductListSkeleton 컴포넌트를 렌더하기로 했습니다.

'use client';
import React, { useEffect, useState } from 'react';
import { Product, ProductProps } from '@/types/globalTypes';
import useProduct from '@/hooks/useProduct';
import getProductsInPage from '@/utils/getProductsInPage';
import Products from './productList/Products';
import ProductListSkeleton from './productList/ProductListSkeleton';

const ProductList: React.FC<ProductProps> = ({ category, page }) => {
  const { products } = useProduct();
  const [productsInPage, setProductsInPage] = useState<Product[]>([]);

  useEffect(() => {
    if (products.length === 0) return;
    setProductsInPage(getProductsInPage(category, page, products));
  }, [products]);

  return (
    <>
      {productsInPage.length === 0 ? (
        <ProductListSkeleton />
      ) : (
        <ul className="grid grid-cols-1 gap-8 h-full sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
          <Products products={productsInPage} />
        </ul>
      )}
    </>
  );
};

export default ProductList;

src/app/product/[...slug]/components/productList/ProductListSkeleton.tsx

ProductListSkeleton에서는 react-loading-skeleton 라이브러리를 사용해 스켈레톤을 구현했습니다.
그런데 이상하게도 배경색 펄스 애니메이션이 적용되지 않는 문제가 있어 DaisyUI에서 제공하는 skeleton 클래스를 사용해 펄스 애니메이션을 적용했습니다.

import Skeleton from 'react-loading-skeleton';

const ProductListSkeleton: React.FC = () => {
  return (
    <ul className="mt-28 grid grid-cols-1 gap-8 min-h-screen sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
      {Array.from({ length: 12 }).map((_, index) => (
        <li key={index} className="relative h-full animate-pulse pb-36">
          <div className="h-4/5 mb-4 skeleton ">
            <Skeleton height="100%" />
          </div>
          <div className="skeleton ">
            <Skeleton height={20} width={`60%`} />
          </div>
          <div className="mt-2 mb-24 skeleton ">
            <Skeleton height={20} width={`30%`} />
          </div>
        </li>
      ))}
    </ul>
  );
};

export default ProductListSkeleton;

사실, /src/app/loading.tsx에서 이미 pathname.startsWith('/product/') && !pathname.startsWith('/product/detail') 코드로 현재 url/product/ 으로 시작하는 경우에는 스켈레톤을 표시하게 되어있으나 상품 리스트 페이지에서 새로고침을 하게 되면 스켈레톤은 안 보이고 공백만 보여 주는 문제가 있어, ProductListSkeleton 컴포넌트를 만들게 되었습니다.

그래서 현재 스켈레톤 UI가 아래처럼 다르게 나오는 문제가 있습니다.

상품 리스트로 처음 접근할 때 스켈레톤 UI

상품 리스트 url 입력해서 접근할 때 or 새로고침 시 스켈레톤 UI

이 부분은 다음에 시간될 때 고치기로..

src/app/product/[...slug]/components/productList/Products.tsx

cartItems, wishlistItemsWishlistButtonCartButton 컴포넌트에서 각각 불러올까 하다가 중복성을 줄이기 위해 Products에서 Props로 전달해 주기로 했습니다.
이부분은 기존과 동일합니다.

import { Product } from '@/types/globalTypes';
import Image from 'next/image';
import Link from 'next/link';
import WishlistButton from './Products/WishlistButton';
import CartButton from './Products/CartButton';
import useStore from '@/hooks/useStore';

interface ProductsProps {
  products: Product[];
}
const Products: React.FC<ProductsProps> = ({ products }) => {
  const { cartItems, wishlistItems } = useStore();
  return (
    <>
      {products.map((product) => (
        <li key={product.id} className="h-full">
          <div className="h-4/5">
            <Link
              href={`/product/detail/${product.id}`}
              className="flex items-center w-full h-full relative"
            >
              <Image
                src={product.image}
                alt={product.title}
                width={0}
                height={0}
                sizes="100vw"
                style={{
                  width: '100%',
                  height: 'auto',
                  padding: '20%',
                }}
                priority
              />
            </Link>
          </div>
          <div>
            <Link href={`/product/detail/${product.id}`}>{product.title}</Link>
            <p className="my-2">${product.price}</p>
          </div>
          <div className="flex">
            <div className="mr-2">
              <WishlistButton product={product} wishlist={wishlistItems} />
            </div>
            <div>
              <CartButton product={product} cartItems={cartItems} />
            </div>
          </div>
        </li>
      ))}
    </>
  );
};
export default Products;

src/app/product/[...slug]/components/productList/Products/CartButton.tsx

기존 CartButton 컴포넌트에 작성된 토글 로직은 외부로 분리시켰습니다.

'use client';
import React from 'react';
import { Product, CartItems } from '@/types/globalTypes';
import { PiShoppingBagFill, PiShoppingBagLight } from 'react-icons/pi';
import useStore from '@/hooks/useStore';

interface CartButtonProps {
  product: Product;
  cartItems: CartItems;
}

const CartButton: React.FC<CartButtonProps> = ({ product, cartItems }) => {
  const { toggleCartItem } = useStore();

  return (
    <button
      onClick={() => {
        toggleCartItem(product);
      }}
      aria-label="장바구니"
    >
      {cartItems[product.id] ? (
        <PiShoppingBagFill style={{ fontSize: '28px' }} />
      ) : (
        <PiShoppingBagLight style={{ fontSize: '28px' }} />
      )}
    </button>
  );
};

export default CartButton;

src/app/product/[...slug]/components/productList/Products/WishlistButton.tsx

기존 WishlistButton 컴포넌트에 작성된 토글 로직은 외부로 분리시켰습니다.

'use client';
import React from 'react';
import { Product, WishlistItems } from '@/types/globalTypes';
import { PiHeartFill, PiHeartLight } from 'react-icons/pi';
import { useSession } from 'next-auth/react';
import useStore from '@/hooks/useStore';

interface WishlistButtonProps {
  product: Product;
  wishlist: WishlistItems;
}

const WishlistButton: React.FC<WishlistButtonProps> = ({
  product,
  wishlist,
}) => {
  const { toggleWishlistItems } = useStore();
  const { status } = useSession();

  return (
    <button
      onClick={() => {
        if (status === 'unauthenticated') {
          alert('로그인이 필요한 기능입니다.');
          return;
        }
        toggleWishlistItems(product);
      }}
      aria-label="찜"
    >
      {wishlist[product.id] ? (
        <PiHeartFill style={{ fontSize: '28px' }} />
      ) : (
        <PiHeartLight style={{ fontSize: '28px' }} />
      )}
    </button>
  );
};

export default WishlistButton;

src/app/product/[...slug]/components/Pagenation.tsx

페이지네이션 버튼을 클릭 시 해당 페이지로 라우팅 되는 동작을 Link 컴포넌트로 변경할까 고민했으나 동적으로 라우팅되기 때문에 useRouter를 유지하기로 결정했습니다.

'use client';
import React from 'react';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { CATEGORIES, ITEMS_PER_PAGE } from '@/constants/product';
import { Product, ProductProps } from '@/types/globalTypes';
import useRouterPush from '@/hooks/useRouterPush';
import filterProductsByCategory from '@/utils/filterProductsByCategory';
import useProduct from '@/hooks/useProduct';

const Pagenation: React.FC<ProductProps> = ({ category, page }) => {
  const { navigateToSelectedProductListPage } = useRouterPush();
  const { products } = useProduct();
  const productsInCategory: Product[] = filterProductsByCategory(
    products,
    CATEGORIES[category]
  );
  const total: number = Math.ceil(productsInCategory.length / ITEMS_PER_PAGE);

  return (
    <ul className="flex justify-center ">
      <>
        <li className="p-2.5">
          <button
            name="newer"
            className="flex items-center disabled:opacity-20 "
            onClick={() =>
              navigateToSelectedProductListPage(category, page - 1)
            }
            disabled={page === 1}
            style={{ fontSize: '20px' }}
            aria-label="이전 페이지"
          >
            <FiChevronLeft />
          </button>
        </li>
        {Array.from({ length: total }, (_, index) => index + 1).map(
          (pageNumber) => (
            <li key={pageNumber} className="p-2.5">
              <button
                className="disabled:text-zinc-300 text-black  text-base dark:text-white dark:disabled:text-zinc-300"
                onClick={() =>
                  navigateToSelectedProductListPage(category, pageNumber)
                }
                disabled={pageNumber === page}
                aria-label={`${page} page`}
              >
                {pageNumber}
              </button>
            </li>
          )
        )}
        <li className="p-2.5">
          <button
            name="older"
            className="flex items-center disabled:opacity-20"
            onClick={() =>
              navigateToSelectedProductListPage(category, page + 1)
            }
            disabled={page === total}
            style={{ fontSize: '20px' }}
            aria-label="다음 페이지"
          >
            <FiChevronRight />
          </button>
        </li>
      </>
    </ul>
  );
};

export default Pagenation;

src/hooks/useProduct.ts

useStoreproducts 데이터를 관리하기 위한 커스텀 훅입니다.
현재 이 훅은 스토어로부터 products 데이터를 가져오고, 데이터 dispatch를 수행하는 기능만을 포함하고 있습니다."

import getAllProductsFakeStore from '@/_utils/getAllProductsFakeStore';
import { useAppDispatch } from './useAppDispatch';
import { Product } from '@/types/globalTypes';
import { setProducts } from '@/slices/productSlice';
import { useAppSelector } from './useAppSelector';
import { AppDispatch } from '@/types/reduxTypes';

const useProduct = () => {
  const dispatch: AppDispatch = useAppDispatch();
  
  const products = useAppSelector((state) => state.product.products);
  
  const setProductsStore = async () => {
    const products: Product[] = await getAllProductsFakeStore();
    dispatch(setProducts(products));
  };

  return {
    products,
    setProductsStore,
  };
};

export default useProduct;

src/hooks/useStore.ts

useStore는 위시리스트와 장바구니 데이터를 관리하기 위한 커스텀 훅입니다.
Redux의 useStore가 자동으로 import되는 문제가 있어 이름을 바꿔야할지 고민 중입니다.
바꾼다면 useShoppingStore으로 변경 예정입니다.

현재 각 모듈을 하나의 파일로 작성하려고 하고 있습니다만 이렇게 보니 import해 오는 코드가 길어 보여, 다시 연관있는 모듈은 하나의 파일로 합칠지 고민을 좀 해야겠습니다.

import { resetCartItems, setCartItems } from '@/slices/cartSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { CartItems, Product, WishlistItems } from '@/types/globalTypes';
import { resetWishlistItems, setWishlistItems } from '@/slices/wishListSlice';
import getUserCartItems from '@/_utils/getUserCartItems';
import getUserWishlistItems from '@/_utils/getUserWishlistItems';
import getCartItemsLocalStorage from '@/_utils/getCartItemsLocalStorage';
import { useAppSelector } from './useAppSelector';
import checkItemExistsById from '@/utils/checkItemExistsById';
import { useSession } from 'next-auth/react';
import setCartItemsFireStore from '@/_utils/setCartItemsFireStore';
import setWishlistItemsFireStore from '@/_utils/setWishlistItemsFireStore';
import setCartItemsLocalStorage from '@/_utils/setCartItemsLocalStorage';

const useStore = () => {
  const { data: session, status } = useSession();
  const dispatch = useAppDispatch();
  const cartItems = useAppSelector((state) => state.cart.cartItems);
  const wishlistItems = useAppSelector((state) => state.wishlist.wishlistItems);

  const resetStore = () => {
    dispatch(resetCartItems());
    dispatch(resetWishlistItems());
  };

  const initializeUserStore = async (email: string) => {
    const cartItems: CartItems = await getUserCartItems(email);
    const wishlistItems: WishlistItems = await getUserWishlistItems(email);
    dispatch(setCartItems(cartItems));
    dispatch(setWishlistItems(wishlistItems));
  };

  const initializeStore = async () => {
    const cartItems: CartItems = getCartItemsLocalStorage();
    dispatch(setCartItems(cartItems));
  };

  const toggleCartItem = (product: Product) => {
    if (checkItemExistsById(product.id, cartItems)) {
      removeCartItem(product);
    } else {
      addCartItem(product);
    }
  };

  const addCartItem = (product: Product) => {
    const newCartItems: CartItems = {
      ...cartItems,
      [product.id]: { product: product, count: 1 },
    };
    dispatch(setCartItems(newCartItems));
    if (status === 'unauthenticated') {
      setCartItemsLocalStorage(newCartItems);
    } else {
      if (session?.user.email)
        setCartItemsFireStore(newCartItems, session?.user.email);
    }
  };

  const removeCartItem = (product: Product) => {
    const { [product.id]: removeItem, ...newCartItems } = cartItems;
    dispatch(setCartItems(newCartItems));
    if (status === 'unauthenticated') {
      setCartItemsLocalStorage(newCartItems);
    } else {
      if (session?.user.email)
        setCartItemsFireStore(newCartItems, session?.user.email);
    }
  };

  const toggleWishlistItems = (product: Product) => {
    if (checkItemExistsById(product.id, wishlistItems)) {
      removeWishlistItem(product);
    } else {
      addWishlistItem(product);
    }
  };

  const addWishlistItem = (product: Product) => {
    const newWishlistItems: WishlistItems = {
      ...wishlistItems,
      [product.id]: product,
    };
    dispatch(setWishlistItems(newWishlistItems));
    if (session?.user.email)
      setWishlistItemsFireStore(newWishlistItems, session?.user.email);
  };

  const removeWishlistItem = (product: Product) => {
    const { [product.id]: removeItem, ...newWishlistItems } = wishlistItems;
    dispatch(setWishlistItems(newWishlistItems));
    if (session?.user.email)
      setWishlistItemsFireStore(newWishlistItems, session?.user.email);
  };

  return {
    resetStore,
    initializeUserStore,
    initializeStore,
    cartItems,
    wishlistItems,
    toggleCartItem,
    toggleWishlistItems,
  };
};

export default useStore;

src/_utils/setCartItemsFireStore.ts

장바구니에 변경이 있는 경우 Firestore에 해당 내용을 반영하는 함수입니다.

import { db } from '@/app/firebaseConfig';
import { CartItems } from '@/types/globalTypes';
import { doc, DocumentReference, updateDoc } from 'firebase/firestore';

const setCartItemsFireStore = (newCartItems: CartItems, email: string) => {
  try {
    const userDocumentReference: DocumentReference = doc(db, 'users', email);
    updateDoc(userDocumentReference, { cartItems: newCartItems });
  } catch (error) {
    console.error('setting error in setCartItemsFireStore:', error);
  }
};

export default setCartItemsFireStore;

src/_utils/setCartItemsLocalStorage copy.ts

장바구니에 변경이 있는 경우 LocalStorage에 해당 내용을 반영하는 함수입니다.

import { CARTITEMS_KEY } from '@/constants/localStorageKeys';
import { CartItems } from '@/types/globalTypes';

const setCartItemsLocalStorage = (newCartItems: CartItems) => {
  try {
    localStorage.setItem(CARTITEMS_KEY, JSON.stringify(newCartItems));
  } catch (error) {
    console.error('setting error in setCartItemsLocalStorage:', error);
  }
};

export default setCartItemsLocalStorage;

src/_utils/setWishlistItemsFireStore.ts

위시리스트에 변경이 있는 경우 Firestore에 해당 내용을 반영하는 함수입니다.

import { db } from '@/app/firebaseConfig';
import { WishlistItems } from '@/types/globalTypes';
import { doc, DocumentReference, updateDoc } from 'firebase/firestore';

const setWishlistItemsFireStore = (
  newWishlistItems: WishlistItems,
  email: string
) => {
  try {
    const userDocumentReference: DocumentReference = doc(db, 'users', email);
    updateDoc(userDocumentReference, { wishlistItems: newWishlistItems });
  } catch (error) {
    console.error('setting error in setWishlistItemsFireStore:', error);
  }
};

export default setWishlistItemsFireStore;

src/utils/checkItemExistsById.ts

장바구니와 위시리스트 데이터에 이미 존재하는 아이템인지 확인하기 위해 만든 함수입니다.
원래는 장바구니 데이터를 체크하는 함수와 위시리스트 데이터를 체크하는 함수를 따로 만들려고 했으나 동일한 코드 구조로 작성되기 때문에 checkItemExistsById으로 통합했습니다.

const checkItemExistsById = (id: number, items: {} | any[]) => {
  const stringifyId = id.toString();

  if (Array.isArray(items)) {
    return items.includes(stringifyId);
  } else {
    return Object.keys(items).includes(stringifyId);
  }
};

export default checkItemExistsById;

src/utils/filterProductsByCategory.ts

전체 상품 목록에서 특정 카테고리에 해당하는 아이템만 필터링하여 반환하는 함수입니다.

import { Product } from '@/types/globalTypes';

const filterProductsByCategory = (products: Product[], category: string) => {
  if (category === 'all') return products;
  return products.filter((product) => product.category === category);
};

export default filterProductsByCategory;

src/utils/getProductsInPage.ts

filterProductsByCategory을 사용해 필터링된 상품 목록에서 현재 페이지에 해당하는 상품들을 반환하는 함수입니다.

import { CATEGORIES, ITEMS_PER_PAGE } from '@/constants/product';
import { CategoryKey, Product } from '@/types/globalTypes';
import filterProductsByCategory from '@/utils/filterProductsByCategory';

const getProductsInPage = (
  category: CategoryKey,
  page: number,
  products: Product[]
) => {
  let filteredProducts: Product[] = products;
  filteredProducts = filterProductsByCategory(products, CATEGORIES[category]);
  const total = filteredProducts.length;
  const startIndex = ITEMS_PER_PAGE * (page - 1);
  const endIndex = startIndex + ITEMS_PER_PAGE;
  if (endIndex <= total) {
    return filteredProducts.slice(startIndex, endIndex);
  } else {
    return filteredProducts.slice(startIndex);
  }
};

export default getProductsInPage;

마치며

어제부터 작업을 했었는데, 오늘 끝났습니다 🥲
4시간 넘게 작업한 게 예상대로 동작하지 않아 (페이지네이션 발생 시 두 번 렌더링 되는 문제) 기존으로 롤백 시키고 처음부터 다시 진행하게 되어서 시간이 오래걸렸습니다.

지금까지 리팩토링한 코드로 로딩 시간을 확인해 봤는데 확실히 짧아졌습니다.
3초에서 0.5초로 줄어든 느낌?..

내일부터는 개인 프로젝트의 기획부터 개발까지 진행할 예정이라 당분간은 리팩토링 작업이 어려울 거 같지만, 그래도 짬짬이 진행해 보려고 합니다.

아무래도 너무 오랜 시간 후에 다시 진행하면 파일 구조를 까먹을 수 있기 때문에 리팩토링 시 힘들 거 같아서요..

profile
프론트엔드 개발자

0개의 댓글

관련 채용 정보