이 글은 누구를 위한 것인가
- 신규 상품 출시 전 수요를 미리 검증하고 싶은 팀
- 한정판 상품의 사전 예약 시스템을 만들어야 하는 개발자
- 보증금 결제와 잔금 청구 플로우를 설계해야 하는 팀
들어가며
사전 예약은 수요 검증과 현금 확보를 동시에 한다. 1,000개 한정 상품에 2,000명이 예약하면 생산 결정이 쉬워진다. 보증금만 받아도 운영 자금이 생긴다.
이 글은 bluefoxdev.kr의 이커머스 사전 예약 시스템 를 참고하여 작성했습니다.
1. 사전 예약 상태 머신
[Pre-order 상태 흐름]
DRAFT (준비중)
↓ 공개 시작
OPEN (예약 접수 중)
↓ 예약 마감 또는 수량 초과
CLOSED (예약 마감)
↓ 생산 결정
CONFIRMED (생산 확정)
↓ 잔금 청구 시작
CHARGING (잔금 청구 중)
↓ 출고 준비 완료
FULFILLING (출고 중)
↓ 전체 배송 완료
COMPLETED (완료)
[예약 건 상태]
RESERVED → DEPOSIT_PAID → FULLY_PAID → SHIPPED → DELIVERED
↘ CANCELLED (보증금 환불)
2. 보증금 결제 및 잔금 청구
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
class PreorderStatus(Enum):
RESERVED = "reserved"
DEPOSIT_PAID = "deposit_paid"
FULLY_PAID = "fully_paid"
CANCELLED = "cancelled"
@dataclass
class PreorderItem:
id: str
user_id: str
product_id: str
quantity: int
unit_price: int
deposit_amount: int
remaining_amount: int
status: PreorderStatus
async def create_preorder(user_id: str, product_id: str, quantity: int) -> PreorderItem:
"""사전 예약 생성 및 보증금 결제"""
product = await get_preorder_product(product_id)
if product["reserved_count"] >= product["max_quantity"]:
raise ValueError("예약 수량 초과")
unit_price = product["price"]
deposit_rate = product["deposit_rate"] # 보통 10-30%
deposit_amount = round(unit_price * quantity * deposit_rate)
remaining_amount = unit_price * quantity - deposit_amount
# 보증금 결제 처리
payment_result = await process_payment({
"user_id": user_id,
"amount": deposit_amount,
"type": "preorder_deposit",
"description": f"[사전예약] {product['name']} 보증금",
})
if not payment_result["success"]:
raise ValueError("보증금 결제 실패")
# 예약 생성
preorder = await db.fetchrow("""
INSERT INTO preorders
(user_id, product_id, quantity, unit_price, deposit_amount, remaining_amount,
deposit_payment_id, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'deposit_paid')
RETURNING *
""", user_id, product_id, quantity, unit_price,
deposit_amount, remaining_amount, payment_result["payment_id"])
# 예약 수량 증가
await db.execute(
"UPDATE preorder_products SET reserved_count = reserved_count + $1 WHERE id = $2",
quantity, product_id
)
await send_preorder_confirmation(user_id, preorder)
return preorder
async def charge_remaining_balance(preorder_product_id: str):
"""출시 확정 후 잔금 일괄 청구"""
preorders = await db.fetch("""
SELECT p.*, u.email, u.name
FROM preorders p
JOIN users u ON p.user_id = u.id
WHERE p.product_id = $1
AND p.status = 'deposit_paid'
""", preorder_product_id)
success_count = 0
failed_preorders = []
for preorder in preorders:
try:
payment_result = await process_payment({
"user_id": preorder["user_id"],
"amount": preorder["remaining_amount"],
"type": "preorder_balance",
"saved_payment_method_id": await get_saved_payment(preorder["user_id"]),
"description": f"[사전예약 잔금] {preorder['product_name']}",
})
if payment_result["success"]:
await db.execute(
"UPDATE preorders SET status='fully_paid', balance_payment_id=$1 WHERE id=$2",
payment_result["payment_id"], preorder["id"]
)
success_count += 1
else:
failed_preorders.append(preorder["id"])
await notify_payment_failure(preorder["user_id"], preorder["id"])
except Exception as e:
failed_preorders.append(preorder["id"])
# 잔금 미납자 재시도 알림
if failed_preorders:
await schedule_payment_retry(failed_preorders, retry_after_days=3)
return {
"total": len(preorders),
"success": success_count,
"failed": len(failed_preorders),
}
async def cancel_preorder(preorder_id: str, reason: str) -> bool:
"""예약 취소 및 보증금 환불"""
preorder = await get_preorder(preorder_id)
product = await get_preorder_product(preorder["product_id"])
# 취소 가능 기한 확인
if product["status"] == "FULFILLING":
raise ValueError("출고 시작 후에는 취소 불가")
# 보증금 환불 (취소 시점에 따라 수수료 차등)
days_before_release = (product["release_date"] - datetime.now()).days
refund_rate = 1.0 if days_before_release > 7 else 0.5 # 7일 이내 50% 환불
refund_amount = int(preorder["deposit_amount"] * refund_rate)
await process_refund(preorder["deposit_payment_id"], refund_amount)
await db.execute(
"UPDATE preorders SET status='cancelled', cancel_reason=$1 WHERE id=$2",
reason, preorder_id
)
return True
마무리
사전 예약의 핵심은 고객과의 신뢰 계약이다. 보증금을 받았으면 출시 일정을 반드시 지켜야 하고, 취소 정책은 명확히 사전에 공지해야 한다. 잔금 청구는 출고 7일 전에 하고, 미납자에게 3-5일의 재납부 기간을 주는 것이 이탈률을 낮춘다.