From a924bbefa3b303cb78c3b424a967063dcd73564f Mon Sep 17 00:00:00 2001 From: soyeong Date: Wed, 22 Apr 2026 18:02:08 +0900 Subject: [PATCH 01/19] =?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/19] 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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] 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/19] =?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/19] =?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/19] 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 From e2c93d31978397c477a4e6b872022f4d13797f60 Mon Sep 17 00:00:00 2001 From: soyeong Date: Mon, 27 Apr 2026 12:12:28 +0900 Subject: [PATCH 16/19] =?UTF-8?q?Test:=20=EB=B6=84=EC=82=B0=EB=9D=BD=20k6?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=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 --- k6/event-order-load-test.js | 247 ++++++++++++++++++ replacements.txt | 1 + .../application/EventOrderFacade.java | 16 +- .../security/config/SecurityConfig.java | 3 +- .../controller/EventOrderController.java | 2 +- 5 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 k6/event-order-load-test.js create mode 100644 replacements.txt diff --git a/k6/event-order-load-test.js b/k6/event-order-load-test.js new file mode 100644 index 0000000..cde2574 --- /dev/null +++ b/k6/event-order-load-test.js @@ -0,0 +1,247 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +export const options = { + scenarios: { + event_order_test: { + executor: 'per-vu-iterations', + vus: Number(__ENV.VUS || '1000'), + iterations: 1, + maxDuration: '3m', + }, + }, + thresholds: { + http_req_failed: ['rate<0.30'], + + /** + * 전체 요청 p95 + * 성공/실패를 모두 포함한 사용자 체감 기준 + */ + order_duration_ms: ['p(95)<5000'], + + /** + * 성공 요청 p95 + * 실제 주문에 성공한 사용자 기준 응답 시간 + */ + order_success_duration_ms: ['p(95)<5000'], + + /** + * 실패 요청 p95 + * 품절/락 실패 등 실패 응답을 받은 사용자 기준 응답 시간 + */ + order_fail_duration_ms: ['p(95)<5000'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://app:8090'; +const PRODUCT_ID = __ENV.PRODUCT_ID || '4'; +const START_USER_ID = Number(__ENV.START_USER_ID || '1'); +const SLOW_LIMIT_MS = Number(__ENV.SLOW_LIMIT_MS || '5000'); + +/** + * Counter = "누적 카운트" + * + * add(1) 할 때마다 +1씩 증가 + * 최종 결과에 총합이 출력됨 + */ + +const orderSuccess = new Counter('order_success_count'); // 성공 주문 개수 +const orderFail = new Counter('order_fail_count'); // 실패 주문 개수 +const expectedFail = new Counter('order_expected_fail_count'); // 예상 가능한 실패 (품절 등) + +/** + * 성능 관련 카운트 + */ +const slowRequest = new Counter('slow_request_over_5s_count'); // 5초 초과 요청 개수 +const successSlowRequest = new Counter('success_slow_request_over_5s_count'); // 성공 요청 중 5초 초과 개수 +const failSlowRequest = new Counter('fail_slow_request_over_5s_count'); // 실패 요청 중 5초 초과 개수 + +const httpConnectionFail = new Counter('http_connection_fail_count'); // 서버 연결 실패 +const httpTimeout = new Counter('http_timeout_count'); // timeout 발생 개수 + +/** + * 실패 원인별 카운트 (분석용) + */ +const lockFail = new Counter('lock_fail_count'); // 락 획득 실패 +const userError = new Counter('user_error_count'); // 유저 관련 오류 +const soldOut = new Counter('sold_out_count'); // 재고 부족 +const duplicateOrder = new Counter('duplicate_order_count'); // 중복 주문 +const unknownFail = new Counter('unknown_fail_count'); // 분류 안 된 실패 + +/** + * Rate = "비율 계산" + * + * add(true) → 성공 + * add(false) → 실패 + * + * 최종 결과: + * order_success_rate = 성공 비율 + */ +const orderSuccessRate = new Rate('order_success_rate'); // 주문 성공률 + +/** + * Trend = "값의 분포 통계" + * + * add(숫자)로 값을 계속 넣으면 + * avg / min / max / p90 / p95 등이 계산됨 + */ +const orderDuration = new Trend('order_duration_ms'); // 요청 전체 수행 시간 + +/** + * 성공 요청만 따로 저장하는 Trend + * + * 전체 p95가 높을 때, + * 실제 주문 성공자의 응답이 느린지 확인하기 위한 지표. + */ +const orderSuccessDuration = new Trend('order_success_duration_ms'); + +/** + * 실패 요청만 따로 저장하는 Trend + * + * 실패 응답이 빨리 내려오는지 확인하기 위한 지표. + * 티켓팅에서는 실패 900건이 정상일 수 있으므로 중요함. + */ +const orderFailDuration = new Trend('order_fail_duration_ms'); + +export default function () { + const userId = START_USER_ID + __VU - 1; + + const url = `${BASE_URL}/api/events/products/${PRODUCT_ID}/orders?userId=${userId}`; + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + timeout: '15s', + }; + + const startedAt = Date.now(); + const res = http.post(url, null, params); + const durationMs = Date.now() - startedAt; + + /** + * 전체 요청 시간 기록 + */ + orderDuration.add(durationMs); + + const body = parseJson(res.body); + const code = body?.code || ''; + const message = body?.data?.message || body?.message || ''; + + const isSuccess = res.status === 200 || res.status === 201; + const isExpectedFail = [400, 409, 423, 429].includes(res.status); + + if (durationMs > SLOW_LIMIT_MS) { + slowRequest.add(1); + } + + /** + * status=0은 서버에서 HTTP 응답을 받은 게 아님. + * connection refused, timeout, network error 등이 여기에 해당. + */ + if (res.status === 0) { + const error = String(res.error || '').toLowerCase(); + + /** + * 서버 응답을 못 받은 요청도 실패 요청 시간으로 기록한다. + * timeout이 p95를 얼마나 끌어올리는지 확인 가능. + */ + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + if (error.includes('connection refused')) { + httpConnectionFail.add(1); + } else if (error.includes('timeout')) { + httpTimeout.add(1); + } else { + unknownFail.add(1); + } + + orderFail.add(1); + orderSuccessRate.add(false); + return; + } + + if (isSuccess) { + orderSuccess.add(1); + orderSuccessRate.add(true); + + /** + * 성공 요청만 따로 p95를 계산하기 위해 기록 + */ + orderSuccessDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + successSlowRequest.add(1); + } + } else { + orderFail.add(1); + orderSuccessRate.add(false); + + /** + * 실패 요청만 따로 p95를 계산하기 위해 기록 + */ + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + if (isExpectedFail) { + expectedFail.add(1); + } + + classifyError(code, message); + } + + check(res, { + '성공 또는 예상 가능한 실패': () => isSuccess || isExpectedFail, + '500번대 서버 에러 없음': () => res.status < 500, + 'HTTP 연결 실패 아님': () => res.status !== 0, + '5초 이하 응답': () => durationMs <= SLOW_LIMIT_MS, + }); +} + +function classifyError(code, message) { + if (code === 'L001') { + lockFail.add(1); + return; + } + + if (code.startsWith('U')) { + userError.add(1); + return; + } + + if ( + message.includes('재고') || + message.includes('품절') || + message.toLowerCase().includes('sold') + ) { + soldOut.add(1); + return; + } + + if ( + message.includes('중복') || + message.includes('이미') || + message.toLowerCase().includes('duplicate') + ) { + duplicateOrder.add(1); + return; + } + + unknownFail.add(1); +} + +function parseJson(body) { + try { + return JSON.parse(body); + } catch (e) { + return null; + } +} \ No newline at end of file diff --git a/replacements.txt b/replacements.txt new file mode 100644 index 0000000..bd41257 --- /dev/null +++ b/replacements.txt @@ -0,0 +1 @@ +soyount7509==>REMOVED_SECRET diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java index 1abab28..638c7f9 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -27,7 +27,7 @@ public EventOrderResponse createEventOrderWithoutLock(Long productId, Long userI } /** - * Redis 분산락 - Fail Fast 전략 적용 버전 + * Redis Lettuce 분산락 - Fail Fast 전략 적용 버전 * * 상품 기준으로 락을 걸어 * 동일 상품에 대한 동시 주문을 직렬화한다. @@ -51,7 +51,7 @@ public EventOrderResponse createEventOrderWithRedisLockFailFast(Long productId, } /** - * Redis 분산락 - Retry 전략 적용 버전 + * Redis Lettuce 분산락 - Retry 전략 적용 버전 * * 상품 기준으로 락을 걸어 * 동일 상품에 대한 동시 주문을 직렬화한다. @@ -71,7 +71,7 @@ public EventOrderResponse createEventOrderWithRedisLockRetry(Long productId, Lon } /** - * Redis 분산락 - Blocking 전략 적용 버전 + * Redis Lettuce 분산락 - Blocking 전략 적용 버전 * * 상품 기준으로 락을 걸어 * 동일 상품에 대한 동시 주문을 직렬화한다. @@ -91,7 +91,7 @@ public EventOrderResponse createEventOrderWithRedisLockBlocking(Long productId, } /** - * Redis 분산락 - AOP FailFast 전략 적용 버전 + * Redis Lettuce 분산락 - AOP FailFast 전략 적용 버전 */ @RedisLock( key = "'lock:product:' + #productId", @@ -103,7 +103,7 @@ public EventOrderResponse createEventOrderWithRedisLockAopFailFast(Long productI } /** - * Redis 분산락 - AOP Retry 전략 적용 버전 + * Redis Lettuce 분산락 - AOP Retry 전략 적용 버전 */ @RedisLock( key = "'lock:product:' + #productId", @@ -115,7 +115,7 @@ public EventOrderResponse createEventOrderWithRedisLockAopRetry(Long productId, } /** - * Redis 분산락 - AOP Blocking 전략 적용 버전 + * Redis Lettuce 분산락 - AOP Blocking 전략 적용 버전 */ @RedisLock( key = "'lock:product:' + #productId", @@ -141,7 +141,7 @@ public EventOrderResponse createEventOrderWithRedisLockAopBlocking(Long productI */ @RedissonLock( key = "'lock:product:' + #productId", - waitTimeSeconds = 2, + waitTimeSeconds = 1, leaseTimeSeconds = 5 ) public EventOrderResponse createEventOrderWithRedissonLockAopRetry(Long productId, Long userId) { @@ -192,7 +192,7 @@ public EventOrderResponse createEventOrderWithRedissonLockAopRetryWatchdog(Long */ @RedissonLock( key = "'lock:product:' + #productId", - waitTimeSeconds = 10, + waitTimeSeconds = 3, leaseTimeSeconds = -1 ) public EventOrderResponse createEventOrderWithRedissonLockAopBlockingWatchdog(Long productId, Long userId) { 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 3cc0683..647aa41 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 @@ -38,7 +38,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/auth/login", "/api/auth/signup", "/api/auth/reissue", - "/api/keywords/search" + "/api/keywords/search", + "/api/events/products/*/orders" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/auth/check-duplicate", 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 index 7aea47d..efbcf77 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java @@ -30,7 +30,7 @@ public ResponseEntity> createEventOrder( @RequestParam Long userId ){ return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success(HttpStatus.CREATED, eventOrderFacade.createEventOrderWithoutLock(productId, userId))); + .body(ApiResponse.success(HttpStatus.CREATED, eventOrderFacade.createEventOrderWithRedissonLockAopRetry(productId, userId))); } } From 9cdb43b80f17338f9d233fa976b49308878a7573 Mon Sep 17 00:00:00 2001 From: soyeong Date: Mon, 27 Apr 2026 22:30:57 +0900 Subject: [PATCH 17/19] =?UTF-8?q?Redis=20=EB=B6=84=EC=82=B0=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/EventOrderFacade.java | 16 +-- .../common/lock/annotation/RedissonLock.java | 22 ++-- .../lock/aspect/RedissonLockAspect.java | 4 +- .../lock/service/RedissonLockService.java | 56 ++-------- .../controller/EventOrderController.java | 100 +++++++++++++++--- .../product/repository/ProductRepository.java | 9 ++ .../service/ProductCommandServiceImpl.java | 4 +- 7 files changed, 123 insertions(+), 88 deletions(-) diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java index 638c7f9..16e620d 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/EventOrderFacade.java @@ -141,8 +141,8 @@ public EventOrderResponse createEventOrderWithRedisLockAopBlocking(Long productI */ @RedissonLock( key = "'lock:product:' + #productId", - waitTimeSeconds = 1, - leaseTimeSeconds = 5 + waitTimeMillis = 300, + leaseTimeMillis = 3000 ) public EventOrderResponse createEventOrderWithRedissonLockAopRetry(Long productId, Long userId) { return eventOrderService.createEventOrder(productId, userId); @@ -154,8 +154,8 @@ public EventOrderResponse createEventOrderWithRedissonLockAopRetry(Long productI */ @RedissonLock( key = "'lock:product:' + #productId", - waitTimeSeconds = 10, - leaseTimeSeconds = 10 + waitTimeMillis = 1000, + leaseTimeMillis = 5000 ) public EventOrderResponse createEventOrderWithRedissonLockAopBlocking(Long productId, Long userId) { return eventOrderService.createEventOrder(productId, userId); @@ -174,8 +174,8 @@ public EventOrderResponse createEventOrderWithRedissonLockAopBlocking(Long produ */ @RedissonLock( key = "'lock:product:' + #productId", - waitTimeSeconds = 2, - leaseTimeSeconds = -1 + waitTimeMillis = 300, + leaseTimeMillis = -1 ) public EventOrderResponse createEventOrderWithRedissonLockAopRetryWatchdog(Long productId, Long userId) { return eventOrderService.createEventOrder(productId, userId); @@ -192,8 +192,8 @@ public EventOrderResponse createEventOrderWithRedissonLockAopRetryWatchdog(Long */ @RedissonLock( key = "'lock:product:' + #productId", - waitTimeSeconds = 3, - leaseTimeSeconds = -1 + waitTimeMillis = 1000, + leaseTimeMillis = -1 ) public EventOrderResponse createEventOrderWithRedissonLockAopBlockingWatchdog(Long productId, Long userId) { return eventOrderService.createEventOrder(productId, userId); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java index fae161a..a6141db 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/annotation/RedissonLock.java @@ -1,28 +1,20 @@ package jpa.basic.alldayprojectcommerce.common.lock.annotation; -import java.lang.annotation.*; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -@Documented public @interface RedissonLock { - /** - * SpEL 기반 락 키 - * 예: "'lock:product:' + #productId" - */ String key(); /** - * 락 획득을 기다릴 최대 시간 + * 🔥 milliseconds 기준으로 변경 */ - long waitTimeSeconds() default 3; + long waitTimeMillis() default 0; - /** - * 락 점유 시간 - * - * - 양수: 지정 시간 후 자동 해제 - * - -1: leaseTime 없이 watchdog 사용 - */ - long leaseTimeSeconds() default 5; + long leaseTimeMillis() default 3000; } \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java index b5bc16a..982ec47 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/aspect/RedissonLockAspect.java @@ -30,8 +30,8 @@ public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) { return redissonLockService.executeWithLock( key, - redissonLock.waitTimeSeconds(), - redissonLock.leaseTimeSeconds(), + redissonLock.waitTimeMillis(), + redissonLock.leaseTimeMillis(), () -> proceed(joinPoint) ); } 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 index b558bb7..dc68337 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/lock/service/RedissonLockService.java @@ -20,56 +20,28 @@ public class RedissonLockService { public T executeWithLock( String key, - long waitTimeSeconds, - long leaseTimeSeconds, + long waitTimeMillis, + long leaseTimeMillis, Supplier supplier ) { - /** - * RLock = Redisson의 분산락 객체 - * - * - key 기준으로 락 생성 - * - 내부적으로 Redis 사용 - */ - RLock lock = redissonClient.getLock(key); + 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) { + + if (leaseTimeMillis < 0) { /** * 🔥 watchdog 모드 - * - * - TTL을 자동으로 계속 연장 - * - 비즈니스 로직이 길어도 안전 - * - * Lettuce에서는 직접 구현 불가능 */ - locked = lock.tryLock(waitTimeSeconds, TimeUnit.SECONDS); + locked = lock.tryLock(waitTimeMillis, TimeUnit.MILLISECONDS); } else { /** * 🔥 일반 TTL 방식 - * - * - leaseTime 지나면 자동 unlock - * - Lettuce의 TTL과 동일 개념 */ - locked = lock.tryLock(waitTimeSeconds, leaseTimeSeconds, TimeUnit.SECONDS); + locked = lock.tryLock(waitTimeMillis, leaseTimeMillis, TimeUnit.MILLISECONDS); } - /** - * 🔥 락 획득 실패 - * - * - waitTime 동안 기다렸는데도 실패하면 여기로 옴 - */ if (!locked) { log.info("[RedissonLock] 락 획득 실패 key={}", key); throw new CustomException(ErrorCode.LOCK_ACQUISITION_FAILED); @@ -77,9 +49,6 @@ public T executeWithLock( log.info("[RedissonLock] 락 획득 성공 key={}", key); - /** - * 🔥 실제 비즈니스 로직 실행 - */ return supplier.get(); } catch (InterruptedException e) { @@ -87,17 +56,6 @@ public T executeWithLock( 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); 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 index efbcf77..d9f3321 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java @@ -12,9 +12,95 @@ @RequiredArgsConstructor @RequestMapping("/api/events") public class EventOrderController { + private final EventOrderFacade eventOrderFacade; - /* + /** + * v1 - 락 없음 + */ + @PostMapping("/v1/products/{productId}/orders") + public ResponseEntity> v1( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithoutLock(productId, userId)); + } + + /** + * v2 - DB 비관락만 + */ + @PostMapping("/v2/products/{productId}/orders") + public ResponseEntity> v2( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithPessimisticLock(productId, userId)); + } + + /** + * v3 - Redisson Retry + */ + @PostMapping("/v3/products/{productId}/orders") + public ResponseEntity> v3( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopRetry(productId, userId)); + } + + /** + * v4 - Retry + Watchdog + */ + @PostMapping("/v4/products/{productId}/orders") + public ResponseEntity> v4( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopRetryWatchdog(productId, userId)); + } + + /** + * v5 - FailFast + */ + @PostMapping("/v5/products/{productId}/orders") + public ResponseEntity> v5( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopFailFast(productId, userId)); + } + + /** + * v6 - Blocking + */ + @PostMapping("/v6/products/{productId}/orders") + public ResponseEntity> v6( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlocking(productId, userId)); + } + + /** + * v7 - Blocking + Watchdog + */ + @PostMapping("/v7/products/{productId}/orders") + public ResponseEntity> v7( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog(productId, userId)); + } + + private ResponseEntity> ok(EventOrderResponse response) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(HttpStatus.CREATED, response)); + } +} + + + +/* 동시성 확인을 위한 이벤트 티켓 무료 나눔 API 모바일 티켓이라고 가정, 배송비 X 이벤트 카테고리에 있는 제품들은 상세 조회를 눌렀을 때 @@ -23,14 +109,4 @@ public class EventOrderController { 장바구니 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.createEventOrderWithRedissonLockAopRetry(productId, userId))); - } - -} + */ \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java index d85cb95..eb72268 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/repository/ProductRepository.java @@ -1,8 +1,17 @@ package jpa.basic.alldayprojectcommerce.domain.product.repository; +import jakarta.persistence.LockModeType; import jpa.basic.alldayprojectcommerce.domain.product.entity.Product; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface ProductRepository extends JpaRepository, ProductRepositoryCustom { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :productId") + Optional findByIdForUpdate(@Param("productId") Long productId); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java index 7b54d7e..7786096 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java @@ -23,7 +23,7 @@ public class ProductCommandServiceImpl implements ProductCommandService { @Override public void decreaseStock(Long productId, int quantity, Long orderId) { // TODO : 재고 증가 메서드인데 일반 조회 사용중. 추후 동시성 문제 해결 부분에서 비관적 락 적용 고려 - Product product = productRepository.findById(productId) + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); product.decreaseStock(quantity); saveStockHistory(product, quantity, orderId); @@ -34,7 +34,7 @@ public void decreaseStock(Long productId, int quantity, Long orderId) { public void increaseStock(Long productId, int quantity, Long orderId) { // TODO : 재고 증가 메서드인데 일반 조회 사용중. 추후 동시성 문제 해결 부분에서 비관적 락 적용 고려 - Product product = productRepository.findById(productId) + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); product.increaseStock(quantity); saveStockHistory(product, -quantity, orderId); From 366e585b48611eaed99b074a6650942e8b6c5c23 Mon Sep 17 00:00:00 2001 From: soyeong Date: Tue, 28 Apr 2026 05:40:22 +0900 Subject: [PATCH 18/19] =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=85=8C=EC=8A=A4=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 --- k6/event-order-version-test.js | 179 ++++++++++++++++++ k6/reset-event-test.sql | 17 ++ run-event-order-compare.sh | 71 +++++++ .../application/EventOrderFacade.java | 83 +++++--- .../controller/EventOrderController.java | 54 ++++-- .../service/event/EventOrderService.java | 1 + .../service/event/EventOrderServiceImpl.java | 56 ++++++ .../service/ProductCommandService.java | 1 + .../service/ProductCommandServiceImpl.java | 25 ++- 9 files changed, 441 insertions(+), 46 deletions(-) create mode 100644 k6/event-order-version-test.js create mode 100644 k6/reset-event-test.sql create mode 100755 run-event-order-compare.sh diff --git a/k6/event-order-version-test.js b/k6/event-order-version-test.js new file mode 100644 index 0000000..29e6561 --- /dev/null +++ b/k6/event-order-version-test.js @@ -0,0 +1,179 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +export const options = { + scenarios: { + event_order_test: { + executor: 'per-vu-iterations', + vus: Number(__ENV.VUS || '100'), + iterations: 1, + maxDuration: '3m', + }, + }, + summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max'], + thresholds: { + order_duration_ms: ['p(95)<5000'], + order_success_duration_ms: ['p(95)<5000'], + order_fail_duration_ms: ['p(95)<5000'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://app:8090'; +const VERSION = __ENV.VERSION || 'v1'; +const PRODUCT_ID = __ENV.PRODUCT_ID || '4'; +const START_USER_ID = Number(__ENV.START_USER_ID || '1'); +const SLOW_LIMIT_MS = Number(__ENV.SLOW_LIMIT_MS || '5000'); + +const orderSuccess = new Counter('order_success_count'); +const orderFail = new Counter('order_fail_count'); +const expectedFail = new Counter('order_expected_fail_count'); + +const lockFail = new Counter('lock_fail_count'); +const userError = new Counter('user_error_count'); +const soldOut = new Counter('sold_out_count'); +const duplicateOrder = new Counter('duplicate_order_count'); +const unknownFail = new Counter('unknown_fail_count'); + +const slowRequest = new Counter('slow_request_over_5s_count'); +const successSlowRequest = new Counter('success_slow_request_over_5s_count'); +const failSlowRequest = new Counter('fail_slow_request_over_5s_count'); + +const httpConnectionFail = new Counter('http_connection_fail_count'); +const httpTimeout = new Counter('http_timeout_count'); + +const orderSuccessRate = new Rate('order_success_rate'); + +const orderDuration = new Trend('order_duration_ms', true); +const orderSuccessDuration = new Trend('order_success_duration_ms', true); +const orderFailDuration = new Trend('order_fail_duration_ms', true); + +export default function () { + const userId = START_USER_ID + __VU - 1; + + const url = `${BASE_URL}/api/events/${VERSION}/products/${PRODUCT_ID}/orders?userId=${userId}`; + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + timeout: '20s', + tags: { + version: VERSION, + }, + }; + + const startedAt = Date.now(); + const res = http.post(url, null, params); + const durationMs = Date.now() - startedAt; + + orderDuration.add(durationMs); + + const body = parseJson(res.body); + const code = body?.code || ''; + const message = body?.data?.message || body?.message || ''; + + const isSuccess = res.status === 200 || res.status === 201; + const isExpectedFail = [400, 409, 423, 429].includes(res.status); + + if (durationMs > SLOW_LIMIT_MS) { + slowRequest.add(1); + } + + if (res.status === 0) { + orderFail.add(1); + orderSuccessRate.add(false); + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + const error = String(res.error || '').toLowerCase(); + + if (error.includes('connection refused')) { + httpConnectionFail.add(1); + } else if (error.includes('timeout')) { + httpTimeout.add(1); + } else { + unknownFail.add(1); + } + + return; + } + + if (isSuccess) { + orderSuccess.add(1); + orderSuccessRate.add(true); + orderSuccessDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + successSlowRequest.add(1); + } + } else { + orderFail.add(1); + orderSuccessRate.add(false); + orderFailDuration.add(durationMs); + + if (durationMs > SLOW_LIMIT_MS) { + failSlowRequest.add(1); + } + + if (isExpectedFail) { + expectedFail.add(1); + } + + classifyError(code, message); + } + + check(res, { + [`${VERSION} 성공 또는 예상 가능한 실패`]: () => isSuccess || isExpectedFail, + [`${VERSION} 500번대 서버 에러 없음`]: () => res.status < 500, + [`${VERSION} HTTP 연결 실패 아님`]: () => res.status !== 0, + [`${VERSION} ${SLOW_LIMIT_MS}ms 이하 응답`]: () => durationMs <= SLOW_LIMIT_MS, + }); +} + +function classifyError(code, message) { + const lowerMessage = String(message || '').toLowerCase(); + + if (code === 'L001' || message.includes('락') || lowerMessage.includes('lock')) { + lockFail.add(1); + return; + } + + if (code.startsWith('U')) { + userError.add(1); + return; + } + + if ( + message.includes('재고') || + message.includes('품절') || + lowerMessage.includes('sold') || + lowerMessage.includes('stock') + ) { + soldOut.add(1); + return; + } + + if ( + message.includes('중복') || + message.includes('이미') || + lowerMessage.includes('duplicate') || + lowerMessage.includes('already') + ) { + duplicateOrder.add(1); + return; + } + + unknownFail.add(1); +} + +function parseJson(body) { + try { + return JSON.parse(body); + } catch (e) { + return null; + } +} \ No newline at end of file diff --git a/k6/reset-event-test.sql b/k6/reset-event-test.sql new file mode 100644 index 0000000..aa607d9 --- /dev/null +++ b/k6/reset-event-test.sql @@ -0,0 +1,17 @@ +SET FOREIGN_KEY_CHECKS = 0; + +TRUNCATE TABLE order_users; +TRUNCATE TABLE order_products; +TRUNCATE TABLE orders; +TRUNCATE TABLE product_stock_logs; + +SET FOREIGN_KEY_CHECKS = 1; + +UPDATE products +SET stock = 100, + status = 'ON_SALE' +WHERE id = ${PRODUCT_ID}; + +SELECT id, name, stock, status +FROM products +WHERE id = ${PRODUCT_ID}; \ No newline at end of file diff --git a/run-event-order-compare.sh b/run-event-order-compare.sh new file mode 100755 index 0000000..34c20ac --- /dev/null +++ b/run-event-order-compare.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -u +VERSIONS=("v1" "v2" "v3" "v4" "v5" "v6" "v7" "v8") + +VUS=${VUS:-200} +PRODUCT_ID=${PRODUCT_ID:-4} +BASE_URL=${BASE_URL:-http://app:8090} +DB_PASSWORD=${DB_PASSWORD:-12345678} + +reset_db() { + echo "DB reset: PRODUCT_ID=${PRODUCT_ID}" + + docker compose exec -T mysql mysql \ + -uroot \ + -p"${DB_PASSWORD}" \ + allday_project_commerce <> v3( } /** - * v4 - Retry + Watchdog + * v4 - Redisson Retry + Watchdog */ @PostMapping("/v4/products/{productId}/orders") public ResponseEntity> v4( @@ -60,7 +80,7 @@ public ResponseEntity> v4( } /** - * v5 - FailFast + * v5 - Redisson + FailFast */ @PostMapping("/v5/products/{productId}/orders") public ResponseEntity> v5( @@ -71,7 +91,7 @@ public ResponseEntity> v5( } /** - * v6 - Blocking + * v6 - Redisson + Blocking */ @PostMapping("/v6/products/{productId}/orders") public ResponseEntity> v6( @@ -82,7 +102,7 @@ public ResponseEntity> v6( } /** - * v7 - Blocking + Watchdog + * v7 - Redisson Blocking + Watchdog */ @PostMapping("/v7/products/{productId}/orders") public ResponseEntity> v7( @@ -92,21 +112,21 @@ public ResponseEntity> v7( return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog(productId, userId)); } + /** + * v8 - Redisson Blocking + Watchdog + 비관락 + */ + @PostMapping("/v8/products/{productId}/orders") + public ResponseEntity> v8( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdogWithPessimisticLock(productId, userId)); + } + + + private ResponseEntity> ok(EventOrderResponse response) { return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.success(HttpStatus.CREATED, response)); } } - - - -/* - 동시성 확인을 위한 이벤트 티켓 무료 나눔 API - 모바일 티켓이라고 가정, 배송비 X - 이벤트 카테고리에 있는 제품들은 상세 조회를 눌렀을 때 - 장바구니 버튼이 없고 주문하기를 눌렀을 때 이 API가 바로 호출되는 것으로 프론트 구성한다고 가정 - 상세 조회에서는 상품 상태(판매중, 품절, 단종)와 상품 재고 확인 가능 - 장바구니 X, 주문서 X, 내 정보 수정 X 모두 거치지 않음 - 주문 성공 시 주문 목록 조회로 리다이렉트 - 주문 실패 시 '주문에 실패했습니다' 문구 띄우고 이벤트 상품 목록 조회로 리다이렉트 - */ \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java index 4293fbc..907d3f8 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderService.java @@ -4,5 +4,6 @@ public interface EventOrderService { EventOrderResponse createEventOrder(Long productId, Long userId); + EventOrderResponse createEventOrderWithPessimisticLock(Long productId, Long userId); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java index c0807a2..c1a54a3 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/service/event/EventOrderServiceImpl.java @@ -106,6 +106,62 @@ public EventOrderResponse createEventOrder(Long productId, Long userId) { 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()); + + } + + @Override + public EventOrderResponse createEventOrderWithPessimisticLock(Long productId, Long userId) { + + // 이벤트 아이템은 항상 유저 한명당 1개만 가능 + int quantity = 1; + + // 유저 검증 + // 유저 정보를 사전에 미리 등록해놨어어야 주문 가능하도록 설계 + User user = userQueryService.getById(userId); + if (!user.hasRequiredInfo()) { + throw new CustomException(ErrorCode.USER_ORDERER_INFO_REQUIRED); + } + + // 유저 한 명당 이벤트 상품은 하나만 구매 가능. + // 이 상품에 대해서 주문 상태가 COMPLETED인 주문이 있는지 검증 + if(orderProductRepository.existsCompletedEventOrder(productId,userId, OrderStatus.COMPLETED)){ + throw new CustomException(ErrorCode.EVENT_ORDER_ALREADY_EXISTS); + + } + + // TODO : 비관적 락 + Product product = productCommandService.decreaseStockWithPessimisticLock(productId, quantity); + + // 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); + + // 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()); diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java index d60a710..f1ad5eb 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandService.java @@ -7,6 +7,7 @@ public interface ProductCommandService { void decreaseStock(Long productId, int quantity, Long orderId); void increaseStock(Long productId, int quantity, Long orderId); void saveStockHistory(Product product, int quantity, Long orderId); + Product decreaseStockWithPessimisticLock(Long productId, int quantity) ; void checkStock(Long productId, int quantity); } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java index 7786096..fb897ab 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/product/service/ProductCommandServiceImpl.java @@ -1,5 +1,6 @@ package jpa.basic.alldayprojectcommerce.domain.product.service; +import jpa.basic.alldayprojectcommerce.domain.product.entity.ProductStatus; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -22,13 +23,33 @@ public class ProductCommandServiceImpl implements ProductCommandService { // 재고를 차감 한다. @Override public void decreaseStock(Long productId, int quantity, Long orderId) { - // TODO : 재고 증가 메서드인데 일반 조회 사용중. 추후 동시성 문제 해결 부분에서 비관적 락 적용 고려 - Product product = productRepository.findByIdForUpdate(productId) + Product product = productRepository.findById(productId) .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + product.decreaseStock(quantity); saveStockHistory(product, quantity, orderId); } + // 재고를 차감 한다. + @Override + public Product decreaseStockWithPessimisticLock(Long productId, int quantity) { + Product product = productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + + 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); + } + + product.decreaseStock(quantity); + // TODO 비동기 방식으로 처리하기. 동시성 문제의 성능 개선을 위함 +// saveStockHistory(product, quantity, orderId); + return product; + } + // 재고를 증가시킨다. @Override public void increaseStock(Long productId, int quantity, Long orderId) { From 4bf3d51b5ffb44adcf48608946e4b74a85d67901 Mon Sep 17 00:00:00 2001 From: soyeong Date: Tue, 28 Apr 2026 06:11:35 +0900 Subject: [PATCH 19/19] =?UTF-8?q?Feat:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=EC=84=A0=ED=83=9D=20-=20=EB=B6=84?= =?UTF-8?q?=EC=82=B0=EB=9D=BD=20Blocking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k6-test-1-1.js => k6/k6-test-1-1.js | 0 k6-test-1-2.js => k6/k6-test-1-2.js | 0 k6-test-1-3.js => k6/k6-test-1-3.js | 0 k6-test-1-4.js => k6/k6-test-1-4.js | 0 k6-test-5-1.js => k6/k6-test-5-1.js | 0 k6-test-5-2.js => k6/k6-test-5-2.js | 0 k6-test-6.js => k6/k6-test-6.js | 0 k6-test-rampup.js => k6/k6-test-rampup.js | 0 k6-test-v1.js => k6/k6-test-v1.js | 0 k6-test-v2.js => k6/k6-test-v2.js | 0 .../application/OrderPaymentFacade.java | 3 ++- .../domain/order/controller/EventOrderController.java | 11 ++++++++++- 12 files changed, 12 insertions(+), 2 deletions(-) rename k6-test-1-1.js => k6/k6-test-1-1.js (100%) rename k6-test-1-2.js => k6/k6-test-1-2.js (100%) rename k6-test-1-3.js => k6/k6-test-1-3.js (100%) rename k6-test-1-4.js => k6/k6-test-1-4.js (100%) rename k6-test-5-1.js => k6/k6-test-5-1.js (100%) rename k6-test-5-2.js => k6/k6-test-5-2.js (100%) rename k6-test-6.js => k6/k6-test-6.js (100%) rename k6-test-rampup.js => k6/k6-test-rampup.js (100%) rename k6-test-v1.js => k6/k6-test-v1.js (100%) rename k6-test-v2.js => k6/k6-test-v2.js (100%) diff --git a/k6-test-1-1.js b/k6/k6-test-1-1.js similarity index 100% rename from k6-test-1-1.js rename to k6/k6-test-1-1.js diff --git a/k6-test-1-2.js b/k6/k6-test-1-2.js similarity index 100% rename from k6-test-1-2.js rename to k6/k6-test-1-2.js diff --git a/k6-test-1-3.js b/k6/k6-test-1-3.js similarity index 100% rename from k6-test-1-3.js rename to k6/k6-test-1-3.js diff --git a/k6-test-1-4.js b/k6/k6-test-1-4.js similarity index 100% rename from k6-test-1-4.js rename to k6/k6-test-1-4.js diff --git a/k6-test-5-1.js b/k6/k6-test-5-1.js similarity index 100% rename from k6-test-5-1.js rename to k6/k6-test-5-1.js diff --git a/k6-test-5-2.js b/k6/k6-test-5-2.js similarity index 100% rename from k6-test-5-2.js rename to k6/k6-test-5-2.js diff --git a/k6-test-6.js b/k6/k6-test-6.js similarity index 100% rename from k6-test-6.js rename to k6/k6-test-6.js diff --git a/k6-test-rampup.js b/k6/k6-test-rampup.js similarity index 100% rename from k6-test-rampup.js rename to k6/k6-test-rampup.js diff --git a/k6-test-v1.js b/k6/k6-test-v1.js similarity index 100% rename from k6-test-v1.js rename to k6/k6-test-v1.js diff --git a/k6-test-v2.js b/k6/k6-test-v2.js similarity index 100% rename from k6-test-v2.js rename to k6/k6-test-v2.js diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/application/OrderPaymentFacade.java b/src/main/java/jpa/basic/alldayprojectcommerce/application/OrderPaymentFacade.java index 86bdcf1..ccfef78 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/application/OrderPaymentFacade.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/application/OrderPaymentFacade.java @@ -58,8 +58,9 @@ public ConfirmPaymentResponse confirmOrderPayment( // 1. 재고 차감 List orderProducts = orderQueryService.getOrderProducts(order.getId()); + // 기본 주문에서는 분산락 적용 X, 비관락만 사용 for (OrderProductInfo orderProduct : orderProducts) { - productCommandService.decreaseStock(orderProduct.productId(), orderProduct.quantity(), order.getId()); + productCommandService.decreaseStockWithPessimisticLock(orderProduct.productId(), orderProduct.quantity()); } // 2. OrderUser 스냅샷 저장 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 index 0dcb64c..2b1283a 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/order/controller/EventOrderController.java @@ -16,7 +16,16 @@ public class EventOrderController { private final EventOrderFacade eventOrderFacade; // 최종 선택 - + /** + * Redisson Blocking + Watchdog + */ + @PostMapping("/products/{productId}/orders") + public ResponseEntity> createEventOrder( + @PathVariable Long productId, + @RequestParam Long userId + ) { + return ok(eventOrderFacade.createEventOrderWithRedissonLockAopBlockingWatchdog(productId, userId)); + }