Dayner에서 이벤트성, 일회성 쿠폰을 발급하고 관리하는 방법 [1]
개요#
Dayner를 운영하다 보니 이벤트성 쿠폰, 회원가입 축하 쿠폰, 생일 쿠폰처럼 성격이 다른 쿠폰 요구사항이 하나둘 붙기 시작했습니다.
처음에는 쿠폰 코드를 하드코딩하거나 if-else 분기로 처리해도 버틸 만했습니다. 그런데 종류가 늘어나기 시작하니 정책을 바꾸는 일과 발급 로직을 건드리는 일이 자꾸 같이 묶였습니다.
그래서 쿠폰 정책은 DB로 빼고, 발급 로직은 전략 패턴(Strategy Pattern)으로 나누는 쪽으로 갔습니다. 이 글은 그때 구조를 어떻게 정리했는지에 대한 기록입니다.
클라이언트 요청 사항 분석#
Dayner 운영팀에서 요청한 쿠폰 기능 요구사항은 다음과 같았습니다:
- 신학기, 크리스마스 등 이벤트성 쿠폰을 특정 기간에 발급
- 회원가입 시 일회성 웰컴 쿠폰 발급
- 생일인 회원에게 생일 쿠폰 자동 발급 (배치)
- 쿠폰마다 할인율, 최소 주문금액, 만료일이 다름
- 동일 쿠폰을 중복 발급하면 안 됨
단순히 쿠폰 코드를 발급하는 것 이상으로, 정책(Policy) 개념이 필요하다고 판단했습니다.
하드코딩 vs DB 관리#
하드코딩 방식의 문제점#
public void issueCoupon(Long userId, String couponType) {
if (couponType.equals("WELCOME")) {
// 할인율 10%, 만료 30일
couponRepository.save(new Coupon(userId, 10, 30));
} else if (couponType.equals("BIRTHDAY")) {
// 할인율 20%, 만료 7일
couponRepository.save(new Coupon(userId, 20, 7));
} else if (couponType.equals("NEW_SEMESTER")) {
// 신학기 이벤트: 할인율 15%, 만료 14일
couponRepository.save(new Coupon(userId, 15, 14));
}
// 쿠폰 종류가 늘어날수록 분기가 무한정 증가...
}이 방식은 새로운 쿠폰 종류가 생길 때마다 코드 수정과 배포가 필요하고, 쿠폰 조건 변경(예: 할인율 수정)도 코드 레벨에서만 가능하다는 문제가 있습니다.
DB 관리 방식#
쿠폰의 정책을 DB 테이블로 분리하면 운영팀이 직접 쿠폰 조건을 관리할 수 있고, 코드 변경 없이 새로운 쿠폰을 추가할 수 있습니다.
CouponPolicy 엔티티 설계#
@Entity
@Table(name = "coupon_policy")
public class CouponPolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Enumerated(EnumType.STRING)
private PolicyType policyType;
private int discountRate;
private int minOrderAmount;
private int validDays;
private LocalDateTime eventStartAt;
private LocalDateTime eventEndAt;
private boolean active;
}validDays는 쿠폰 발급 시점으로부터 만료일까지의 일수입니다. 이벤트 쿠폰의 경우 eventStartAt, eventEndAt으로 발급 가능 기간을 제한합니다.
PolicyType enum#
쿠폰의 종류를 enum으로 정의하면 타입 안전성을 확보할 수 있고, 각 정책 타입에 따른 처리도 명확히 분리됩니다.
public enum PolicyType {
WELCOME, // 회원가입 웰컴 쿠폰
BIRTHDAY, // 생일 쿠폰
EVENT, // 이벤트성 쿠폰 (신학기, 크리스마스 등)
MANUAL // 운영팀 수동 발급
}CouponProcessor#
PolicyType마다 발급 로직이 달라지므로, CouponProcessor 인터페이스를 정의하고 타입별로 구현체를 분리했습니다.
public interface CouponProcessor {
PolicyType getSupportedType();
void issue(Long userId, CouponPolicy policy);
}@Component
public class EventCouponProcessor implements CouponProcessor {
private final CouponRepository couponRepository;
public EventCouponProcessor(CouponRepository couponRepository) {
this.couponRepository = couponRepository;
}
@Override
public PolicyType getSupportedType() {
return PolicyType.EVENT;
}
@Override
public void issue(Long userId, CouponPolicy policy) {
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(policy.getEventStartAt()) || now.isAfter(policy.getEventEndAt())) {
throw new IllegalStateException("이벤트 기간이 아닙니다.");
}
LocalDateTime expiredAt = now.plusDays(policy.getValidDays());
Coupon coupon = Coupon.of(userId, policy, expiredAt);
couponRepository.save(coupon);
}
}@Component
public class WelcomeCouponProcessor implements CouponProcessor {
private final CouponRepository couponRepository;
public WelcomeCouponProcessor(CouponRepository couponRepository) {
this.couponRepository = couponRepository;
}
@Override
public PolicyType getSupportedType() {
return PolicyType.WELCOME;
}
@Override
public void issue(Long userId, CouponPolicy policy) {
boolean alreadyIssued = couponRepository.existsByUserIdAndPolicyId(userId, policy.getId());
if (alreadyIssued) {
throw new IllegalStateException("이미 웰컴 쿠폰이 발급되었습니다.");
}
LocalDateTime expiredAt = LocalDateTime.now().plusDays(policy.getValidDays());
Coupon coupon = Coupon.of(userId, policy, expiredAt);
couponRepository.save(coupon);
}
}신학기 이벤트 적용 예시#
신학기 이벤트 쿠폰 정책을 DB에 등록하면 다음과 같습니다:
| 필드 | 값 |
|---|---|
| name | 2025 신학기 이벤트 쿠폰 |
| policyType | EVENT |
| discountRate | 15 |
| minOrderAmount | 20000 |
| validDays | 14 |
| eventStartAt | 2025-02-20 00:00:00 |
| eventEndAt | 2025-03-15 23:59:59 |
| active | true |
발급 요청이 들어오면 EventCouponProcessor가 현재 시각을 이벤트 기간과 비교한 뒤 쿠폰을 발급합니다. 이벤트 기간이 변경되어도 DB 데이터만 수정하면 되니 코드 배포가 필요 없습니다.
여기까지 오면 쿠폰 정책을 DB에서 관리하고, PolicyType에 따라 CouponProcessor 구현체를 나누는 기본 뼈대는 잡힙니다.
다만 이걸로 끝은 아니었습니다. 같은 쿠폰을 1년에 한 번만 주거나, 이벤트당 1회만 주는 식의 발급 빈도(IssuanceFrequency) 제한은 여전히 남아 있었고, 이 조건을 어디에 둘지가 또 다른 문제였습니다.
2편에서는 그 발급 빈도 조건을 전략 패턴으로 한 번 더 분리해서, 정책이 늘어나도 덜 흔들리는 쪽으로 가져간 이야기를 이어서 적었습니다.