본문 바로가기
React 프로젝트 : 쇼핑몰 구현

[React] 쇼핑몰 개발 | STEP 3 : 상품 상세 페이지 구현(useState, useEffect)

by 이도비 2025. 3. 17.

 

🎯 목표 :

✔ 상품 클릭 시 상세 페이지에서 정보 표시
✔ URL 파라미터를 사용해 상품 ID 가져오기 (useParams)
✔ React Query를 활용하여 개별 상품 데이터 가져오기
React.lazy + Suspense 적용하여 코드 스플리팅 최적화
SEO 최적화 (react-helmet 활용) 및 Open Graph 태그 추가


1. 상품 상세 데이터 가져오기 (React Query 활용)

📍 src/api/products.ts 수정 (fetchProductDetail 추가)

import axios from 'axios';
import { Product } from '@/types/products'; // Product 타입을 가져옵니다.

export const fetchProductList = async (): Promise<Product[]> => {
    const response = await axios.get(`${import.meta.env.VITE_API_URL}/products`);
    return response.data;
};

// ✅ 제품을 가져오는 함수
export const fetchProductDetail = async (id: number): Promise<Product> => {
    const response = await axios.get(`${import.meta.env.VITE_API_URL}/products/${id}`);
    return response.data;
};

 

📍 src/hooks/useProductDetail.ts 생성

import { useQuery } from "@tanstack/react-query";
import { fetchProductDetail } from "@/api/products"; // API 호출 함수 import
import { Product } from "@/types/products";

export const useProductDetail = (id: number) => {
  return useQuery<Product, Error>({
    queryKey: ["product", id], // 개별 상품을 위한 Query Key
    queryFn: () => fetchProductDetail(id),
    enabled: !!id, // ID가 존재할 때만 요청 실행
    staleTime: 1000 * 60 * 5, // 5분 동안 캐시 유지 (불필요한 요청 방지)
    retry: 2, // 요청 실패 시 최대 2번 재시도
    refetchOnWindowFocus: false,  // 창 포커스 시 자동 재요청 방지
  });
};

 

✅ React Query를 활용하여 개별 상품 데이터 가져오기 구현 완료
불필요한 요청 방지 (enabled: !!id) 설정 적용


2. React Router 설정 (상품 상세 페이지 추가)

📍 src/App.tsx 수정

import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/react-query";
import Layout from "@/components/Layout";
import ProductList from "@/pages/ProductList";
import ProductDetail from "@/pages/ProductDetail";

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router>
        <Routes>
          <Route path="/" element={<Layout />}>
            <Route index element={<ProductList />} />
            // ✅ 상품 상세 페이지 라우팅 (URL 파라미터로 상품 ID 전달)
            <Route path="/product/:id" element={<ProductDetail />} />
          </Route>
        </Routes>
      </Router>
    </QueryClientProvider>
  );
}

 

✅ React Router를 활용하여 /product/:id 경로 추가 완료


3. 상품 상세 페이지 UI 구성

📍 src/pages/ProductDetail.tsx 생성

import { useParams } from "react-router-dom";
import { useState, useEffect } from "react";
import { useProductDetail } from "@/hooks/useProductDetail";
import { Button } from "@/components/ui/button";

