OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 72 / 90
stack-analysis
CHAPTER 72 / 90
읽기 약 2
FUNCTION

상품 카탈로그: 카테고리/태그/변형


핵심 개념

tree 카테고리·variant 옵션·attribute·검색 인덱스 — 쿠팡 스타일.

본문

카테고리 — 트리 구조

SQL📋 코드 (30줄)
-- Adjacency List + Materialized Path
CREATE TABLE categories (
  id UUID PRIMARY KEY,
  parent_id UUID REFERENCES categories(id),
  name VARCHAR NOT NULL,
  slug VARCHAR UNIQUE NOT NULL,
  path LTREE,  -- '식품.음료.커피'
  level INT NOT NULL,
  sort_order INT DEFAULT 0
);

CREATE INDEX idx_categories_path ON categories USING GIST (path);


-- 트리거로 path 자동 갱신
CREATE FUNCTION update_category_path() RETURNS trigger AS $$
DECLARE
  parent_path LTREE;
BEGIN
  IF NEW.parent_id IS NULL THEN
    NEW.path := NEW.slug::ltree;
    NEW.level := 1;
  ELSE
    SELECT path INTO parent_path FROM categories WHERE id = NEW.parent_id;
    NEW.path := parent_path || NEW.slug::ltree;
    NEW.level := nlevel(NEW.path);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SQL📋 코드 (12줄)
-- 쿼리
-- 식품 카테고리의 모든 하위 (재귀)
SELECT * FROM categories WHERE path <@ '식품';

-- 음료의 직계 자식만
SELECT * FROM categories
WHERE parent_id = (SELECT id FROM categories WHERE slug = '음료');

-- 경로 탐색 (breadcrumb)
SELECT * FROM categories
WHERE path @> (SELECT path FROM categories WHERE id = ?)
ORDER BY level;

변형(Variant) — Color × Size

SQL📋 코드 (40줄)
-- 옵션 정의
CREATE TABLE option_types (
  id UUID PRIMARY KEY,
  name VARCHAR UNIQUE NOT NULL  -- color, size, material
);

CREATE TABLE option_values (
  id UUID PRIMARY KEY,
  option_type_id UUID REFERENCES option_types(id),
  value VARCHAR NOT NULL  -- 'red', 'L'
);


-- 상품 → 옵션 연결
CREATE TABLE product_options (
  product_id UUID,
  option_type_id UUID,
  PRIMARY KEY (product_id, option_type_id)
);


-- 변형 (모든 조합)
CREATE TABLE product_variants (
  id UUID PRIMARY KEY,
  product_id UUID,
  sku VARCHAR UNIQUE NOT NULL,
  name VARCHAR NOT NULL,  -- "Red - L"
  attributes JSONB,        -- { color: 'red', size: 'L' }
  price DECIMAL(10,0),
  stock_quantity INT DEFAULT 0,
  image_url VARCHAR
);


-- 변형의 옵션 값
CREATE TABLE variant_option_values (
  variant_id UUID REFERENCES product_variants(id),
  option_value_id UUID REFERENCES option_values(id),
  PRIMARY KEY (variant_id, option_value_id)
);

변형 자동 생성

TYPESCRIPT📋 코드 (27줄)
async function generateVariants(productId: string, options: Record<string, string[]>) {
  // options = { color: ['red', 'blue'], size: ['S', 'M', 'L'] }
  // → 6개 조합 (red-S, red-M, red-L, blue-S, blue-M, blue-L)

  const combinations = cartesianProduct(Object.values(options));

  return Promise.all(combinations.map(async (combo) => {
    const attrs = Object.keys(options).reduce((acc, key, i) => {
      acc[key] = combo[i];
      return acc;
    }, {} as any);

    const sku = `${productId.slice(0, 8)}-${combo.join('-')}`.toUpperCase();
    const name = combo.join(' - ');

    return db.productVariant.create({
      data: { productId, sku, name, attributes: attrs, price: 0, stockQuantity: 0 },
    });
  }));
}

function cartesianProduct(arrays: any[][]): any[][] {
  return arrays.reduce<any[][]>((acc, curr) =>
    acc.flatMap(c => curr.map(v => [...c, v])),
    [[]]
  );
}

속성(Attributes) — 검색 필터

SQL📋 코드 (22줄)
-- EAV (Entity-Attribute-Value) 안티패턴 → JSONB 사용
ALTER TABLE products ADD COLUMN attributes JSONB DEFAULT '{}';

-- 예시
UPDATE products SET attributes = '{
  "brand": "나이키",
  "material": "면 100%",
  "origin": "베트남",
  "weight_g": 250,
  "tags": ["여름", "캐주얼"]
}'::jsonb WHERE id = ?;


-- GIN 인덱스
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);


-- 필터 검색
SELECT * FROM products
WHERE attributes @> '{"brand": "나이키"}'
  AND attributes ? 'tags'
  AND attributes->'tags' @> '["여름"]';

검색 + 필터 API

TYPESCRIPT📋 코드 (44줄)
app.get('/api/products', async (req, res) => {
  const {
    q, category, brand, color, size,
    minPrice, maxPrice,
    sort = 'created_desc',
    page = 1, limit = 24,
  } = req.query;

  const where: any = { status: 'active' };

  if (category) {
    // category 트리 모두
    const cat = await db.category.findUnique({ where: { slug: category as string } });
    where.category = { path: { startsWith: cat!.path } };
  }

  if (brand) where.attributes = { ...where.attributes, path: ['brand'], equals: brand };
  if (q) where.OR = [
    { name: { contains: q, mode: 'insensitive' } },
    { description: { contains: q, mode: 'insensitive' } },
  ];
  if (minPrice) where.basePrice = { ...where.basePrice, gte: Number(minPrice) };
  if (maxPrice) where.basePrice = { ...where.basePrice, lte: Number(maxPrice) };

  const orderBy = {
    'price_asc': { basePrice: 'asc' },
    'price_desc': { basePrice: 'desc' },
    'popular': { ordersCount: 'desc' },
    'created_desc': { createdAt: 'desc' },
  }[sort as string] ?? { createdAt: 'desc' };

  const [items, total] = await Promise.all([
    db.product.findMany({
      where,
      orderBy,
      skip: (Number(page) - 1) * Number(limit),
      take: Number(limit),
      include: { variants: { where: { stockQuantity: { gt: 0 } }, take: 1 } },
    }),
    db.product.count({ where }),
  ]);

  res.json({ items, total, page: Number(page) });
});

다음 챕터

CH.73 "장바구니와 체크아웃 흐름".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 상품 카탈로그 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

상품 카탈로그 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 상품 카탈로그
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
상품 카탈로그 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
상품 카탈로그: 카테고리/태그/변형 이 3가지만 확실히 잡으세요
1.카테고리는 ltree (PostgreSQL) — 트리 쿼리 효율적
2.변형은 옵션 조합으로 자동 생성 — Color × Size
3.attributes는 JSONB + GIN 인덱스 — 무한 확장 가능


공유하기
진행도 72 / 90