🎯 목표 :
✔ 상품 클릭 시 상세 페이지에서 정보 표시
✔ 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 반영
'React 프로젝트 : 쇼핑몰 구현' 카테고리의 다른 글
[React] 쇼핑몰 개발 | STEP 4 : 장바구니 기능 구현 (Redux 적용) (0) | 2025.03.18 |
---|---|
[React] 쇼핑몰 개발 | STEP 2 : 상품 목록 페이지 구현 (React Query 활용) (0) | 2025.03.16 |
[React] 쇼핑몰 개발 | STEP 1 : 프로젝트 환경 설정 & 기본 레이아웃 구성 (1) | 2025.03.15 |
[React] 쇼핑몰 개발 | STEP 0 : React 학습 로드맵 (0) | 2025.03.14 |