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 안에 직접 쓰면 문제가 쌓이기 시작합니다. 새로운 빈도 조건이 생길 때마다 모든 구현체를 열어야 하고, 같은 검증 코드가 여러 프로세서에 흩어지기도 하거든요. 그래서 빈도 검증만 별도의 전략(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에 맞는 전략을 반환합니다. List<EligibilityStrategy>를 생성자로 주입받으면 Spring이 구현체를 알아서 다 모아주니까, 전략을 하나 더 추가해도 팩토리를 따로 손댈 필요가 없습니다.

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는 자격 검증에 집중하는 구조가 됐고, 새로운 빈도 조건이 생겨도 기존 코드를 뜯기보다 전략 하나 더 추가하는 쪽으로 갈 수 있게 됐는데, 팩토리까지 자동으로 수집해주니 그 부담도 꽤 줄더라고요.

Connected Notes