Next.js로 쇼핑몰 만들기 #2 상품 카탈로그

6 분 소요

지난 시간에는 라우트 트리와 데이터 모델을 잡고 상품 시드 JSON 까지 준비했습니다. 이번에는 진짜 화면을 그립니다 — /products 목록, /products/[slug] 상세, 두 페이지를 완성하는 게 목표입니다. 두 페이지 모두 Server Component 로 만들 수 있는 영역입니다. 데이터는 정적 JSON 이고, 사용자 인터랙션이 없는 화면이니까요.

공통 유틸 — 가격 포맷팅 #

가격이 여러 곳에서 등장하므로 포맷팅 함수를 미리 분리해둡니다. Intl.NumberFormat 이 표준 라이브러리에 들어있어 외부 의존성 없이 처리할 수 있습니다.

src/app/lib/format.js:

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:

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/imagefill 모드를 썼습니다. 부모 컨테이너가 relative + aspect-square 로 정해진 크기를 가지면, 이미지가 그 안을 채우면서 object-cover 로 잘립니다. 카드들의 높이가 들쭉날쭉해지지 않는 가장 단순한 패턴입니다.

Link 가 카드 전체를 감싸도록 두면 클릭 영역이 카드 전체가 됩니다. 모바일에서 특히 중요한 디테일입니다.

next/image 도메인 설정 #

이번 글의 이미지는 모두 public/ 하위의 로컬 자산이라 추가 설정이 필요 없지만, 나중에 외부 이미지(CDN, S3 등)를 쓰게 되면 next.config.mjsimages.remotePatterns 등록이 필요합니다. 일단 기본 설정 그대로 두고 진행합니다.

이미지가 아직 준비되지 않은 슬러그가 있다면 next/image 에서 404가 발생해 빨간 X가 표시될 수 있습니다. 학습 단계에서는 임의의 placeholder 이미지(예: https://placehold.co/600x600)를 등록하거나, JSON 의 image 경로를 /next.svg 처럼 기존 자산으로 임시로 바꿔두면 됩니다. 외부 placeholder 를 쓰려면 next.config.mjs 에 도메인을 등록해야 합니다.

next.config.mjs — 외부 placeholder 를 쓸 경우
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'placehold.co' },
    ],
  },
};

export default nextConfig;

/products — 상품 목록 페이지 #

이제 본 페이지를 만듭니다. 기본은 전체 상품을 그리고, ?category=apparel 같은 쿼리스트링이 오면 그 카테고리만 보여줍니다.

src/app/products/page.js:

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:

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:

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:

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 로 서버를 띄우고 둘러봅니다.

카테고리 칩을 클릭하면 URL 이 바뀌고 그리드가 좁아집니다. 새로고침해도 URL 의 ?category= 가 같은 상태를 그대로 복원합니다. Server Component + 쿼리스트링 조합의 자연스러운 결과입니다.

마무리 #

이번 글에서는 카탈로그의 두 페이지를 완성했습니다.

  • 가격 포맷팅 유틸을 만들었다
  • 카드 컴포넌트를 분리해 목록과 홈에서 공유했다
  • /products 에 카테고리 필터를 searchParams 로 붙였다
  • /products/[slug]generateStaticParamsnotFound() 를 적용했다
  • 공통 헤더와 홈 페이지를 다듬었다

여기까지의 코드는 모두 Server Component 입니다. JavaScript 가 비활성화돼도 카탈로그를 둘러보는 데 문제가 없습니다.

다음 글부터 분위기가 바뀝니다. “Next.js로 쇼핑몰 만들기 #3 장바구니” 에서는 클라이언트 사이드 상태가 등장합니다. Context API + localStorage 로 장바구니를 관리하고, “장바구니에 담기” 버튼을 동작시키고, 헤더의 수량 뱃지까지 살리는 곳까지 갑니다.

X