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

이커머스

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

이 글은 누구를 위한 것인가

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

들어가며

사전 예약은 수요 검증과 현금 확보를 동시에 한다. 1,000개 한정 상품에 2,000명이 예약하면 생산 결정이 쉬워진다. 보증금만 받아도 운영 자금이 생긴다. 다만 보증금은 받은 시점에 수익이 아니므로, 인식 시점은 IFRS 15의 수행의무 기준을 따라야 한다. 승인만 걸고 나중에 청구하는 흐름은 결제 문서의 authorize/capture 분리를 참고한다.

이 글은 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일의 재납부 기간을 주는 것이 이탈률을 낮춘다.