쿠폰·프로모션 엔진 설계 — 중복 적용, 우선순위, 어뷰징 방어까지

커머스

쿠폰프로모션할인어뷰징커머스

이 글은 누구를 위한 것인가

  • 쿠폰/프로모션 시스템을 직접 구축하거나 리팩터링하는 커머스 백엔드 엔지니어
  • "쿠폰 중복 적용이 가능한가요?" 같은 기획 요청에 기술 설계로 답해야 하는 팀
  • 어뷰징으로 프로모션 예산이 과다 소진되는 문제를 겪고 있는 팀

들어가며

쿠폰과 프로모션은 커머스 플랫폼에서 가장 다양한 비즈니스 규칙이 얽히는 영역이다. "10% 할인", "3만원 이상 구매 시 5천원 할인", "첫 구매 전용", "특정 카테고리에만 적용", "다른 쿠폰과 중복 불가" — 이 조합이 수십 가지로 늘어나면 if-else 분기로는 버틸 수 없다.

이 글은 확장 가능한 쿠폰 타입 모델, 복합 할인 계산 엔진, 어뷰징 방어 전략을 순서대로 다룬다.


1. 쿠폰 타입 모델링

할인 방식 분류

할인 방식설명예시
PERCENTAGE금액의 X% 할인10% 할인
FIXED_AMOUNT고정 금액 할인5,000원 할인
FREE_SHIPPING배송비 무료배송비 할인
BUY_X_GET_YX개 구매 시 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 사용 시점 검증

검증 항목발급 시사용 시
계정 자격 (첫 구매 여부)OO (재검증)
유효 기간OO (재검증)
사용 한도△ (예약)O (최종 확인)
최소 주문 금액XO

발급 시에만 검증하면 쿠폰을 발급받은 후 조건이 바뀌어도 사용할 수 있다. 사용 시점에 반드시 재검증해야 한다.

동시 사용 방지 (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 레벨 제약으로 막아야 애플리케이션 버그로 뚫리지 않는다.