Kuma's Curious Paradise
[데브 캠프] 7일차 쿠폰 로직 이해 및 추가 기능 구현하기 본문
1. 쿠폰 사용 전, 유효성 검사
사용자가 만료되었거나 이미 사용된 쿠폰을 적용하려 할 수 있으므로 issuedCoupon.use() 메서드를 호출하기 전에 쿠폰 유효성을 검사하고 쿠폰을 사용하도록 하였다.
쿠폰을 사용하기 전, 쿠폰 유효성(isValid, isUsed, validFrom, validUntil)을 검사한다. 쿠폰이 더욱 늘어날 수 있다고 생각하여 쿠폰 타입을 검사하는 부분도 함께 구현하였다.
IssuedCouponServiceImpl class - isValidCoupon(), isCouponTypeValid() 추가
@Service
@RequiredArgsConstructor
public class IssuedCouponServiceImpl implements IssuedCouponService {
private final IssuedCouponRepository issuedCouponRepository;
@Override
public void useCoupon(IssuedCoupon issuedCoupon) {
issuedCoupon.use();
issuedCouponRepository.save(issuedCoupon);
}
@Override
public boolean isValidCoupon(IssuedCoupon issuedCoupon) {
Date now = new Date();
return issuedCoupon.isValid() && !issuedCoupon.isUsed() && now.after(issuedCoupon.getValidFrom()) && now.before(issuedCoupon.getValidUntil());
}
@Override
public boolean isCouponTypeValid(IssuedCoupon issuedCoupon) {
return issuedCoupon.getCoupon().getCouponType().equalsIgnoreCase("PERCENT-OFF") || issuedCoupon.getCoupon().getCouponType().equalsIgnoreCase("FIXED-AMOUNT-OFF");
}
private Optional<IssuedCoupon> findCouponById(Long couponId) {
return issuedCouponRepository.findById(couponId);
}
}
2. 쿠폰 동시 적용 방지
하나의 쿠폰을 가지고 주문1, 주문2, 주문3에 동시적으로 적용하려 하는 경우를 방지하고자 ,데이터베이스 레벨에서 쿠폰을 꺼내올 때 쿠폰 상태를 보고 꺼내오도록 로직을 수정하였다. 동시성을 제어하기에 완벽한 방법은 아니지만, 먼저 구현할 수 있는 레벨에서 최대한 구현해 보고자 하였다.
@Override
public void useCoupon(IssuedCoupon issuedCoupon) throws Exception {
int updatedRows = issuedCouponRepository.useCouponIfValid(issuedCoupon.getId());
if (updatedRows == 0) {
throw new Exception("The coupon is either already used or not valid.");
}
}
@Repository
public interface IssuedCouponRepository extends JpaRepository<IssuedCoupon, Long> {
@Modifying
@Query("UPDATE IssuedCoupon ic SET ic.isUsed = true, ic.isValid = false, ic.usedAt = CURRENT_TIMESTAMP WHERE ic.id = :couponId AND ic.isUsed = false AND ic.isValid = true")
int useCouponIfValid(Long couponId);
}
3. 쿠폰 + 포인트 적용 후 주문 금액이 음수가 되지 않도록 검증
getCheckoutPrice() 에서 쿠폰과 포인트 적용 후 최종 주문 금액이 음수가 되지 않도록 검증하는 로직을 추가하였다. 쇼핑몰에서는 일반적으로 쿠폰 할인을 먼저 적용한 후에 남은 금액에 대해 포인트를 사용한다. 더 저렴한 금액을 제공하기 때문. 따라서 밑의 로직도 쿠폰 적용 후 포인트를 사용하도록 구현하였다.
public double getCheckoutPrice() {
double amount = items.stream().mapToDouble(OrderItem::getEntryPrice).sum();
Coupon coupon = this.usedIssuedCoupon.getCoupon();
if (coupon != null) {
if (coupon.getCouponType().equalsIgnoreCase("PERCENT-OFF")) {
amount *= (1 - coupon.getAmount());
} else if (coupon.getCouponType().equalsIgnoreCase("FIXED-AMOUNT-OFF")) {
amount -= coupon.getAmount();
}
}
amount -= this.pointAmountUsed;
if (amount < 0) {
throw new IllegalArgumentException("최종 주문 금액이 음수가 될 수 없습니다.");
}
this.amount = amount;
return amount;
}
4. 쿠폰 적용 금액 미리보기
order 생성 시 쿠폰을 적용했을 때의 최소 결제 금액을 반환해주도록 로직을 수정하였다. 사용자가 가진 모든 쿠폰들 중 가장 할인 금액이 높은 쿠폰을 적용한다.
최근 쇼핑몰에서는 미리 쿠폰을 적용하여 가장 저렴하게 살 수 있는 가격을 표시해 준다. 이는 사용자의 편의성을 높일 뿐더러 사용자로 하여금 가격이 싸다고 느끼게 하기 때문에 물건을 사도록 유도하는 기능이 있다.
[PaymentFacadeImpl class]
@Transactional
public OrderResponseDto initOrder(CreateOrderDto createOrderDto) throws Exception {
Order order = orderService.createOrder(
createOrderDto.getUser(),
createOrderDto.getOrderItems(),
createOrderDto.getShippingInfo());
// 쿠폰 적용 후 주문 예상 금액
double minimumOrderAmountAfterCoupons = orderService.calculateMinimumOrderAmountAfterCoupons(order);
// 쿠폰 적용
orderService.applyCouponToOrder(order.getId(), createOrderDto.getCoupon());
// 포인트 적용
double pointAmountToUse = createOrderDto.getPointAmountToUse(); // 사용자가 원하는 포인트 사용 금액
double finalAmountAfterPoints = minimumOrderAmountAfterCoupons - pointAmountToUse;
finalAmountAfterPoints = finalAmountAfterPoints > 0 ? finalAmountAfterPoints : 0; // 최종 금액이 0보다 작아지지 않도록 처리
orderService.applyPointToOrder(order.getId(), pointAmountToUse);
return new OrderResponseDto(order.getId(), finalAmountAfterPoints);
}
[OrderServiceImpl class]
public double calculateMinimumOrderAmountAfterCoupons(Order order) {
double minimumAmount = Double.MAX_VALUE;
List<IssuedCoupon> userCoupons = issuedCouponRepository.findAllByUser(order.getUser());
for (IssuedCoupon coupon : userCoupons) {
double tempAmount = applyCoupon(order, coupon.getCoupon());
if (tempAmount < minimumAmount) {
minimumAmount = tempAmount;
}
}
return minimumAmount == Double.MAX_VALUE ? order.getAmount() : minimumAmount;
}
private double applyCoupon(Order order, Coupon coupon) {
double discountAmount = 0.0;
switch (coupon.getCouponType()) {
case "PERCENT-OFF":
discountAmount = order.getAmount() * coupon.getAmount() / 100;
break;
case "FIXED-AMOUNT-OFF":
discountAmount = coupon.getAmount();
break;
default:
break;
}
double finalAmount = order.getAmount() - discountAmount;
return finalAmount > 0 ? finalAmount : 0;
}