이 글은 누구를 위한 것인가
- 다양한 할인 규칙(정률/정액/무료배송)을 유연하게 관리하려는 팀
- 쿠폰 중복 적용, 최소 주문금액, 카테고리 제한 로직을 설계하는 개발자
- 프로모션 우선순위 충돌을 체계적으로 해결하려는 팀
들어가며
"이 쿠폰은 세일 상품에는 적용 안 됩니다", "최대 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 → 우선순위 순 누적 방식으로 처리한다.