dev notes

Dayner에서 이벤트성, 일회성 쿠폰을 발급하고 관리하는 방법 [2]

2025-03-077 min read
공유

개요#

1편에서 쿠폰 정책을 DB로 분리하고 PolicyType에 따라 CouponProcessor 구현체를 나누고 나니, 다음으로 걸린 건 발급 조건이었습니다.

이벤트 쿠폰, 생일 쿠폰, 회원가입 쿠폰은 발급 자체는 비슷한데 "언제", "몇 번", "누구한테"가 계속 달랐습니다. 이걸 프로세서 안에 계속 넣기 시작하면 금방 분기문이 불어날 것 같았습니다.

그래서 이번에는 쿠폰 발급의 빈도(IssuanceFrequency)와 제한 조건을 전략 패턴으로 따로 뺐습니다.

핵심 개념#

쿠폰 시스템의 핵심 질문: 얼마나, 누구한테, 언제 쿠폰을 발급할 것인가?

발급 빈도와 제한 조건을 명확히 정의해야 이벤트 쿠폰, 생일 쿠폰, 회원가입 쿠폰 등 다양한 상황에 유연하게 대응할 수 있습니다.

IssuanceFrequency enum 정의#

발급 빈도를 enum으로 정의합니다. 각 값은 이 쿠폰을 얼마나 자주 발급받을 수 있는지를 나타냅니다.

java
public enum IssuanceFrequency {
    ONCE_PER_USER,   // 사용자당 평생 1회
    ONCE_PER_YEAR,   // 연 1회 (생일 쿠폰 등)
    ONCE_PER_EVENT   // 이벤트당 1회
}

CouponPolicy 엔티티에 IssuanceFrequency 필드를 추가합니다.

java
@Enumerated(EnumType.STRING)
private IssuanceFrequency issuanceFrequency;

전략 패턴 도입 이유#

발급 빈도 검증 로직을 CouponProcessor 안에 직접 작성하면 다음 문제가 생깁니다:

  • 새로운 빈도 조건이 추가될 때마다 CouponProcessor 구현체를 모두 수정해야 함
  • 빈도 검증 로직이 여러 프로세서에 중복됨
  • 단일 책임 원칙(SRP) 위반

발급 빈도 검증을 별도의 전략(Strategy)으로 분리하면, CouponProcessor는 발급 자체에만 집중하고 빈도 검증은 전략 객체에 맡길 수 있습니다.

EligibilityStrategy 인터페이스#

java
public interface EligibilityStrategy {
    IssuanceFrequency getSupportedFrequency();
    void validate(Long userId, CouponPolicy policy);
}

전략 구현체#

OncePerUserStrategy#

java
@Component
public class OncePerUserStrategy implements EligibilityStrategy {
 
    private final CouponRepository couponRepository;
 
    public OncePerUserStrategy(CouponRepository couponRepository) {
        this.couponRepository = couponRepository;
    }
 
    @Override
    public IssuanceFrequency getSupportedFrequency() {
        return IssuanceFrequency.ONCE_PER_USER;
    }
 
    @Override
    public void validate(Long userId, CouponPolicy policy) {
        boolean alreadyIssued = couponRepository.existsByUserIdAndPolicyId(userId, policy.getId());
        if (alreadyIssued) {
            throw new IllegalStateException("이미 발급받은 쿠폰입니다.");
        }
    }
}

OncePerYearStrategy#

java
@Component
public class OncePerYearStrategy implements EligibilityStrategy {
 
    private final CouponRepository couponRepository;
 
    public OncePerYearStrategy(CouponRepository couponRepository) {
        this.couponRepository = couponRepository;
    }
 
    @Override
    public IssuanceFrequency getSupportedFrequency() {
        return IssuanceFrequency.ONCE_PER_YEAR;
    }
 
    @Override
    public void validate(Long userId, CouponPolicy policy) {
        int currentYear = LocalDate.now().getYear();
        boolean alreadyIssuedThisYear = couponRepository.existsByUserIdAndPolicyIdAndIssuedYear(
            userId, policy.getId(), currentYear
        );
        if (alreadyIssuedThisYear) {
            throw new IllegalStateException("올해 이미 발급받은 쿠폰입니다.");
        }
    }
}

OncePerEventStrategy#

java
@Component
public class OncePerEventStrategy implements EligibilityStrategy {
 
    private final CouponRepository couponRepository;
 
    public OncePerEventStrategy(CouponRepository couponRepository) {
        this.couponRepository = couponRepository;
    }
 
    @Override
    public IssuanceFrequency getSupportedFrequency() {
        return IssuanceFrequency.ONCE_PER_EVENT;
    }
 
    @Override
    public void validate(Long userId, CouponPolicy policy) {
        boolean alreadyIssued = couponRepository.existsByUserIdAndPolicyId(userId, policy.getId());
        if (alreadyIssued) {
            throw new IllegalStateException("이 이벤트 쿠폰은 이미 발급받았습니다.");
        }
    }
}

