Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 쿠폰 API 구현 - 비관적 락, 낙관적 락 #21

Closed
wants to merge 11 commits into from
10 changes: 10 additions & 0 deletions src/main/java/com/example/book_your_seat/coupon/CouponConst.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.book_your_seat.coupon;

public final class CouponConst {
public static final String INVALID_COUPON_AMOUNT = "쿠폰 수량은 0개 이상이어야 합니다.";
public static final String INVALID_COUPON_ID = "일치하는 쿠폰이 없습니다! Id: ";
public static final String DUPLICATE_BAD_REQUEST = "선착순 쿠폰은 중복 발급 받을 수 없습니다.";
public static final String COUPON_OUT_OF_STOCK = "쿠폰이 모두 소진되었습니다.";

private CouponConst() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.example.book_your_seat.coupon.controller;

import com.example.book_your_seat.coupon.controller.dto.CouponCreateRequest;
import com.example.book_your_seat.coupon.controller.dto.CouponIdResponse;
import com.example.book_your_seat.coupon.controller.dto.UserCouponResponse;
import com.example.book_your_seat.coupon.service.CouponCommandService;
import com.example.book_your_seat.coupon.service.CouponQueryService;
import com.example.book_your_seat.user.domain.User;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

import static com.example.book_your_seat.common.SessionConst.LOGIN_USER;

@RequiredArgsConstructor
@RequestMapping("/api/v1/coupons")
@RestController
public class CouponController {

private final CouponCommandService couponCommandService;
private final CouponQueryService couponQueryService;

/**
* 쿠폰 생성 (추후 관리자 권한 추가)
* @param couponCreateRequest{amount, discountRate}
* @return couponResponse{couponId}
*/
@PostMapping
public ResponseEntity<CouponIdResponse> createCoupon(@RequestBody @Valid CouponCreateRequest couponCreateRequest) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(couponCommandService.createCoupon(couponCreateRequest));
}
tnals2384 marked this conversation as resolved.
Show resolved Hide resolved

/**
* 내 쿠폰 목록 조회
* @param user
* @return List<UserCouponResponse{userCouponId, discountRate, expirationDate, isUsed}>
*/
@GetMapping("/my")
public ResponseEntity<List<UserCouponResponse>> getUserCoupons(@SessionAttribute(LOGIN_USER) User user) {
return ResponseEntity.ok()
.body(couponQueryService.getUserCoupons(user));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example.book_your_seat.coupon.controller.dto;

import com.example.book_your_seat.coupon.domain.DiscountRate;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDate;

import static com.example.book_your_seat.coupon.CouponConst.INVALID_COUPON_AMOUNT;

public record CouponCreateRequest(
@NotNull
@Min(value = 0, message = INVALID_COUPON_AMOUNT)
tnals2384 marked this conversation as resolved.
Show resolved Hide resolved
int amount,

@NotNull
DiscountRate discountRate,

@NotNull
@Future
LocalDate expirationDate
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.book_your_seat.coupon.controller.dto;

public record CouponIdResponse (
Long couponId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.book_your_seat.coupon.controller.dto;

public record UserCouponIdResponse(
Long userCouponId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.book_your_seat.coupon.controller.dto;

import com.example.book_your_seat.coupon.domain.DiscountRate;

import java.time.LocalDate;

public record UserCouponResponse(
Long userCouponId,
DiscountRate discountRate,
LocalDate expirationDate,
Boolean isUsed
) {
}
33 changes: 21 additions & 12 deletions src/main/java/com/example/book_your_seat/coupon/domain/Coupon.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
package com.example.book_your_seat.coupon.domain;

import com.example.book_your_seat.common.entity.BaseEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand All @@ -30,15 +24,30 @@ public class Coupon extends BaseEntity {
@Enumerated(EnumType.STRING)
private DiscountRate discountRate;

private LocalDate expirationDate;

@OneToMany(mappedBy = "coupon", cascade = CascadeType.ALL)
private final List<UserCoupon> userCoupons = new ArrayList<>();

public Coupon(int amount, DiscountRate discountRate) {
//낙관적 락을 위한 버전
@Version
private Long version;

public Coupon(int amount, DiscountRate discountRate, LocalDate expirationDate) {
this.amount = amount;
this.discountRate = discountRate;
this.expirationDate = expirationDate;
}

public boolean noAmount() {
return this.amount <= 0;
}

public void addUserCoupon(UserCoupon userCoupon) {
this.userCoupons.add(userCoupon);
}

public void decreaseAmount() {
this.amount -= 1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ public class UserCoupon extends BaseEntity {
@JoinColumn(name = "coupon_id")
private Coupon coupon;

private boolean isUsed;

public UserCoupon(User user, Coupon coupon) {
this.user = user;
this.coupon = coupon;
this.isUsed = false;
user.adduserCoupon(this);
coupon.addUserCoupon(this);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.book_your_seat.coupon.facade;

import com.example.book_your_seat.coupon.service.CouponCommandService;
import com.example.book_your_seat.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OptimisticLockCouponFacade {

private final CouponCommandService couponCommandService;

public void issueCouponWithOptimistic(User user, Long couponId) throws InterruptedException {
while(true) {
try {
couponCommandService.issueCouponWithOptimistic(user, couponId);
break;
} catch (ObjectOptimisticLockingFailureException e) {
Thread.sleep(50);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
package com.example.book_your_seat.coupon.repository;

import com.example.book_your_seat.coupon.domain.Coupon;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface CouponRepository extends JpaRepository<Coupon, Long> {

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :couponId")
Optional<Coupon> findByIdWithPessimistic(Long couponId);

@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT c FROM Coupon c WHERE c.id = :couponId")
Optional<Coupon> findByIdWithOptimistic(Long couponId);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
package com.example.book_your_seat.coupon.repository;

import com.example.book_your_seat.coupon.domain.UserCoupon;
import com.example.book_your_seat.user.domain.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface UserCouponRepository extends JpaRepository<UserCoupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<UserCoupon> findByUserIdAndCouponId(Long userId, Long couponId);

@Query("SELECT uc FROM UserCoupon uc JOIN FETCH uc.coupon WHERE uc.user = :user")
List<UserCoupon> findAllByUser(User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.book_your_seat.coupon.service;

import com.example.book_your_seat.coupon.controller.dto.CouponCreateRequest;
import com.example.book_your_seat.coupon.controller.dto.CouponIdResponse;
import com.example.book_your_seat.coupon.controller.dto.UserCouponIdResponse;
import com.example.book_your_seat.user.domain.User;

public interface CouponCommandService {
UserCouponIdResponse issueCouponWithPessimistic(User user, Long couponId);
UserCouponIdResponse issueCouponWithOptimistic(User user, Long couponId);
CouponIdResponse createCoupon(CouponCreateRequest couponCreateRequest);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.example.book_your_seat.coupon.service;

import com.example.book_your_seat.coupon.controller.dto.CouponCreateRequest;
import com.example.book_your_seat.coupon.controller.dto.CouponIdResponse;
import com.example.book_your_seat.coupon.domain.Coupon;
import com.example.book_your_seat.coupon.domain.UserCoupon;
import com.example.book_your_seat.coupon.controller.dto.UserCouponIdResponse;
import com.example.book_your_seat.coupon.repository.CouponRepository;
import com.example.book_your_seat.coupon.repository.UserCouponRepository;
import com.example.book_your_seat.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.example.book_your_seat.coupon.CouponConst.COUPON_OUT_OF_STOCK;
import static com.example.book_your_seat.coupon.CouponConst.DUPLICATE_BAD_REQUEST;

@Service
@Transactional
@RequiredArgsConstructor
public class CouponCommandServiceImpl implements CouponCommandService {

private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
private final CouponQueryService couponQueryService;

/*
쿠폰 발급 - 비관적 락
*/
@Override
public UserCouponIdResponse issueCouponWithPessimistic(User user, Long couponId) {

Coupon coupon = couponQueryService.findByIdWithPessimistic(couponId);

//선착순 쿠폰 중복수령 방지
checkDuplicate(user.getId(), couponId);

//수량 체크
if(coupon.noAmount()) {
throw new IllegalArgumentException(COUPON_OUT_OF_STOCK);
}

coupon.decreaseAmount();

return new UserCouponIdResponse(
userCouponRepository.save(new UserCoupon(user, coupon)).getId()
);
}

/*
쿠폰 발급 - 낙관적 락
*/
@Override
public UserCouponIdResponse issueCouponWithOptimistic(User user, Long couponId) {

Coupon coupon = couponQueryService.findByIdWithOptimistic(couponId);

//선착순 쿠폰 중복수령 방지
checkDuplicate(user.getId(), couponId);

//수량 체크
if(coupon.noAmount()) {
throw new IllegalArgumentException(COUPON_OUT_OF_STOCK);
}

coupon.decreaseAmount();

return new UserCouponIdResponse(
userCouponRepository.save(new UserCoupon(user, coupon)).getId()
);

}

/*
쿠폰 생성
*/
@Override
public CouponIdResponse createCoupon(CouponCreateRequest request) {
return new CouponIdResponse(
couponRepository.save(new Coupon(request.amount(), request.discountRate(),request.expirationDate())).getId()
);
}

private void checkDuplicate(Long userId, Long couponId) {
userCouponRepository.findByUserIdAndCouponId(userId, couponId).ifPresent(userCoupon -> {
throw new IllegalArgumentException(DUPLICATE_BAD_REQUEST);
}
);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석이 친절하네용..!! bb

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.book_your_seat.coupon.service;

import com.example.book_your_seat.coupon.controller.dto.UserCouponResponse;
import com.example.book_your_seat.coupon.domain.Coupon;
import com.example.book_your_seat.user.domain.User;

import java.util.List;

public interface CouponQueryService {
Coupon findByIdWithPessimistic(Long couponId);
Coupon findByIdWithOptimistic(Long couponId);
List<UserCouponResponse> getUserCoupons(User user);
}
Loading
Loading