동시성 문제 해결을 위해 낙관적 락을 적용하던 중 한가지 문제가 생겼다
지금은 상품을 주문할때만 락을 적용하였지만 이 뿐만이아닌 주문취소, 재고변경 등 재고를 수정하는 경우에 충돌이 발생할 수 있기 때문에
이 경우도 재시도를 진행하도록 설정해야한다
문제점
재고 변경
public ProductVariant updateProductStock(Long productVariantId, UpdateProductStockRequest request, Member member) throws CustomException {
ProductVariant productVariant = getProductVariantWithMember(productVariantId);
productVariant.getProduct().getStore().validateOwner(member);
productVariant.changeStock(request.getStockQuantity());
return productVariant;
}
이 부분은 ProductVariantService
에서 상점 주인이 상품의 재고를 변경할때 사용하는 메서드이다
단순히 현재 재고에서 증가, 감소 시키는 것이아닌 원하는 재고값으로 변경할 수 있다
주문
@Override
public Order createOrder(OrderByProductRequest request, Member member, Address address) throws CustomException {
Order order = Order.of(request, member, address);
ProductVariant productVariant = productVariantRepository.findByIdWithStore(request.getProductVariantId())
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
productVariant.verifyStatusAndReduceStock(request.getQuantity());
OrderItem orderItem = orderItemFactory.createOrderItem(order, productVariant, member, request.getQuantity(), request.getCouponId());
order.addOrderItem(orderItem);
return order;
}
@Override
public Order createOrder(OrderByCartRequest request, Member member, Address address) throws CustomException {
Order order = Order.of(request, member, address);
for (OrderByCartRequest.CartItemRequest cartItemRequest : request.getCartItems()) {
CartItem cartItem = cartItemRepository.findByIdWithStore(cartItemRequest.getCartItemId())
.orElseThrow(() -> new CustomException(ErrorCode.CART_ITEM_NOT_FOUND));
cartItem.getProductVariant().verifyStatusAndReduceStock(cartItem.getQuantity());
OrderItem orderItem = orderItemFactory.createOrderItem(order, cartItem.getProductVariant(), member, cartItem.getQuantity(), cartItemRequest.getCouponId());
order.addOrderItem(orderItem);
cartItemRepository.delete(cartItem);
}
return order;
}
이 부분은 전략 패턴을 사용하여 주문을 생성하는 코드이다
여기서 직접 productVariant에 접근해서 재고를 감소시키고있고
public static OrderItem of(Order order, ProductVariant productVariant, MemberCoupon memberCoupon, int quantity, int discountPrice) {
productVariant.increaseSalesCount(quantity);
return new OrderItem(order,
productVariant,
memberCoupon,
productVariant.getPrice(),
quantity,
discountPrice,
productVariant.getPrice() * quantity - discountPrice
);
}
주문 상품을 생성하는 정적 팩토리 메서드에서 판매량을 증가시키고있다
주문 취소
public Order cancelOrder(Long orderId, Member member) throws CustomException {
Order order = orderRepository.findByIdWithMemberAndAddress(orderId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
order.validateOrderOwner(member);
order.cancel();
return order;
}
public OrderItem cancelOrderItem(Long orderItemId, Member member) throws CustomException {
OrderItem orderItem = orderItemRepository.findById(orderItemId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
orderItem.getOrder().validateOrderOwner(member);
orderItem.cancel();
return orderItem;
}
이 부분은 OrderService
에서 주문전체 취소와 주문상품 개별 취소하는 메서드이다
public void cancel() throws CustomException {
this.delivery.cancel();
this.productVariant.increaseSalesCount(quantity);
this.productVariant.addStock(quantity);
this.status = OrderItemStatus.CANCEL;
}
기존에 orderItem에서 직접 productVariant 판매량과 재고를 원상복구 시키고있다
지금 이렇게 각각의 책임이 분산되어있고 락이 걸려 재시도를 할때 상품에 대한 트랜잭션을 분리시키지 못하고있다
무슨 생각으로 이렇게 코드를 작성한지 모르겠다;;;;
이 부분을 고쳐보겠다
해결
책임 분리
먼저 상품재고과 판매량에대한 책임을 ProductVariantService로 옮겨주었다
public ProductVariant reduceStockForOrder(Long productVariantId, int quantity) throws CustomException {
ProductVariant productVariant = getProductVariantWithMember(productVariantId);
productVariant.verifyStatusAndReduceStock(quantity);
productVariant.increaseSalesCount(quantity);
return productVariant;
}
public ProductVariant addStockForCancelOrder(Long productVariantId, int quantity) throws CustomException {
ProductVariant productVariant = getProductVariantWithMember(productVariantId);
productVariant.addStock(quantity);
productVariant.decreaseSalesCount(quantity);
return productVariant;
}
일단 이렇게 주문할 때와 주문을 취소하는 상황에 따라 분리시켜주고
주문 로직 수정
@Component
@RequiredArgsConstructor
public class CartOrderStrategy implements OrderStrategy<OrderByCartRequest>{
private final CartItemRepository cartItemRepository;
private final OrderItemFactory orderItemFactory;
private final ProductVariantService productVariantService;
@Override
public Order createOrder(OrderByCartRequest request, Member member, Address address) throws CustomException {
Order order = Order.of(request, member, address);
for (OrderByCartRequest.CartItemRequest cartItemRequest : request.getCartItems()) {
CartItem cartItem = cartItemRepository.findByIdWithStore(cartItemRequest.getCartItemId())
.orElseThrow(() -> new CustomException(ErrorCode.CART_ITEM_NOT_FOUND));
ProductVariant productVariant = productVariantService.reduceStockForOrder(cartItem.getProductVariant().getId(), cartItem.getQuantity());
OrderItem orderItem = orderItemFactory.createOrderItem(order, productVariant, member, cartItem.getQuantity(), cartItemRequest.getCouponId());
order.addOrderItem(orderItem);
cartItemRepository.delete(cartItem);
}
return order;
}
}
@Component
@RequiredArgsConstructor
public class ProductOrderStrategy implements OrderStrategy<OrderByProductRequest> {
private final ProductVariantService productVariantService;
private final OrderItemFactory orderItemFactory;
@Override
public Order createOrder(OrderByProductRequest request, Member member, Address address) throws CustomException {
Order order = Order.of(request, member, address);
ProductVariant productVariant = productVariantService.reduceStockForOrder(request.getProductVariantId(), request.getQuantity());
OrderItem orderItem = orderItemFactory.createOrderItem(order, productVariant, member, request.getQuantity(), request.getCouponId());
order.addOrderItem(orderItem);
return order;
}
}
각각의 전략에서 상품에 대한 재고감소와 판매량증가를 ProductVariantService를 통해 진행하도록 수정하였다
주문 취소 로직 수정
@Transactional
public Order cancelOrder(Long orderId, Member member) throws CustomException {
Order order = orderRepository.findByIdWithMemberAndAddress(orderId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
order.validateOrderOwner(member);
order.cancel();
List<OrderItem> orderItems = orderItemRepository.findByOrderId(orderId);
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
productVariantService.addStockForCancelOrder(orderItem.getProductVariant().getId(), orderItem.getQuantity());
}
return order;
}
@Transactional
public OrderItem cancelOrderItem(Long orderItemId, Member member) throws CustomException {
OrderItem orderItem = orderItemRepository.findById(orderItemId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
orderItem.getOrder().validateOrderOwner(member);
orderItem.cancel();
productVariantService.addStockForCancelOrder(orderItem.getProductVariant().getId(), orderItem.getQuantity());
return orderItem;
}
주문을 취소하는 경우도 ProductVariantService에서 처리하도록 수정하였다
낙관적 락 재시도 수정
기존에 재시도를 할때의 문제점이 상품에 대한 락 예외가 발생하였을 경우
상품에 대한 부분만 재시도를 하는것이 아닌 전체 주문 생성부터 시작해 재시도를 하게된다
이제 책임을 분리하였으니 상품 재고 변경, 증가, 감소 (판매량도 포함) 상품에 대한 부분만 재시도를 하도록 바꿔줄 수 있다
@Retryable(
retryFor = {
ObjectOptimisticLockingFailureException.class,
},
maxAttempts = 3,
backoff = @Backoff(delay = 300)
)
@LogAction("상품 재고 변경")
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ProductVariant updateProductStock(Long productVariantId, UpdateProductStockRequest request, Member member) throws CustomException {
ProductVariant productVariant = getProductVariantWithMember(productVariantId);
productVariant.getProduct().getStore().validateOwner(member);
productVariant.changeStock(request.getStockQuantity());
return productVariant;
}
@Recover // updateProductStock()
public ProductVariant recoverUpdateProductStock(Exception e, Long productVariantId, UpdateProductStockRequest request, Member member) throws CustomException {
throw new CustomException(ErrorCode.PLEASE_RETRY);
}
@Retryable(
retryFor = {
ObjectOptimisticLockingFailureException.class,
},
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ProductVariant reduceStockForOrder(Long productVariantId, int quantity) throws CustomException {
ProductVariant productVariant = getProductVariantWithMember(productVariantId);
productVariant.verifyStatusAndReduceStock(quantity);
productVariant.increaseSalesCount(quantity);
return productVariant;
}
@Recover // reduceStockForOrder()
public ProductVariant recoverReduceStockForOrder(Exception e, Long productVariantId, int quantity) throws CustomException {
throw new CustomException(ErrorCode.PLEASE_RETRY);
}
@Retryable(
retryFor = {
ObjectOptimisticLockingFailureException.class,
},
maxAttempts = 3,
backoff = @Backoff(delay = 300)
)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ProductVariant addStockForCancelOrder(Long productVariantId, int quantity) throws CustomException {
ProductVariant productVariant = getProductVariantWithMember(productVariantId);
productVariant.addStock(quantity);
productVariant.decreaseSalesCount(quantity);
return productVariant;
}
@Recover // addStockForCancalOrder()
public ProductVariant recoverAddStockForCancelOrder(Exception e, Long productVariantId, int quantity) throws CustomException {
throw new CustomException(ErrorCode.PLEASE_RETRY);
}
이렇게 바꿔줄수있다 @Retryable과 @Recover가 반복되어 불편하여 AOP를 적용하여 바꿔줄수있을거같은데 아직 잘모르겠다
일단 이정도까지만 해놓고 나중에 방법을 찾아서 개선해봐야겠다
Jmeter 테스트
이전에 했던대로 Jmeter를 사용하여 테스트해본다

다 실패하였다 뭐지... 로그를 살펴보았다

HikariPool-1 - Connection is not available 예외가 발생하였는데
사용가능한 커넥션을 모두 사용중이여서 기다리다 타임아웃이 발생하였다고 한다
설정에서 커넥션 풀의 사이즈를 늘리거나 대기시간을 늘리면 되겠지만
일단 Jemter의 동시 스레드 수를 5개로 줄여서 다시 시도해본다

5개 요청중 3개 성공 2개 실패라는 결과가나왔다

실패시 예외 응답도 정상적으로 응답되고

DB에서도 판매량이 3개로 증가하였고 재고도 3개가 정상적으로 줄어들었다
그리고 이제 더이상 db에서 데드락이 걸리지않는다 재시도시 처음부터가 아닌 상품관련 업데이트만 수행해서 그런것 같다