From b0c263d02440e661a66df52d27e17c514c3b5ff2 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Wed, 25 Sep 2024 18:02:50 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B5=AC=ED=98=84=20-=20=EB=B9=84=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=9D=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_your_seat/coupon/CouponConst.java | 9 +++ .../coupon/controller/CouponController.java | 28 +++++++ .../book_your_seat/coupon/domain/Coupon.java | 6 ++ .../coupon/dto/UserCouponResponse.java | 6 ++ .../coupon/repository/CouponRepository.java | 9 +++ .../repository/UserCouponRepository.java | 6 ++ .../coupon/service/CouponCommandService.java | 8 ++ .../service/CouponCommandServiceImpl.java | 49 ++++++++++++ .../coupon/service/CouponQueryService.java | 7 ++ .../service/CouponQueryServiceImpl.java | 23 ++++++ .../coupon/CouponCommandServiceImplTest.java | 78 +++++++++++++++++++ 11 files changed, 229 insertions(+) create mode 100644 src/main/java/com/example/book_your_seat/coupon/CouponConst.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/controller/CouponController.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/dto/UserCouponResponse.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/service/CouponCommandServiceImpl.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/service/CouponQueryService.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/service/CouponQueryServiceImpl.java create mode 100644 src/test/java/com/example/book_your_seat/service/coupon/CouponCommandServiceImplTest.java 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..4ef1481 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/CouponConst.java @@ -0,0 +1,9 @@ +package com.example.book_your_seat.coupon; + +public final class CouponConst { + 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..bfdc1bb --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/CouponController.java @@ -0,0 +1,28 @@ +package com.example.book_your_seat.coupon.controller; + +import com.example.book_your_seat.coupon.dto.UserCouponResponse; +import com.example.book_your_seat.coupon.service.CouponCommandService; +import com.example.book_your_seat.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static com.example.book_your_seat.common.SessionConst.LOGIN_USER; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/coupons") +@RestController +public class CouponController { + + private final CouponCommandService couponCommandService; + + @PostMapping() + public ResponseEntity issueCouponWithPessimistic( + @SessionAttribute(LOGIN_USER) User user, @RequestParam("couponId") Long couponId + ) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(couponCommandService.issueCouponWithPessimistic(user, couponId)); + } +} 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..318554e 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 @@ -10,8 +10,10 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; + import java.util.ArrayList; import java.util.List; + import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -41,4 +43,8 @@ public Coupon(int amount, DiscountRate discountRate) { 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/dto/UserCouponResponse.java b/src/main/java/com/example/book_your_seat/coupon/dto/UserCouponResponse.java new file mode 100644 index 0000000..02953dd --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/dto/UserCouponResponse.java @@ -0,0 +1,6 @@ +package com.example.book_your_seat.coupon.dto; + +public record UserCouponResponse( + Long userCouponId +) { +} 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..ce17377 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,16 @@ 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); } 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..e99850e 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,13 @@ package com.example.book_your_seat.coupon.repository; import com.example.book_your_seat.coupon.domain.UserCoupon; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; + +import java.util.Optional; public interface UserCouponRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByUserIdAndCouponId(Long userId, Long couponId); } 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..0b9b315 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java @@ -0,0 +1,8 @@ +package com.example.book_your_seat.coupon.service; + +import com.example.book_your_seat.coupon.dto.UserCouponResponse; +import com.example.book_your_seat.user.domain.User; + +public interface CouponCommandService { + UserCouponResponse issueCouponWithPessimistic(User user, Long couponId); +} 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..3e03f1a --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandServiceImpl.java @@ -0,0 +1,49 @@ +package com.example.book_your_seat.coupon.service; + +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.dto.UserCouponResponse; +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 UserCouponRepository userCouponRepository; + private final CouponQueryService couponQueryService; + + //비관적 락 + public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { + + Coupon coupon = couponQueryService.findByIdWithPessimistic(couponId); + + //선착순 쿠폰 중복수령 방지 + checkDuplicate(user.getId(), couponId); + + //수량 체크 + if(coupon.getAmount() > 0) { + coupon.decreaseAmount(); + } else { + throw new IllegalArgumentException(COUPON_OUT_OF_STOCK); + } + + return new UserCouponResponse( + userCouponRepository.save(new UserCoupon(user, coupon)).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..97a0946 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryService.java @@ -0,0 +1,7 @@ +package com.example.book_your_seat.coupon.service; + +import com.example.book_your_seat.coupon.domain.Coupon; + +public interface CouponQueryService { + Coupon findByIdWithPessimistic(Long couponId); +} 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..d311a2d --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/service/CouponQueryServiceImpl.java @@ -0,0 +1,23 @@ +package com.example.book_your_seat.coupon.service; + +import com.example.book_your_seat.coupon.domain.Coupon; +import com.example.book_your_seat.coupon.repository.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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; + + @Override + public Coupon findByIdWithPessimistic(Long couponId) { + return couponRepository.findByIdWithPessimistic(couponId) + .orElseThrow(() -> new IllegalArgumentException(INVALID_COUPON_ID + couponId)); + } + +} 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..e11adde --- /dev/null +++ b/src/test/java/com/example/book_your_seat/service/coupon/CouponCommandServiceImplTest.java @@ -0,0 +1,78 @@ +package com.example.book_your_seat.service.coupon; + +import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.coupon.domain.Coupon; +import com.example.book_your_seat.coupon.domain.DiscountRate; +import com.example.book_your_seat.coupon.repository.CouponRepository; +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.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@SpringBootTest +public class CouponCommandServiceImplTest extends IntegerTestSupport { + @Autowired + private CouponCommandServiceImpl couponCommandServiceImpl; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserRepository userRepository; + + private List testUsers; + private Coupon testCoupon; + private final int THREAD_COUNT = 16; + + @BeforeEach + public void setUpCoupon() { + 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, DiscountRate.FIVE)); + } + + @AfterEach + public void tearDownCoupon() { + userRepository.deleteAll(); + couponRepository.deleteAll(); + } + + @Test + @DisplayName("비관적 락을 이용하여 동시에 100명이 쿠폰 발급을 요청한다.") + public void issueCouponWithPessimisticTest() throws InterruptedException { + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(testUsers.size()); + + 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(); + Assertions.assertEquals(0, updateCoupon.getAmount()); + } +} From f09d6bb848f95a8cb618d54cbd4181925caddf35 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 00:19:16 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B5=AC=ED=98=84=20-=20=EB=82=99=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=9D=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/controller/CouponController.java | 20 ++---------- .../book_your_seat/coupon/domain/Coupon.java | 14 +++----- .../facade/OptimisticLockCouponFacade.java | 25 +++++++++++++++ .../coupon/repository/CouponRepository.java | 4 +++ .../coupon/service/CouponCommandService.java | 2 ++ .../service/CouponCommandServiceImpl.java | 27 ++++++++++++++-- .../coupon/service/CouponQueryService.java | 1 + .../service/CouponQueryServiceImpl.java | 6 ++++ .../coupon/CouponCommandServiceImplTest.java | 32 +++++++++++++++++++ 9 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/example/book_your_seat/coupon/facade/OptimisticLockCouponFacade.java 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 index bfdc1bb..f250a70 100644 --- 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 @@ -1,28 +1,12 @@ package com.example.book_your_seat.coupon.controller; -import com.example.book_your_seat.coupon.dto.UserCouponResponse; -import com.example.book_your_seat.coupon.service.CouponCommandService; -import com.example.book_your_seat.user.domain.User; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import static com.example.book_your_seat.common.SessionConst.LOGIN_USER; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RequestMapping("/api/v1/coupons") @RestController public class CouponController { - private final CouponCommandService couponCommandService; - - @PostMapping() - public ResponseEntity issueCouponWithPessimistic( - @SessionAttribute(LOGIN_USER) User user, @RequestParam("couponId") Long couponId - ) { - return ResponseEntity - .status(HttpStatus.CREATED) - .body(couponCommandService.issueCouponWithPessimistic(user, couponId)); - } } 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 318554e..a8ca05e 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,15 +1,7 @@ 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 jakarta.persistence.*; import java.util.ArrayList; import java.util.List; @@ -35,6 +27,10 @@ public class Coupon extends BaseEntity { @OneToMany(mappedBy = "coupon", cascade = CascadeType.ALL) private final List userCoupons = new ArrayList<>(); + //낙관적 락을 위한 버전 + @Version + private Long version; + public Coupon(int amount, DiscountRate discountRate) { this.amount = amount; this.discountRate = discountRate; 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 ce17377..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 @@ -13,4 +13,8 @@ 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/service/CouponCommandService.java b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java index 0b9b315..ab3e0d1 100644 --- 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 @@ -5,4 +5,6 @@ public interface CouponCommandService { UserCouponResponse issueCouponWithPessimistic(User user, Long couponId); + UserCouponResponse issueCouponWithOptimistic(User user, Long couponId); + } 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 index 3e03f1a..7dade2a 100644 --- 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 @@ -29,17 +29,38 @@ public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { checkDuplicate(user.getId(), couponId); //수량 체크 - if(coupon.getAmount() > 0) { - coupon.decreaseAmount(); - } else { + if(coupon.getAmount() <= 0) { throw new IllegalArgumentException(COUPON_OUT_OF_STOCK); } + coupon.decreaseAmount(); + return new UserCouponResponse( userCouponRepository.save(new UserCoupon(user, coupon)).getId() ); } + //낙관적 락 + public UserCouponResponse issueCouponWithOptimistic(User user, Long couponId) { + + Coupon coupon = couponQueryService.findByIdWithOptimistic(couponId); + + //선착순 쿠폰 중복수령 방지 + checkDuplicate(user.getId(), couponId); + + //수량 체크 + if(coupon.getAmount() <= 0) { + throw new IllegalArgumentException(COUPON_OUT_OF_STOCK); + } + + coupon.decreaseAmount(); + + return new UserCouponResponse( + userCouponRepository.save(new UserCoupon(user, coupon)).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 index 97a0946..c894ee7 100644 --- 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 @@ -4,4 +4,5 @@ public interface CouponQueryService { Coupon findByIdWithPessimistic(Long couponId); + Coupon findByIdWithOptimistic(Long couponId); } 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 index d311a2d..d934117 100644 --- 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 @@ -20,4 +20,10 @@ public Coupon findByIdWithPessimistic(Long 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)); + } + } 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 index e11adde..8ac23d2 100644 --- 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 @@ -5,6 +5,7 @@ import com.example.book_your_seat.coupon.domain.DiscountRate; import com.example.book_your_seat.coupon.repository.CouponRepository; import com.example.book_your_seat.coupon.service.CouponCommandServiceImpl; +import com.example.book_your_seat.coupon.facade.OptimisticLockCouponFacade; import com.example.book_your_seat.user.domain.User; import com.example.book_your_seat.user.repository.UserRepository; import org.junit.jupiter.api.*; @@ -22,6 +23,9 @@ public class CouponCommandServiceImplTest extends IntegerTestSupport { @Autowired private CouponCommandServiceImpl couponCommandServiceImpl; + @Autowired + private OptimisticLockCouponFacade optimisticLockCouponFacade; + @Autowired private CouponRepository couponRepository; @@ -75,4 +79,32 @@ public void issueCouponWithPessimisticTest() throws InterruptedException { Coupon updateCoupon = couponRepository.findById(testCoupon.getId()).orElseThrow(); Assertions.assertEquals(0, updateCoupon.getAmount()); } + + @Test + @DisplayName("낙관적 락을 이용하여 동시에 100명이 쿠폰 발급을 요청한다.") + public void issueCouponWithOptimisticTest() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(testUsers.size()); + + 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(); + Assertions.assertEquals(0, updateCoupon.getAmount()); + } } From 991e6a644c3a858bcb2853bfd6a469b52c702c1c Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 09:52:44 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20dto=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9C=84=EC=B9=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/{ => controller}/dto/UserCouponResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/example/book_your_seat/coupon/{ => controller}/dto/UserCouponResponse.java (53%) diff --git a/src/main/java/com/example/book_your_seat/coupon/dto/UserCouponResponse.java b/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponResponse.java similarity index 53% rename from src/main/java/com/example/book_your_seat/coupon/dto/UserCouponResponse.java rename to src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponResponse.java index 02953dd..663ac9b 100644 --- a/src/main/java/com/example/book_your_seat/coupon/dto/UserCouponResponse.java +++ b/src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponResponse.java @@ -1,4 +1,4 @@ -package com.example.book_your_seat.coupon.dto; +package com.example.book_your_seat.coupon.controller.dto; public record UserCouponResponse( Long userCouponId From 1d7d6bc6257d2989a7446e26f534e03770c086a1 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 09:53:04 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_your_seat/coupon/CouponConst.java | 1 + .../coupon/controller/CouponController.java | 20 +++++++++++-- .../controller/dto/CouponCreateRequest.java | 16 ++++++++++ .../controller/dto/CouponIdResponse.java | 6 ++++ .../coupon/service/CouponCommandService.java | 5 +++- .../service/CouponCommandServiceImpl.java | 26 +++++++++++++++-- .../coupon/CouponCommandServiceImplTest.java | 29 +++++++++++++++++-- 7 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponCreateRequest.java create mode 100644 src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponIdResponse.java 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 index 4ef1481..ebb62f3 100644 --- a/src/main/java/com/example/book_your_seat/coupon/CouponConst.java +++ b/src/main/java/com/example/book_your_seat/coupon/CouponConst.java @@ -1,6 +1,7 @@ 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 = "쿠폰이 모두 소진되었습니다."; 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 index f250a70..a940036 100644 --- 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 @@ -1,12 +1,28 @@ 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.service.CouponCommandService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RequestMapping("/api/v1/coupons") @RestController public class CouponController { + private final CouponCommandService couponCommandService; + + /** + * 쿠폰 생성 (추후 관리자 권한 추가) + * @param couponCreateRequest{amount, discountRate} + * @return couponResponse{couponId} + */ + @PostMapping + public ResponseEntity createCoupon(@RequestBody @Valid CouponCreateRequest couponCreateRequest) { + return ResponseEntity.status(HttpStatus.CREATED).body(couponCommandService.createCoupon(couponCreateRequest)); + } } 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..f4afec3 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/coupon/controller/dto/CouponCreateRequest.java @@ -0,0 +1,16 @@ +package com.example.book_your_seat.coupon.controller.dto; + +import com.example.book_your_seat.coupon.domain.DiscountRate; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +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 +) { +} 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/service/CouponCommandService.java b/src/main/java/com/example/book_your_seat/coupon/service/CouponCommandService.java index ab3e0d1..eea2907 100644 --- 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 @@ -1,10 +1,13 @@ package com.example.book_your_seat.coupon.service; -import com.example.book_your_seat.coupon.dto.UserCouponResponse; +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.user.domain.User; public interface CouponCommandService { UserCouponResponse issueCouponWithPessimistic(User user, Long couponId); UserCouponResponse 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 index 7dade2a..c4c77e4 100644 --- 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 @@ -1,8 +1,11 @@ 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.dto.UserCouponResponse; +import com.example.book_your_seat.coupon.controller.dto.UserCouponResponse; +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; @@ -17,10 +20,14 @@ @RequiredArgsConstructor public class CouponCommandServiceImpl implements CouponCommandService { + private final CouponRepository couponRepository; private final UserCouponRepository userCouponRepository; private final CouponQueryService couponQueryService; - //비관적 락 + /* + 쿠폰 발급 - 비관적 락 + */ + @Override public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { Coupon coupon = couponQueryService.findByIdWithPessimistic(couponId); @@ -40,7 +47,10 @@ public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { ); } - //낙관적 락 + /* + 쿠폰 발급 - 낙관적 락 + */ + @Override public UserCouponResponse issueCouponWithOptimistic(User user, Long couponId) { Coupon coupon = couponQueryService.findByIdWithOptimistic(couponId); @@ -61,6 +71,16 @@ public UserCouponResponse issueCouponWithOptimistic(User user, Long couponId) { } + /* + 쿠폰 생성 + */ + @Override + public CouponIdResponse createCoupon(CouponCreateRequest request) { + return new CouponIdResponse( + couponRepository.save(new Coupon(request.amount(), request.discountRate())).getId() + ); + } + private void checkDuplicate(Long userId, Long couponId) { userCouponRepository.findByUserIdAndCouponId(userId, couponId).ifPresent(userCoupon -> { throw new IllegalArgumentException(DUPLICATE_BAD_REQUEST); 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 index 8ac23d2..a053cac 100644 --- 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 @@ -1,9 +1,11 @@ package com.example.book_your_seat.service.coupon; import com.example.book_your_seat.IntegerTestSupport; +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.domain.DiscountRate; 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.coupon.facade.OptimisticLockCouponFacade; import com.example.book_your_seat.user.domain.User; @@ -18,6 +20,9 @@ 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.*; + @SpringBootTest public class CouponCommandServiceImplTest extends IntegerTestSupport { @Autowired @@ -31,6 +36,8 @@ public class CouponCommandServiceImplTest extends IntegerTestSupport { @Autowired private UserRepository userRepository; + @Autowired + private UserCouponRepository userCouponRepository; private List testUsers; private Coupon testCoupon; @@ -44,13 +51,14 @@ public void setUpCoupon() { } userRepository.saveAll(testUsers); - testCoupon = couponRepository.saveAndFlush(new Coupon(100, DiscountRate.FIVE)); + testCoupon = couponRepository.saveAndFlush(new Coupon(100, FIVE)); } @AfterEach public void tearDownCoupon() { userRepository.deleteAll(); couponRepository.deleteAll(); + userCouponRepository.deleteAll(); } @Test @@ -77,7 +85,7 @@ public void issueCouponWithPessimisticTest() throws InterruptedException { System.out.println(stopTime - startTime + "ms"); Coupon updateCoupon = couponRepository.findById(testCoupon.getId()).orElseThrow(); - Assertions.assertEquals(0, updateCoupon.getAmount()); + assertEquals(0, updateCoupon.getAmount()); } @Test @@ -105,6 +113,21 @@ public void issueCouponWithOptimisticTest() throws InterruptedException { System.out.println(stopTime - startTime + "ms"); Coupon updateCoupon = couponRepository.findById(testCoupon.getId()).orElseThrow(); - Assertions.assertEquals(0, updateCoupon.getAmount()); + assertEquals(0, updateCoupon.getAmount()); + } + + @Test + @DisplayName("쿠폰을 한개 생성한다.") + public void createCoupon() { + //given + CouponCreateRequest request = new CouponCreateRequest(100, FIVE); + + //when + Long couponId = couponCommandServiceImpl.createCoupon(request).couponId(); + Coupon coupon = couponRepository.findById(couponId).get(); + + //then + assertEquals(100, coupon.getAmount()); + assertEquals(FIVE, coupon.getDiscountRate()); } } From f7d2cf568e3f3f4ae9547768a85702541c2b3fcd Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 10:30:45 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EA=B8=B0=ED=95=9C,=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=97=AC=EB=B6=80=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/book_your_seat/coupon/domain/Coupon.java | 3 +++ .../com/example/book_your_seat/coupon/domain/UserCoupon.java | 2 ++ 2 files changed, 5 insertions(+) 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 a8ca05e..3fdf363 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 @@ -3,6 +3,7 @@ import com.example.book_your_seat.common.entity.BaseEntity; import jakarta.persistence.*; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -24,6 +25,8 @@ 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<>(); 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..67d7332 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,6 +31,8 @@ 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; From 4dba4cba80ef9f750bde88075c29aff9211971da Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 11:08:17 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/controller/CouponController.java | 19 +++++ .../controller/dto/CouponCreateRequest.java | 10 ++- .../controller/dto/UserCouponIdResponse.java | 6 ++ .../controller/dto/UserCouponResponse.java | 9 +- .../book_your_seat/coupon/domain/Coupon.java | 10 +-- .../coupon/domain/UserCoupon.java | 1 + .../repository/UserCouponRepository.java | 6 ++ .../coupon/service/CouponCommandService.java | 6 +- .../service/CouponCommandServiceImpl.java | 12 +-- .../coupon/service/CouponQueryService.java | 5 ++ .../service/CouponQueryServiceImpl.java | 17 ++++ .../coupon/CouponCommandServiceImplTest.java | 19 +++-- .../coupon/CouponQueryServiceImplTest.java | 83 +++++++++++++++++++ 13 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/example/book_your_seat/coupon/controller/dto/UserCouponIdResponse.java create mode 100644 src/test/java/com/example/book_your_seat/service/coupon/CouponQueryServiceImplTest.java 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 index a940036..3a658b4 100644 --- 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 @@ -2,19 +2,27 @@ 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; /** * 쿠폰 생성 (추후 관리자 권한 추가) @@ -25,4 +33,15 @@ public class CouponController { 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 index f4afec3..45fedff 100644 --- 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 @@ -1,16 +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 - DiscountRate discountRate + @Future + LocalDate expirationDate ) { } 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 index 663ac9b..b267b39 100644 --- 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 @@ -1,6 +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 + 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 3fdf363..788c17f 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 @@ -2,15 +2,14 @@ import com.example.book_your_seat.common.entity.BaseEntity; import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -34,9 +33,10 @@ public class Coupon extends BaseEntity { @Version private Long version; - public Coupon(int amount, DiscountRate discountRate) { + public Coupon(int amount, DiscountRate discountRate, LocalDate expirationDate) { this.amount = amount; this.discountRate = discountRate; + this.expirationDate = expirationDate; } public void addUserCoupon(UserCoupon userCoupon) { 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 67d7332..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 @@ -36,6 +36,7 @@ public class UserCoupon extends BaseEntity { 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/repository/UserCouponRepository.java b/src/main/java/com/example/book_your_seat/coupon/repository/UserCouponRepository.java index e99850e..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,13 +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 index eea2907..364337d 100644 --- 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 @@ -2,12 +2,12 @@ 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.controller.dto.UserCouponIdResponse; import com.example.book_your_seat.user.domain.User; public interface CouponCommandService { - UserCouponResponse issueCouponWithPessimistic(User user, Long couponId); - UserCouponResponse issueCouponWithOptimistic(User user, Long couponId); + 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 index c4c77e4..048a7f8 100644 --- 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 @@ -4,7 +4,7 @@ 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.UserCouponResponse; +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; @@ -28,7 +28,7 @@ public class CouponCommandServiceImpl implements CouponCommandService { 쿠폰 발급 - 비관적 락 */ @Override - public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { + public UserCouponIdResponse issueCouponWithPessimistic(User user, Long couponId) { Coupon coupon = couponQueryService.findByIdWithPessimistic(couponId); @@ -42,7 +42,7 @@ public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { coupon.decreaseAmount(); - return new UserCouponResponse( + return new UserCouponIdResponse( userCouponRepository.save(new UserCoupon(user, coupon)).getId() ); } @@ -51,7 +51,7 @@ public UserCouponResponse issueCouponWithPessimistic(User user, Long couponId) { 쿠폰 발급 - 낙관적 락 */ @Override - public UserCouponResponse issueCouponWithOptimistic(User user, Long couponId) { + public UserCouponIdResponse issueCouponWithOptimistic(User user, Long couponId) { Coupon coupon = couponQueryService.findByIdWithOptimistic(couponId); @@ -65,7 +65,7 @@ public UserCouponResponse issueCouponWithOptimistic(User user, Long couponId) { coupon.decreaseAmount(); - return new UserCouponResponse( + return new UserCouponIdResponse( userCouponRepository.save(new UserCoupon(user, coupon)).getId() ); @@ -77,7 +77,7 @@ public UserCouponResponse issueCouponWithOptimistic(User user, Long couponId) { @Override public CouponIdResponse createCoupon(CouponCreateRequest request) { return new CouponIdResponse( - couponRepository.save(new Coupon(request.amount(), request.discountRate())).getId() + couponRepository.save(new Coupon(request.amount(), request.discountRate(),request.expirationDate())).getId() ); } 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 index c894ee7..e38cf6c 100644 --- 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 @@ -1,8 +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 index d934117..957b83d 100644 --- 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 @@ -1,11 +1,16 @@ 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 @@ -13,6 +18,7 @@ @RequiredArgsConstructor public class CouponQueryServiceImpl implements CouponQueryService { private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; @Override public Coupon findByIdWithPessimistic(Long couponId) { @@ -26,4 +32,15 @@ public Coupon findByIdWithOptimistic(Long 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/service/coupon/CouponCommandServiceImplTest.java b/src/test/java/com/example/book_your_seat/service/coupon/CouponCommandServiceImplTest.java index a053cac..a2b8b41 100644 --- 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 @@ -3,17 +3,20 @@ import com.example.book_your_seat.IntegerTestSupport; 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.domain.DiscountRate; +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.coupon.facade.OptimisticLockCouponFacade; import com.example.book_your_seat.user.domain.User; import com.example.book_your_seat.user.repository.UserRepository; -import org.junit.jupiter.api.*; +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 org.springframework.boot.test.context.SpringBootTest; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -21,7 +24,7 @@ import java.util.concurrent.Executors; import static com.example.book_your_seat.coupon.domain.DiscountRate.FIVE; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest public class CouponCommandServiceImplTest extends IntegerTestSupport { @@ -44,18 +47,18 @@ public class CouponCommandServiceImplTest extends IntegerTestSupport { private final int THREAD_COUNT = 16; @BeforeEach - public void setUpCoupon() { + 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)); + testCoupon = couponRepository.saveAndFlush(new Coupon(100, FIVE, LocalDate.of(2024,11,01))); } @AfterEach - public void tearDownCoupon() { + public void tearDown() { userRepository.deleteAll(); couponRepository.deleteAll(); userCouponRepository.deleteAll(); @@ -120,7 +123,7 @@ public void issueCouponWithOptimisticTest() throws InterruptedException { @DisplayName("쿠폰을 한개 생성한다.") public void createCoupon() { //given - CouponCreateRequest request = new CouponCreateRequest(100, FIVE); + CouponCreateRequest request = new CouponCreateRequest(100, FIVE, LocalDate.of(2024,11,01)); //when Long couponId = couponCommandServiceImpl.createCoupon(request).couponId(); 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..c3596e0 --- /dev/null +++ b/src/test/java/com/example/book_your_seat/service/coupon/CouponQueryServiceImplTest.java @@ -0,0 +1,83 @@ +package com.example.book_your_seat.service.coupon; + +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 org.springframework.boot.test.context.SpringBootTest; + +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; + +@SpringBootTest + +public class CouponQueryServiceImplTest { + @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() { + userRepository.deleteAll(); + couponRepository.deleteAll(); + userCouponRepository.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::userCouponId) + .containsExactly(1L, 2L); + + 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); + } +} From 7516ede0aec94c28f78969cd7564e99240114e21 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 11:17:08 +0900 Subject: [PATCH 07/11] =?UTF-8?q?test:=20=EC=BF=A0=ED=8F=B0=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=EC=8B=9C=20=EC=98=88=EC=99=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponCommandServiceImplTest.java | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) 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 index a2b8b41..eb95414 100644 --- 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 @@ -25,6 +25,7 @@ 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; @SpringBootTest public class CouponCommandServiceImplTest extends IntegerTestSupport { @@ -39,6 +40,7 @@ public class CouponCommandServiceImplTest extends IntegerTestSupport { @Autowired private UserRepository userRepository; + @Autowired private UserCouponRepository userCouponRepository; @@ -49,7 +51,7 @@ public class CouponCommandServiceImplTest extends IntegerTestSupport { @BeforeEach public void setUp() { testUsers = new ArrayList<>(); - for (int i = 1; i <= 100; i++) { + for (int i = 1; i <= 101; i++) { testUsers.add(new User("nickname", "username", "test" + i + "@test.com", "passwordpassword")); } @@ -69,7 +71,7 @@ public void tearDown() { public void issueCouponWithPessimisticTest() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); - CountDownLatch latch = new CountDownLatch(testUsers.size()); + CountDownLatch latch = new CountDownLatch(100); long startTime = System.currentTimeMillis(); for (User testUser : testUsers) { @@ -91,11 +93,30 @@ public void issueCouponWithPessimisticTest() throws InterruptedException { assertEquals(0, updateCoupon.getAmount()); } + @Test + @DisplayName("비관적 락을 이용하여 동시에 101명이 쿠폰 발급을 요청하면 1명은 쿠폰을 받지 못한다.") + public void issueCouponWithPessimisticFailTest() throws InterruptedException { + + 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명이 쿠폰 발급을 요청한다.") public void issueCouponWithOptimisticTest() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); - CountDownLatch latch = new CountDownLatch(testUsers.size()); + CountDownLatch latch = new CountDownLatch(100); long startTime = System.currentTimeMillis(); for (User testUser : testUsers) { @@ -120,7 +141,26 @@ public void issueCouponWithOptimisticTest() throws InterruptedException { } @Test - @DisplayName("쿠폰을 한개 생성한다.") + @DisplayName("낙관적 락을 이용하여 동시에 101명이 쿠폰 발급을 요청하면 1명은 쿠폰을 받지 못한다.") + public void issueCouponWithOptimisticFailTest() throws InterruptedException { + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(101); + + for (User testUser : testUsers) { + executorService.submit(() -> { + try { + assertThrows(IllegalArgumentException.class, () -> couponCommandServiceImpl.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)); From 54f3e89b407f2137a67485840768d1db20ae4ec9 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 12:24:42 +0900 Subject: [PATCH 08/11] =?UTF-8?q?test:=20=EC=8B=A4=EC=A0=9C=20Mysql=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/coupon/CouponCommandServiceImplTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index eb95414..c0126bc 100644 --- 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 @@ -61,9 +61,9 @@ public void setUp() { @AfterEach public void tearDown() { - userRepository.deleteAll(); - couponRepository.deleteAll(); userCouponRepository.deleteAll(); + couponRepository.deleteAll(); + userRepository.deleteAll(); } @Test @@ -113,7 +113,7 @@ public void issueCouponWithPessimisticFailTest() throws InterruptedException { } @Test - @DisplayName("낙관적 락을 이용하여 동시에 100명이 쿠폰 발급을 요청한다.") + @DisplayName("낙관적 락을 이용하여 동시에 100명이 쿠폰 발급을 요청한다.(실제 MySQL에서는 데드락 발생)") public void issueCouponWithOptimisticTest() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch latch = new CountDownLatch(100); @@ -141,7 +141,7 @@ public void issueCouponWithOptimisticTest() throws InterruptedException { } @Test - @DisplayName("낙관적 락을 이용하여 동시에 101명이 쿠폰 발급을 요청하면 1명은 쿠폰을 받지 못한다.") + @DisplayName("낙관적 락을 이용하여 동시에 101명이 쿠폰 발급을 요청하면 1명은 쿠폰을 받지 못한다.(실제 MySQL에서는 데드락 발생)") public void issueCouponWithOptimisticFailTest() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); From 330d2aba2e7ea9cbc75f6b68c8807ad313bf0de0 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 14:35:16 +0900 Subject: [PATCH 09/11] =?UTF-8?q?test:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponCommandServiceImplTest.java | 15 ++++++++++----- .../coupon/CouponQueryServiceImplTest.java | 10 +++------- 2 files changed, 13 insertions(+), 12 deletions(-) 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 index c0126bc..48f4d38 100644 --- 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 @@ -1,6 +1,5 @@ package com.example.book_your_seat.service.coupon; -import com.example.book_your_seat.IntegerTestSupport; 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; @@ -28,7 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -public class CouponCommandServiceImplTest extends IntegerTestSupport { +public class CouponCommandServiceImplTest { @Autowired private CouponCommandServiceImpl couponCommandServiceImpl; @@ -46,12 +45,12 @@ public class CouponCommandServiceImplTest extends IntegerTestSupport { private List testUsers; private Coupon testCoupon; - private final int THREAD_COUNT = 16; + private final int THREAD_COUNT = 32; @BeforeEach public void setUp() { testUsers = new ArrayList<>(); - for (int i = 1; i <= 101; i++) { + for (int i = 1; i <= 100; i++) { testUsers.add(new User("nickname", "username", "test" + i + "@test.com", "passwordpassword")); } @@ -96,6 +95,9 @@ public void issueCouponWithPessimisticTest() throws InterruptedException { @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); @@ -143,6 +145,9 @@ public void issueCouponWithOptimisticTest() throws InterruptedException { @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); @@ -150,7 +155,7 @@ public void issueCouponWithOptimisticFailTest() throws InterruptedException { for (User testUser : testUsers) { executorService.submit(() -> { try { - assertThrows(IllegalArgumentException.class, () -> couponCommandServiceImpl.issueCouponWithOptimistic(testUser, testCoupon.getId())); + assertThrows(IllegalArgumentException.class, () -> optimisticLockCouponFacade.issueCouponWithOptimistic(testUser, testCoupon.getId())); } finally { latch.countDown(); } 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 index c3596e0..222c473 100644 --- 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 @@ -24,7 +24,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest - public class CouponQueryServiceImplTest { @Autowired private CouponQueryServiceImpl couponQueryService; @@ -34,6 +33,7 @@ public class CouponQueryServiceImplTest { @Autowired private UserRepository userRepository; + @Autowired private UserCouponRepository userCouponRepository; @@ -46,14 +46,13 @@ 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() { - userRepository.deleteAll(); - couponRepository.deleteAll(); userCouponRepository.deleteAll(); + couponRepository.deleteAll(); + userRepository.deleteAll(); } @Test @@ -68,9 +67,6 @@ public void getUserCouponsTest() { //then assertEquals(2, userCouponList.size()); - assertThat(userCouponList).extracting(UserCouponResponse::userCouponId) - .containsExactly(1L, 2L); - assertThat(userCouponList).extracting(UserCouponResponse::discountRate) .containsExactly(FIVE, FIFTEEN); From 211cf572f8b1847b335551d0dfac422d8d3f95e6 Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 14:48:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_your_seat/coupon/controller/CouponController.java | 6 ++++-- .../{IntegerTestSupport.java => IntegralTestSupport.java} | 3 +-- .../book_your_seat/service/concert/ConcertServiceTest.java | 4 ++-- .../example/book_your_seat/service/concert/ConcertTest.java | 4 ++-- .../service/coupon/CouponCommandServiceImplTest.java | 5 ++--- .../service/coupon/CouponQueryServiceImplTest.java | 5 ++--- 6 files changed, 13 insertions(+), 14 deletions(-) rename src/test/java/com/example/book_your_seat/{IntegerTestSupport.java => IntegralTestSupport.java} (72%) 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 index 3a658b4..909e136 100644 --- 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 @@ -31,7 +31,8 @@ public class CouponController { */ @PostMapping public ResponseEntity createCoupon(@RequestBody @Valid CouponCreateRequest couponCreateRequest) { - return ResponseEntity.status(HttpStatus.CREATED).body(couponCommandService.createCoupon(couponCreateRequest)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(couponCommandService.createCoupon(couponCreateRequest)); } /** @@ -41,7 +42,8 @@ public ResponseEntity createCoupon(@RequestBody @Valid CouponC */ @GetMapping("/my") public ResponseEntity> getUserCoupons(@SessionAttribute(LOGIN_USER) User user) { - return ResponseEntity.ok().body(couponQueryService.getUserCoupons(user)); + return ResponseEntity.ok() + .body(couponQueryService.getUserCoupons(user)); } } 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 index 48f4d38..e33986c 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -13,7 +14,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDate; import java.util.ArrayList; @@ -26,8 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -@SpringBootTest -public class CouponCommandServiceImplTest { +public class CouponCommandServiceImplTest extends IntegralTestSupport { @Autowired private CouponCommandServiceImpl couponCommandServiceImpl; 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 index 222c473..2b6b49a 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -13,7 +14,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDate; import java.util.List; @@ -23,8 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; -@SpringBootTest -public class CouponQueryServiceImplTest { +public class CouponQueryServiceImplTest extends IntegralTestSupport { @Autowired private CouponQueryServiceImpl couponQueryService; From c3e707d30e008ea6929e8b398922d6500728ceae Mon Sep 17 00:00:00 2001 From: tnals2384 Date: Thu, 26 Sep 2024 15:08:06 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_your_seat/coupon/controller/CouponController.java | 3 ++- .../java/com/example/book_your_seat/coupon/domain/Coupon.java | 4 ++++ .../coupon/service/CouponCommandServiceImpl.java | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) 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 index 909e136..4247069 100644 --- 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 @@ -31,7 +31,8 @@ public class CouponController { */ @PostMapping public ResponseEntity createCoupon(@RequestBody @Valid CouponCreateRequest couponCreateRequest) { - return ResponseEntity.status(HttpStatus.CREATED) + return ResponseEntity + .status(HttpStatus.CREATED) .body(couponCommandService.createCoupon(couponCreateRequest)); } 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 788c17f..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 @@ -39,6 +39,10 @@ public Coupon(int amount, DiscountRate discountRate, LocalDate expirationDate) { this.expirationDate = expirationDate; } + public boolean noAmount() { + return this.amount <= 0; + } + public void addUserCoupon(UserCoupon userCoupon) { this.userCoupons.add(userCoupon); } 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 index 048a7f8..fdf6a23 100644 --- 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 @@ -36,7 +36,7 @@ public UserCouponIdResponse issueCouponWithPessimistic(User user, Long couponId) checkDuplicate(user.getId(), couponId); //수량 체크 - if(coupon.getAmount() <= 0) { + if(coupon.noAmount()) { throw new IllegalArgumentException(COUPON_OUT_OF_STOCK); } @@ -59,7 +59,7 @@ public UserCouponIdResponse issueCouponWithOptimistic(User user, Long couponId) checkDuplicate(user.getId(), couponId); //수량 체크 - if(coupon.getAmount() <= 0) { + if(coupon.noAmount()) { throw new IllegalArgumentException(COUPON_OUT_OF_STOCK); }