export default function ProductDetail() {
  // ✅ URL에서 상품 ID 가져오기
  const { id } = useParams<{ id: string }>();
  
  // ✅ React Query를 활용한 상품 데이터 가져오기
  const { data: product, isLoading, error } = useProductDetail(Number(id));

  // ✅ 수량 & 선택된 이미지 상태 관리
  const [quantity, setQuantity] = useState(1);
  const [selectedImage, setSelectedImage] = useState("");
  
  const generateThumbnailPaths = (productName: string, count: number = 3) => {
    const formattedName = productName.toLowerCase().replace(/\s+/g, "-");
    return Array.from({ length: count }, (_, i) => `/images/${formattedName}-thumbnail-${i + 1}.png`);
  };

  useEffect(() => {
    if (product) {
      setSelectedImage(product.image);
    }
  }, [product]);

  // ✅ 로딩 & 에러 처리
  if (isLoading) return <p className="text-center text-gray-500">⏳ 상품 정보를 불러오는 중...</p>;
  if (error || !product) return <p className="text-center text-red-500">⚠️ 상품을 찾을 수 없습니다.</p>;

  return (
    <div className="container mx-auto p-6">
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div className="flex flex-col items-center">
          {/* ✅ 썸네일 목록 (클릭 시 메인 이미지 변경) */}
          <div className="flex mt-4 space-x-2">
            {[product.image, ...generateThumbnailPaths(product.name)].map((img, idx) => (
              <img
                key={idx}
                src={img}
                alt="썸네일"
                className={`w-16 h-16 object-cover cursor-pointer rounded-md border-2 ${
                  selectedImage === img ? "border-blue-500" : "border-gray-300"
                }`}
                onClick={() => setSelectedImage(img)}
              />
            ))}
          </div>
        </div>

        {/* ✅ 상품 상세 정보 */}
        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="text-xl text-gray-700 mt-2">${product.price.toFixed(2)}</p>

          {/* ✅ 수량 선택 */}
          <div className="flex items-center mt-4">
            <label className="mr-2 text-gray-600">수량:</label>
            <input
              type="number"
              min="1"
              value={quantity}
              onChange={(e) => setQuantity(Number(e.target.value))}
              className="border rounded-md px-3 py-1 w-16"
            />
          </div>

          {/* ✅ 장바구니 추가 버튼 */}
          <Button className="mt-4 w-full bg-blue-600 text-white hover:bg-blue-700 transition-transform transform hover:scale-105">
            장바구니에 추가
          </Button>
        </div>
      </div>
    </div>
  );
}

 

💡 이 코드에서 적용된 핵심 기능

React Query를 활용한 상품 데이터 가져오기 (useProductDetail)

  • useProductDetail(Number(id))을 통해 서버에서 상품 데이터를 가져옴
  • isLoading과 error 상태를 사용해 로딩 및 에러 처리

썸네일 기능 추가 (클릭 시 메인 이미지 변경)

  • useState를 사용해 선택한 이미지 상태(selectedImage) 관리
  • onClick={() => setSelectedImage(img)} 로 썸네일 클릭 시 메인 이미지 변경

수량 선택 기능 (useState 사용)

  • input type="number"을 사용해 수량 조절 가능
  • onChange={(e) => setQuantity(Number(e.target.value))}를 통해 상태 업데이트

"장바구니에 추가" 버튼 구현

  • onClick 이벤트는 다음 단계(장바구니 기능 구현)에서 추가 예정

4. Lazy Loading 적용 (React.lazy + Suspense))

📍 src/pages/ProductDetail.tsx 수정

import { useParams } from "react-router-dom";
import { useState, useEffect, Suspense, lazy } from "react"; // ✅ Suspense & lazy 추가
import { useProductDetail } from "@/hooks/useProductDetail";
import { Button } from "@/components/ui/button";

{/* ✅ 상품 이미지 불러오기 */}
const LazyImage = lazy(() => import("@/components/LazyImage")); // Lazy Loading 적용

