사전 예약·예약 구매 시스템: 출시 전 수요 검증과 보증금 결제 설계

이커머스

사전 예약Pre-order보증금 결제수요 검증이커머스 출시

이 글은 누구를 위한 것인가

  • 신규 상품 출시 전 수요를 미리 검증하고 싶은 팀
  • 한정판 상품의 사전 예약 시스템을 만들어야 하는 개발자
  • 보증금 결제와 잔금 청구 플로우를 설계해야 하는 팀

들어가며

사전 예약은 수요 검증과 현금 확보를 동시에 한다. 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일의 재납부 기간을 주는 것이 이탈률을 낮춘다.