From a924bbefa3b303cb78c3b424a967063dcd73564f Mon Sep 17 00:00:00 2001 From: soyeong Date: Wed, 22 Apr 2026 18:02:08 +0900 Subject: [PATCH 01/15] =?UTF-8?q?Feat:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EB=B0=9C=EC=83=9D=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=B3=B8=20API=20=EA=B0=9C=EB=B0=9C.=20Pr?= =?UTF-8?q?oductController=EC=97=90=20ServiceImpl=20=EC=B0=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=B6=80=EB=B6=84=EC=9D=B4=20=EC=9E=88?= =?UTF-8?q?=EA=B8=B8=EB=9E=98=20=EC=88=98=EC=A0=95=ED=95=98=EC=98=80?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/EventOrderFacade.java | 18 +++ .../common/exception/ErrorCode.java | 1 + .../security/config/SecurityConfig.java | 1 + .../controller/EventOrderController.java | 36 ++++++ .../dto/response/EventOrderResponse.java | 10 ++ .../repository/OrderProductRepository.java | 15 +++ .../order/service/EventOrderService.java | 8 ++ .../order/service/EventOrderServiceImpl.java | 115 ++++++++++++++++++ .../order/service/OrderCommandService.java | 1 + .../service/OrderCommandServiceImpl.java | 6 +- .../product/controller/ProductController.java | 7 +- 11 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/EventOrderResponse.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java new file mode 100644 index 0000000..cd9e51b --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -0,0 +1,18 @@ +package jpa.basic.alldayprojectcommerce.application; + +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; +import jpa.basic.alldayprojectcommerce.domain.order.service.EventOrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EventOrderFacade { + + private final EventOrderService eventOrderService; + + + public EventOrderResponse createEventOrderWithoutLock(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java index 6b73515..77507fa 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java @@ -31,6 +31,7 @@ public enum ErrorCode { ORDER_USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "O005", "주문자 정보를 찾을 수 없습니다."), ORDER_INVALID_UID(HttpStatus.BAD_REQUEST, "O005", "유효하지 않은 주문 UID 입니다."), ORDER_STATUS_NOT_PENDING(HttpStatus.BAD_REQUEST,"O006","주문 상태가 결제 대기 상태가 아닙니다."), + EVENT_ORDER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST,"O007","이미 주문한 이벤트 상품입니다."), // 상품 관련 에러 코드(P###) ORDER_STATUS_NOT_COMPLETED(HttpStatus.BAD_REQUEST,"O007","주문 상태가 결제 완료 상태가 아닙니다."), diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java index 1422045..c895379 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java @@ -47,6 +47,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/keywords/top5" ).permitAll() .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/api/events/").permitAll() .anyRequest().authenticated() ) .addFilterBefore( diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java new file mode 100644 index 0000000..7aea47d --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java @@ -0,0 +1,36 @@ +package jpa.basic.alldayprojectcommerce.domain.order.controller; + +import jpa.basic.alldayprojectcommerce.application.EventOrderFacade; +import jpa.basic.alldayprojectcommerce.common.ApiResponse; +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/events") +public class EventOrderController { + private final EventOrderFacade eventOrderFacade; + + /* + 동시성 확인을 위한 이벤트 티켓 무료 나눔 API + 모바일 티켓이라고 가정, 배송비 X + 이벤트 카테고리에 있는 제품들은 상세 조회를 눌렀을 때 + 장바구니 버튼이 없고 주문하기를 눌렀을 때 이 API가 바로 호출되는 것으로 프론트 구성한다고 가정 + 상세 조회에서는 상품 상태(판매중, 품절, 단종)와 상품 재고 확인 가능 + 장바구니 X, 주문서 X, 내 정보 수정 X 모두 거치지 않음 + 주문 성공 시 주문 목록 조회로 리다이렉트 + 주문 실패 시 '주문에 실패했습니다' 문구 띄우고 이벤트 상품 목록 조회로 리다이렉트 + */ + @PostMapping("/products/{productId}/orders") + public ResponseEntity> createEventOrder( + @PathVariable Long productId, + @RequestParam Long userId + ){ + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(HttpStatus.CREATED, eventOrderFacade.createEventOrderWithoutLock(productId, userId))); + } + +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/EventOrderResponse.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/EventOrderResponse.java new file mode 100644 index 0000000..255fedf --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/dto/response/EventOrderResponse.java @@ -0,0 +1,10 @@ +package jpa.basic.alldayprojectcommerce.domain.order.dto.response; + +import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; + +public record EventOrderResponse(String orderUid, + OrderStatus status) { + public static EventOrderResponse from(String orderUid, OrderStatus status) { + return new EventOrderResponse(orderUid, status); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java index 23d3fb2..13e8653 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java @@ -2,6 +2,8 @@ import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderProduct; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -10,4 +12,17 @@ public interface OrderProductRepository extends JpaRepository findByOrderId(Long orderId); + @Query(""" + select case when count(op) > 0 then true else false end + from OrderProduct op + join Order o on o.id = op.orderId + where op.productId = :productId + and o.userId = :userId + and o.status = jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus.COMPLETED +""") + boolean existsCompletedEventOrder( + @Param("productId") Long productId, + @Param("userId") Long userId + ); + } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java new file mode 100644 index 0000000..15b9fca --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java @@ -0,0 +1,8 @@ +package jpa.basic.alldayprojectcommerce.domain.order.service; + +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; + +public interface EventOrderService { + EventOrderResponse createEventOrder(Long productId, Long userId); + +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java new file mode 100644 index 0000000..7bb48fe --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java @@ -0,0 +1,115 @@ +package jpa.basic.alldayprojectcommerce.domain.order.service; + +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.common.util.IdFactory; +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; +import jpa.basic.alldayprojectcommerce.domain.order.entity.Order; +import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderProduct; +import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; +import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderProductRepository; +import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderRepository; +import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; +import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; +import jpa.basic.alldayprojectcommerce.domain.product.service.ProductCommandService; +import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; +import jpa.basic.alldayprojectcommerce.domain.user.entity.User; +import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class EventOrderServiceImpl implements EventOrderService { + + private final OrderRepository orderRepository; + private final OrderProductRepository orderProductRepository; + private final ProductQueryService productQueryService; + private final UserQueryService userQueryService; + private final ProductCommandService productCommandService; + private final OrderCommandService orderCommandService; + + /* + 동시성 확인을 위한 이벤트 티켓 무료 나눔 메서드 + 유저 검증 + 재고 검증 후 차감 + 주문 완료 상태로 저장(COMPLETED), 결제가 없으니 PENDING 없음 + 주문 상품 스냅샷 저장 + 주문 유저 스냅샷 저장 + */ + @Override + public EventOrderResponse createEventOrder(Long productId, Long userId) { + + // 이벤트 아이템은 항상 유저 한명당 1개만 가능 + int quantity = 1; + + // 유저 검증 + // 유저 정보를 사전에 미리 등록해놨어어야 주문 가능하도록 설계 + User user = userQueryService.getById(userId); + if (!user.hasRequiredInfo()) { + throw new CustomException(ErrorCode.USER_ORDERER_INFO_REQUIRED); + } + + // 상품 존재 검증 + Product product = productQueryService.getByProductId(productId); + + // 유저 한 명당 이벤트 상품은 하나만 구매 가능. + // 이 상품에 대해서 주문 상태가 COMPLETED인 주문이 있는지 검증 + if(orderProductRepository.existsCompletedEventOrder(productId,userId)){ + throw new CustomException(ErrorCode.EVENT_ORDER_ALREADY_EXISTS); + + } + + // 판매 중인 상품인지 확인 + if (product.getStatus() != ProductStatus.ON_SALE) { + throw new CustomException(ErrorCode.PRODUCT_NOT_ON_SALE); + } + + // 재고 확인 + if (product.getStock() < quantity) { + throw new CustomException(ErrorCode.PRODUCT_OUT_OF_STOCK); + } + + + // orderUid 생성 + String orderUid = IdFactory.generateWithDate("ORD", 8); + + + // Order 저장 - orderId 발급 + Order order = Order.builder() + .userId(userId) + .orderUid(orderUid) + .totalAmount(product.getPrice()) + .status(OrderStatus.COMPLETED) + .build(); + + Order savedOrder = orderRepository.save(order); + + // TODO : 재고 차감 락 적용 + productCommandService.decreaseStock(productId, quantity, savedOrder.getId()); + + // OrderItem 저장 - 스냅샷 + + orderProductRepository.save(OrderProduct.builder() + .orderId(savedOrder.getId()) + .productId(product.getId()) + .productName(product.getName()) + .productPrice(product.getPrice()) + .quantity(quantity) + .build()); + + // OrderUser 저장 - 스냅샷 + orderCommandService.saveOrderUser(savedOrder.getId(), user.getName(), user.getPhone(), user.getAddress()); + + + log.info("[이벤트 주문 생성] userId: {}, orderUid: {}, productId: {}", + userId, orderUid, product.getId()); + + return EventOrderResponse.from(orderUid, savedOrder.getStatus()); + + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandService.java index 85b0ae7..2be74e6 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandService.java @@ -2,6 +2,7 @@ import jpa.basic.alldayprojectcommerce.domain.order.dto.request.CreateOrderRequest; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.CreateOrderResponse; +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; import jpa.basic.alldayprojectcommerce.domain.order.entity.Order; public interface OrderCommandService { diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java index 6f09a53..19e88c9 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java @@ -1,11 +1,14 @@ package jpa.basic.alldayprojectcommerce.domain.order.service; +import jdk.jfr.Event; import jpa.basic.alldayprojectcommerce.common.exception.CustomException; import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; import jpa.basic.alldayprojectcommerce.common.util.IdFactory; import jpa.basic.alldayprojectcommerce.domain.order.dto.request.CreateOrderRequest; import jpa.basic.alldayprojectcommerce.domain.order.dto.request.OrderItemRequest; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.CreateOrderResponse; +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; +import jpa.basic.alldayprojectcommerce.domain.order.dto.response.GetOneOrderResponse; import jpa.basic.alldayprojectcommerce.domain.order.entity.Order; import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderProduct; import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; @@ -15,7 +18,9 @@ import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderUserRepository; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; +import jpa.basic.alldayprojectcommerce.domain.product.service.ProductCommandService; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; +import jpa.basic.alldayprojectcommerce.domain.user.entity.User; import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,7 +40,6 @@ public class OrderCommandServiceImpl implements OrderCommandService { private final OrderProductRepository orderProductRepository; private final OrderUserRepository orderUserRepository; private final ProductQueryService productQueryService; - private final UserQueryService userQueryService; /** * 주문서 생성 diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java index f182ee8..249ac68 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/controller/ProductController.java @@ -4,6 +4,7 @@ import jpa.basic.alldayprojectcommerce.common.ApiResponse; import jpa.basic.alldayprojectcommerce.domain.product.dto.response.GetAllProductResponse; import jpa.basic.alldayprojectcommerce.domain.product.dto.response.GetOneProductResponse; +import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryServiceImpl; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -19,17 +20,17 @@ @RequiredArgsConstructor public class ProductController { - private final ProductQueryServiceImpl productQueryServiceImpl; + private final ProductQueryService productQueryService; @GetMapping("/{productId}") public ResponseEntity> getOne (@PathVariable("productId") Long id){ - return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, productQueryServiceImpl.getOneProduct(id))); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, productQueryService.getOneProduct(id))); } @GetMapping public ResponseEntity>> getAll( @PageableDefault(size = 10, page = 0, sort = "id", direction = Sort.Direction.DESC) Pageable pageable){ - return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, productQueryServiceImpl.getAllProduct(pageable))); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(HttpStatus.OK, productQueryService.getAllProduct(pageable))); } } From 96a569ab2170fa275699a70ca525c9251a03fa09 Mon Sep 17 00:00:00 2001 From: soyeong Date: Thu, 23 Apr 2026 12:38:03 +0900 Subject: [PATCH 02/15] Test: EventOrder Without Lock Test - ticket 10 Users 100 --- .DS_Store | Bin 0 -> 6148 bytes src/.DS_Store | Bin 0 -> 6148 bytes src/main/.DS_Store | Bin 0 -> 6148 bytes src/main/java/.DS_Store | Bin 0 -> 6148 bytes .../common/config/DummyDataUser.java | 57 ++++++ .../domain/user/.DS_Store | Bin 0 -> 6148 bytes src/main/resources/application-local.yml | 2 +- src/main/resources/application-prod.yml | 6 +- src/main/resources/application-test.yml | 31 +++ .../EventOrderFacadeConcurrencyTest.java | 189 ++++++++++++++++++ 10 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 .DS_Store create mode 100644 src/.DS_Store create mode 100644 src/main/.DS_Store create mode 100644 src/main/java/.DS_Store create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/config/DummyDataUser.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/user/.DS_Store create mode 100644 src/main/resources/application-test.yml create mode 100644 src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..81229e8d26382bd3469cb3d9bb2de3f1954844ac GIT binary patch literal 6148 zcmeH~Jqp4=5QS&dB4Cr!avKle4VIuM@B*S@B?yZB9^E%TjnP_yyn&f-XEsBUS7b9H zqQmpN5$Q#wgBxXSVPuMYE)TiO>2iLYjtb*@FJ*K=2U&T%hcRwa*e@u>x3=Er<$CqZN!+^)bZi z-VT<$t|nVB+C_8t(7dzS6a&*}7cEF&S{)2jfC`Khm`C2*`M-mIoBu~GOsN1B_%j7` zvE6S6yi}g8AFpTiLso6w;GkcQ@b(jc#E#+>+ztE17GO=bASy8a2)GOkRN$uyya2`( B5q1Co literal 0 HcmV?d00001 diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..254fc3aa651e4450a614b710682d5cd8457a722c GIT binary patch literal 6148 zcmeH~J8r{33`B>H3IlFbrrah6@C_D*bAntTAH#qFe+t9>9^FGOn<%aW!59MMTXIE6 znnEiAu+43^21Wo&x)X06CT7eBobZJM&imc<=lgOwdXcv1fT#2kll|Nlq<|EV0#ZN< zNP!tCkjMCPHKS+JqeuZMFb@U%`%vi4nrxl%>0pQvfE-v3<2q&uvUq{4$=1mV&2oCM zY_%9eydLdj$?Iyeb@p~x4j-0xHlJc>*4tr)3C(IiK?+ELi2^S@AN~A)r$3tiCoM{) zfE0K#1#H-UY&U$VJX`;~p4UIK>gz@)<8p>yKLJeqD1N1faliP2tjX5N3Qa!(A%lVx H_*Vt)CSMYs literal 0 HcmV?d00001 diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..db5230729bac487d668cd322e4d37e689eaf857b GIT binary patch literal 6148 zcmeH~F>V4u3`M_T3nZE?Q%=JHxWNd)337ozPzn@@0zo}T=iB2Zo789(eM|Nmd)C_B zU+k;_*zSJb0waJG-HDBdi5c?&7aZ}x@#}p1JYBEfUZgEL;3<8?WIwkBDIf);fE17d zQeZ|3$ccLbDoBkOER*qQJ7}qo4l=`d{<^q(!L| zkOKcq0UP%3`yF2@&(>eB=k<@Q`nu7{xSZj~PXH4?ir?sA+%LW$YqE8+Leq~x$eIl3=DjjOdR@&d^>$!yr&SL|#= zL|4z#Qlt}+DcmRv3q4chW4Xv(_UF^%c(~oJR&pC9tpM+&x1ZYt6`%rCfC^9nDli}g z@*tni2J}pP6e>UkhM|Cc9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*kwzfpO%Wo&Ov7xB0) 0) { + return; + } + + // 10,000명 더미 유저 생성 + List users = new ArrayList<>(); + + for (int i = 1; i <= 10000; i++) { + // 이메일과 비밀번호를 먼저 세팅하여 User 엔티티 생성 + User user = User.createUser( + "user" + i + "@test.com", + "encoded-password-" + i + ); + + // 주문 가능 조건에 필요한 프로필 정보 세팅 + user.updateProfile( + "유저" + i, + "010-1234-" + String.format("%04d", i % 10000), + "서울시 강남구 테스트로 " + i + ); + + users.add(user); + + // 1000명 단위로 배치 저장하여 메모리 사용량 절약 + if (i % 1000 == 0) { + userRepository.saveAll(users); + users.clear(); + } + } + + // 혹시 남은 데이터가 있으면 마지막 저장 + if (!users.isEmpty()) { + userRepository.saveAll(users); + } + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..14668ff58e2a258984b6ebf278490e87b582f926 GIT binary patch literal 6148 zcmeHKJ5EC}5S%3`B4|=l`U>2@ijos>0Ym~464FBiigd5axi}iLpMvN?7n*2RT93Wn zvE?b=z6D^b&)prc1hAkx;^@QFeBXUyR~0cLooDRvf)5OM$6=EFd%(HZ%zwfgc|Z8i zyZ!cY7+yU=CIzH`6p#W^Knk2qfhw(wo3ov$gQS2IxD*Ba`_Sl)y>Lv7PX~u+0f=*k z!#Iy#g4jGj?1f_@BQ#4YF{xH9h9#ZxR(ZW}OiVhgnh&d+tvVEo+j)MAbXZT+C new IllegalArgumentException("테스트 종료 후 티켓 상품을 찾을 수 없습니다.")); + + int currentStock = ticketProduct.getStock(); + + // 현재 재고가 100보다 작으면 부족분만큼 복구 + if (currentStock < 100) { + ticketProduct.increaseStock(100 - currentStock); + } + + // 현재 재고가 100보다 크면 초과분만큼 차감 + if (currentStock > 100) { + ticketProduct.decreaseStock(currentStock - 100); + } + + productRepository.save(ticketProduct); + } + + @Test + @DisplayName("락 없는 주문 생성 - 재고 10개 티켓에 100명 동시 요청 시 정합성이 깨질 수 있다") + void createEventOrderWithoutLock_concurrency_fail() throws Exception { + + // given + + // 테스트용 티켓 상품을 productId로 조회 + Product ticketProduct = productQueryService.getByProductId(TEST_TICKET_PRODUCT_ID); + + // 더미데이터 티켓 재고(100)를 테스트 시나리오용 재고(10)로 맞춘다. + // 현재 재고가 10보다 많으면 초과분을 차감 + if (ticketProduct.getStock() > TEST_TICKET_STOCK) { + ticketProduct.decreaseStock(ticketProduct.getStock() - TEST_TICKET_STOCK); + } + + // 현재 재고가 10보다 적으면 부족분을 증가 + if (ticketProduct.getStock() < TEST_TICKET_STOCK) { + ticketProduct.increaseStock(TEST_TICKET_STOCK - ticketProduct.getStock()); + } + + productRepository.save(ticketProduct); + + // 유저 1번 ~ 100번이 모두 존재하는지 사전 검증 + // DummyDataUser가 정상 적재되었다는 전제를 확인하는 용도 + for (long userId = 1L; userId <= TOTAL_REQUEST_COUNT; userId++) { + userQueryService.getById(userId); + } + + // 동시 실행용 스레드 풀 + ExecutorService executorService = Executors.newFixedThreadPool(100); + + // 모든 작업 종료를 기다리기 위한 래치 + CountDownLatch doneLatch = new CountDownLatch(TOTAL_REQUEST_COUNT); + + // 모든 스레드를 최대한 동시에 시작시키기 위한 배리어 + CyclicBarrier startBarrier = new CyclicBarrier(100); + + // 성공/실패 건수 집계용 카운터 + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // when + + for (int i = 0; i < TOTAL_REQUEST_COUNT; i++) { + long userId = i + 1L; + + executorService.submit(() -> { + try { + // 모든 스레드가 이 지점에 도달하면 동시에 시작 + startBarrier.await(); + + // 같은 상품(productId)에 대해 서로 다른 사용자(userId)가 동시에 주문 요청 + // 테스트 대상은 락이 없는 버전 + eventOrderFacade.createEventOrderWithoutLock( + TEST_TICKET_PRODUCT_ID, + userId + ); + + // 예외 없이 주문 완료 시 성공 카운트 증가 + successCount.incrementAndGet(); + + } catch (Exception e) { + // 재고 부족 등 예외 발생 시 실패 카운트 증가 + failCount.incrementAndGet(); + + } finally { + // 현재 작업 종료 표시 + doneLatch.countDown(); + } + }); + } + + // 모든 요청이 끝날 때까지 대기 + doneLatch.await(); + + // 스레드 풀 종료 + executorService.shutdown(); + + // 최종 상품 상태 재조회 + Product savedTicketProduct = productRepository.findById(TEST_TICKET_PRODUCT_ID) + .orElseThrow(() -> new IllegalArgumentException("테스트 후 티켓 상품을 찾을 수 없습니다.")); + + // 최종 주문 수 조회 + long orderCount = orderRepository.count(); + + System.out.println("성공 주문 수 = " + successCount.get()); + System.out.println("실패 주문 수 = " + failCount.get()); + System.out.println("최종 주문 수 = " + orderCount); + System.out.println("최종 재고 수 = " + savedTicketProduct.getStock()); + + // then + + /** + * 동시성 제어가 정상이라면 기대값은 아래와 같아야 한다. + * - 성공 주문 수 = 10 + * - 실패 주문 수 = 90 + * - 최종 주문 수 = 10 + * - 최종 재고 수 = 0 + * + * 하지만 지금은 락 없는 버전을 검증하는 테스트이므로 + * 이 기대값이 깨질 가능성이 높고, + * 그 실패가 바로 동시성 문제가 존재한다는 증거다. + */ + assertThat(successCount.get()).isEqualTo(10); + assertThat(failCount.get()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(savedTicketProduct.getStock()).isEqualTo(0); + } +} \ No newline at end of file From bfa5b4cd9091b6ee15b1ad2646a9688b8ad0f3ec Mon Sep 17 00:00:00 2001 From: soyeong Date: Thu, 23 Apr 2026 14:44:59 +0900 Subject: [PATCH 03/15] =?UTF-8?q?Refactor:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=ED=95=98=EC=97=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/security/config/SecurityConfig.java | 2 +- .../order/repository/OrderProductRepository.java | 16 +++++++++------- .../order/service/EventOrderServiceImpl.java | 3 +-- .../order/service/OrderCommandServiceImpl.java | 6 ------ 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java index c895379..70b46f5 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/security/config/SecurityConfig.java @@ -47,7 +47,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/keywords/top5" ).permitAll() .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/api/events/").permitAll() + .requestMatchers("/api/events/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore( diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java index 13e8653..aeedff0 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/repository/OrderProductRepository.java @@ -1,6 +1,7 @@ package jpa.basic.alldayprojectcommerce.domain.order.repository; import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderProduct; +import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,16 +14,17 @@ public interface OrderProductRepository extends JpaRepository findByOrderId(Long orderId); @Query(""" - select case when count(op) > 0 then true else false end - from OrderProduct op - join Order o on o.id = op.orderId - where op.productId = :productId - and o.userId = :userId - and o.status = jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus.COMPLETED + select case when count(op) > 0 then true else false end + from OrderProduct op + join Order o on o.id = op.orderId + where op.productId = :productId + and o.userId = :userId + and o.status = :status """) boolean existsCompletedEventOrder( @Param("productId") Long productId, - @Param("userId") Long userId + @Param("userId") Long userId, + @Param("status") OrderStatus status ); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java index 7bb48fe..2c21588 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java @@ -59,7 +59,7 @@ public EventOrderResponse createEventOrder(Long productId, Long userId) { // 유저 한 명당 이벤트 상품은 하나만 구매 가능. // 이 상품에 대해서 주문 상태가 COMPLETED인 주문이 있는지 검증 - if(orderProductRepository.existsCompletedEventOrder(productId,userId)){ + if(orderProductRepository.existsCompletedEventOrder(productId,userId, OrderStatus.COMPLETED)){ throw new CustomException(ErrorCode.EVENT_ORDER_ALREADY_EXISTS); } @@ -93,7 +93,6 @@ public EventOrderResponse createEventOrder(Long productId, Long userId) { productCommandService.decreaseStock(productId, quantity, savedOrder.getId()); // OrderItem 저장 - 스냅샷 - orderProductRepository.save(OrderProduct.builder() .orderId(savedOrder.getId()) .productId(product.getId()) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java index 19e88c9..30d95be 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/OrderCommandServiceImpl.java @@ -1,14 +1,11 @@ package jpa.basic.alldayprojectcommerce.domain.order.service; -import jdk.jfr.Event; import jpa.basic.alldayprojectcommerce.common.exception.CustomException; import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; import jpa.basic.alldayprojectcommerce.common.util.IdFactory; import jpa.basic.alldayprojectcommerce.domain.order.dto.request.CreateOrderRequest; import jpa.basic.alldayprojectcommerce.domain.order.dto.request.OrderItemRequest; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.CreateOrderResponse; -import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; -import jpa.basic.alldayprojectcommerce.domain.order.dto.response.GetOneOrderResponse; import jpa.basic.alldayprojectcommerce.domain.order.entity.Order; import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderProduct; import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; @@ -18,10 +15,7 @@ import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderUserRepository; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; -import jpa.basic.alldayprojectcommerce.domain.product.service.ProductCommandService; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; -import jpa.basic.alldayprojectcommerce.domain.user.entity.User; -import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; From 5eae5b2ea9e376aa08e0229329f17cd8f7933de8 Mon Sep 17 00:00:00 2001 From: soyeong Date: Thu, 23 Apr 2026 15:03:37 +0900 Subject: [PATCH 04/15] =?UTF-8?q?Test=20:=20=EB=9D=BD=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/EventOrderFacadeConcurrencyTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index 35a27c4..799a0b3 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -109,9 +109,11 @@ void createEventOrderWithoutLock_concurrency_fail() throws Exception { // 동시 실행용 스레드 풀 ExecutorService executorService = Executors.newFixedThreadPool(100); + // CountDownLatch : 여러 작업이 끝날 때까지 기다리는 도구 // 모든 작업 종료를 기다리기 위한 래치 CountDownLatch doneLatch = new CountDownLatch(TOTAL_REQUEST_COUNT); + // CyclicBarrier : 모든 스레드를 동시에 출발시키기 위한 도구 // 모든 스레드를 최대한 동시에 시작시키기 위한 배리어 CyclicBarrier startBarrier = new CyclicBarrier(100); From b40378bf39d91382bdee0d8cca3c17430d3d4766 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 01:00:51 +0900 Subject: [PATCH 05/15] =?UTF-8?q?Refactor=20:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81.=20=EB=AA=A8=EB=93=A0=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=B6=80=20=EC=A0=9C=EC=99=B8=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=B8=A1=EC=A0=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=ED=95=B5=EC=8B=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=A0=9C=EC=99=B8=ED=95=9C=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=EC=9D=80=20=EC=8B=9C=EA=B0=84=20=EC=B8=A1=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/EventOrderFacade.java | 2 +- .../{ => event}/EventOrderService.java | 2 +- .../{ => event}/EventOrderServiceImpl.java | 3 +- .../EventOrderFacadeConcurrencyTest.java | 216 ++++++++---------- 4 files changed, 102 insertions(+), 121 deletions(-) rename src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/{ => event}/EventOrderService.java (74%) rename src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/{ => event}/EventOrderServiceImpl.java (96%) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java index cd9e51b..8a14611 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -1,7 +1,7 @@ package jpa.basic.alldayprojectcommerce.application; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; -import jpa.basic.alldayprojectcommerce.domain.order.service.EventOrderService; +import jpa.basic.alldayprojectcommerce.domain.order.service.event.EventOrderService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java similarity index 74% rename from src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java rename to src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java index 15b9fca..4293fbc 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java @@ -1,4 +1,4 @@ -package jpa.basic.alldayprojectcommerce.domain.order.service; +package jpa.basic.alldayprojectcommerce.domain.order.service.event; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java similarity index 96% rename from src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java rename to src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java index 2c21588..c0807a2 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java @@ -1,4 +1,4 @@ -package jpa.basic.alldayprojectcommerce.domain.order.service; +package jpa.basic.alldayprojectcommerce.domain.order.service.event; import jpa.basic.alldayprojectcommerce.common.exception.CustomException; import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; @@ -9,6 +9,7 @@ import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderProductRepository; import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderRepository; +import jpa.basic.alldayprojectcommerce.domain.order.service.OrderCommandService; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductCommandService; diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index 799a0b3..b95d06a 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -4,188 +4,168 @@ import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import jpa.basic.alldayprojectcommerce.domain.product.repository.ProductRepository; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; -import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; -import org.junit.jupiter.api.AfterEach; 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 org.springframework.test.context.ActiveProfiles; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CyclicBarrier; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; -@ActiveProfiles("test") @SpringBootTest -class EventOrderFacadeConcurrencyTest { - - /** - * DummyDataProduct에서 티켓이 4번째로 저장된다는 전제로 사용하는 테스트용 상품 ID - */ - private static final Long TEST_TICKET_PRODUCT_ID = 4L; - - /** - * 동시에 주문 요청할 사용자 수 - */ - private static final int TOTAL_REQUEST_COUNT = 100; - - /** - * 테스트 시나리오에서 사용할 티켓 재고 수 - */ - private static final int TEST_TICKET_STOCK = 10; +@ActiveProfiles("test") +class EventOrderConcurrencyTest { @Autowired private EventOrderFacade eventOrderFacade; @Autowired - private ProductQueryService productQueryService; - - @Autowired - private UserQueryService userQueryService; + private ProductRepository productRepository; @Autowired - private ProductRepository productRepository; + private ProductQueryService productQueryService; @Autowired private OrderRepository orderRepository; - @AfterEach - void tearDown() { - // 테스트 중 생성된 주문 데이터만 정리 - orderRepository.deleteAllInBatch(); + private static final Long TEST_TICKET_PRODUCT_ID = 4L; + private static final int TEST_TICKET_STOCK = 10; + private static final int TOTAL_REQUEST_COUNT = 100; + private static final int THREAD_POOL = 100; - // 다음 테스트를 위해 티켓 재고를 원래 더미값 100으로 복구 - Product ticketProduct = productRepository.findById(TEST_TICKET_PRODUCT_ID) - .orElseThrow(() -> new IllegalArgumentException("테스트 종료 후 티켓 상품을 찾을 수 없습니다.")); + /** + * 🔥 핵심 테스트 + */ + @Test + @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") + void createEventOrderWithoutLock_concurrency_test() throws Exception { - int currentStock = ticketProduct.getStock(); + // given + prepareStock(TEST_TICKET_STOCK); - // 현재 재고가 100보다 작으면 부족분만큼 복구 - if (currentStock < 100) { - ticketProduct.increaseStock(100 - currentStock); - } + // when + ConcurrencyTestResult result = runConcurrencyTest( + // TODO : 락 종류마다 메서드 수정 + userId -> eventOrderFacade.createEventOrderWithoutLock( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); - // 현재 재고가 100보다 크면 초과분만큼 차감 - if (currentStock > 100) { - ticketProduct.decreaseStock(currentStock - 100); - } + // then + printResult(result); - productRepository.save(ticketProduct); - } + Product product = productRepository.findById(TEST_TICKET_PRODUCT_ID).orElseThrow(); + long orderCount = orderRepository.count(); - @Test - @DisplayName("락 없는 주문 생성 - 재고 10개 티켓에 100명 동시 요청 시 정합성이 깨질 수 있다") - void createEventOrderWithoutLock_concurrency_fail() throws Exception { + assertThat(result.successCount).isEqualTo(10); + assertThat(result.failCount).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } - // given - // 테스트용 티켓 상품을 productId로 조회 - Product ticketProduct = productQueryService.getByProductId(TEST_TICKET_PRODUCT_ID); - // 더미데이터 티켓 재고(100)를 테스트 시나리오용 재고(10)로 맞춘다. - // 현재 재고가 10보다 많으면 초과분을 차감 - if (ticketProduct.getStock() > TEST_TICKET_STOCK) { - ticketProduct.decreaseStock(ticketProduct.getStock() - TEST_TICKET_STOCK); - } - // 현재 재고가 10보다 적으면 부족분을 증가 - if (ticketProduct.getStock() < TEST_TICKET_STOCK) { - ticketProduct.increaseStock(TEST_TICKET_STOCK - ticketProduct.getStock()); - } - productRepository.save(ticketProduct); - // 유저 1번 ~ 100번이 모두 존재하는지 사전 검증 - // DummyDataUser가 정상 적재되었다는 전제를 확인하는 용도 - for (long userId = 1L; userId <= TOTAL_REQUEST_COUNT; userId++) { - userQueryService.getById(userId); - } + /** + * 🔥 공통 동시성 실행 로직 + */ + private ConcurrencyTestResult runConcurrencyTest(TestExecutor executor) throws Exception { - // 동시 실행용 스레드 풀 - ExecutorService executorService = Executors.newFixedThreadPool(100); + ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL); - // CountDownLatch : 여러 작업이 끝날 때까지 기다리는 도구 - // 모든 작업 종료를 기다리기 위한 래치 CountDownLatch doneLatch = new CountDownLatch(TOTAL_REQUEST_COUNT); + CyclicBarrier startBarrier = new CyclicBarrier(TOTAL_REQUEST_COUNT); - // CyclicBarrier : 모든 스레드를 동시에 출발시키기 위한 도구 - // 모든 스레드를 최대한 동시에 시작시키기 위한 배리어 - CyclicBarrier startBarrier = new CyclicBarrier(100); - - // 성공/실패 건수 집계용 카운터 AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); - // when + long startTime = System.nanoTime(); for (int i = 0; i < TOTAL_REQUEST_COUNT; i++) { long userId = i + 1L; - executorService.submit(() -> { + threadPool.submit(() -> { try { - // 모든 스레드가 이 지점에 도달하면 동시에 시작 startBarrier.await(); - // 같은 상품(productId)에 대해 서로 다른 사용자(userId)가 동시에 주문 요청 - // 테스트 대상은 락이 없는 버전 - eventOrderFacade.createEventOrderWithoutLock( - TEST_TICKET_PRODUCT_ID, - userId - ); + executor.execute(userId); - // 예외 없이 주문 완료 시 성공 카운트 증가 successCount.incrementAndGet(); } catch (Exception e) { - // 재고 부족 등 예외 발생 시 실패 카운트 증가 failCount.incrementAndGet(); - } finally { - // 현재 작업 종료 표시 doneLatch.countDown(); } }); } - // 모든 요청이 끝날 때까지 대기 - doneLatch.await(); + boolean completed = doneLatch.await(20, TimeUnit.SECONDS); + threadPool.shutdown(); - // 스레드 풀 종료 - executorService.shutdown(); + long endTime = System.nanoTime(); - // 최종 상품 상태 재조회 - Product savedTicketProduct = productRepository.findById(TEST_TICKET_PRODUCT_ID) - .orElseThrow(() -> new IllegalArgumentException("테스트 후 티켓 상품을 찾을 수 없습니다.")); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); - // 최종 주문 수 조회 - long orderCount = orderRepository.count(); + return new ConcurrencyTestResult( + successCount.get(), + failCount.get(), + elapsedMillis, + completed + ); + } + + /** + * 🔥 재고 초기화 (공통 준비 코드) + * 티켓 재고를 10개로 맞추는 코드 + */ + private void prepareStock(int stock) { + Product product = productQueryService.getByProductId(TEST_TICKET_PRODUCT_ID); - System.out.println("성공 주문 수 = " + successCount.get()); - System.out.println("실패 주문 수 = " + failCount.get()); - System.out.println("최종 주문 수 = " + orderCount); - System.out.println("최종 재고 수 = " + savedTicketProduct.getStock()); + if (product.getStock() > stock) { + product.decreaseStock(product.getStock() - stock); + } - // then + if (product.getStock() < stock) { + product.increaseStock(stock - product.getStock()); + } - /** - * 동시성 제어가 정상이라면 기대값은 아래와 같아야 한다. - * - 성공 주문 수 = 10 - * - 실패 주문 수 = 90 - * - 최종 주문 수 = 10 - * - 최종 재고 수 = 0 - * - * 하지만 지금은 락 없는 버전을 검증하는 테스트이므로 - * 이 기대값이 깨질 가능성이 높고, - * 그 실패가 바로 동시성 문제가 존재한다는 증거다. - */ - assertThat(successCount.get()).isEqualTo(10); - assertThat(failCount.get()).isEqualTo(90); - assertThat(orderCount).isEqualTo(10); - assertThat(savedTicketProduct.getStock()).isEqualTo(0); + productRepository.save(product); + } + + /** + * 🔥 결과 출력 + */ + private void printResult(ConcurrencyTestResult result) { + System.out.println("===== 결과 ====="); + System.out.println("성공 수 = " + result.successCount()); + System.out.println("실패 수 = " + result.failCount()); + System.out.println("총 실행 시간(ms) = " + result.elapsedMillis()); + System.out.println("모든 요청 완료 여부 = " + result.completed()); + System.out.println("================"); + } + + /** + * 🔥 실행 인터페이스 (핵심!) + */ + @FunctionalInterface + interface TestExecutor { + void execute(Long userId); } + + /** + * 🔥 결과 객체 + */ + record ConcurrencyTestResult( + int successCount, + int failCount, + long elapsedMillis, + boolean completed + ) {} } \ No newline at end of file From 749db357487b35f25236510e6f3e3af15039f461 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 02:30:10 +0900 Subject: [PATCH 06/15] =?UTF-8?q?Refactor=20:=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20Retry=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/EventOrderFacade.java | 73 ++++++- .../common/exception/ErrorCode.java | 2 + .../EventOrderFacadeConcurrencyTest.java | 189 +++++++++++++++--- 3 files changed, 230 insertions(+), 34 deletions(-) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java index 8a14611..dfcfb5b 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -1,5 +1,6 @@ package jpa.basic.alldayprojectcommerce.application; +import jpa.basic.alldayprojectcommerce.common.lock.service.RedisLockService; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; import jpa.basic.alldayprojectcommerce.domain.order.service.event.EventOrderService; import lombok.RequiredArgsConstructor; @@ -10,9 +11,79 @@ public class EventOrderFacade { private final EventOrderService eventOrderService; + private final RedisLockService redisLockService; - + /** + * 락을 사용하지 않은 버전 + * @param productId 제품 ID + * @param userId 주문자 ID + * @return + */ public EventOrderResponse createEventOrderWithoutLock(Long productId, Long userId) { return eventOrderService.createEventOrder(productId, userId); } + + /** + * Redis 분산락 - Fail Fast 전략 적용 버전 + * + * 상품 기준으로 락을 걸어 + * 동일 상품에 대한 동시 주문을 직렬화한다. + * + * @param productId 제품 ID + * @param userId 주문자 ID + */ + public EventOrderResponse createEventOrderWithRedisLockFailFast(Long productId, Long userId) { + + // 🔥 락 키 설계 (핵심) + String key = "lock:product:" + productId; + + // TTL (초) + long timeoutSeconds = 5; + + return redisLockService.executeWithLockFailFast( + key, + timeoutSeconds, + () -> eventOrderService.createEventOrder(productId, userId) + ); + } + + /** + * Redis 분산락 - Retry 전략 적용 버전 + * + * 상품 기준으로 락을 걸어 + * 동일 상품에 대한 동시 주문을 직렬화한다. + * + * @param productId 제품 ID + * @param userId 주문자 ID + */ + public EventOrderResponse createEventOrderWithRedisLockRetry(Long productId, Long userId) { + + String key = "lock:product:" + productId; + + return redisLockService.executeWithLockRetry( + key, + 5, + () -> eventOrderService.createEventOrder(productId, userId) + ); + } + + /** + * Redis 분산락 - Blocking 전략 적용 버전 + * + * 상품 기준으로 락을 걸어 + * 동일 상품에 대한 동시 주문을 직렬화한다. + * + * @param productId 제품 ID + * @param userId 주문자 ID + */ + public EventOrderResponse createEventOrderWithRedisLockBlocking(Long productId, Long userId) { + + String key = "lock:product:" + productId; + + return redisLockService.executeWithLockBlocking( + key, + 5, + () -> eventOrderService.createEventOrder(productId, userId) + ); + } } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java index 77507fa..3a3b7e2 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/ErrorCode.java @@ -52,6 +52,8 @@ public enum ErrorCode { PAYMENT_ORDER_NOT_MATCHES(HttpStatus.BAD_REQUEST, "PAY004", "입력한 주문에 대하여 생성된 결제 건이 아닙니다."), PAYMENT_INVALID_UID(HttpStatus.BAD_REQUEST, "PAY005", "유효하지 않은 결제 UID 입니다."), + // Lock 관련 + LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT,"L001","락 획득에 실패했습니다.") ; diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index b95d06a..7bf41af 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -4,6 +4,7 @@ import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import jpa.basic.alldayprojectcommerce.domain.product.repository.ProductRepository; import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -33,22 +34,25 @@ class EventOrderConcurrencyTest { private static final Long TEST_TICKET_PRODUCT_ID = 4L; private static final int TEST_TICKET_STOCK = 10; + private static final int DEFAULT_TICKET_STOCK = 100; private static final int TOTAL_REQUEST_COUNT = 100; private static final int THREAD_POOL = 100; + private static final long TEST_TIMEOUT_SECONDS = 20L; + + @AfterEach + void tearDown() { + clearOrders(); + restoreStock(DEFAULT_TICKET_STOCK); + } - /** - * 🔥 핵심 테스트 - */ @Test @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") - void createEventOrderWithoutLock_concurrency_test() throws Exception { - + void createEventOrder_withoutLock_fail() throws Exception { // given - prepareStock(TEST_TICKET_STOCK); + prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - // TODO : 락 종류마다 메서드 수정 userId -> eventOrderFacade.createEventOrderWithoutLock( TEST_TICKET_PRODUCT_ID, userId @@ -56,27 +60,116 @@ void createEventOrderWithoutLock_concurrency_test() throws Exception { ); // then - printResult(result); + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("WithoutLock", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // 락 없는 버전은 정합성이 깨지는 것이 목적 + // 즉 정상 기대값(10/90/10/0)과 달라야 한다. + boolean isExactlyCorrect = + result.successCount() == 10 && + result.failCount() == 90 && + orderCount == 10 && + product.getStock() == 0; + + assertThat(isExactlyCorrect).isFalse(); + } + + @Test + @DisplayName("Redis 분산락 FailFast 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_failFast() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockFailFast( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); - Product product = productRepository.findById(TEST_TICKET_PRODUCT_ID).orElseThrow(); + // then + Product product = getTestProduct(); long orderCount = orderRepository.count(); - assertThat(result.successCount).isEqualTo(10); - assertThat(result.failCount).isEqualTo(90); + printResult("Redis FailFast", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 + // 성공은 0 또는 1 정도가 자연스럽다. + assertThat(result.successCount()).isBetween(0, 1); + assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); + + // 주문 수도 성공 수와 같아야 한다. + assertThat(orderCount).isEqualTo(result.successCount()); + + // 재고는 최소 9, 최대 10이 자연스럽다. + assertThat(product.getStock()).isBetween(9, 10); + } + + @Test + @DisplayName("Redis 분산락 Retry 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_retry_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockRetry( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis Retry", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); assertThat(orderCount).isEqualTo(10); assertThat(product.getStock()).isEqualTo(0); } + @Test + @DisplayName("Redis 분산락 Blocking 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_blocking_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockBlocking( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + printResult("Redis Blocking", result, orderCount, product.getStock()); + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } /** - * 🔥 공통 동시성 실행 로직 + * 공통 동시성 실행 로직 */ private ConcurrencyTestResult runConcurrencyTest(TestExecutor executor) throws Exception { - ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL); CountDownLatch doneLatch = new CountDownLatch(TOTAL_REQUEST_COUNT); @@ -97,7 +190,6 @@ private ConcurrencyTestResult runConcurrencyTest(TestExecutor executor) throws E executor.execute(userId); successCount.incrementAndGet(); - } catch (Exception e) { failCount.incrementAndGet(); } finally { @@ -106,11 +198,10 @@ private ConcurrencyTestResult runConcurrencyTest(TestExecutor executor) throws E }); } - boolean completed = doneLatch.await(20, TimeUnit.SECONDS); + boolean completed = doneLatch.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); threadPool.shutdown(); long endTime = System.nanoTime(); - long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); return new ConcurrencyTestResult( @@ -122,46 +213,78 @@ private ConcurrencyTestResult runConcurrencyTest(TestExecutor executor) throws E } /** - * 🔥 재고 초기화 (공통 준비 코드) - * 티켓 재고를 10개로 맞추는 코드 + * 테스트 시작 전 공통 준비 + */ + private void prepareTestData(int stock) { + clearOrders(); + prepareStock(stock); + } + + /** + * 주문 데이터 초기화 + */ + private void clearOrders() { + orderRepository.deleteAllInBatch(); + } + + /** + * 테스트용 티켓 재고를 원하는 값으로 맞춘다. */ private void prepareStock(int stock) { - Product product = productQueryService.getByProductId(TEST_TICKET_PRODUCT_ID); + Product product = getTestProduct(); + int currentStock = product.getStock(); - if (product.getStock() > stock) { - product.decreaseStock(product.getStock() - stock); + if (currentStock > stock) { + product.decreaseStock(currentStock - stock); + } else if (currentStock < stock) { + product.increaseStock(stock - currentStock); } - if (product.getStock() < stock) { - product.increaseStock(stock - product.getStock()); + productRepository.save(product); + } + + /** + * 테스트 종료 후 재고를 기본값으로 복구 + */ + private void restoreStock(int stock) { + Product product = getTestProduct(); + int currentStock = product.getStock(); + + if (currentStock > stock) { + product.decreaseStock(currentStock - stock); + } else if (currentStock < stock) { + product.increaseStock(stock - currentStock); } productRepository.save(product); } /** - * 🔥 결과 출력 + * 테스트용 상품 조회 + */ + private Product getTestProduct() { + return productQueryService.getByProductId(TEST_TICKET_PRODUCT_ID); + } + + /** + * 결과 출력 */ - private void printResult(ConcurrencyTestResult result) { - System.out.println("===== 결과 ====="); + private void printResult(String label, ConcurrencyTestResult result, long orderCount, int stock) { + System.out.println("===== 결과 : " + label + " ====="); System.out.println("성공 수 = " + result.successCount()); System.out.println("실패 수 = " + result.failCount()); System.out.println("총 실행 시간(ms) = " + result.elapsedMillis()); System.out.println("모든 요청 완료 여부 = " + result.completed()); - System.out.println("================"); + System.out.println("최종 주문 수 = " + orderCount); + System.out.println("최종 재고 수 = " + stock); + System.out.println("=============================="); } - /** - * 🔥 실행 인터페이스 (핵심!) - */ @FunctionalInterface interface TestExecutor { void execute(Long userId); } - /** - * 🔥 결과 객체 - */ record ConcurrencyTestResult( int successCount, int failCount, From cf51bd858a1b76679286fabeea739fab0888d53c Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 02:46:20 +0900 Subject: [PATCH 07/15] =?UTF-8?q?Feat:=20Redis=20Lock=20cherry=20pick=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0.=20?= =?UTF-8?q?=EB=9D=BD=20=EC=97=86=EB=8A=94=20=EB=B2=84=EC=A0=84,=20?= =?UTF-8?q?=EB=A0=88=EB=94=94=EC=8A=A4=20=EB=9D=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lock/repository/RedisLockRepository.java | 71 +++++++++ .../common/lock/service/RedisLockService.java | 150 ++++++++++++++++++ src/main/resources/application-test.yml | 2 +- 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java new file mode 100644 index 0000000..67c2925 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/repository/RedisLockRepository.java @@ -0,0 +1,71 @@ +package jpa.basic.alldayprojectcommerce.common.lock.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Collections; + +/** + * Redis 명령 담당 + * setIfAbsent, Lua Script 해제 + * 저수준 인프라 역할 + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class RedisLockRepository { + + private final StringRedisTemplate redisTemplate; + + /** + * Redis 락 획득 시도 + * + * setIfAbsent == SET NX + * TTL을 함께 걸어 서버 장애 시 락이 영구 점유되지 않도록 한다. + * + * @param key 락 키 (예: lock:product:4) + * @param value 락 소유자 식별값 (UUID) + * @param timeoutSeconds 락 유지 시간 + * @return 락 획득 성공 여부 + */ + public boolean tryLock(String key, String value, long timeoutSeconds) { + Boolean result = redisTemplate.opsForValue() + .setIfAbsent(key, value, Duration.ofSeconds(timeoutSeconds)); + + return Boolean.TRUE.equals(result); + } + + /** + * Redis 락 해제 + * + * 본인이 획득한 락만 해제해야 하므로 + * value(UUID)를 비교한 뒤 동일할 때만 delete 한다. + * 이 과정을 Lua Script로 원자적으로 수행한다. + * + * @param key 락 키 + * @param value 락 소유자 식별값 (UUID) + * @return 실제 삭제 여부 + */ + public boolean unlock(String key, String value) { + String script = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """; + + Long result = redisTemplate.execute( + new DefaultRedisScript<>(script, Long.class), + Collections.singletonList(key), + value + ); + log.info("[RedisLock] unlock 시도 key={}, value={}", key, value); + + return result != null && result == 1L; + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java new file mode 100644 index 0000000..5cadd0e --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java @@ -0,0 +1,150 @@ +package jpa.basic.alldayprojectcommerce.common.lock.service; + +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.common.lock.repository.RedisLockRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.UUID; +import java.util.function.Supplier; + +/** + * 실패 시 즉시 예외 + * 비즈니스 로직 실행 + * finally에서 락 해제 + * + * 락 흐름 제어 담당 + * key/value/ttl/예외 처리 + * 상위 비즈니스에서 쓰기 쉬움 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisLockService { + + private final RedisLockRepository redisLockRepository; + + /** + * Redis 분산락을 획득한 뒤 비즈니스 로직 실행 + * + * 현재 단계에서는 Fail Fast 전략을 사용한다. + * 즉, 락 획득에 실패하면 재시도하지 않고 바로 예외를 던진다. + * + * @param key 락 키 (예: lock:product:4) + * @param timeoutSeconds 락 유지 시간 + * @param supplier 락 안에서 실행할 비즈니스 로직 + * @return supplier 실행 결과 + * @param 반환 타입 + */ + public T executeWithLockFailFast(String key, long timeoutSeconds, Supplier supplier) { + String lockValue = UUID.randomUUID().toString(); + + boolean locked = redisLockRepository.tryLock(key, lockValue, timeoutSeconds); + + if (!locked) { + log.info("[RedisLock] 락 획득 실패 key={}", key); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + log.info("[RedisLock] 락 획득 성공 key={}, value={}", key, lockValue); + + try { + // 목표하는 메서드 수행 + return supplier.get(); + } finally { + // 락 해제 + boolean unlocked = redisLockRepository.unlock(key, lockValue); + + if (unlocked) { + log.info("[RedisLock] 락 해제 성공 key={}, value={}", key, lockValue); + } else { + log.warn("[RedisLock] 락 해제 실패 또는 이미 해제됨 key={}, value={}", key, lockValue); + } + } + } + + + public T executeWithLockRetry(String key, long timeoutSeconds, Supplier supplier) { + + String lockValue = UUID.randomUUID().toString(); + + // 최대 재시도 횟수 + int maxRetryCount = 10; + // 재시도 간격(ms) + long retryIntervalMillis = 100L; // 최대 대기 시간은 대략 (20-1) * 100ms = 1.9초 + // TODO : 10개보다 적게 성공한다면 + // 1. 재시도 횟수 증가 + // 2. 대기 시간 약간 증가 + + for (int attempt = 1; attempt <= maxRetryCount; attempt++) { + + boolean locked = redisLockRepository.tryLock(key, lockValue, timeoutSeconds); + + if (locked) { + log.info("[RedisLock-Retry] 락 획득 성공 key={}, attempt={}", key, attempt); + + try { + return supplier.get(); + } finally { + redisLockRepository.unlock(key, lockValue); + } + } + + log.info("[RedisLock-Retry] 락 획득 실패 key={}, attempt={}", key, attempt); + + if (attempt == maxRetryCount) { + break; + } + + try { + Thread.sleep(retryIntervalMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } + + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + public T executeWithLockBlocking(String key, long timeoutSeconds, Supplier supplier) { + + String lockValue = UUID.randomUUID().toString(); + + long waitStartTime = System.currentTimeMillis(); + + long maxWaitMillis = 5000; // 🔥 최대 5초만 기다림 + long retryIntervalMillis = 50; + + while (true) { + + boolean locked = redisLockRepository.tryLock(key, lockValue, timeoutSeconds); + + if (locked) { + log.info("[RedisLock-Blocking] 락 획득 성공 key={}", key); + + try { + return supplier.get(); + } finally { + redisLockRepository.unlock(key, lockValue); + } + } + + // 🔥 최대 대기 시간 초과 + if (System.currentTimeMillis() - waitStartTime > maxWaitMillis) { + log.warn("[RedisLock-Blocking] 락 획득 타임아웃 key={}", key); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + try { + Thread.sleep(retryIntervalMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } + } + +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 50410a0..83c7002 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -8,7 +8,7 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQLDialect hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: format_sql: false From 1fbd947a6c3eb4361bc235c50826ed428207f95f Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 11:56:27 +0900 Subject: [PATCH 08/15] =?UTF-8?q?Test:=20Redis=20Retry=20Connection=20Pool?= =?UTF-8?q?=20=EB=B9=84=EA=B5=90=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradlew | 0 .../common/lock/service/RedisLockService.java | 2 +- src/main/resources/application-test.yml | 10 +++++++++- .../application/EventOrderFacadeConcurrencyTest.java | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java index 5cadd0e..ead8f5d 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedisLockService.java @@ -71,7 +71,7 @@ public T executeWithLockRetry(String key, long timeoutSeconds, Supplier s String lockValue = UUID.randomUUID().toString(); // 최대 재시도 횟수 - int maxRetryCount = 10; + int maxRetryCount = 15; // 재시도 간격(ms) long retryIntervalMillis = 100L; // 최대 대기 시간은 대략 (20-1) * 100ms = 1.9초 // TODO : 10개보다 적게 성공한다면 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 83c7002..654f176 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -5,6 +5,13 @@ spring: url: jdbc:mysql://localhost:3307/allday_project_commerce?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: root password: soyoung7509 + hikari: + maximum-pool-size: 30 + minimum-idle: 30 + connection-timeout: 3000 + idle-timeout: 600000 + max-lifetime: 1800000 + pool-name: Hikari-Test-Pool jpa: database-platform: org.hibernate.dialect.MySQLDialect hibernate: @@ -22,7 +29,8 @@ spring: logging: level: root: info - jpa.basic.alldayprojectcommerce: debug + jpa.basic.alldayprojectcommerce: + debugcom.zaxxer.hikari: debug # 쿼리 확인이 필요할때만 활성화 # org.hibernate.SQL: debug diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index 7bf41af..45fb5cb 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -18,7 +18,7 @@ @SpringBootTest @ActiveProfiles("test") -class EventOrderConcurrencyTest { +class EventOrderFacadeConcurrencyTest { @Autowired private EventOrderFacade eventOrderFacade; From e97a6a1074522dbd813dde5c3273925cbfd61fa7 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 15:58:03 +0900 Subject: [PATCH 09/15] =?UTF-8?q?Feat:=20Redis=20Lock=EC=9D=84=20AOP?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=98=EC=97=AC=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC.=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../application/EventOrderFacade.java | 40 +++++ .../common/lock/annotation/RedisLock.java | 27 +++ .../common/lock/aspect/RedisLockAspect.java | 164 ++++++++++++++++++ .../common/lock/enums/RedisLockStrategy.java | 7 + src/main/resources/application-test.yml | 2 +- .../EventOrderFacadeConcurrencyTest.java | 87 ++++++++++ 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java diff --git a/build.gradle b/build.gradle index da27cfe..603db76 100644 --- a/build.gradle +++ b/build.gradle @@ -66,3 +66,8 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +// 파라미터명을 SpEL에서 쓰려면 Gradle 컴파일 옵션에 -parameters가 필요 +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += ['-parameters'] +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java index dfcfb5b..cc9e5f2 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -1,5 +1,7 @@ package jpa.basic.alldayprojectcommerce.application; +import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedisLock; +import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy; import jpa.basic.alldayprojectcommerce.common.lock.service.RedisLockService; import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; import jpa.basic.alldayprojectcommerce.domain.order.service.event.EventOrderService; @@ -86,4 +88,42 @@ public EventOrderResponse createEventOrderWithRedisLockBlocking(Long productId, () -> eventOrderService.createEventOrder(productId, userId) ); } + + /** + * Redis 분산락 - AOP FailFast 전략 적용 버전 + */ + @RedisLock( + key = "'lock:product:' + #productId", + timeoutSeconds = 5, + strategy = RedisLockStrategy.FAIL_FAST + ) + public EventOrderResponse createEventOrderWithRedisLockAopFailFast(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redis 분산락 - AOP Retry 전략 적용 버전 + */ + @RedisLock( + key = "'lock:product:' + #productId", + timeoutSeconds = 5, + strategy = RedisLockStrategy.RETRY + ) + public EventOrderResponse createEventOrderWithRedisLockAopRetry(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + /** + * Redis 분산락 - AOP Blocking 전략 적용 버전 + */ + @RedisLock( + key = "'lock:product:' + #productId", + timeoutSeconds = 5, + strategy = RedisLockStrategy.BLOCKING + ) + public EventOrderResponse createEventOrderWithRedisLockAopBlocking(Long productId, Long userId) { + return eventOrderService.createEventOrder(productId, userId); + } + + } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java new file mode 100644 index 0000000..9a90763 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedisLock.java @@ -0,0 +1,27 @@ +package jpa.basic.alldayprojectcommerce.common.lock.annotation; + +import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedisLock { + + /** + * SpEL 기반 락 키 + * 예: "'lock:product:' + #productId" + */ + String key(); + + /** + * Redis 락 TTL + */ + long timeoutSeconds() default 5; + + /** + * 락 획득 전략 + */ + RedisLockStrategy strategy() default RedisLockStrategy.RETRY; +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java new file mode 100644 index 0000000..aeae805 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedisLockAspect.java @@ -0,0 +1,164 @@ +package jpa.basic.alldayprojectcommerce.common.lock.aspect; + +import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedisLock; +import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy; +import jpa.basic.alldayprojectcommerce.common.lock.service.RedisLockService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +/** + * RedisLockAspect + * + * 역할: + * @RedisLock 애노테이션이 붙은 메서드를 가로채서 + * Redis 분산락 획득 → 실제 메서드 실행 → Redis 분산락 해제 흐름을 적용한다. + * + * 즉, 비즈니스 코드에서 직접 redisLockService.executeWithLock...()을 호출하지 않아도 + * 애노테이션만 붙이면 락 처리가 자동으로 적용된다. + */ +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisLockAspect { + + /** + * 이 Aspect는 "언제 어떤 key로 어떤 전략의 락을 걸지" 결정하고, + * 실제 락 처리는 RedisLockService에게 위임한다. + */ + private final RedisLockService redisLockService; + + /** + * SpEL 표현식을 해석하기 위한 파서 + * + * 예: + * "'lock:product:' + #productId" + * + * productId가 4라면 + * "lock:product:4" 로 변환한다. + */ + private final ExpressionParser parser = new SpelExpressionParser(); + + /** + * @RedisLock 애노테이션이 붙은 메서드를 감싸는 Around Advice + * + * @Around("@annotation(redisLock)") + * - @RedisLock이 붙은 메서드가 호출되면 이 메서드가 먼저 실행된다. + */ + @Around("@annotation(redisLock)") + public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) { + + /** + * redisLock 파라미터로 어노테이션에 적은 값을 가져온다. + * key = "'lock:product:' + #productId" + * timeoutSeconds = 5 + * strategy = RETRY + */ + + // 1. 애노테이션에 작성한 key 표현식을 실제 Redis Lock Key로 변환 + String key = parseKey(joinPoint, redisLock.key()); + // 2. 애노테이션에서 설정한 락 전략과 TTL 값을 가져온다. + RedisLockStrategy strategy = redisLock.strategy(); + long timeoutSeconds = redisLock.timeoutSeconds(); + log.info("[RedisLock-AOP] key={}, strategy={}", key, strategy); + + return switch (strategy) { + case FAIL_FAST -> redisLockService.executeWithLockFailFast( + key, + timeoutSeconds, + /** + * 핵심: + * () -> proceed(joinPoint) + * + * 이 람다가 바로 "락을 잡은 뒤 실행할 실제 비즈니스 로직"이다. + */ + () -> proceed(joinPoint) + ); + + case RETRY -> redisLockService.executeWithLockRetry( + key, + timeoutSeconds, + () -> proceed(joinPoint) + ); + + case BLOCKING -> redisLockService.executeWithLockBlocking( + key, + timeoutSeconds, + () -> proceed(joinPoint) + ); + }; + } + + private Object proceed(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + /* + * joinPoint.proceed()는 Throwable을 던질 수 있다. + * + * 하지만 Supplier는 checked exception을 던질 수 없으므로 + * checked exception은 RuntimeException으로 감싸서 던진다. + */ + throw new RuntimeException(e); + } + } + + private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) { + /* + * MethodSignature: + * 현재 AOP가 가로챈 메서드의 시그니처 정보 + * + * 여기서 파라미터 이름을 얻을 수 있다. + * + * 예: + * createEventOrderWithRedisLockAopRetry(Long productId, Long userId) + * + * parameterNames = ["productId", "userId"] + * args = [4L, 1L] + */ + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + + /* + * SpEL에서 사용할 변수 저장소 + * + * context에 productId=4, userId=1 같은 변수를 넣어두면 + * SpEL 표현식에서 #productId, #userId로 사용할 수 있다. + */ + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + /* + * 파라미터 이름과 실제 인자값을 매칭해서 context에 등록한다. + * + * 예: + * parameterNames[0] = "productId" + * args[0] = 4L + * + * context.setVariable("productId", 4L) + */ + context.setVariable(parameterNames[i], args[i]); + } + + /* + * SpEL 표현식을 실제 문자열로 평가한다. + * + * 예: + * "'lock:product:' + #productId" + * → "lock:product:4" + */ + return parser.parseExpression(keyExpression).getValue(context, String.class); + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java new file mode 100644 index 0000000..142ab92 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/enums/RedisLockStrategy.java @@ -0,0 +1,7 @@ +package jpa.basic.alldayprojectcommerce.common.lock.enums; + +public enum RedisLockStrategy { + FAIL_FAST, + RETRY, + BLOCKING +} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 654f176..c6d4843 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -15,7 +15,7 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQLDialect hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: format_sql: false diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index 45fb5cb..6f0a5ca 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -166,6 +166,93 @@ void createEventOrder_redisLettuce_blocking_success() throws Exception { assertThat(product.getStock()).isEqualTo(0); } + @Test + @DisplayName("Redis 분산락 - AOP 버전 - FailFast 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_failFast() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopFailFast( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP FailFast", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 + // 성공은 0 또는 1 정도가 자연스럽다. + assertThat(result.successCount()).isBetween(0, 1); + assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); + + // 주문 수도 성공 수와 같아야 한다. + assertThat(orderCount).isEqualTo(result.successCount()); + + // 재고는 최소 9, 최대 10이 자연스럽다. + assertThat(product.getStock()).isBetween(9, 10); + } + + @Test + @DisplayName("Redis 분산락 - AOP 버전 - Retry 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_retry_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopRetry( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP Retry", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redis 분산락 - AOP 버전 - Blocking 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_blocking_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopBlocking( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP Blocking", result, orderCount, product.getStock()); + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + /** * 공통 동시성 실행 로직 */ From 0f9cd61de624fce4b7b7a9f7f408b04374748bd0 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 19:05:54 +0900 Subject: [PATCH 10/15] =?UTF-8?q?Feat:=20Redisson=20+=20AOP=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=9C=20=EB=9D=BD=20=EA=B5=AC=ED=98=84,?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EC=A3=BC=EB=AC=B8=EC=9D=B4=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EC=99=94=EC=9D=84=20=EB=95=8C=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=9D=80=20=EC=95=84=EC=A7=81=20=EB=9D=BD=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=EC=9D=84=20=ED=98=B8=EC=B6=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EC=B6=94?= =?UTF-8?q?=ED=9B=84=20=EC=B5=9C=ED=9B=84=20=EC=82=AC=EC=9A=A9=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9D=B4=20=ED=99=95=EC=A0=95=EB=90=98=EB=A9=B4=20Eve?= =?UTF-8?q?ntOrderController=EC=97=90=EC=84=9C=20EventOrderFacade=EC=97=90?= =?UTF-8?q?=20=EC=9E=88=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=20=EA=B3=A8=EB=9D=BC=EC=84=9C=20=ED=98=B8=EC=B6=9C=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20=EB=90=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + src/.DS_Store | Bin 6148 -> 6148 bytes src/main/java/jpa/.DS_Store | Bin 0 -> 6148 bytes .../jpa/basic/alldayprojectcommerce/.DS_Store | Bin 0 -> 6148 bytes .../application/EventOrderFacade.java | 74 +++++ .../common/config/RedissonConfig.java | 41 +++ .../common/lock/annotation/RedissonLock.java | 28 ++ .../lock/aspect/RedissonLockAspect.java | 63 ++++ .../lock/service/RedissonLockService.java | 107 +++++++ .../alldayprojectcommerce/domain/.DS_Store | Bin 0 -> 6148 bytes .../domain/order/.DS_Store | Bin 0 -> 6148 bytes .../domain/order/service/.DS_Store | Bin 0 -> 6148 bytes src/test/.DS_Store | Bin 0 -> 6148 bytes src/test/java/.DS_Store | Bin 0 -> 6148 bytes src/test/java/jpa/.DS_Store | Bin 0 -> 6148 bytes src/test/java/jpa/basic/.DS_Store | Bin 0 -> 6148 bytes .../jpa/basic/alldayprojectcommerce/.DS_Store | Bin 0 -> 6148 bytes .../EventOrderFacadeConcurrencyTest.java | 268 ++++++++++++------ 18 files changed, 503 insertions(+), 80 deletions(-) create mode 100644 src/main/java/jpa/.DS_Store create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/.DS_Store create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedissonConfig.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/.DS_Store create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/.DS_Store create mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/.DS_Store create mode 100644 src/test/.DS_Store create mode 100644 src/test/java/.DS_Store create mode 100644 src/test/java/jpa/.DS_Store create mode 100644 src/test/java/jpa/basic/.DS_Store create mode 100644 src/test/java/jpa/basic/alldayprojectcommerce/.DS_Store diff --git a/build.gradle b/build.gradle index 603db76..7e4a38e 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,8 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.51.0' } tasks.named('test') { diff --git a/src/.DS_Store b/src/.DS_Store index 254fc3aa651e4450a614b710682d5cd8457a722c..48299fa3419bd1468f9f3c8cb2ae052421e873e6 100644 GIT binary patch delta 98 zcmZoMXfc=|&e%3FQH+&?fq{WzVxkBq6OaJ{OcMjF8JQ;PNN}<+lrW?+6f=};jEG{N rY#_q2nVW-$gRyO6;&}Li5tC$iq delta 76 zcmZoMXfc=|&Zs&uQJ9f&Vvht50|NsS5Q6~Y!~pA!7tGlwUU1#a&LP0TsJijscjn3b aB8r@hKb diff --git a/src/main/java/jpa/.DS_Store b/src/main/java/jpa/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7cc8ca88006cf460156eaa64cafa86d66f244eb0 GIT binary patch literal 6148 zcmeH~O^O0R4254D2Lv}RUDjp}FgGZqJ%JZ+R0Ltbg}ASy`;yAW*7eW~FOYhbRHc8u zMOO!aZSJRUUN-<>&vF{;vNY zl_(Se5%^~WY(DIU4PPqH)|c1w{64cjZ**#G=kW9sz`&2#YTO5g$gr4wHO literal 0 HcmV?d00001 diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..68c33b59641dd39b0d546b8b182884d7e3a131ba GIT binary patch literal 6148 zcmeHKyH3ME5S)VuMM{&B@_vCoI7Q(L_<@92NR|Rdg7ohAZDt=lEGI1mnvM3xx3{w= zPvOl1D4SDv2dn`s>4x~QWo~wEKC`Qg7#6S5-VBd#IH*ASx zpnp2J_y|B;Fzm)>pCyRZ0>qX$1~Nh`K_v#Y+I#M*H-$D{$ proceed(joinPoint) + ); + } + + private Object proceed(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(keyExpression).getValue(context, String.class); + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java new file mode 100644 index 0000000..b558bb7 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java @@ -0,0 +1,107 @@ +package jpa.basic.alldayprojectcommerce.common.lock.service; + +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedissonLockService { + + private final RedissonClient redissonClient; + + public T executeWithLock( + String key, + long waitTimeSeconds, + long leaseTimeSeconds, + Supplier supplier + ) { + /** + * RLock = Redisson의 분산락 객체 + * + * - key 기준으로 락 생성 + * - 내부적으로 Redis 사용 + */ + RLock lock = redissonClient.getLock(key); + + boolean locked = false; + + try { + /** + * tryLock + * + * waitTimeSeconds: 락을 기다리는 시간 (Blocking 느낌) + * leaseTimeSeconds: 락 유지 시간 + * + * Lettuce FailFast 전략 -> waitTime = 0 + * Retry -> waitTime > 0 짧게 + * Blocking -> waitTime을 크게 + */ + if (leaseTimeSeconds < 0) { + /** + * 🔥 watchdog 모드 + * + * - TTL을 자동으로 계속 연장 + * - 비즈니스 로직이 길어도 안전 + * + * Lettuce에서는 직접 구현 불가능 + */ + locked = lock.tryLock(waitTimeSeconds, TimeUnit.SECONDS); + } else { + /** + * 🔥 일반 TTL 방식 + * + * - leaseTime 지나면 자동 unlock + * - Lettuce의 TTL과 동일 개념 + */ + locked = lock.tryLock(waitTimeSeconds, leaseTimeSeconds, TimeUnit.SECONDS); + } + + /** + * 🔥 락 획득 실패 + * + * - waitTime 동안 기다렸는데도 실패하면 여기로 옴 + */ + if (!locked) { + log.info("[RedissonLock] 락 획득 실패 key={}", key); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + log.info("[RedissonLock] 락 획득 성공 key={}", key); + + /** + * 🔥 실제 비즈니스 로직 실행 + */ + return supplier.get(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); + + } finally { + /** + * 🔥 매우 중요 + * + * lock.isHeldByCurrentThread() → 내가 잡은 락인지 확인 + * + * Lettuce에서는: + * UUID + Lua Script로 직접 구현했지만 + * + * Redisson에서는: + * 내부적으로 자동 관리됨 + */ + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("[RedissonLock] 락 해제 성공 key={}", key); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/domain/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7db28e429ca589f77a13a706f339b923a8085e2a GIT binary patch literal 6148 zcmeH~J#ND=422)l6bO(dV@54GKyM%f&IxjXqIEhIFpwg>x zvU{*BwP-^;ALZ1N`)X?I+RI_td|2Muyo;e(FNYNdG^-&RL_h>)1YUZ)^Yi~if7buc zN)(EK2>dewwjR#IkuQ~J>#x`I{Cj47-ssfW&f(!FfPo*ydwLkxi%+OEwRL5NrXPX8 Kpg{!wl)xR#4iks~ literal 0 HcmV?d00001 diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..68a811a372383b67d1d49b9dea98ede04976ef51 GIT binary patch literal 6148 zcmeHKOKL(v5Ufsw2yR?vIakOH1~Dh_0unV42ndm6{Z^hUN3;5~h`eN{3qv*Z)J#v; z3{#8OuL0QNbNd9$0Zi$RIQlR)-*+F`T}6yY=NWI^vEme_$X><=7 zrRr^n<-Hu5VqXp|RcpCuG#?swR_kJ5TFXTf0+?nO4kSPVJp$tz&+Yu*z`ynXy%L5b zKmvb8K$~{kuJKZFwtl>x<#&;_d4WTHJBGKP00edvFW_!iPd0((&{7o{7=HvD0|yfL GDS;P?6BCdC literal 0 HcmV?d00001 diff --git a/src/test/.DS_Store b/src/test/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..db5230729bac487d668cd322e4d37e689eaf857b GIT binary patch literal 6148 zcmeH~F>V4u3`M_T3nZE?Q%=JHxWNd)337ozPzn@@0zo}T=iB2Zo789(eM|Nmd)C_B zU+k;_*zSJb0waJG-HDBdi5c?&7aZ}x@#}p1JYBEfUZgEL;3<8?WIwkBDIf);fE17d zQeZ|3$ccLbDoBkOER*qQJ7}qo4l=`d{<^q(!L| zkOKcq0UP%3`yF2@&(>eB=k<@Q`nu7{xSZj~PXH4?ir?sA+%LW$YqE8+Leq~x$eIl3=DjjOdR@&d^>$!yr&SL|#= zL|4z#Qlt}+DcmRv3q4chW4Xv(_UF^%c(~oJR&pC9tpM+&x1ZYt6`%rCfC^9nDli}g z@*tni2J}pP6e>UkhM|Cc9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*kwzfpO%Wo&Ov7xB0)N-<>&vF{;vNY zl_(Se5%^~WY(DIU4PPqH)|c1w{64cjZ**#G=kW9sz`&2#YTO5g$gr4wHO literal 0 HcmV?d00001 diff --git a/src/test/java/jpa/basic/.DS_Store b/src/test/java/jpa/basic/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ab6e84be9840ff10c47be0eb0baf8def4bc4d120 GIT binary patch literal 6148 zcmeHKyH3ME5S)b+k!aE&QQj}`2UZlmfFA&CQ=lLt9tE8%zKc&|_R&O!CK63Fd)k}3 z_0F9sZC z=*ytj%>%@)a86`|W=SO`)vCp?q%+*lKsX+wzsFwk}@IYi*<7(LLvz r?#6XcI7K-oMmgrj%kfntWnS|&pLd0GV$c~6I#E9Zu8T|x{I>!>ti2jO literal 0 HcmV?d00001 diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/.DS_Store b/src/test/java/jpa/basic/alldayprojectcommerce/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cf88542fabdd0a22faf8fd6c478235abebd43cb1 GIT binary patch literal 6148 zcmeHKJFdb&477m)i6$lGUV$5|5S-v$;5|S{NQfW-eJaky(cbveM1c+(G>*Kp>v(px zQ^c`|=<>E(h%_QHg&WGrqChzIILKE1pyTG7}X7izWvSx>(emeFqo-SGgIZ^>C&{tp_ z%f{;e8vdgH?~}Nq0#x9u6wt})XSKwYvbGK`XSKG#f8mz%fSY0N6bxRDfnJWWuyQ>2 cq{u5a$NQSt1v(vZrvv#jV7kz#z^4`X2e^I}^8f$< literal 0 HcmV?d00001 diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index 6f0a5ca..50ea80d 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -44,49 +44,171 @@ void tearDown() { clearOrders(); restoreStock(DEFAULT_TICKET_STOCK); } +// +// @Test +// @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") +// void createEventOrder_withoutLock_fail() throws Exception { +// // given +// prepareTestData(TEST_TICKET_STOCK); +// +// // when +// ConcurrencyTestResult result = runConcurrencyTest( +// userId -> eventOrderFacade.createEventOrderWithoutLock( +// TEST_TICKET_PRODUCT_ID, +// userId +// ) +// ); +// +// // then +// Product product = getTestProduct(); +// long orderCount = orderRepository.count(); +// +// printResult("WithoutLock", result, orderCount, product.getStock()); +// +// assertThat(result.completed()).isTrue(); +// +// // 락 없는 버전은 정합성이 깨지는 것이 목적 +// // 즉 정상 기대값(10/90/10/0)과 달라야 한다. +// boolean isExactlyCorrect = +// result.successCount() == 10 && +// result.failCount() == 90 && +// orderCount == 10 && +// product.getStock() == 0; +// +// assertThat(isExactlyCorrect).isFalse(); +// } +// +// @Test +// @DisplayName("Redis 분산락 FailFast 전략 - 성능 및 정합성 테스트") +// void createEventOrder_redisLettuce_failFast() throws Exception { +// // given +// prepareTestData(TEST_TICKET_STOCK); +// +// // when +// ConcurrencyTestResult result = runConcurrencyTest( +// userId -> eventOrderFacade.createEventOrderWithRedisLockFailFast( +// TEST_TICKET_PRODUCT_ID, +// userId +// ) +// ); +// +// // then +// Product product = getTestProduct(); +// long orderCount = orderRepository.count(); +// +// printResult("Redis FailFast", result, orderCount, product.getStock()); +// +// assertThat(result.completed()).isTrue(); +// +// // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 +// // 성공은 0 또는 1 정도가 자연스럽다. +// assertThat(result.successCount()).isBetween(0, 1); +// assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); +// +// // 주문 수도 성공 수와 같아야 한다. +// assertThat(orderCount).isEqualTo(result.successCount()); +// +// // 재고는 최소 9, 최대 10이 자연스럽다. +// assertThat(product.getStock()).isBetween(9, 10); +// } +// +// @Test +// @DisplayName("Redis 분산락 Retry 전략 - 성능 및 정합성 테스트") +// void createEventOrder_redisLettuce_retry_success() throws Exception { +// // given +// prepareTestData(TEST_TICKET_STOCK); +// +// // when +// ConcurrencyTestResult result = runConcurrencyTest( +// userId -> eventOrderFacade.createEventOrderWithRedisLockRetry( +// TEST_TICKET_PRODUCT_ID, +// userId +// ) +// ); +// +// // then +// Product product = getTestProduct(); +// long orderCount = orderRepository.count(); +// +// printResult("Redis Retry", result, orderCount, product.getStock()); +// +// assertThat(result.completed()).isTrue(); +// assertThat(result.successCount()).isEqualTo(10); +// assertThat(result.failCount()).isEqualTo(90); +// assertThat(orderCount).isEqualTo(10); +// assertThat(product.getStock()).isEqualTo(0); +// } +// +// @Test +// @DisplayName("Redis 분산락 Blocking 전략 - 성능 및 정합성 테스트") +// void createEventOrder_redisLettuce_blocking_success() throws Exception { +// // given +// prepareTestData(TEST_TICKET_STOCK); +// +// // when +// ConcurrencyTestResult result = runConcurrencyTest( +// userId -> eventOrderFacade.createEventOrderWithRedisLockBlocking( +// TEST_TICKET_PRODUCT_ID, +// userId +// ) +// ); +// +// // then +// Product product = getTestProduct(); +// long orderCount = orderRepository.count(); +// +// printResult("Redis Blocking", result, orderCount, product.getStock()); +// +// assertThat(result.completed()).isTrue(); +// assertThat(result.successCount()).isEqualTo(10); +// assertThat(result.failCount()).isEqualTo(90); +// assertThat(orderCount).isEqualTo(10); +// assertThat(product.getStock()).isEqualTo(0); +// } +// +// @Test +// @DisplayName("Redis 분산락 - AOP 버전 - FailFast 전략 - 성능 및 정합성 테스트") +// void createEventOrder_redisLettuce_aop_failFast() throws Exception { +// // given +// prepareTestData(TEST_TICKET_STOCK); +// +// // when +// ConcurrencyTestResult result = runConcurrencyTest( +// userId -> eventOrderFacade.createEventOrderWithRedisLockAopFailFast( +// TEST_TICKET_PRODUCT_ID, +// userId +// ) +// ); +// +// // then +// Product product = getTestProduct(); +// long orderCount = orderRepository.count(); +// +// printResult("Redis AOP FailFast", result, orderCount, product.getStock()); +// +// assertThat(result.completed()).isTrue(); +// +// // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 +// // 성공은 0 또는 1 정도가 자연스럽다. +// assertThat(result.successCount()).isBetween(0, 1); +// assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); +// +// // 주문 수도 성공 수와 같아야 한다. +// assertThat(orderCount).isEqualTo(result.successCount()); +// +// // 재고는 최소 9, 최대 10이 자연스럽다. +// assertThat(product.getStock()).isBetween(9, 10); +// } @Test - @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") - void createEventOrder_withoutLock_fail() throws Exception { - // given - prepareTestData(TEST_TICKET_STOCK); - - // when - ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithoutLock( - TEST_TICKET_PRODUCT_ID, - userId - ) - ); - - // then - Product product = getTestProduct(); - long orderCount = orderRepository.count(); - - printResult("WithoutLock", result, orderCount, product.getStock()); - - assertThat(result.completed()).isTrue(); - - // 락 없는 버전은 정합성이 깨지는 것이 목적 - // 즉 정상 기대값(10/90/10/0)과 달라야 한다. - boolean isExactlyCorrect = - result.successCount() == 10 && - result.failCount() == 90 && - orderCount == 10 && - product.getStock() == 0; - - assertThat(isExactlyCorrect).isFalse(); - } - - @Test - @DisplayName("Redis 분산락 FailFast 전략 - 성능 및 정합성 테스트") - void createEventOrder_redisLettuce_failFast() throws Exception { + @DisplayName("Redis Lettuce 분산락 - AOP 버전 - Retry 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_retry_success() throws Exception { // given prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithRedisLockFailFast( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopRetry( TEST_TICKET_PRODUCT_ID, userId ) @@ -96,31 +218,24 @@ void createEventOrder_redisLettuce_failFast() throws Exception { Product product = getTestProduct(); long orderCount = orderRepository.count(); - printResult("Redis FailFast", result, orderCount, product.getStock()); + printResult("Redis AOP Retry", result, orderCount, product.getStock()); assertThat(result.completed()).isTrue(); - - // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 - // 성공은 0 또는 1 정도가 자연스럽다. - assertThat(result.successCount()).isBetween(0, 1); - assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); - - // 주문 수도 성공 수와 같아야 한다. - assertThat(orderCount).isEqualTo(result.successCount()); - - // 재고는 최소 9, 최대 10이 자연스럽다. - assertThat(product.getStock()).isBetween(9, 10); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); } @Test - @DisplayName("Redis 분산락 Retry 전략 - 성능 및 정합성 테스트") - void createEventOrder_redisLettuce_retry_success() throws Exception { + @DisplayName("Redis Lettuce 분산락 - AOP 버전 - Blocking 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_blocking_success() throws Exception { // given prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithRedisLockRetry( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopBlocking( TEST_TICKET_PRODUCT_ID, userId ) @@ -130,8 +245,7 @@ void createEventOrder_redisLettuce_retry_success() throws Exception { Product product = getTestProduct(); long orderCount = orderRepository.count(); - printResult("Redis Retry", result, orderCount, product.getStock()); - + printResult("Redis AOP Blocking", result, orderCount, product.getStock()); assertThat(result.completed()).isTrue(); assertThat(result.successCount()).isEqualTo(10); assertThat(result.failCount()).isEqualTo(90); @@ -140,14 +254,14 @@ void createEventOrder_redisLettuce_retry_success() throws Exception { } @Test - @DisplayName("Redis 분산락 Blocking 전략 - 성능 및 정합성 테스트") - void createEventOrder_redisLettuce_blocking_success() throws Exception { + @DisplayName("Redisson 분산락 - AOP - Retry - TTL - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_retry_ttl() throws Exception { // given prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithRedisLockBlocking( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopRetry( TEST_TICKET_PRODUCT_ID, userId ) @@ -157,7 +271,7 @@ void createEventOrder_redisLettuce_blocking_success() throws Exception { Product product = getTestProduct(); long orderCount = orderRepository.count(); - printResult("Redis Blocking", result, orderCount, product.getStock()); + printResult("Redisson AOP", result, orderCount, product.getStock()); assertThat(result.completed()).isTrue(); assertThat(result.successCount()).isEqualTo(10); @@ -167,14 +281,14 @@ void createEventOrder_redisLettuce_blocking_success() throws Exception { } @Test - @DisplayName("Redis 분산락 - AOP 버전 - FailFast 전략 - 성능 및 정합성 테스트") - void createEventOrder_redisLettuce_aop_failFast() throws Exception { + @DisplayName("Redisson 분산락 - AOP - Blocking - TTL - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_blocking_ttl() throws Exception { // given prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithRedisLockAopFailFast( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopBlocking( TEST_TICKET_PRODUCT_ID, userId ) @@ -184,31 +298,24 @@ void createEventOrder_redisLettuce_aop_failFast() throws Exception { Product product = getTestProduct(); long orderCount = orderRepository.count(); - printResult("Redis AOP FailFast", result, orderCount, product.getStock()); + printResult("Redisson AOP", result, orderCount, product.getStock()); assertThat(result.completed()).isTrue(); - - // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 - // 성공은 0 또는 1 정도가 자연스럽다. - assertThat(result.successCount()).isBetween(0, 1); - assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); - - // 주문 수도 성공 수와 같아야 한다. - assertThat(orderCount).isEqualTo(result.successCount()); - - // 재고는 최소 9, 최대 10이 자연스럽다. - assertThat(product.getStock()).isBetween(9, 10); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); } @Test - @DisplayName("Redis 분산락 - AOP 버전 - Retry 전략 - 성능 및 정합성 테스트") - void createEventOrder_redisLettuce_aop_retry_success() throws Exception { + @DisplayName("Redisson 분산락 - AOP - Retry - Watchdog - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_retry_watchdog() throws Exception { // given prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithRedisLockAopRetry( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopRetryWatchdog( TEST_TICKET_PRODUCT_ID, userId ) @@ -218,7 +325,7 @@ void createEventOrder_redisLettuce_aop_retry_success() throws Exception { Product product = getTestProduct(); long orderCount = orderRepository.count(); - printResult("Redis AOP Retry", result, orderCount, product.getStock()); + printResult("Redisson Watchdog AOP", result, orderCount, product.getStock()); assertThat(result.completed()).isTrue(); assertThat(result.successCount()).isEqualTo(10); @@ -228,14 +335,14 @@ void createEventOrder_redisLettuce_aop_retry_success() throws Exception { } @Test - @DisplayName("Redis 분산락 - AOP 버전 - Blocking 전략 - 성능 및 정합성 테스트") - void createEventOrder_redisLettuce_aop_blocking_success() throws Exception { + @DisplayName("Redisson 분산락 - AOP - Blocking - Watchdog - 성능 및 정합성 테스트") + void createEventOrder_redisson_aop_blocking_watchdog() throws Exception { // given prepareTestData(TEST_TICKET_STOCK); // when ConcurrencyTestResult result = runConcurrencyTest( - userId -> eventOrderFacade.createEventOrderWithRedisLockAopBlocking( + userId -> eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog( TEST_TICKET_PRODUCT_ID, userId ) @@ -245,7 +352,8 @@ void createEventOrder_redisLettuce_aop_blocking_success() throws Exception { Product product = getTestProduct(); long orderCount = orderRepository.count(); - printResult("Redis AOP Blocking", result, orderCount, product.getStock()); + printResult("Redisson Watchdog AOP", result, orderCount, product.getStock()); + assertThat(result.completed()).isTrue(); assertThat(result.successCount()).isEqualTo(10); assertThat(result.failCount()).isEqualTo(90); From 79aca209e7d904a9c6e973d09150194e2cd03586 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 19:23:34 +0900 Subject: [PATCH 11/15] =?UTF-8?q?Chore=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/service/EventOrderService.java | 8 - .../order/service/EventOrderServiceImpl.java | 114 ------- .../EventOrderFacadeConcurrencyTest.java | 310 +++++++++--------- 3 files changed, 155 insertions(+), 277 deletions(-) delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java deleted file mode 100644 index 15b9fca..0000000 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderService.java +++ /dev/null @@ -1,8 +0,0 @@ -package jpa.basic.alldayprojectcommerce.domain.order.service; - -import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; - -public interface EventOrderService { - EventOrderResponse createEventOrder(Long productId, Long userId); - -} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java deleted file mode 100644 index 2c21588..0000000 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/EventOrderServiceImpl.java +++ /dev/null @@ -1,114 +0,0 @@ -package jpa.basic.alldayprojectcommerce.domain.order.service; - -import jpa.basic.alldayprojectcommerce.common.exception.CustomException; -import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; -import jpa.basic.alldayprojectcommerce.common.util.IdFactory; -import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse; -import jpa.basic.alldayprojectcommerce.domain.order.entity.Order; -import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderProduct; -import jpa.basic.alldayprojectcommerce.domain.order.entity.OrderStatus; -import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderProductRepository; -import jpa.basic.alldayprojectcommerce.domain.order.repository.OrderRepository; -import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; -import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; -import jpa.basic.alldayprojectcommerce.domain.product.service.ProductCommandService; -import jpa.basic.alldayprojectcommerce.domain.product.service.ProductQueryService; -import jpa.basic.alldayprojectcommerce.domain.user.entity.User; -import jpa.basic.alldayprojectcommerce.domain.user.service.UserQueryService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class EventOrderServiceImpl implements EventOrderService { - - private final OrderRepository orderRepository; - private final OrderProductRepository orderProductRepository; - private final ProductQueryService productQueryService; - private final UserQueryService userQueryService; - private final ProductCommandService productCommandService; - private final OrderCommandService orderCommandService; - - /* - 동시성 확인을 위한 이벤트 티켓 무료 나눔 메서드 - 유저 검증 - 재고 검증 후 차감 - 주문 완료 상태로 저장(COMPLETED), 결제가 없으니 PENDING 없음 - 주문 상품 스냅샷 저장 - 주문 유저 스냅샷 저장 - */ - @Override - public EventOrderResponse createEventOrder(Long productId, Long userId) { - - // 이벤트 아이템은 항상 유저 한명당 1개만 가능 - int quantity = 1; - - // 유저 검증 - // 유저 정보를 사전에 미리 등록해놨어어야 주문 가능하도록 설계 - User user = userQueryService.getById(userId); - if (!user.hasRequiredInfo()) { - throw new CustomException(ErrorCode.USER_ORDERER_INFO_REQUIRED); - } - - // 상품 존재 검증 - Product product = productQueryService.getByProductId(productId); - - // 유저 한 명당 이벤트 상품은 하나만 구매 가능. - // 이 상품에 대해서 주문 상태가 COMPLETED인 주문이 있는지 검증 - if(orderProductRepository.existsCompletedEventOrder(productId,userId, OrderStatus.COMPLETED)){ - throw new CustomException(ErrorCode.EVENT_ORDER_ALREADY_EXISTS); - - } - - // 판매 중인 상품인지 확인 - if (product.getStatus() != ProductStatus.ON_SALE) { - throw new CustomException(ErrorCode.PRODUCT_NOT_ON_SALE); - } - - // 재고 확인 - if (product.getStock() < quantity) { - throw new CustomException(ErrorCode.PRODUCT_OUT_OF_STOCK); - } - - - // orderUid 생성 - String orderUid = IdFactory.generateWithDate("ORD", 8); - - - // Order 저장 - orderId 발급 - Order order = Order.builder() - .userId(userId) - .orderUid(orderUid) - .totalAmount(product.getPrice()) - .status(OrderStatus.COMPLETED) - .build(); - - Order savedOrder = orderRepository.save(order); - - // TODO : 재고 차감 락 적용 - productCommandService.decreaseStock(productId, quantity, savedOrder.getId()); - - // OrderItem 저장 - 스냅샷 - orderProductRepository.save(OrderProduct.builder() - .orderId(savedOrder.getId()) - .productId(product.getId()) - .productName(product.getName()) - .productPrice(product.getPrice()) - .quantity(quantity) - .build()); - - // OrderUser 저장 - 스냅샷 - orderCommandService.saveOrderUser(savedOrder.getId(), user.getName(), user.getPhone(), user.getAddress()); - - - log.info("[이벤트 주문 생성] userId: {}, orderUid: {}, productId: {}", - userId, orderUid, product.getId()); - - return EventOrderResponse.from(orderUid, savedOrder.getStatus()); - - } -} diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java index 50ea80d..fa0d426 100644 --- a/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java +++ b/src/test/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacadeConcurrencyTest.java @@ -44,161 +44,161 @@ void tearDown() { clearOrders(); restoreStock(DEFAULT_TICKET_STOCK); } -// -// @Test -// @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") -// void createEventOrder_withoutLock_fail() throws Exception { -// // given -// prepareTestData(TEST_TICKET_STOCK); -// -// // when -// ConcurrencyTestResult result = runConcurrencyTest( -// userId -> eventOrderFacade.createEventOrderWithoutLock( -// TEST_TICKET_PRODUCT_ID, -// userId -// ) -// ); -// -// // then -// Product product = getTestProduct(); -// long orderCount = orderRepository.count(); -// -// printResult("WithoutLock", result, orderCount, product.getStock()); -// -// assertThat(result.completed()).isTrue(); -// -// // 락 없는 버전은 정합성이 깨지는 것이 목적 -// // 즉 정상 기대값(10/90/10/0)과 달라야 한다. -// boolean isExactlyCorrect = -// result.successCount() == 10 && -// result.failCount() == 90 && -// orderCount == 10 && -// product.getStock() == 0; -// -// assertThat(isExactlyCorrect).isFalse(); -// } -// -// @Test -// @DisplayName("Redis 분산락 FailFast 전략 - 성능 및 정합성 테스트") -// void createEventOrder_redisLettuce_failFast() throws Exception { -// // given -// prepareTestData(TEST_TICKET_STOCK); -// -// // when -// ConcurrencyTestResult result = runConcurrencyTest( -// userId -> eventOrderFacade.createEventOrderWithRedisLockFailFast( -// TEST_TICKET_PRODUCT_ID, -// userId -// ) -// ); -// -// // then -// Product product = getTestProduct(); -// long orderCount = orderRepository.count(); -// -// printResult("Redis FailFast", result, orderCount, product.getStock()); -// -// assertThat(result.completed()).isTrue(); -// -// // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 -// // 성공은 0 또는 1 정도가 자연스럽다. -// assertThat(result.successCount()).isBetween(0, 1); -// assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); -// -// // 주문 수도 성공 수와 같아야 한다. -// assertThat(orderCount).isEqualTo(result.successCount()); -// -// // 재고는 최소 9, 최대 10이 자연스럽다. -// assertThat(product.getStock()).isBetween(9, 10); -// } -// -// @Test -// @DisplayName("Redis 분산락 Retry 전략 - 성능 및 정합성 테스트") -// void createEventOrder_redisLettuce_retry_success() throws Exception { -// // given -// prepareTestData(TEST_TICKET_STOCK); -// -// // when -// ConcurrencyTestResult result = runConcurrencyTest( -// userId -> eventOrderFacade.createEventOrderWithRedisLockRetry( -// TEST_TICKET_PRODUCT_ID, -// userId -// ) -// ); -// -// // then -// Product product = getTestProduct(); -// long orderCount = orderRepository.count(); -// -// printResult("Redis Retry", result, orderCount, product.getStock()); -// -// assertThat(result.completed()).isTrue(); -// assertThat(result.successCount()).isEqualTo(10); -// assertThat(result.failCount()).isEqualTo(90); -// assertThat(orderCount).isEqualTo(10); -// assertThat(product.getStock()).isEqualTo(0); -// } -// -// @Test -// @DisplayName("Redis 분산락 Blocking 전략 - 성능 및 정합성 테스트") -// void createEventOrder_redisLettuce_blocking_success() throws Exception { -// // given -// prepareTestData(TEST_TICKET_STOCK); -// -// // when -// ConcurrencyTestResult result = runConcurrencyTest( -// userId -> eventOrderFacade.createEventOrderWithRedisLockBlocking( -// TEST_TICKET_PRODUCT_ID, -// userId -// ) -// ); -// -// // then -// Product product = getTestProduct(); -// long orderCount = orderRepository.count(); -// -// printResult("Redis Blocking", result, orderCount, product.getStock()); -// -// assertThat(result.completed()).isTrue(); -// assertThat(result.successCount()).isEqualTo(10); -// assertThat(result.failCount()).isEqualTo(90); -// assertThat(orderCount).isEqualTo(10); -// assertThat(product.getStock()).isEqualTo(0); -// } -// -// @Test -// @DisplayName("Redis 분산락 - AOP 버전 - FailFast 전략 - 성능 및 정합성 테스트") -// void createEventOrder_redisLettuce_aop_failFast() throws Exception { -// // given -// prepareTestData(TEST_TICKET_STOCK); -// -// // when -// ConcurrencyTestResult result = runConcurrencyTest( -// userId -> eventOrderFacade.createEventOrderWithRedisLockAopFailFast( -// TEST_TICKET_PRODUCT_ID, -// userId -// ) -// ); -// -// // then -// Product product = getTestProduct(); -// long orderCount = orderRepository.count(); -// -// printResult("Redis AOP FailFast", result, orderCount, product.getStock()); -// -// assertThat(result.completed()).isTrue(); -// -// // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 -// // 성공은 0 또는 1 정도가 자연스럽다. -// assertThat(result.successCount()).isBetween(0, 1); -// assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); -// -// // 주문 수도 성공 수와 같아야 한다. -// assertThat(orderCount).isEqualTo(result.successCount()); -// -// // 재고는 최소 9, 최대 10이 자연스럽다. -// assertThat(product.getStock()).isBetween(9, 10); -// } + + @Test + @DisplayName("락 없는 주문 생성 - 성능 및 정합성 테스트") + void createEventOrder_withoutLock_fail() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithoutLock( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("WithoutLock", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // 락 없는 버전은 정합성이 깨지는 것이 목적 + // 즉 정상 기대값(10/90/10/0)과 달라야 한다. + boolean isExactlyCorrect = + result.successCount() == 10 && + result.failCount() == 90 && + orderCount == 10 && + product.getStock() == 0; + + assertThat(isExactlyCorrect).isFalse(); + } + + @Test + @DisplayName("Redis 분산락 FailFast 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_failFast() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockFailFast( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis FailFast", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 + // 성공은 0 또는 1 정도가 자연스럽다. + assertThat(result.successCount()).isBetween(0, 1); + assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); + + // 주문 수도 성공 수와 같아야 한다. + assertThat(orderCount).isEqualTo(result.successCount()); + + // 재고는 최소 9, 최대 10이 자연스럽다. + assertThat(product.getStock()).isBetween(9, 10); + } + + @Test + @DisplayName("Redis 분산락 Retry 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_retry_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockRetry( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis Retry", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redis 분산락 Blocking 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_blocking_success() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockBlocking( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis Blocking", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + assertThat(result.successCount()).isEqualTo(10); + assertThat(result.failCount()).isEqualTo(90); + assertThat(orderCount).isEqualTo(10); + assertThat(product.getStock()).isEqualTo(0); + } + + @Test + @DisplayName("Redis 분산락 - AOP 버전 - FailFast 전략 - 성능 및 정합성 테스트") + void createEventOrder_redisLettuce_aop_failFast() throws Exception { + // given + prepareTestData(TEST_TICKET_STOCK); + + // when + ConcurrencyTestResult result = runConcurrencyTest( + userId -> eventOrderFacade.createEventOrderWithRedisLockAopFailFast( + TEST_TICKET_PRODUCT_ID, + userId + ) + ); + + // then + Product product = getTestProduct(); + long orderCount = orderRepository.count(); + + printResult("Redis AOP FailFast", result, orderCount, product.getStock()); + + assertThat(result.completed()).isTrue(); + + // FailFast는 락 획득 실패 시 즉시 종료 전략이므로 + // 성공은 0 또는 1 정도가 자연스럽다. + assertThat(result.successCount()).isBetween(0, 1); + assertThat(result.failCount()).isEqualTo(TOTAL_REQUEST_COUNT - result.successCount()); + + // 주문 수도 성공 수와 같아야 한다. + assertThat(orderCount).isEqualTo(result.successCount()); + + // 재고는 최소 9, 최대 10이 자연스럽다. + assertThat(product.getStock()).isBetween(9, 10); + } @Test @DisplayName("Redis Lettuce 분산락 - AOP 버전 - Retry 전략 - 성능 및 정합성 테스트") From bb073b90fa8d3254ec571c8e4aa94a8e8f7260a5 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 19:29:14 +0900 Subject: [PATCH 12/15] chore: remove .DS_Store --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 2 ++ src/.DS_Store | Bin 6148 -> 0 bytes src/main/.DS_Store | Bin 6148 -> 0 bytes src/main/java/.DS_Store | Bin 6148 -> 0 bytes src/main/java/jpa/.DS_Store | Bin 6148 -> 0 bytes .../jpa/basic/alldayprojectcommerce/.DS_Store | Bin 6148 -> 0 bytes .../basic/alldayprojectcommerce/domain/.DS_Store | Bin 6148 -> 0 bytes .../alldayprojectcommerce/domain/order/.DS_Store | Bin 6148 -> 0 bytes .../domain/order/service/.DS_Store | Bin 6148 -> 0 bytes .../alldayprojectcommerce/domain/user/.DS_Store | Bin 6148 -> 0 bytes src/test/.DS_Store | Bin 6148 -> 0 bytes src/test/java/.DS_Store | Bin 6148 -> 0 bytes src/test/java/jpa/.DS_Store | Bin 6148 -> 0 bytes src/test/java/jpa/basic/.DS_Store | Bin 6148 -> 0 bytes .../jpa/basic/alldayprojectcommerce/.DS_Store | Bin 6148 -> 0 bytes 16 files changed, 2 insertions(+) delete mode 100644 .DS_Store delete mode 100644 src/.DS_Store delete mode 100644 src/main/.DS_Store delete mode 100644 src/main/java/.DS_Store delete mode 100644 src/main/java/jpa/.DS_Store delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/.DS_Store delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/.DS_Store delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/.DS_Store delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/.DS_Store delete mode 100644 src/main/java/jpa/basic/alldayprojectcommerce/domain/user/.DS_Store delete mode 100644 src/test/.DS_Store delete mode 100644 src/test/java/.DS_Store delete mode 100644 src/test/java/jpa/.DS_Store delete mode 100644 src/test/java/jpa/basic/.DS_Store delete mode 100644 src/test/java/jpa/basic/alldayprojectcommerce/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 81229e8d26382bd3469cb3d9bb2de3f1954844ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jqp4=5QS&dB4Cr!avKle4VIuM@B*S@B?yZB9^E%TjnP_yyn&f-XEsBUS7b9H zqQmpN5$Q#wgBxXSVPuMYE)TiO>2iLYjtb*@FJ*K=2U&T%hcRwa*e@u>x3=Er<$CqZN!+^)bZi z-VT<$t|nVB+C_8t(7dzS6a&*}7cEF&S{)2jfC`Khm`C2*`M-mIoBu~GOsN1B_%j7` zvE6S6yi}g8AFpTiLso6w;GkcQ@b(jc#E#+>+ztE17GO=bASy8a2)GOkRN$uyya2`( B5q1Co diff --git a/.gitignore b/.gitignore index a00fec8..48842d7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ out/ .vscode/ .env + +.DS_Store diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 48299fa3419bd1468f9f3c8cb2ae052421e873e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ5EC}5S)cqM50Ma=__ypD+(vz0to~pBp^jX|0>SK(U|=diJqR6h6ZM(_1Nnj zTb|RP@4HXzsv?f&XT0nVPmlZk8yAI(2Zv|@hy%l6 zoJTJ~Y#t!?!ZDE%nkAK(RI3)llFoRmyk0maCLIV4u3`M_T3nZE?Q%=JHxWNd)337ozPzn@@0zo}T=iB2Zo789(eM|Nmd)C_B zU+k;_*zSJb0waJG-HDBdi5c?&7aZ}x@#}p1JYBEfUZgEL;3<8?WIwkBDIf);fE17d zQeZ|3$ccLbDoBkOER*qQJ7}qo4l=`d{<^q(!L| zkOKcq0UP%3`yF2@&(>eB=k<@Q`nu7{xSZj~PXH4?ir?sA+%LW$YqE8+Leq~x$eIl3=DjjOdR@&d^>$!yr&SL|#= zL|4z#Qlt}+DcmRv3q4chW4Xv(_UF^%c(~oJR&pC9tpM+&x1ZYt6`%rCfC^9nDli}g z@*tni2J}pP6e>UkhM|Cc9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*kwzfpO%Wo&Ov7xB0)N-<>&vF{;vNY zl_(Se5%^~WY(DIU4PPqH)|c1w{64cjZ**#G=kW9sz`&2#YTO5g$gr4wHO diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/.DS_Store deleted file mode 100644 index 68c33b59641dd39b0d546b8b182884d7e3a131ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyH3ME5S)VuMM{&B@_vCoI7Q(L_<@92NR|Rdg7ohAZDt=lEGI1mnvM3xx3{w= zPvOl1D4SDv2dn`s>4x~QWo~wEKC`Qg7#6S5-VBd#IH*ASx zpnp2J_y|B;Fzm)>pCyRZ0>qX$1~Nh`K_v#Y+I#M*H-$D{$>x zvU{*BwP-^;ALZ1N`)X?I+RI_td|2Muyo;e(FNYNdG^-&RL_h>)1YUZ)^Yi~if7buc zN)(EK2>dewwjR#IkuQ~J>#x`I{Cj47-ssfW&f(!FfPo*ydwLkxi%+OEwRL5NrXPX8 Kpg{!wl)xR#4iks~ diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/.DS_Store deleted file mode 100644 index 68a811a372383b67d1d49b9dea98ede04976ef51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKOKL(v5Ufsw2yR?vIakOH1~Dh_0unV42ndm6{Z^hUN3;5~h`eN{3qv*Z)J#v; z3{#8OuL0QNbNd9$0Zi$RIQlR)-*+F`T}6yY=NWI^vEme_$X><=7 zrRr^n<-Hu5VqXp|RcpCuG#?swR_kJ5TFXTf0+?nO4kSPVJp$tz&+Yu*z`ynXy%L5b zKmvb8K$~{kuJKZFwtl>x<#&;_d4WTHJBGKP00edvFW_!iPd0((&{7o{7=HvD0|yfL GDS;P?6BCdC diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/.DS_Store b/src/main/java/jpa/basic/alldayprojectcommerce/domain/user/.DS_Store deleted file mode 100644 index 14668ff58e2a258984b6ebf278490e87b582f926..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ5EC}5S%3`B4|=l`U>2@ijos>0Ym~464FBiigd5axi}iLpMvN?7n*2RT93Wn zvE?b=z6D^b&)prc1hAkx;^@QFeBXUyR~0cLooDRvf)5OM$6=EFd%(HZ%zwfgc|Z8i zyZ!cY7+yU=CIzH`6p#W^Knk2qfhw(wo3ov$gQS2IxD*Ba`_Sl)y>Lv7PX~u+0f=*k z!#Iy#g4jGj?1f_@BQ#4YF{xH9h9#ZxR(ZW}OiVhgnh&d+tvVEo+j)MAbXZT+CV4u3`M_T3nZE?Q%=JHxWNd)337ozPzn@@0zo}T=iB2Zo789(eM|Nmd)C_B zU+k;_*zSJb0waJG-HDBdi5c?&7aZ}x@#}p1JYBEfUZgEL;3<8?WIwkBDIf);fE17d zQeZ|3$ccLbDoBkOER*qQJ7}qo4l=`d{<^q(!L| zkOKcq0UP%3`yF2@&(>eB=k<@Q`nu7{xSZj~PXH4?ir?sA+%LW$YqE8+Leq~x$eIl3=DjjOdR@&d^>$!yr&SL|#= zL|4z#Qlt}+DcmRv3q4chW4Xv(_UF^%c(~oJR&pC9tpM+&x1ZYt6`%rCfC^9nDli}g z@*tni2J}pP6e>UkhM|Cc9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*kwzfpO%Wo&Ov7xB0)N-<>&vF{;vNY zl_(Se5%^~WY(DIU4PPqH)|c1w{64cjZ**#G=kW9sz`&2#YTO5g$gr4wHO diff --git a/src/test/java/jpa/basic/.DS_Store b/src/test/java/jpa/basic/.DS_Store deleted file mode 100644 index ab6e84be9840ff10c47be0eb0baf8def4bc4d120..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyH3ME5S)b+k!aE&QQj}`2UZlmfFA&CQ=lLt9tE8%zKc&|_R&O!CK63Fd)k}3 z_0F9sZC z=*ytj%>%@)a86`|W=SO`)vCp?q%+*lKsX+wzsFwk}@IYi*<7(LLvz r?#6XcI7K-oMmgrj%kfntWnS|&pLd0GV$c~6I#E9Zu8T|x{I>!>ti2jO diff --git a/src/test/java/jpa/basic/alldayprojectcommerce/.DS_Store b/src/test/java/jpa/basic/alldayprojectcommerce/.DS_Store deleted file mode 100644 index cf88542fabdd0a22faf8fd6c478235abebd43cb1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJFdb&477m)i6$lGUV$5|5S-v$;5|S{NQfW-eJaky(cbveM1c+(G>*Kp>v(px zQ^c`|=<>E(h%_QHg&WGrqChzIILKE1pyTG7}X7izWvSx>(emeFqo-SGgIZ^>C&{tp_ z%f{;e8vdgH?~}Nq0#x9u6wt})XSKwYvbGK`XSKG#f8mz%fSY0N6bxRDfnJWWuyQ>2 cq{u5a$NQSt1v(vZrvv#jV7kz#z^4`X2e^I}^8f$< From 188545584b6b399acc06402a4024e7d247641212 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 19:32:58 +0900 Subject: [PATCH 13/15] =?UTF-8?q?Chore-=EB=A1=9C=EA=B9=85=20=EB=A3=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index d03c30c..9539735 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -24,7 +24,7 @@ logging: root: info com: example: - alldayproject: debug + alldayprojectcommerce: debug org: hibernate: SQL: debug From 27abb9e5c472da2c66841d5e96a9a13e48258d53 Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 19:39:44 +0900 Subject: [PATCH 14/15] =?UTF-8?q?chore:=20application-test.yml=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index c6d4843..6a4a49e 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -2,12 +2,12 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3307/allday_project_commerce?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: root - password: soyoung7509 + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} hikari: - maximum-pool-size: 30 - minimum-idle: 30 + maximum-pool-size: 10 + minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 From d1d6a401f5b486c6cc0a894808c6c404b219ddec Mon Sep 17 00:00:00 2001 From: soyeong Date: Fri, 24 Apr 2026 19:43:48 +0900 Subject: [PATCH 15/15] Remove secrets --- src/main/resources/application-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 6a4a49e..e492a94 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -36,4 +36,4 @@ logging: # org.hibernate.SQL: debug # org.hibernate.orm.jdbc.bind: trace jwt: - secret-key: dGVzdC10ZXN0LXRlc3QtdGVzdC10ZXN0LXRlc3QtdGVzdC10ZXN0LXRlc3QtdGVzdA== \ No newline at end of file + secret-key: ${JWT_SECRET_KEY} \ No newline at end of file