Next.js로 쇼핑몰 만들기 #2 상품 카탈로그
지난 시간에는 라우트 트리와 데이터 모델을 잡고 상품 시드 JSON 까지 준비했습니다. 이번에는 진짜 화면을 그립니다 — /products 목록, /products/[slug] 상세, 두 페이지를 완성하는 게 목표입니다. 두 페이지 모두 Server Component 로 만들 수 있는 영역입니다. 데이터는 정적 JSON 이고, 사용자 인터랙션이 없는 화면이니까요.
공통 유틸 — 가격 포맷팅 #
가격이 여러 곳에서 등장하므로 포맷팅 함수를 미리 분리해둡니다. Intl.NumberFormat 이 표준 라이브러리에 들어있어 외부 의존성 없이 처리할 수 있습니다.
src/app/lib/format.js:
const krw = new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
});
export function formatPrice(amount) {
return krw.format(amount);
}24000 을 넣으면 ₩24,000 이 나옵니다. 매 화면마다 toLocaleString 을 흩어 호출하는 대신 한 곳에 모아두면 통화 단위가 바뀔 때(예: 다른 통화 지원) 한 줄만 수정하면 됩니다.
ProductCard 컴포넌트 #
목록과 홈에서 모두 쓸 카드 컴포넌트를 먼저 만듭니다. 카드 하나에 들어갈 요소:
- 상품 이미지
- 상품명
- 가격
- 품절 배지 (stock === 0 일 때)
src/app/components/ProductCard.js:
import Link from 'next/link';
import Image from 'next/image';
import { formatPrice } from '../lib/format';
export default function ProductCard({ product }) {
const isOutOfStock = product.stock === 0;
return (
<Link href={`/products/${product.slug}`} className="block group">
<div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover transition group-hover:scale-105"
/>
{isOutOfStock && (
<span className="absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs text-white">
품절
</span>
)}
</div>
<div className="mt-2 text-sm">{product.name}</div>
<div className="mt-1 text-base font-semibold">{formatPrice(product.price)}</div>
</Link>
);
}next/image 의 fill 모드를 썼습니다. 부모 컨테이너가 relative + aspect-square 로 정해진 크기를 가지면, 이미지가 그 안을 채우면서 object-cover 로 잘립니다. 카드들의 높이가 들쭉날쭉해지지 않는 가장 단순한 패턴입니다.
Link 가 카드 전체를 감싸도록 두면 클릭 영역이 카드 전체가 됩니다. 모바일에서 특히 중요한 디테일입니다.
next/image 도메인 설정 #
이번 글의 이미지는 모두 public/ 하위의 로컬 자산이라 추가 설정이 필요 없지만, 나중에 외부 이미지(CDN, S3 등)를 쓰게 되면 next.config.mjs 의 images.remotePatterns 등록이 필요합니다. 일단 기본 설정 그대로 두고 진행합니다.
이미지가 아직 준비되지 않은 슬러그가 있다면 next/image 에서 404가 발생해 빨간 X가 표시될 수 있습니다. 학습 단계에서는 임의의 placeholder 이미지(예: https://placehold.co/600x600)를 등록하거나, JSON 의 image 경로를 /next.svg 처럼 기존 자산으로 임시로 바꿔두면 됩니다. 외부 placeholder 를 쓰려면 next.config.mjs 에 도메인을 등록해야 합니다.
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'placehold.co' },
],
},
};
export default nextConfig;/products — 상품 목록 페이지
#
이제 본 페이지를 만듭니다. 기본은 전체 상품을 그리고, ?category=apparel 같은 쿼리스트링이 오면 그 카테고리만 보여줍니다.
src/app/products/page.js:
import Link from 'next/link';
import ProductCard from '../components/ProductCard';
import { getAllCategories, getProductsByCategory } from '../lib/products';
export const metadata = {
title: '상품',
description: '판매 중인 상품 목록입니다.',
};
export default async function ProductsPage({ searchParams }) {
const { category } = await searchParams;
const categories = getAllCategories();
const products = getProductsByCategory(category);
return (
<main className="mx-auto max-w-5xl px-4 py-8">
<h1 className="text-2xl font-bold">상품</h1>
<nav className="mt-4 flex flex-wrap gap-2">
<CategoryChip current={category} value={undefined} label="전체" />
{categories.map((c) => (
<CategoryChip key={c} current={category} value={c} label={c} />
))}
</nav>
{products.length === 0 ? (
<p className="mt-8 text-gray-500">조건에 맞는 상품이 없습니다.</p>
) : (
<ul className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{products.map((p) => (
<li key={p.slug}>
<ProductCard product={p} />
</li>
))}
</ul>
)}
</main>
);
}
function CategoryChip({ current, value, label }) {
const isActive = current === value || (!current && !value);
const href = value ? `/products?category=${value}` : '/products';
return (
<Link
href={href}
className={
'rounded-full border px-3 py-1 text-sm ' +
(isActive ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:border-gray-500')
}
>
{label}
</Link>
);
}몇 가지 짚어둘 만한 포인트:
searchParams는 await 합니다 — Next.js 16 부터params/searchParams가 Promise 입니다. 이전 글들과 동일한 패턴입니다.- 카테고리 칩이 그대로 링크입니다 — JS 가 없어도 동작합니다. Server Component 의 강점이 자연스럽게 살아납니다.
isActive판정 —current === value로 비교하되, “전체” 칩은current가 없을 때 활성화되도록 별도 분기를 둡니다.- 그리드 반응형 — 모바일 2열, 태블릿 3열, 데스크톱 4열. Tailwind 의
grid-cols-{n}으로 깔끔하게 처리됩니다.
/products/[slug] — 상품 상세 페이지
#
상품 하나를 자세히 보여주는 페이지입니다. 큰 이미지 + 설명 + 가격 + 장바구니 버튼이 들어갑니다.
src/app/products/[slug]/page.js:
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getAllProducts, getProductBySlug } from '../../lib/products';
import { formatPrice } from '../../lib/format';
export function generateStaticParams() {
return getAllProducts().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) return {};
return {
title: product.name,
description: product.description,
};
}
export default async function ProductDetailPage({ params }) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) notFound();
const isOutOfStock = product.stock === 0;
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="grid gap-8 md:grid-cols-2">
<div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
priority
/>
</div>
<div className="flex flex-col gap-4">
<div className="text-sm text-gray-500">{product.category}</div>
<h1 className="text-2xl font-bold">{product.name}</h1>
<div className="text-2xl font-semibold">{formatPrice(product.price)}</div>
<p className="text-gray-700">{product.description}</p>
{isOutOfStock ? (
<button
type="button"
disabled
className="rounded-md bg-gray-300 px-4 py-3 text-white"
>
품절
</button>
) : (
<button
type="button"
className="rounded-md bg-black px-4 py-3 text-white hover:bg-gray-800"
>
장바구니에 담기
</button>
)}
<div className="text-sm text-gray-500">
재고: {product.stock}개
</div>
</div>
</div>
</main>
);
}핵심 포인트:
generateStaticParams— 빌드 타임에 모든 슬러그를 정적으로 생성합니다. JSON 의 모든 상품이 빌드 결과물에 정적 페이지로 들어갑니다. 페이지를 열 때 서버 렌더링 비용이 없어집니다.generateMetadata— 각 상품 페이지가 자기만의<title>과<meta description>을 갖습니다. SEO 와 SNS 미리보기에 그대로 반영됩니다.notFound()— 잘못된 슬러그가 오면 404 페이지로 보냅니다.if (!product) return <NotFoundUI />같이 직접 처리하는 것보다 깔끔하고, Next.js 가 적절한 상태 코드까지 같이 보내줍니다.priority— 상세 페이지의 메인 이미지에 우선 로드 힌트를 줍니다. LCP(Largest Contentful Paint) 가 개선됩니다.- “장바구니에 담기” 버튼 — 지금은 빈 버튼입니다. 다음 글에서 클릭 핸들러를 붙입니다.
홈 페이지 정리 #
홈은 카탈로그 진입점 역할만 합니다. 1편에서 만들었던 기본 page.js 를 가볍게 정리합니다.
src/app/page.js:
import Link from 'next/link';
import ProductCard from './components/ProductCard';
import { getAllProducts } from './lib/products';
export default function HomePage() {
const featured = getAllProducts().slice(0, 4);
return (
<main className="mx-auto max-w-5xl px-4 py-8">
<section className="rounded-lg bg-gray-100 p-8 text-center">
<h1 className="text-3xl font-bold">우리 쇼핑몰</h1>
<p className="mt-2 text-gray-600">학습용 쇼핑몰 빌드 — 좋은 상품들이 기다리고 있습니다.</p>
<Link
href="/products"
className="mt-4 inline-block rounded-md bg-black px-5 py-2 text-white"
>
전체 상품 보기
</Link>
</section>
<section className="mt-8">
<h2 className="text-xl font-bold">추천 상품</h2>
<ul className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
{featured.map((p) => (
<li key={p.slug}>
<ProductCard product={p} />
</li>
))}
</ul>
</section>
</main>
);
}“추천 상품” 은 일단 첫 4 개를 그냥 잘라 씁니다. 실서비스라면 frontmatter 에 featured: true 같은 플래그를 두거나 별도 큐레이션 데이터를 두지만, 학습 단계에서는 이 정도로 충분합니다.
공통 레이아웃 — 헤더 #
여러 페이지에 걸쳐 같은 헤더가 보여야 하니 layout.js 에 헤더를 둡니다. 1편에서 만든 RootLayout 을 보강합니다.
src/app/layout.js:
import Link from 'next/link';
import './globals.css';
export const metadata = {
title: {
default: '우리 쇼핑몰',
template: '%s | 우리 쇼핑몰',
},
description: '학습용 쇼핑몰 빌드',
};
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<header className="border-b">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<Link href="/" className="font-bold">우리 쇼핑몰</Link>
<nav className="flex gap-4 text-sm">
<Link href="/products">상품</Link>
<Link href="/cart">장바구니</Link>
</nav>
</div>
</header>
{children}
</body>
</html>
);
}장바구니 수량 뱃지는 일부러 비워뒀습니다. 장바구니 상태는 클라이언트에서 관리하므로 다음 글에서 Client Component 를 따로 끼워 넣는 식으로 처리합니다.
동작 확인 #
pnpm dev 로 서버를 띄우고 둘러봅니다.
- http://localhost:3000 — 홈, 추천 상품 4 개
- http://localhost:3000/products — 전체 상품 그리드
- http://localhost:3000/products?category=apparel — 의류만
- http://localhost:3000/products/minimal-tee-black — 상세 페이지
- http://localhost:3000/products/react-book — 품절 상품(버튼 비활성)
- http://localhost:3000/products/does-not-exist — 404 페이지
카테고리 칩을 클릭하면 URL 이 바뀌고 그리드가 좁아집니다. 새로고침해도 URL 의 ?category= 가 같은 상태를 그대로 복원합니다. Server Component + 쿼리스트링 조합의 자연스러운 결과입니다.
마무리 #
이번 글에서는 카탈로그의 두 페이지를 완성했습니다.
- 가격 포맷팅 유틸을 만들었다
- 카드 컴포넌트를 분리해 목록과 홈에서 공유했다
/products에 카테고리 필터를searchParams로 붙였다/products/[slug]에generateStaticParams와notFound()를 적용했다- 공통 헤더와 홈 페이지를 다듬었다
여기까지의 코드는 모두 Server Component 입니다. JavaScript 가 비활성화돼도 카탈로그를 둘러보는 데 문제가 없습니다.
다음 글부터 분위기가 바뀝니다. “Next.js로 쇼핑몰 만들기 #3 장바구니” 에서는 클라이언트 사이드 상태가 등장합니다. Context API + localStorage 로 장바구니를 관리하고, “장바구니에 담기” 버튼을 동작시키고, 헤더의 수량 뱃지까지 살리는 곳까지 갑니다.