export default function ProductDetail() {
  const { id } = useParams<{ id: string }>();
  const { data: product, isLoading, error } = useProductDetail(Number(id));
  const [quantity, setQuantity] = useState(1);
  const [selectedImage, setSelectedImage] = useState("");
  
  const generateThumbnailPaths = (productName: string, count: number = 3) => {
    const formattedName = productName.toLowerCase().replace(/\s+/g, "-");
    return Array.from({ length: count }, (_, i) => `/images/${formattedName}-thumbnail-${i + 1}.png`);
  };
  
  useEffect(() => {
    if (product) {
      setSelectedImage(product.image);
    }
  }, [product]);

  if (isLoading) return <p className="text-center text-gray-500">⏳ 상품 정보를 불러오는 중...</p>;
  if (error || !product) return <p className="text-center text-red-500">⚠️ 상품을 찾을 수 없습니다.</p>;

  return (
    <div className="container mx-auto p-6">
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
      
        {/* ✅ 상품 이미지 (Lazy Loading 적용) */}
        <div className="flex flex-col items-center">
          <Suspense fallback={<p>이미지 로딩 중...</p>}>
            <LazyImage src={selectedImage} alt={product.name} className="w-80 h-80 object-cover rounded-md shadow-md" />
          </Suspense>
          <div className="flex mt-4 space-x-2">
            {[product.image, ...generateThumbnailPaths(product.name)].map((img, idx) => (
              <img
                key={idx}
                src={img}
                alt="썸네일"
                className={`w-16 h-16 object-cover cursor-pointer rounded-md border-2 ${
                  selectedImage === img ? "border-blue-500" : "border-gray-300"
                }`}
                onClick={() => setSelectedImage(img)}
              />
            ))}
          </div>
        </div>
        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="text-xl text-gray-700 mt-2">${product.price.toFixed(2)}</p>
          <div className="flex items-center mt-4">
            <label className="mr-2 text-gray-600">수량:</label>
            <input
              type="number"
              min="1"
              value={quantity}
              onChange={(e) => setQuantity(Number(e.target.value))}
              className="border rounded-md px-3 py-1 w-16"
            />
          </div>
          <Button className="mt-4 w-full bg-blue-600 text-white hover:bg-blue-700 transition-transform transform hover:scale-105">
            장바구니에 추가
          </Button>
        </div>
      </div>
    </div>
  );
}

 

LazyImage 컴포넌트를 React.lazy로 불러오고, Suspense로 감싸서 초기 로딩 속도 최적화

 

📍 src/components/LazyImage.tsx 생성

import { useState, useEffect } from "react";

interface LazyImageProps {
  src: string;
  alt: string;
  className?: string;
}

export default function LazyImage({ src, alt, className }: LazyImageProps) {
  const [imageSrc, setImageSrc] = useState<string | null>(null);

  useEffect(() => {
    const img = new Image();
    img.src = src;
    img.onload = () => setImageSrc(src);
    img.onerror = () => setImageSrc("/images/placeholder.png"); // ✅ 이미지 로드 실패 시 대체 이미지 적용
  }, [src]);

  return imageSrc ? (
    <img src={imageSrc} alt={alt} className={className} />
  ) : (
    <div className={`bg-gray-300 animate-pulse ${className}`} />
  );
}

 

Lazy Loading 최적화 및 이미지 로드 실패 시 대체 이미지 적용

 

📍 public/images 폴더에 대체 이미지 업로드(이미지 로드 실패 시 기본 이미지로 사용)


5. SEO 최적화 (메타 태그 추가)

📍 React Helmet Async 설치 (SEO 최적화 및 SNS 미리보기 적용을 위한 필수 라이브러리)

pnpm add react-helmet-async

 

검색 엔진에서 페이지 정보를 인식할 수 있도록 메타 태그 관리

SNS에서 페이지 링크 공유 시 미리보기(제목, 설명, 이미지)를 최적화

 

📍 App.tsx에 HelmetProvider 적용전역 SEO 및 메타 태그 관리를 위해 추가

import './App.css'
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/react-query";
import Layout from "@/components/Layout";
import ProductList from "@/pages/ProductList";
import ProductDetail from "@/pages/ProductDetail";
import { HelmetProvider } from "react-helmet-async"; // ✅ HelmetProvider 추가

export default function App() {
  return (
    <HelmetProvider> {/* ✅ SEO 최적화를 위해 필요 */}
      <QueryClientProvider client={queryClient}>
        <Router>
          <Routes>
            <Route path="/" element={<Layout />}>
              <Route index element={<ProductList />} />
              <Route path="/product/:id" element={<ProductDetail />} />
            </Route>
          </Routes>
        </Router>
      </QueryClientProvider>
    </HelmetProvider>
  );
}

 

 <Helmet>을 사용하려면 최상위 컴포넌트에서 HelmetProvider로 감싸야 함

프로젝트 전반에서 일관된 SEO 설정 및 Open Graph 적용 가능

 

📍 상품 상세 페이지에서 <Helmet>을 사용하여 메타 태그 추가 → 상품별 검색 & 공유 최적화

