이 글은 누구를 위한 것인가
- 쿠폰/프로모션 시스템을 직접 구축하거나 리팩터링하는 커머스 백엔드 엔지니어
- "쿠폰 중복 적용이 가능한가요?" 같은 기획 요청에 기술 설계로 답해야 하는 팀
- 어뷰징으로 프로모션 예산이 과다 소진되는 문제를 겪고 있는 팀
들어가며
쿠폰과 프로모션은 커머스 플랫폼에서 가장 다양한 비즈니스 규칙이 얽히는 영역이다. "10% 할인", "3만원 이상 구매 시 5천원 할인", "첫 구매 전용", "특정 카테고리에만 적용", "다른 쿠폰과 중복 불가" — 이 조합이 수십 가지로 늘어나면 if-else 분기로는 버틸 수 없다.
이 글은 확장 가능한 쿠폰 타입 모델, 복합 할인 계산 엔진, 어뷰징 방어 전략을 순서대로 다룬다.
1. 쿠폰 타입 모델링
할인 방식 분류
| 할인 방식 | 설명 | 예시 |
|---|---|---|
PERCENTAGE | 금액의 X% 할인 | 10% 할인 |
FIXED_AMOUNT | 고정 금액 할인 | 5,000원 할인 |
FREE_SHIPPING | 배송비 무료 | 배송비 할인 |
BUY_X_GET_Y | X개 구매 시 Y개 무료 | 2+1 행사 |
BUNDLE | 특정 묶음 구매 할인 | 상품 A+B 함께 구매 시 15% |
적용 범위 분류
| 적용 범위 | 설명 |
|---|---|
ORDER | 주문 전체 금액에 적용 |
PRODUCT | 특정 상품에만 적용 |
CATEGORY | 특정 카테고리 상품에만 적용 |
BRAND | 특정 브랜드 상품에만 적용 |
SHIPPING | 배송비에만 적용 |
DB 스키마 핵심
CREATE TABLE coupons (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL, -- 사용자가 입력하는 코드
discount_type TEXT NOT NULL, -- PERCENTAGE | FIXED_AMOUNT | FREE_SHIPPING
discount_value DECIMAL(10,2) NOT NULL, -- 할인율 또는 할인액
max_discount DECIMAL(10,2), -- 최대 할인 한도 (퍼센트 쿠폰)
min_order_amount DECIMAL(10,2), -- 최소 주문 금액 조건
scope_type TEXT NOT NULL, -- ORDER | PRODUCT | CATEGORY
scope_ids JSONB, -- 적용 대상 ID 배열
stackable BOOLEAN DEFAULT FALSE, -- 다른 쿠폰과 중복 사용 가능 여부
priority INTEGER DEFAULT 0, -- 중복 적용 시 순서 (낮을수록 먼저)
usage_limit INTEGER, -- 전체 발급 한도
per_user_limit INTEGER DEFAULT 1, -- 사용자당 사용 한도
starts_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE coupon_usages (
id TEXT PRIMARY KEY,
coupon_id TEXT NOT NULL REFERENCES coupons(id),
user_id TEXT NOT NULL,
order_id TEXT NOT NULL,
used_at TIMESTAMPTZ DEFAULT NOW(),
discount_amount DECIMAL(10,2) NOT NULL -- 실제 할인된 금액
);
2. 할인 계산 엔진
계산 파이프라인
주문 항목 입력
│
▼
1. 적용 가능한 쿠폰/프로모션 목록 조회
│
▼
2. 각 쿠폰의 적용 조건 검증
- 유효 기간, 최소 주문 금액, 적용 범위
│
▼
3. 중복 적용 가능 여부 판단
- stackable=true 쿠폰끼리만 중복 가능
- stackable=false 쿠폰은 최대 1개만
│
▼
4. 우선순위(priority) 기준 정렬
│
▼
5. 순서대로 할인 계산 (적용 후 금액에 다음 쿠폰 적용)
│
▼
6. 최종 할인 금액 합산 및 한도 적용
할인 계산 예시
주문 금액: 50,000원 적용 쿠폰:
- 쿠폰 A: 10% 할인, max_discount=3,000원, priority=1
- 쿠폰 B: 2,000원 고정 할인, priority=2
1단계 (쿠폰 A): 50,000 × 10% = 5,000 → max_discount 3,000 적용 → 3,000원 할인
중간 금액: 47,000원
2단계 (쿠폰 B): 47,000 - 2,000 = 45,000원
최종 할인: 5,000원 (= 3,000 + 2,000)
최종 결제: 45,000원
중요: 할인을 순차적으로 적용할지(Cascading) 원래 금액 기준으로 각각 계산할지(Parallel)는 비즈니스 정책으로 결정하고 명세에 명시해야 한다. 두 방식의 결과가 다르다.
할인 한도와 음수 방지
function applyDiscount(price: number, coupon: Coupon): number {
let discount = 0;
if (coupon.discountType === 'PERCENTAGE') {
discount = price * (coupon.discountValue / 100);
if (coupon.maxDiscount) {
discount = Math.min(discount, coupon.maxDiscount);
}
} else if (coupon.discountType === 'FIXED_AMOUNT') {
discount = coupon.discountValue;
}
// 할인 후 금액이 음수가 되지 않도록 보장
discount = Math.min(discount, price);
return Math.max(price - discount, 0);
}
3. 자동 프로모션 vs 쿠폰 코드
쿠폰 코드 입력 방식 외에 조건 충족 시 자동 적용되는 프로모션도 설계해야 한다.
| 유형 | 방식 | 예시 |
|---|---|---|
| 쿠폰 코드 | 사용자가 코드 입력 | SUMMER10 |
| 자동 프로모션 | 조건 충족 시 자동 적용 | 3만원 이상 구매 시 배송비 무료 |
| 타임 세일 | 특정 시간대 자동 적용 | 오전 10시~11시 20% 할인 |
| 첫 구매 할인 | 첫 주문 자동 감지 | 신규 가입 후 첫 주문 15% |
자동 프로모션은 쿠폰 테이블과 별도 테이블로 관리하거나, code 컬럼을 NULL로 허용해 같은 테이블에서 관리할 수 있다. 쿠폰 코드 없는 자동 할인은 priority가 항상 낮게(먼저 계산) 설정한다.
4. 중복 적용 정책 설계
가장 많은 기획 혼선이 생기는 영역이다. 정책을 코드가 아닌 데이터로 표현해야 나중에 변경이 쉽다.
중복 적용 그룹
같은 그룹 내에서는 중복 불가, 다른 그룹 간은 중복 가능.
ALTER TABLE coupons ADD COLUMN stack_group TEXT;
-- stack_group이 같은 쿠폰끼리는 중복 불가
-- stack_group이 NULL이면 다른 쿠폰과 중복 불가 (exclusive)
예시:
stack_group = 'welcome'쿠폰들: 웰컴 쿠폰 중 1개만stack_group = 'shipping'쿠폰들: 배송비 쿠폰 중 1개만stack_group = NULL쿠폰: 단독 사용 전용
5. 어뷰징 방어
쿠폰 어뷰징은 비즈니스 손실로 직결된다. 다계층 방어가 필요하다.
다중 계정 탐지
-- 동일 IP 또는 기기에서 여러 계정으로 쿠폰 사용
SELECT user_id, COUNT(DISTINCT coupon_id) as coupon_count
FROM coupon_usages cu
JOIN users u ON cu.user_id = u.id
WHERE u.registration_ip = :suspicious_ip
AND cu.used_at > NOW() - INTERVAL '24 hours'
GROUP BY user_id
HAVING COUNT(DISTINCT coupon_id) > 3;
발급 시점 검증 vs 사용 시점 검증
| 검증 항목 | 발급 시 | 사용 시 |
|---|---|---|
| 계정 자격 (첫 구매 여부) | O | O (재검증) |
| 유효 기간 | O | O (재검증) |
| 사용 한도 | △ (예약) | O (최종 확인) |
| 최소 주문 금액 | X | O |
발급 시에만 검증하면 쿠폰을 발급받은 후 조건이 바뀌어도 사용할 수 있다. 사용 시점에 반드시 재검증해야 한다.
동시 사용 방지 (Race Condition)
같은 쿠폰을 여러 탭에서 동시에 체크아웃하면 사용 한도를 초과할 수 있다.
-- 낙관적 잠금으로 사용 횟수 초과 방지
INSERT INTO coupon_usages (coupon_id, user_id, order_id, discount_amount)
SELECT :coupon_id, :user_id, :order_id, :discount_amount
WHERE (
SELECT COUNT(*) FROM coupon_usages WHERE coupon_id = :coupon_id
) < (SELECT usage_limit FROM coupons WHERE id = :coupon_id)
반환된 row가 없으면 이미 한도 초과 → 결제 거절.
어뷰징 탐지 지표
- 시간당 동일 쿠폰 사용 요청 급증 (비율 알림)
- 신규 가입 후 즉시 고가 쿠폰 사용
- 쿠폰 코드 패턴 브루트포스 시도 (짧은 시간 내 많은 유효하지 않은 코드 시도)
- 취소 후 재사용 패턴 (쿠폰 사용 → 주문 취소 → 쿠폰 복원 반복)
6. 운영 지표
| 지표 | 의미 |
|---|---|
| 쿠폰 사용률 (발급 대비 사용) | 쿠폰 매력도 측정 |
| 쿠폰 기여 매출 | 프로모션 ROI |
| 평균 할인율 | 마진 압박 모니터링 |
| 어뷰징 탐지 건수 | 방어 효과 측정 |
| 쿠폰 사용 후 재구매율 | 신규 고객 전환 효과 |
맺으며
쿠폰 엔진은 단순해 보이지만 비즈니스 규칙이 가장 빠르게 변하는 영역 중 하나다. 할인 유형과 적용 범위를 데이터로 모델링하고, 중복 적용 정책을 코드가 아닌 컬럼으로 표현하면 기획 변경에 코드 수정 없이 대응할 수 있다.
어뷰징 방어는 사후 대응이 아닌 설계 단계에서 포함되어야 한다. 특히 동시성 문제로 인한 한도 초과는 DB 레벨 제약으로 막아야 애플리케이션 버그로 뚫리지 않는다.