쿠폰/프로모션 엔진 설계: 복잡한 할인 규칙을 유연하게

이커머스

쿠폰프로모션할인 엔진이커머스백엔드

이 글은 누구를 위한 것인가

  • 다양한 할인 규칙(정률/정액/무료배송)을 유연하게 관리하려는 팀
  • 쿠폰 중복 적용, 최소 주문금액, 카테고리 제한 로직을 설계하는 개발자
  • 프로모션 우선순위 충돌을 체계적으로 해결하려는 팀

들어가며

"이 쿠폰은 세일 상품에는 적용 안 됩니다", "최대 1만원 할인", "다른 쿠폰과 중복 불가" — 이런 조건들을 하드코딩하면 비즈니스 요구가 바뀔 때마다 배포가 필요하다. 규칙 엔진으로 설계하면 운영팀이 직접 조건을 구성할 수 있다.

이 글은 bluefoxdev.kr의 쿠폰 프로모션 엔진 가이드 를 참고하여 작성했습니다.


1. 프로모션 엔진 아키텍처

[할인 타입]
  PERCENTAGE: 정률 할인 (10%)
  FIXED_AMOUNT: 정액 할인 (5,000원)
  FREE_SHIPPING: 배송비 면제
  BUY_X_GET_Y: N개 구매 시 M개 증정
  TIERED: 구간별 할인 (3만원↑ 10%, 5만원↑ 15%)

[적용 조건 (AND 조합)]
  min_order_amount: 최소 주문금액
  applicable_categories: 적용 카테고리
  applicable_products: 적용 상품
  excluded_products: 제외 상품
  user_segment: 신규/VIP/특정 그룹
  valid_from / valid_until: 유효기간
  usage_limit_total: 전체 발급 한도
  usage_limit_per_user: 사용자별 한도

[중복 사용 규칙]
  STACKABLE: 다른 쿠폰과 중복 가능
  EXCLUSIVE: 단독 사용만 가능
  CATEGORY_EXCLUSIVE: 같은 카테고리 쿠폰과 중복 불가

[우선순위]
  숫자가 높을수록 먼저 적용
  동일 우선순위: 할인액이 큰 쿠폰 우선

2. 프로모션 엔진 구현

interface Promotion {
  id: string;
  code?: string;
  type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING' | 'TIERED';
  value: number;
  maxDiscount?: number;
  conditions: PromotionCondition;
  stackable: 'STACKABLE' | 'EXCLUSIVE';
  priority: number;
}

interface CartItem {
  productId: string;
  categoryId: string;
  price: number;
  quantity: number;
}

class PromotionEngine {
  async calculateDiscount(params: {
    cart: CartItem[];
    appliedCodes: string[];
    userId: string;
  }) {
    const promotions = await db.promotion.findMany({
      where: { code: { in: params.appliedCodes }, isActive: true },
    });

    const eligible = promotions.filter(p => this.isEligible(p, params.cart));
    const applicable = this.resolveStackingRules(eligible);

    const discounts: { promotionId: string; discount: number }[] = [];
    let orderTotal = params.cart.reduce((s, i) => s + i.price * i.quantity, 0);

    for (const promo of applicable.sort((a, b) => b.priority - a.priority)) {
      const discount = this.computeDiscount(promo, orderTotal);
      if (discount > 0) {
        discounts.push({ promotionId: promo.id, discount });
        orderTotal -= discount;
      }
    }

    return { discounts, totalDiscount: discounts.reduce((s, d) => s + d.discount, 0) };
  }

  private isEligible(promo: Promotion, cart: CartItem[]): boolean {
    const now = new Date();
    const cond = promo.conditions;
    if (cond.validFrom && cond.validFrom > now) return false;
    if (cond.validUntil && cond.validUntil < now) return false;

    const total = cart.reduce((s, i) => s + i.price * i.quantity, 0);
    if (cond.minOrderAmount && total < cond.minOrderAmount) return false;

    if (cond.applicableCategories?.length) {
      if (!cart.some(i => cond.applicableCategories!.includes(i.categoryId))) return false;
    }
    return true;
  }

  private resolveStackingRules(promotions: Promotion[]): Promotion[] {
    if (promotions.some(p => p.stackable === 'EXCLUSIVE')) {
      return [promotions.filter(p => p.stackable === 'EXCLUSIVE')
        .sort((a, b) => b.value - a.value)[0]];
    }
    return promotions;
  }

  private computeDiscount(promo: Promotion, orderTotal: number): number {
    let discount = 0;
    if (promo.type === 'PERCENTAGE') discount = Math.round(orderTotal * promo.value / 100);
    else if (promo.type === 'FIXED_AMOUNT') discount = promo.value;
    else if (promo.type === 'FREE_SHIPPING') discount = 3000;
    if (promo.maxDiscount) discount = Math.min(discount, promo.maxDiscount);
    return Math.min(discount, orderTotal);
  }
}

const db = {} as any;

마무리

프로모션 엔진의 핵심은 조건과 할인 계산 로직을 데이터로 표현하는 것이다. 하드코딩 대신 DB에 조건을 저장하면 운영팀이 배포 없이 프로모션을 추가/수정할 수 있다. 중복 사용 규칙은 EXCLUSIVE → 가장 유리한 단일 쿠폰, STACKABLE → 우선순위 순 누적 방식으로 처리한다.