본문 바로가기

바이브코딩

15. 목록과 상세 구현 - Next.js 제품 카탈로그 페이지

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 동적 메타데이터, 정적 경로 생성

다음 편에서는 문의하기 폼을 구현합니다.

💬 제품 페이지 관련 질문 있으면 댓글로 남겨주세요!