이 글은 누구를 위한 것인가
- "스킨케어 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% 높이는 강력한 전략이다.