import { useParams } from "react-router-dom";
import { useState, useEffect, Suspense, lazy } from "react";
import { useProductDetail } from "@/hooks/useProductDetail";
import { Helmet } from "react-helmet-async"; // ✅ SEO 최적화를 위한 Helmet 추가
import { Button } from "@/components/ui/button";

const LazyImage = lazy(() => import("@/components/LazyImage")); // Lazy Loading 적용

export default function ProductDetail() {
  const { id } = useParams<{ id: string }>();
  const { data: product, isLoading, error } = useProductDetail(Number(id));
  const [quantity, setQuantity] = useState(1);
  const [selectedImage, setSelectedImage] = useState("");

  const generateThumbnailPaths = (productName: string, count: number = 3) => {
    const formattedName = productName.toLowerCase().replace(/\s+/g, "-");
    return Array.from({ length: count }, (_, i) => `/images/${formattedName}-thumbnail-${i + 1}.png`);
  };
  
  useEffect(() => {
    if (product) {
      setSelectedImage(product.image);
    }
  }, [product]);

  if (isLoading) return <p className="text-center text-gray-500">⏳ 상품 정보를 불러오는 중...</p>;
  if (error || !product) return <p className="text-center text-red-500">⚠️ 상품을 찾을 수 없습니다.</p>;

  return (
    <div className="container mx-auto p-6">
      {/* ✅ SEO 최적화 (Helmet) 추가 */}
      <Helmet>
        {/* 검색 엔진에 표시될 페이지 제목 */}
        <title>{product.name} - 쇼핑몰</title>
        {/* 검색 결과에 표시될 설명 (상품명 + 가격) */}
        <meta name="description" content={`${product.name} - ${product.price}원`} />
        {/* SNS 공유 시 표시될 제목 */}
        <meta property="og:title" content={`${product.name} - 쇼핑몰`} />
        {/* SNS 공유 시 표시될 이미지 (절대 경로로 설정) */}
        <meta property="og:image" content={`${window.location.origin}${selectedImage}`} />
      </Helmet>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div className="flex flex-col items-center">
          <Suspense fallback={<p>이미지 로딩 중...</p>}>
            <LazyImage src={selectedImage} alt={product.name} className="w-80 h-80 object-cover rounded-md shadow-md" />
          </Suspense>
          <div className="flex mt-4 space-x-2">
            {[product.image, ...generateThumbnailPaths(product.name)].map((img, idx) => (
              <img
                key={idx}
                src={img}
                alt="썸네일"
                className={`w-16 h-16 object-cover cursor-pointer rounded-md border-2 ${
                  selectedImage === img ? "border-blue-500" : "border-gray-300"
                }`}
                onClick={() => setSelectedImage(img)}
              />
            ))}
          </div>
        </div>
        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="text-xl text-gray-700 mt-2">${product.price.toFixed(2)}</p>
          <div className="flex items-center mt-4">
            <label className="mr-2 text-gray-600">수량:</label>
            <input
              type="number"
              min="1"
              value={quantity}
              onChange={(e) => setQuantity(Number(e.target.value))}
              className="border rounded-md px-3 py-1 w-16"
            />
          </div>
          <Button className="mt-4 w-full bg-blue-600 text-white hover:bg-blue-700 transition-transform transform hover:scale-105">
            장바구니에 추가
          </Button>
        </div>
      </div>
    </div>
  );
}

 

SEO 최적화 및 SNS 공유 시 미리보기 개선 완료!


6. 프로젝트 실행 및 최종 테스트

pnpm dev

 

http://localhost:5173에서 실행 확인

 

 Step 3 완료! 🎉

✅ 상품 목록에서 클릭 시 상세 페이지 정상 동작 확인
✅ SEO 최적화 및 Open Graph 태그 적용 확인
✅ Lazy Loading으로 이미지 로딩 최적화 확인


🚀 다음 단계 : 장바구니 기능 구현

📌 다음으로 할 작업 :

1️⃣ Redux를 활용하여 장바구니 기능 구현
2️⃣ Redux와 React Query를 함께 활용하여 최적화
3️⃣ "장바구니에 추가" 버튼 클릭 시 Redux 상태 업데이트 및 UI 반영