상품 번들·세트 구성: BOM 관리와 재고 동기화 시스템 설계

이커머스

상품 번들BOM재고 관리세트 상품이커머스 DB

이 글은 누구를 위한 것인가

  • "스킨케어 3종 세트"처럼 여러 상품을 묶어 판매하는 이커머스 팀
  • 번들 상품의 재고가 개별 상품 재고와 연동되지 않아 오버셀링이 발생하는 팀
  • 번들 해체 반품 처리 로직을 설계해야 하는 개발자

들어가며

번들 상품은 객단가를 높이는 훌륭한 전략이다. 하지만 구현이 잘못되면 "개별 상품 재고는 있는데 번들은 품절"이거나 "번들을 팔았는데 개별 재고에서 차감이 안 됨" 같은 문제가 생긴다.

이 글은 bluefoxdev.kr의 이커머스 재고 관리 가이드 를 참고하여 작성했습니다.


1. 번들 유형

[번들 상품 유형]

고정 번들 (Fixed Bundle):
  구성: A + B + C 고정
  재고: 독립 SKU 또는 컴포넌트 기반
  예: 스타터 패키지, 기기+악세서리 세트

유연 번들 (Configurable Bundle):
  구성: A 선택 + B 중 택1 + C 옵션
  재고: 항상 컴포넌트 기반
  예: 맞춤 선물세트, 자유구성 도시락

단순 묶음 (Multi-pack):
  구성: 동일 상품 N개
  재고: 단순 수량 배수
  예: 3+1, 세제 3팩

[재고 관리 방식]
방식 A: 번들 전용 재고
  → 사전 조립된 번들로 재고 보유
  → 개별 상품과 재고 분리
  → 단점: 예측 실수 시 재고 낭비

방식 B: 컴포넌트 기반 (권장)
  → 번들 재고 = min(컴포넌트 재고)
  → 개별 + 번들 판매 시 공유 재고
  → 단점: 동시성 처리 주의 필요

2. DB 스키마

-- 상품 (개별 + 번들 모두 여기)
CREATE TABLE products (
    id UUID PRIMARY KEY,
    sku VARCHAR(100) UNIQUE,
    name VARCHAR(500),
    is_bundle BOOLEAN DEFAULT false,
    price DECIMAL(15,2),
    stock_qty INT DEFAULT 0,  -- 번들은 컴포넌트 기반이면 항상 -1 (가상)
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 번들 구성 (BOM)
CREATE TABLE bundle_components (
    bundle_id UUID NOT NULL REFERENCES products(id),
    component_id UUID NOT NULL REFERENCES products(id),
    quantity INT NOT NULL DEFAULT 1,
    PRIMARY KEY (bundle_id, component_id)
);

-- 번들 가격 정책
CREATE TABLE bundle_pricing (
    bundle_id UUID PRIMARY KEY REFERENCES products(id),
    pricing_type VARCHAR(20),  -- 'fixed', 'percentage_off', 'sum_of_parts'
    fixed_price DECIMAL(15,2),
    discount_rate DECIMAL(5,2),  -- percentage_off 시 할인율
    created_at TIMESTAMPTZ DEFAULT NOW()
);

3. 번들 재고 계산

async def get_bundle_available_stock(bundle_id: str) -> int:
    """컴포넌트 재고 기반 번들 가용 재고 계산"""
    
    components = await db.fetch("""
        SELECT bc.component_id, bc.quantity as required_qty, 
               p.stock_qty as available_qty
        FROM bundle_components bc
        JOIN products p ON p.id = bc.component_id
        WHERE bc.bundle_id = $1
    """, bundle_id)
    
    if not components:
        return 0
    
    # 각 컴포넌트로 만들 수 있는 번들 수
    max_bundles = [
        comp['available_qty'] // comp['required_qty']
        for comp in components
    ]
    
    return min(max_bundles)  # 가장 적은 것이 병목

async def reserve_bundle_stock(bundle_id: str, quantity: int) -> bool:
    """번들 주문 시 컴포넌트 재고 차감"""
    
    components = await db.fetch(
        "SELECT component_id, quantity FROM bundle_components WHERE bundle_id=$1",
        bundle_id
    )
    
    async with db.transaction():
        for comp in components:
            result = await db.execute("""
                UPDATE products
                SET stock_qty = stock_qty - $1
                WHERE id = $2 AND stock_qty >= $1
                RETURNING id
            """, comp['quantity'] * quantity, comp['component_id'])
            
            if not result:
                raise ValueError(f"컴포넌트 재고 부족: {comp['component_id']}")
    
    return True

def calculate_bundle_price(bundle_id: str, components: list) -> float:
    """번들 가격 계산"""
    pricing = get_bundle_pricing(bundle_id)
    
    if pricing['type'] == 'fixed':
        return pricing['fixed_price']
    
    components_total = sum(c['price'] * c['qty'] for c in components)
    
    if pricing['type'] == 'percentage_off':
        return components_total * (1 - pricing['discount_rate'] / 100)
    
    return components_total  # sum_of_parts

마무리

번들 상품의 핵심은 컴포넌트 재고를 실시간으로 정확하게 추적하는 것이다. 트랜잭션으로 원자적으로 차감하고, 반품 시 세트 해체 정책(부분 반품 가능 여부)을 미리 정의해두면 운영 혼란을 막을 수 있다. 번들은 객단가를 20-30% 높이는 강력한 전략이다.