EligibilityStrategyFactory#

팩토리 패턴으로 IssuanceFrequency에 맞는 전략을 주입받아 반환합니다. Spring DI를 활용하면 전략 목록을 따로 등록하지 않아도 자동으로 수집됩니다.

java
@Component
public class EligibilityStrategyFactory {
 
    private final Map<IssuanceFrequency, EligibilityStrategy> strategyMap;
 
    public EligibilityStrategyFactory(List<EligibilityStrategy> strategies) {
        this.strategyMap = strategies.stream()
            .collect(Collectors.toMap(
                EligibilityStrategy::getSupportedFrequency,
                Function.identity()
            ));
    }
 
    public EligibilityStrategy getStrategy(IssuanceFrequency frequency) {
        EligibilityStrategy strategy = strategyMap.get(frequency);
        if (strategy == null) {
            throw new IllegalArgumentException("지원하지 않는 발급 빈도입니다: " + frequency);
        }
        return strategy;
    }
}

CouponProcessor에서의 전략 적용#

CouponProcessor는 발급 전에 EligibilityStrategyFactory에서 전략을 꺼내 검증을 위임하는 구조입니다.

java
@Component
public class EventCouponProcessor implements CouponProcessor {
 
    private final CouponRepository couponRepository;
    private final EligibilityStrategyFactory eligibilityStrategyFactory;
 
    public EventCouponProcessor(
        CouponRepository couponRepository,
        EligibilityStrategyFactory eligibilityStrategyFactory
    ) {
        this.couponRepository = couponRepository;
        this.eligibilityStrategyFactory = eligibilityStrategyFactory;
    }
 
    @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("이벤트 기간이 아닙니다.");
        }
 
        EligibilityStrategy strategy = eligibilityStrategyFactory.getStrategy(policy.getIssuanceFrequency());
        strategy.validate(userId, policy);
 
        LocalDateTime expiredAt = now.plusDays(policy.getValidDays());
        Coupon coupon = Coupon.of(userId, policy, expiredAt);
        couponRepository.save(coupon);
    }
}

생일 쿠폰 배치 예시#

생일 쿠폰은 매일 자정에 오늘 생일인 회원을 조회하여 쿠폰을 발급하는 배치로 처리합니다.

java
@Component
public class BirthdayCouponBatch {
 
    private final MemberRepository memberRepository;
    private final CouponPolicyRepository couponPolicyRepository;
    private final CouponProcessorFactory couponProcessorFactory;
 
    public BirthdayCouponBatch(
        MemberRepository memberRepository,
        CouponPolicyRepository couponPolicyRepository,
        CouponProcessorFactory couponProcessorFactory
    ) {
        this.memberRepository = memberRepository;
        this.couponPolicyRepository = couponPolicyRepository;
        this.couponProcessorFactory = couponProcessorFactory;
    }
 
    @Scheduled(cron = "0 0 0 * * *")
    public void issueBirthdayCoupons() {
        MonthDay today = MonthDay.now();
        List<Member> birthdayMembers = memberRepository.findByBirthday(today);
 
        CouponPolicy birthdayPolicy = couponPolicyRepository
            .findByPolicyTypeAndActiveTrue(PolicyType.BIRTHDAY)
            .orElseThrow(() -> new IllegalStateException("생일 쿠폰 정책이 없습니다."));
 
        CouponProcessor processor = couponProcessorFactory.getProcessor(PolicyType.BIRTHDAY);
 
        for (Member member : birthdayMembers) {
            try {
                processor.issue(member.getId(), birthdayPolicy);
            } catch (IllegalStateException e) {
                // 이미 올해 발급된 경우 스킵
            }
        }
    }
}

생일 쿠폰 정책의 issuanceFrequencyONCE_PER_YEAR로 설정하면 OncePerYearStrategy가 자동으로 선택되어 연 1회 발급 제한을 처리합니다. 배치가 실수로 중복 실행되더라도 전략 레이어에서 막아주니 안전합니다.

결국 여기서 하고 싶었던 건 쿠폰 발급 로직을 더 복잡하게 만드는 게 아니라, 복잡해질 수밖에 없는 조건을 어디에 둘지 정리하는 일이었습니다. CouponProcessor 안에 빈도 조건까지 다 넣고 가면 정책이 하나 추가될 때마다 분기문부터 다시 들여다봐야 했을 텐데, 전략으로 빼두고 나니 적어도 어디를 건드려야 하는지는 훨씬 분명해졌습니다.

실제로 얻은 것도 단순했습니다. CouponProcessor는 발급 자체에 집중하고, EligibilityStrategy는 자격 검증에 집중하게 됐고, 새로운 빈도 조건이 생겨도 기존 코드를 뜯기보다 전략 하나 더 추가하는 쪽으로 갈 수 있게 됐습니다. Spring DI로 전략 목록을 자동으로 모으는 구조까지 붙여놓으니 운영하면서 손대는 부담도 확실히 줄었습니다.

Connected Notes