diff --git a/src/main/java/com/example/book_your_seat/coupon/CouponConst.java b/src/main/java/com/example/book_your_seat/coupon/CouponConst.java new file mode 100644 index 0000000..ebb62f3 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/CouponConst.java @@ -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() {} +} diff --git a/src/main/java/com/example/book_your_seat/coupon/controller/CouponController.java b/src/main/java/com/example/book_your_seat/coupon/controller/CouponController.java new file mode 100644 index 0000000..4247069 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/CouponController.java @@ -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 createCoupon(@RequestBody @Valid CouponCreateRequest couponCreateRequest) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(couponCommandService.createCoupon(couponCreateRequest)); + } + + /** + * 내 쿠폰 목록 조회 + * @param user + * @return List + */ + @GetMapping("/my") + public ResponseEntity> getUserCoupons(@SessionAttribute(LOGIN_USER) User user) { + return ResponseEntity.ok() + .body(couponQueryService.getUserCoupons(user)); + } + +} diff --git a/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponCreateRequest.java b/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponCreateRequest.java new file mode 100644 index 0000000..45fedff --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponCreateRequest.java @@ -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) + int amount, + + @NotNull + DiscountRate discountRate, + + @NotNull + @Future + LocalDate expirationDate +) { +} diff --git a/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponIdResponse.java b/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponIdResponse.java new file mode 100644 index 0000000..6596ddd --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponIdResponse.java @@ -0,0 +1,6 @@ +package com.example.book_your_seat.coupon.controller.dto; + +public record CouponIdResponse ( + Long couponId +) { +} diff --git a/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponIdResponse.java b/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponIdResponse.java new file mode 100644 index 0000000..d7f6820 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponIdResponse.java @@ -0,0 +1,6 @@ +package com.example.book_your_seat.coupon.controller.dto; + +public record UserCouponIdResponse( + Long userCouponId +) { +} diff --git a/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponResponse.java b/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponResponse.java new file mode 100644 index 0000000..b267b39 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponResponse.java @@ -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 +) { +} diff --git a/src/main/java/com/example/book_your_seat/coupon/domain/Coupon.java b/src/main/java/com/example/book_your_seat/coupon/domain/Coupon.java index 88dfa59..afa9785 100644 --- a/src/main/java/com/example/book_your_seat/coupon/domain/Coupon.java +++ b/src/main/java/com/example/book_your_seat/coupon/domain/Coupon.java @@ -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) @@ -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 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; + } } diff --git a/src/main/java/com/example/book_your_seat/coupon/domain/UserCoupon.java b/src/main/java/com/example/book_your_seat/coupon/domain/UserCoupon.java index fb34017..a8386b5 100644 --- a/src/main/java/com/example/book_your_seat/coupon/domain/UserCoupon.java +++ b/src/main/java/com/example/book_your_seat/coupon/domain/UserCoupon.java @@ -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); } diff --git a/src/main/java/com/example/book_your_seat/coupon/facade/OptimisticLockCouponFacade.java b/src/main/java/com/example/book_your_seat/coupon/facade/OptimisticLockCouponFacade.java new file mode 100644 index 0000000..54933ce --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/facade/OptimisticLockCouponFacade.java @@ -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); + } + } + } +} diff --git a/src/main/java/com/example/book_your_seat/coupon/repository/CouponRepository.java b/src/main/java/com/example/book_your_seat/coupon/repository/CouponRepository.java index b7e0ed8..6cdc79d 100644 --- a/src/main/java/com/example/book_your_seat/coupon/repository/CouponRepository.java +++ b/src/main/java/com/example/book_your_seat/coupon/repository/CouponRepository.java @@ -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 { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Coupon c WHERE c.id = :couponId") + Optional findByIdWithPessimistic(Long couponId); + + @Lock(LockModeType.OPTIMISTIC) + @Query("SELECT c FROM Coupon c WHERE c.id = :couponId") + Optional findByIdWithOptimistic(Long couponId); } diff --git a/src/main/java/com/example/book_your_seat/coupon/repository/UserCouponRepository.java b/src/main/java/com/example/book_your_seat/coupon/repository/UserCouponRepository.java index 7868602..c4d5646 100644 --- a/src/main/java/com/example/book_your_seat/coupon/repository/UserCouponRepository.java +++ b/src/main/java/com/example/book_your_seat/coupon/repository/UserCouponRepository.java @@ -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 { + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByUserIdAndCouponId(Long userId, Long couponId); + + @Query("SELECT uc FROM UserCoupon uc JOIN FETCH uc.coupon WHERE uc.user = :user") + List findAllByUser(User user); } diff --git a/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java new file mode 100644 index 0000000..364337d --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java @@ -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); + +} diff --git a/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandServiceImpl.java b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandServiceImpl.java new file mode 100644 index 0000000..fdf6a23 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandServiceImpl.java @@ -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); + } + ); + } +} diff --git a/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryService.java b/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryService.java new file mode 100644 index 0000000..e38cf6c --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryService.java @@ -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 getUserCoupons(User user); +} diff --git a/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryServiceImpl.java b/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryServiceImpl.java new file mode 100644 index 0000000..957b83d --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryServiceImpl.java @@ -0,0 +1,46 @@ +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.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 java.util.List; + +import static com.example.book_your_seat.coupon.CouponConst.INVALID_COUPON_ID; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CouponQueryServiceImpl implements CouponQueryService { + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + + @Override + public Coupon findByIdWithPessimistic(Long couponId) { + return couponRepository.findByIdWithPessimistic(couponId) + .orElseThrow(() -> new IllegalArgumentException(INVALID_COUPON_ID + couponId)); + } + + @Override + public Coupon findByIdWithOptimistic(Long couponId) { + return couponRepository.findByIdWithOptimistic(couponId) + .orElseThrow(() -> new IllegalArgumentException(INVALID_COUPON_ID + couponId)); + } + + @Override + public List getUserCoupons(User user) { + return userCouponRepository.findAllByUser(user).stream() + .map(userCoupon -> + new UserCouponResponse( + userCoupon.getId(), + userCoupon.getCoupon().getDiscountRate(), + userCoupon.getCoupon().getExpirationDate(), + userCoupon.isUsed()) + ).toList(); + } +} diff --git a/src/test/java/com/example/book_your_seat/IntegerTestSupport.java b/src/test/java/com/example/book_your_seat/IntegralTestSupport.java similarity index 72% rename from src/test/java/com/example/book_your_seat/IntegerTestSupport.java rename to src/test/java/com/example/book_your_seat/IntegralTestSupport.java index 6069fed..5377049 100644 --- a/src/test/java/com/example/book_your_seat/IntegerTestSupport.java +++ b/src/test/java/com/example/book_your_seat/IntegralTestSupport.java @@ -3,7 +3,6 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -public abstract class IntegerTestSupport { - +public abstract class IntegralTestSupport { } diff --git a/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java b/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java index 4c3ac9b..8f3c851 100644 --- a/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java +++ b/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java @@ -1,6 +1,6 @@ package com.example.book_your_seat.service.concert; -import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.IntegralTestSupport; import com.example.book_your_seat.concert.controller.dto.AddConcertRequest; import com.example.book_your_seat.concert.controller.dto.ConcertResponse; import com.example.book_your_seat.concert.service.ConcertCommandService; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; import java.util.List; -class ConcertServiceTest extends IntegerTestSupport { +class ConcertServiceTest extends IntegralTestSupport { @Autowired private ConcertCommandService concertCommandService; diff --git a/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java b/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java index 6d55c94..cc0b738 100644 --- a/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java +++ b/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java @@ -1,6 +1,6 @@ package com.example.book_your_seat.service.concert; -import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.IntegralTestSupport; import com.example.book_your_seat.concert.domain.Concert; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -9,7 +9,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -class ConcertTest extends IntegerTestSupport { +class ConcertTest extends IntegralTestSupport { // 예매 시작 시간 검증필요.. diff --git a/src/test/java/com/example/book_your_seat/service/coupon/CouponCommandServiceImplTest.java b/src/test/java/com/example/book_your_seat/service/coupon/CouponCommandServiceImplTest.java new file mode 100644 index 0000000..e33986c --- /dev/null +++ b/src/test/java/com/example/book_your_seat/service/coupon/CouponCommandServiceImplTest.java @@ -0,0 +1,180 @@ +package com.example.book_your_seat.service.coupon; + +import com.example.book_your_seat.IntegralTestSupport; +import com.example.book_your_seat.coupon.controller.dto.CouponCreateRequest; +import com.example.book_your_seat.coupon.domain.Coupon; +import com.example.book_your_seat.coupon.facade.OptimisticLockCouponFacade; +import com.example.book_your_seat.coupon.repository.CouponRepository; +import com.example.book_your_seat.coupon.repository.UserCouponRepository; +import com.example.book_your_seat.coupon.service.CouponCommandServiceImpl; +import com.example.book_your_seat.user.domain.User; +import com.example.book_your_seat.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static com.example.book_your_seat.coupon.domain.DiscountRate.FIVE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CouponCommandServiceImplTest extends IntegralTestSupport { + @Autowired + private CouponCommandServiceImpl couponCommandServiceImpl; + + @Autowired + private OptimisticLockCouponFacade optimisticLockCouponFacade; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + private List testUsers; + private Coupon testCoupon; + private final int THREAD_COUNT = 32; + + @BeforeEach + public void setUp() { + testUsers = new ArrayList<>(); + for (int i = 1; i <= 100; i++) { + testUsers.add(new User("nickname", "username", "test" + i + "@test.com", "passwordpassword")); + } + + userRepository.saveAll(testUsers); + testCoupon = couponRepository.saveAndFlush(new Coupon(100, FIVE, LocalDate.of(2024,11,01))); + } + + @AfterEach + public void tearDown() { + userCouponRepository.deleteAll(); + couponRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("비관적 락을 이용하여 동시에 100명이 쿠폰 발급을 요청한다.") + public void issueCouponWithPessimisticTest() throws InterruptedException { + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(100); + + long startTime = System.currentTimeMillis(); + for (User testUser : testUsers) { + executorService.submit(() -> { + try { + couponCommandServiceImpl.issueCouponWithPessimistic(testUser, testCoupon.getId()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + long stopTime = System.currentTimeMillis(); + System.out.println(stopTime - startTime + "ms"); + + Coupon updateCoupon = couponRepository.findById(testCoupon.getId()).orElseThrow(); + assertEquals(0, updateCoupon.getAmount()); + } + + @Test + @DisplayName("비관적 락을 이용하여 동시에 101명이 쿠폰 발급을 요청하면 1명은 쿠폰을 받지 못한다.") + public void issueCouponWithPessimisticFailTest() throws InterruptedException { + User addUser = new User("nickname", "username", "test100"+ "@test.com", "passwordpassword"); + testUsers.add(addUser); + userRepository.saveAndFlush(addUser); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(101); + + for (User testUser : testUsers) { + executorService.submit(() -> { + try { + assertThrows(IllegalArgumentException.class, () -> couponCommandServiceImpl.issueCouponWithPessimistic(testUser, testCoupon.getId())); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + } + + @Test + @DisplayName("낙관적 락을 이용하여 동시에 100명이 쿠폰 발급을 요청한다.(실제 MySQL에서는 데드락 발생)") + public void issueCouponWithOptimisticTest() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(100); + + long startTime = System.currentTimeMillis(); + for (User testUser : testUsers) { + executorService.submit(() -> { + try { + optimisticLockCouponFacade.issueCouponWithOptimistic(testUser, testCoupon.getId()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + long stopTime = System.currentTimeMillis(); + System.out.println(stopTime - startTime + "ms"); + + Coupon updateCoupon = couponRepository.findById(testCoupon.getId()).orElseThrow(); + assertEquals(0, updateCoupon.getAmount()); + } + + @Test + @DisplayName("낙관적 락을 이용하여 동시에 101명이 쿠폰 발급을 요청하면 1명은 쿠폰을 받지 못한다.(실제 MySQL에서는 데드락 발생)") + public void issueCouponWithOptimisticFailTest() throws InterruptedException { + User addUser = new User("nickname", "username", "test100"+ "@test.com", "passwordpassword"); + testUsers.add(addUser); + userRepository.saveAndFlush(addUser); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(101); + + for (User testUser : testUsers) { + executorService.submit(() -> { + try { + assertThrows(IllegalArgumentException.class, () -> optimisticLockCouponFacade.issueCouponWithOptimistic(testUser, testCoupon.getId())); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + } + + @Test + @DisplayName("쿠폰을 한 개 생성한다.") + public void createCoupon() { + //given + CouponCreateRequest request = new CouponCreateRequest(100, FIVE, LocalDate.of(2024,11,01)); + + //when + Long couponId = couponCommandServiceImpl.createCoupon(request).couponId(); + Coupon coupon = couponRepository.findById(couponId).get(); + + //then + assertEquals(100, coupon.getAmount()); + assertEquals(FIVE, coupon.getDiscountRate()); + } +} diff --git a/src/test/java/com/example/book_your_seat/service/coupon/CouponQueryServiceImplTest.java b/src/test/java/com/example/book_your_seat/service/coupon/CouponQueryServiceImplTest.java new file mode 100644 index 0000000..2b6b49a --- /dev/null +++ b/src/test/java/com/example/book_your_seat/service/coupon/CouponQueryServiceImplTest.java @@ -0,0 +1,78 @@ +package com.example.book_your_seat.service.coupon; + +import com.example.book_your_seat.IntegralTestSupport; +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.coupon.domain.UserCoupon; +import com.example.book_your_seat.coupon.repository.CouponRepository; +import com.example.book_your_seat.coupon.repository.UserCouponRepository; +import com.example.book_your_seat.coupon.service.CouponQueryServiceImpl; +import com.example.book_your_seat.user.domain.User; +import com.example.book_your_seat.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.List; + +import static com.example.book_your_seat.coupon.domain.DiscountRate.FIFTEEN; +import static com.example.book_your_seat.coupon.domain.DiscountRate.FIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class CouponQueryServiceImplTest extends IntegralTestSupport { + @Autowired + private CouponQueryServiceImpl couponQueryService; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + private User testUser; + private Coupon testCoupon1; + private Coupon testCoupon2; + + @BeforeEach + public void setUp() { + testUser = userRepository.saveAndFlush(new User("nickname", "username", "email@gmail.com", "userpassword")); + testCoupon1 = couponRepository.saveAndFlush(new Coupon(100, FIVE, LocalDate.of(2024, 11, 01))); + testCoupon2 = couponRepository.saveAndFlush(new Coupon(200, FIFTEEN, LocalDate.of(2024, 11, 01))); + } + + @AfterEach + public void tearDown() { + userCouponRepository.deleteAll(); + couponRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("로그인한 유저의 보유 쿠폰 목록을 조회한다.") + public void getUserCouponsTest() { + //given & when + userCouponRepository.save(new UserCoupon(testUser, testCoupon1)); + userCouponRepository.save(new UserCoupon(testUser, testCoupon2)); + + List userCouponList = couponQueryService.getUserCoupons(testUser); + + //then + assertEquals(2, userCouponList.size()); + + assertThat(userCouponList).extracting(UserCouponResponse::discountRate) + .containsExactly(FIVE, FIFTEEN); + + assertThat(userCouponList).extracting(UserCouponResponse::expirationDate) + .containsExactly(LocalDate.of(2024, 11, 01), LocalDate.of(2024, 11, 01)); + + assertThat(userCouponList).extracting(UserCouponResponse::isUsed) + .containsExactly(false, false); + } +}