
Next.js 제품 카탈로그 페이지 - 15편. 목록과 상세 구현
제품 카탈로그를 구현하여 고객에게 제품을 보여줍니다.
이 글에서는 DB 설계, 목록 페이지, 상세 페이지, 카테고리 필터, SEO 최적화를 다룹니다.
📦 제품 페이지가 필요하다
견적 플랫폼이니까 무엇을 파는지 보여줘야 합니다.
고객이 제품을 보고, 관심 있는 제품에 대해 문의하는 흐름입니다.
Claude에게 물었습니다.
제품 목록과 상세 페이지를 만들어줘.
카테고리로 필터링도 가능하게.🗄️ 제품 데이터 구조
먼저 테이블을 설계했습니다.
create table products (
id uuid primary key default gen_random_uuid(),
name_ko text not null,
name_ja text,
description_ko text,
description_ja text,
category text,
image_url text,
is_active boolean default true,
display_order integer default 0,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 카테고리 인덱스
create index idx_products_category on products(category);
-- 정렬용 인덱스
create index idx_products_order on products(display_order, created_at desc);
다국어 지원을 위해 name_ko, name_ja로 분리했습니다.
샘플 데이터
insert into products (name_ko, name_ja, category, description_ko) values
('산업용 펌프 A형', '産業用ポンプ A型', 'pump', '고효율 산업용 펌프입니다.'),
('산업용 펌프 B형', '産業用ポンプ B型', 'pump', '대용량 산업용 펌프입니다.'),
('밸브 시리즈 100', 'バルブシリーズ100', 'valve', '고압용 밸브입니다.');
📋 제품 조회 함수
// lib/queries/products.ts
import { createClient } from '@/lib/supabase/server'
export async function getProducts(options?: {
category?: string
limit?: number
}) {
const supabase = await createClient()
let query = supabase
.from('products')
.select('*')
.eq('is_active', true)
.order('display_order')
.order('created_at', { ascending: false })
if (options?.category) {
query = query.eq('category', options.category)
}
if (options?.limit) {
query = query.limit(options.limit)
}
const { data, error } = await query
if (error) throw error
return data
}
is_active가 true인 것만 가져옵니다. 삭제 대신 비활성화 패턴입니다.
📋 제품 목록 페이지
// app/[locale]/(public)/products/page.tsx
import { getProducts } from '@/lib/queries/products'
import { getTranslations, getLocale } from 'next-intl/server'
import { ProductCard } from '@/components/products/ProductCard'
export default async function ProductsPage() {
const t = await getTranslations('products')
const locale = await getLocale()
const products = await getProducts()
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">{t('title')}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
locale={locale}
/>
))}
</div>
{products.length === 0 && (
<p className="text-center text-gray-500 py-12">
{t('noProducts')}
</p>
)}
</div>
)
}
제품 카드 컴포넌트
// components/products/ProductCard.tsx
import Link from 'next/link'
import Image from 'next/image'
import { Product } from '@/lib/types'
interface Props {
product: Product
locale: string
}
export function ProductCard({ product, locale }: Props) {
const name = locale === 'ja' ? product.name_ja : product.name_ko
const description = locale === 'ja'
? product.description_ja
: product.description_ko
return (
<Link
href={`/products/${product.id}`}
className="group block bg-white rounded-xl shadow-md overflow-hidden
hover:shadow-lg transition-shadow"
>
{/* 이미지 */}
<div className="relative aspect-[4/3] bg-gray-100">
{product.image_url ? (
<Image
src={product.image_url}
alt={name || product.name_ko}
fill
className="object-cover group-hover:scale-105 transition-transform"
/>
) : (
<div className="flex items-center justify-center h-full">
<span className="text-gray-400">No Image</span>
</div>
)}
</div>
{/* 내용 */}
<div className="p-4">
<h3 className="font-semibold text-lg mb-2 group-hover:text-primary">
{name || product.name_ko}
</h3>
{description && (
<p className="text-gray-600 text-sm line-clamp-2">
{description}
</p>
)}
{product.category && (
<span className="inline-block mt-3 px-2 py-1 bg-gray-100
text-gray-600 text-xs rounded">
{product.category}
</span>
)}
</div>
</Link>
)
}
일본어 데이터가 없으면 한국어로 폴백합니다.
📄 제품 상세 페이지
// lib/queries/products.ts
export async function getProductById(id: string) {
const supabase = await createClient()
const { data, error } = await supabase
.from('products')
.select('*')
.eq('id', id)
.eq('is_active', true)
.single()
if (error) return null
return data
}
// app/[locale]/(public)/products/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getProductById } from '@/lib/queries/products'
import { getLocale } from 'next-intl/server'
import Image from 'next/image'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
interface Props {
params: { id: string }
}
export default async function ProductDetailPage({ params }: Props) {
const locale = await getLocale()
const product = await getProductById(params.id)
if (!product) {
notFound()
}
const name = locale === 'ja' ? product.name_ja : product.name_ko
const description = locale === 'ja'
? product.description_ja
: product.description_ko
return (
<div className="container mx-auto px-4 py-8">
{/* 뒤로가기 */}
<Link
href="/products"
className="text-gray-600 hover:text-gray-900 mb-6 inline-block"
>
← 목록으로
</Link>
<div className="grid md:grid-cols-2 gap-8">
{/* 이미지 */}
<div className="relative aspect-square bg-gray-100 rounded-xl overflow-hidden">
{product.image_url ? (
<Image
src={product.image_url}
alt={name || product.name_ko}
fill
className="object-cover"
priority
/>
) : (
<div className="flex items-center justify-center h-full">
<span className="text-gray-400 text-xl">No Image</span>
</div>
)}
</div>
{/* 정보 */}
<div>
{product.category && (
<span className="text-sm text-primary font-medium">
{product.category}
</span>
)}
<h1 className="text-3xl font-bold mt-2 mb-4">
{name || product.name_ko}
</h1>
{description && (
<p className="text-gray-600 mb-6 whitespace-pre-line">
{description}
</p>
)}
{/* 문의 버튼 */}
<Button asChild size="lg" className="w-full md:w-auto">
<Link href={`/inquiry?product=${product.id}`}>
이 제품 문의하기
</Link>
</Button>
</div>
</div>
</div>
)
}
문의 버튼을 누르면 제품 ID가 URL 파라미터로 전달됩니다.
🔍 카테고리 필터
URL 파라미터로 카테고리를 필터링합니다.
// app/[locale]/(public)/products/page.tsx
interface Props {
searchParams: { category?: string }
}
export default async function ProductsPage({ searchParams }: Props) {
const products = await getProducts({
category: searchParams.category
})
// ...
}
필터 컴포넌트
// components/products/CategoryFilter.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
const categories = [
{ value: '', label: '전체' },
{ value: 'pump', label: '펌프' },
{ value: 'valve', label: '밸브' },
{ value: 'motor', label: '모터' },
]
export function CategoryFilter() {
const router = useRouter()
const searchParams = useSearchParams()
const current = searchParams.get('category') || ''
const handleChange = (category: string) => {
const params = new URLSearchParams(searchParams)
if (category) {
params.set('category', category)
} else {
params.delete('category')
}
router.push(`/products?${params.toString()}`)
}
return (
<div className="flex gap-2 mb-6">
{categories.map((cat) => (
<button
key={cat.value}
onClick={() => handleChange(cat.value)}
className={cn(
'px-4 py-2 rounded-full text-sm transition',
current === cat.value
? 'bg-primary text-white'
: 'bg-gray-100 hover:bg-gray-200'
)}
>
{cat.label}
</button>
))}
</div>
)
}
🖼️ 이미지 최적화
Supabase Storage의 이미지를 Next.js Image 컴포넌트로 쓰려면 설정이 필요합니다.
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'your-supabase-project.supabase.co',
pathname: '/storage/v1/object/public/**',
},
],
},
}
이걸 안 하면 이미지가 안 나옵니다.
⚠️ 삽질했던 것들
이미지가 안 나옴
next.config.js에 도메인을 안 넣었습니다.
remotePatterns에 Supabase 도메인을 추가했습니다.
404 페이지
존재하지 않는 제품 ID로 접근하면 에러가 났습니다.
if (!product) {
notFound() // Next.js 내장 404
}
notFound()를 추가했습니다.
다국어 표시 오류
일본어 데이터가 없는 제품이 있었습니다.
const name = (locale === 'ja' && product.name_ja)
? product.name_ja
: product.name_ko
일본어가 없으면 한국어로 폴백하도록 수정했습니다.
💡 SEO 최적화
Claude가 SEO도 챙겨줬습니다.
동적 메타데이터
// app/[locale]/(public)/products/[id]/page.tsx
import { Metadata } from 'next'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await getProductById(params.id)
if (!product) {
return { title: '제품을 찾을 수 없습니다' }
}
return {
title: product.name_ko,
description: product.description_ko?.slice(0, 160),
openGraph: {
images: product.image_url ? [product.image_url] : [],
},
}
}
각 제품 페이지가 고유한 메타 태그를 가집니다.
정적 경로 생성
export async function generateStaticParams() {
const products = await getProducts()
return products.map((product) => ({
id: product.id,
}))
}
빌드 시점에 모든 제품 페이지를 미리 생성합니다. 로딩이 빨라집니다.
📋 이번 편 요약
| 항목 | 내용 |
|---|---|
| 다룬 기능 | 제품 목록, 상세 페이지, 카테고리 필터 |
| 핵심 파일 | products/page.tsx, products/[id]/page.tsx |
| 다국어 | name_ko, name_ja 필드 분리 |
| SEO | 동적 메타데이터, 정적 경로 생성 |
다음 편에서는 문의하기 폼을 구현합니다.
💬 제품 페이지 관련 질문 있으면 댓글로 남겨주세요!
'바이브코딩' 카테고리의 다른 글
| 17. 검색과 SEO 구조화 데이터 - shadcn/ui FAQ 아코디언 구현 (0) | 2026.01.24 |
|---|---|
| 16. 제품 선택부터 이메일 발송까지 - React Hook Form 문의 폼 구현 (0) | 2026.01.23 |
| 14. 스팸 방지 구현 - Cloudflare Turnstile CAPTCHA (0) | 2026.01.21 |
| 13. 고객과 관리자 권한 분리 - Supabase RBAC 역할 관리 (0) | 2026.01.20 |
| 12. 인증과 권한 체크 구현 - Next.js 미들웨어 페이지 보호 (0) | 2026.01.19 |