TL;DR
CouponTemplate과 UserCoupon을 분리하고, isActive(신규 발급 차단)와 isBlocked(기존 쿠폰 전량 차단)를 별도 책임으로 설계했다. CouponStatus는 DB에 저장하지 않고 조회 시점에 계산한다.
Introduction & Goals
- Context: 쿠폰 정책(할인율, 유효기간)과 발급본(소유자, 사용 여부)을 하나의 테이블로 관리하면 정책 변경 시 발급된 전체 row가 영향을 받는다. 100만 명이 발급받은 쿠폰의 할인율이 잘못됐으면 100만 row를 UPDATE해야 한다.
- Goals: 정책 변경이 기존 발급 쿠폰에 소급 적용되지 않는 구조. 운영자가 신규 발급만 막거나, 기존 쿠폰 사용까지 막거나, 두 가지를 독립적으로 제어할 수 있을 것.
Detailed Design
Data Models
CouponTemplate
id, name (수정 가능)
type, value, minOrderAmount, expiredAt → 불변
isActive → 신규 발급 차단 스위치
isBlocked → DELETE API 호출 시 true, 기존 발급 쿠폰 전량 차단
UserCoupon
id, memberId, templateId
usedAt → null: AVAILABLE, not null: USED
@Version version → 낙관적 락
UNIQUE(memberId, templateId)불변 필드를 둔 이유가 있다. expiredAt을 운영자가 당길 수 있으면 "7일 유효"로 받은 쿠폰이 갑자기 "3일 유효"가 된다. 발급 시점의 조건이 소급 변경되면 안 되기 때문에 type, value, minOrderAmount, expiredAt은 생성 후 수정 불가다.
수정이 필요하면 기존 템플릿을 isActive=false로 내리고 새 템플릿을 만든다. 번거롭지만 그게 맞는 방향이다.
CouponStatus 동적 계산
status 컬럼은 DB에 저장하지 않는다.
CouponStatus getStatus(LocalDateTime templateExpiredAt, boolean templateBlocked) {
if (templateBlocked) return BLOCKED;
if (templateExpiredAt.isBefore(LocalDateTime.now())) return EXPIRED;
if (usedAt != null) return USED;
return AVAILABLE;
}
저장하면 두 가지 문제가 생긴다. expiredAt이 이미 있는데 status=EXPIRED를 따로 저장하면 중복 데이터다. 그리고 배치가 돌기 전 짧은 시간 동안 expiredAt은 지났는데 status는 AVAILABLE인 row가 존재할 수 있다. 어느 쪽이 진실인지 모호해진다.
중복 발급 방지
서비스 레이어의 exists 체크는 의미 없다. 두 요청이 동시에 exists를 통과하면 둘 다 INSERT를 시도한다. TOCTOU 문제다.
실제 보호는 UNIQUE(memberId, templateId) 제약이 한다. 충돌 시 DataIntegrityViolationException → ApiControllerAdvice에서 409로 변환.
Alternatives Considered
| 옵션 | Pros | Cons |
|---|---|---|
| A. 단일 테이블 | 구조 단순 | 정책 변경 시 발급 전체 row UPDATE, 소급 적용 위험 |
| B. isActive 하나로 발급 차단 + 사용 차단 통합 | 필드 추가 없음 | isActive 의미 두 가지 혼용 — 코드 읽는 사람이 어느 쪽인지 모름 |
| C. UserCoupon에 isBlocked 추가 | 개별 차단 가능 | 100만 건 발급이면 100만 row UPDATE 필요 |
| D. CouponTemplate에 isBlocked 추가 (채택) | row 1개 변경으로 전량 차단, 책임 분리 명확 | 필드 하나 추가 |
선택 근거: 운영 시나리오는 "잘못 발급된 쿠폰 전량 차단"이다. UserCoupon에 넣으면 발급 건수에 비례한 UPDATE가 필요하다. CouponTemplate에 넣으면 row 1개만 바꾸면 된다. isActive는 신규 발급 차단 단일 책임을 유지하고, isBlocked는 기존 쿠폰 사용 차단 책임을 분리했다.
Cross-cutting Concerns
- Scalability: UserCoupon 목록 조회는 CouponTemplate JOIN이 필수다. 목록 조회마다 template를 개별 조회하면 N+1이 생긴다. JOIN 쿼리로 한 번에 가져온다.
- Observability: CouponTemplate.isBlocked를 true로 바꾸는 DELETE API는 되돌리기 어렵다. 운영자 실수 방지를 위해 감사 로그가 필요한 시점이다.