쿠폰 발급 로직의 리팩토링 적용기

JeongYong Park·2023년 11월 9일
1

프로젝트에서 쿠폰 발급 로직을 개발하는 역할을 맡게 되었습니다. 먼저 ERD를 보여드리자면 다음과 같습니다.

쿠폰을 발급받기 위해서는 아래와 같은 과정을 거쳐야 합니다.
1. 회원이 프로모션 조건에 부합하는지 확인
2. 쿠폰의 잔여수량이 1이상인지 확인
3. 쿠폰그룹의 발급 기간이 지나지 않았는지 확인
4. 이미 쿠폰을 발급 받았는지 확인

즉 회원이 프로모션 탭에 들어가 쿠폰 받기 버튼을 누르게 되면 위의 조건들을 확인하고 쿠폰을 발급받게 되는 것입니다.

생각보다 복잡한 로직 구현에 머리를 오래 쓰게 되었는데요, 오늘은 이 과정을 공유하고자 합니다.

프로모션 조건

처음에는 회원이 프로모션의 프로모션 조건들에 부합하는지 하나하나 확인하는 과정을 구현하기가 어려웠습니다. 여러 개의 for문과 if 문들의 행진에 정신이 아득해져버려서.. 어떻게 개선할 수 있을지 고민했습니다.

회원이 각 프로모션 조건들에 부합한다 라는 하나의 행동을 가진 인터페이스를 선언하고 각 조건들이 이를 구현하면 되지 않을까? 라는 생각이 들었습니다.

그래서 회원이 프로모션 조건에 부합하는지 확인하기 위해 조건에 부합한지 확인하는 메서드를 가진 인터페이스 하나를 정의했습니다.

public interface PromotionOptionCondition {

    boolean isSatisfied(Order lastOrder);
}

현재 조건은 다음과 같습니다.
1. 특정 마지막 주문일을 기준으로 전/후에 주문이력이 있는지 확인
2. 기존회원인지 신규회원인지 (주문이 있는지 없는지의 여부로 확인)

이제 각 조건들을 정의하고 있는 클래스들을 작성하고 PromotionOptionCondition 인테페이스를 구현하도록 했습니다.

public class MemberTypeCondition implements PromotionOptionCondition {

    private final MemberType memberType;

    public MemberTypeCondition(MemberType memberType) {
        this.memberType = memberType;
    }

    @Override
    public boolean isSatisfied(Order lastOrder) {
        if (lastOrder == null) {
            return memberType == MemberType.NEW_MEMBER;
        }
        return memberType == MemberType.OLD_MEMBER;
    }
}
public class LastOrderCondition implements PromotionOptionCondition {

    private final Instant lastOrderedAt;
    private final Boolean lastOrderBefore;

    public LastOrderCondition(Instant lastOrderedAt, Boolean lastOrderBefore) {
        this.lastOrderedAt = lastOrderedAt;
        this.lastOrderBefore = lastOrderBefore;
    }

    @Override
    public boolean isSatisfied(Order lastOrder) {
        if (lastOrder == null) {
            return true;
        }

        Instant lastOrderedAt = lastOrder.getCreatedAt();
        if (lastOrderBefore) {
            return lastOrderedAt.isBefore(this.lastOrderedAt);
        }
        return lastOrderedAt.isAfter(this.lastOrderedAt);
    }
}

쿠폰 발급

여기까지의 과정을 코드로 옮겨보면 아래와 같습니다.

    @Transactional
    public void issueCoupon(CouponIssueRequest request, Member member) {
        List<PromotionOption> promotionOptions = promotionOptionRepository.findByPromotionId(request.promotionId());

        CouponGroup allMatchedCouponGroup = promotionOptions.stream()
                .filter(promotionOption -> isMemberSatisfied(member, promotionOption.getConditions()))
                //...
    }
    
    private boolean isMemberSatisfied(Member member, List<PromotionOptionCondition> conditions) {
        Order lastOrder = orderRepository.findLastOneByMemberId(member.getId());

        return conditions.stream()
                .allMatch(condition -> condition.isSatisfied(lastOrder));
    }

하나의 프로모션에 해당하는 프로모션 조건들을 모두 찾고 발급해주려는 회원이 조건에 부합한지 확인합니다.

이제 나머지 3개의 조건을 확인하면 되는데요.

  • 쿠폰의 잔여수량이 1이상인지 확인
  • 쿠폰그룹의 발급 기간이 지나지 않았는지 확인
  • 이미 쿠폰을 발급 받았는지 확인

먼저 쿠폰의 잔여수량이 1이상인지 확인하는 메서드를 작성해보겠습니다.

    private boolean hasRemainCoupon(CouponGroup couponGroup) {
        return couponRepository.findByCouponGroupId(couponGroup.getId()).stream()
                .allMatch(coupon -> coupon.getRemainQuantity() > 0);
    }

이제 쿠폰그룹의 발급 기간이 지나지 않았는지 확인해야겠죠?

    private boolean isExpiredCouponGroup(CouponGroup couponGroup) {
        return Instant.now().isAfter(couponGroup.getFinishedAt());
    }

마지막으로 쿠폰을 이미 발급받았는지 확인해봅시다.

    private boolean isAlreadyIssued(Member member, CouponGroup couponGroup) {
        List<Long> couponIds = couponRepository.findIdsByCouponGroupId(couponGroup.getId());
        if (couponGroup.getType() == Type.PERIOD) {
            return memberCouponRepository.existsByMemberIdAndCouponIdIn(member.getId(), couponIds);
        }
        return memberCouponRepository.existsByMemberIdAndCouponIdInAndToday(member.getId(),
                couponIds,
                Instant.now().truncatedTo(ChronoUnit.DAYS),
                Instant.now().truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS));
    }

쿠폰의 종류에는 두 가지가 있는데요.

  • 매일발급 가능한 쿠폰
  • 프로모션 기간내 한 번만 발급가능한 쿠폰

쿠폰의 종류가 두 가지가 존재하기 때문에 쿠폰의 종류에 맞는 쿼리를 작성하게 되었습니다.

이제 이를 하나의 스트림으로 엮어 작성하기만 하면 됩니다!

    @Transactional
    public void issueCoupon(CouponIssueRequest request, Member member) {
        List<PromotionOption> promotionOptions = promotionOptionRepository.findByPromotionId(request.promotionId());

        CouponGroup allMatchedCouponGroup = promotionOptions.stream()
                .filter(promotionOption -> isMemberSatisfied(member, promotionOption.getConditions()))
                .map(this::getCouponGroups)
                .findFirst()
                .flatMap(couponGroups -> couponGroups.stream()
                        .filter(this::hasRemainCoupon)
                        .filter(couponGroup -> !isExpiredCouponGroup(couponGroup))
                        .filter(couponGroup -> !isAlreadyIssued(member, couponGroup))
                        .findFirst())
                .orElseThrow(() -> new ApiException(CouponGroupException.NOT_FOUND));

        issueCouponInCouponGroup(allMatchedCouponGroup, member);
    }

결론

쿠폰 도메인이 정말 복잡하다는 것을 느꼈는데, 실제 현업에서는 어떤 방식으로 처리하고 있을지, 얼마나 복잡할지 궁금해지는 시간이었습니다.

결과적으로 복잡한 이전 로직을 Stream API를 이용하며 개선해 나가는 재밌는 시간이었습니다!

profile
다음 단계를 고민하려고 노력하는 사람입니다

0개의 댓글