본문 바로가기
Spring

[Spring] 동시에 상품 주문 시 Deadlock found when trying to get lock 에러

by worldcenter 2025. 1. 1.

 

기존 상품 주문 로직

소비자가 상품을 주문하면 기존 재고 수량에서 주문 수량 만큼을 뺀 수량을 재고 수량으로 업데이트 합니다. 

이 때 우려되는 상황은 동시에 여러 명의 소비자가 동일한 상품을 주문할 경우 재고 수량에 대한 데이터 정합성이 제대로 유지될까 입니다. 

이를 확인하기 위해 동시성 테스트를 진행했습니다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final B2CMemberRepository b2cMemberRepository;

    @Transactional
    public OrderResponse createOrder(Long memberId, OrderRequest orderRequest) {

        B2CMember b2CMember = b2cMemberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));

        Product product = productRepository.findById(orderRequest.productId())
                .orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));

        // 상품의 수량이 주문 수량의 이상인지 검증
        int quantity = orderRequest.quantity();
        if (quantity > product.getStockQuantity() && product.getStockQuantity() <= 0) {
            throw new IllegalArgumentException("재고 수량이 부족합니다.");
        }

        int updatedQuantity = product.getStockQuantity() - quantity;
        product.updateQuantity(updatedQuantity);

        Long totalPrice = Long.valueOf(product.getPrice() * quantity);

        Orders order = Orders.create(
                product.getName(),
                quantity,
                totalPrice,
                OrderStatus.CONFIRMED,
                DeliveryStatus.NOT_SHIPPED,
                "",   // trackingNumber 초기 값
                product,
                b2CMember,
                product.getId()
        );

        orderRepository.save(order);
        return OrderResponse.from(order);

    }

}

 

 

동시성 테스트 진행

현재 상품의 재고 수량은 299,599개 입니다.

 

K6 부하테스트 도구를 사용하여 동시성 테스트를 진행하였습니다. 해당 테스트는 로컬에서 진행하였으며, 테스트 시나리오는 아래와 같습니다.

테스트 시나리오
5명의 사용자가 동시에 1번 상품을 5개씩 주문하는 시나리오
정상적으로 실행된다면 테스트 종료 후 299,574 재고가 조회되어야 함
import http from 'k6/http';

export const options = {
  vus: 5, // 5명의 동시 사용자
  iterations: 5, // 사용자별로 1번씩 총 5개의 주문 요청
};

export default function () {
  const BASE_URL = 'http://localhost:8082/api/orders'; // 주문 API 엔드포인트

  const payload = JSON.stringify({
    productId: 1,
    quantity: 5,
  });

  const params = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  // POST 요청 전송
  const res = http.post(BASE_URL, payload, params);

}

 

부하테스트를 돌리니 아래와 같이 1개만 성공하고 4개에 대해서는 failed가 발생한 것을 확인할 수 있었습니다.

 

 아래는 애플리케이션 로그에 찍힌 에러메시지 입니다. DB에서 Deadlock이 발생한 것으로 해당 에러가 왜 발생한 것인지 확인하기 위해 DB로그도 살펴보았습니다.

{"timestamp":"2025-01-01 23:01:29.216","level":"WARN ","traceId":"7377de97-7656-4fb3-92b1-70dd0b131ad1","thread":"http-nio-8082-exec-2","logger":"o.h.e.jdbc.spi.SqlExceptionHelper","message":"SQL Error: 1213, SQLState: 40001"} 

{"timestamp":"2025-01-01 23:01:29.216","level":"ERROR","traceId":"7377de97-7656-4fb3-92b1-70dd0b131ad1","thread":"http-nio-8082-exec-2","logger":"o.h.e.jdbc.spi.SqlExceptionHelper","message":"Deadlock found when trying to get lock; try restarting transaction"}

{"timestamp":"2025-01-01 23:01:29.222","level":"ERROR","traceId":"","thread":"http-nio-8082-exec-2","logger":"o.a.c.c.C.[.[.[.[dispatcherServlet]","message":"Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [/* update for com.sparta.impostor.commerce.backend.domain.product.entity.Product */update product set category=?,description=?,member_id=?,modified_at=?,name=?,price=?,status=?,stock_quantity=?,sub_category=? where id=?]; SQL [/* update for com.sparta.impostor.commerce.backend.domain.product.entity.Product */update product set category=?,description=?,member_id=?,modified_at=?,name=?,price=?,status=?,stock_quantity=?,sub_category=? where id=?]] with root cause"} 

 

DB로그를 통해 데드락이 발생한 상황을 알 수 있는데 두 세션이 동시에 쿼리를 던져서 서로 읽기 과정에서 공유 락을 얻고 베타 락으로 전환하려고 할 때 서로가 가지고 있는 공유 락이 잡힌 데이터로 접근하려고 하기에 발생한 문제입니다. 즉, 트랜잭션 1과 2가 S Lock을 가지고 있고 X Lock을 요청하는 상황에서 락이 양립할 수 없기에 교착상태(DeadLock) 가 발생한 것입니다.

락 양립성 함수 공유 락(S) 배타 락(X)
공유 락(S) 가능 불가능
배타 락(X) 불가능 불가능
SHOW ENGINE INNODB STATUS;
2025-01-02 07:39:54 281472582336256 ***
(1) TRANSACTION:
TRANSACTION 9000, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 958, OS thread handle 281471967600384, query id 33483 172.18.0.1 root updating
/* update for com.sparta.impostor.commerce.backend.domain.product.entity.Product
*/update product set category='TOP',description='Aberg',member_id=2,modified_at='2025-01-02 16:39:54.296989',name='Mazda3',price=8526,status='ON_SALE',stock_quantity=299589,sub_category='T_SHIRT' where id=1

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 26 page no 4 n bits 80 index PRIMARY of table `impostor`.`product` trx id 9000 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 26 page no 4 n bits 80 index PRIMARY of table `impostor`.`product` trx id 9000 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
*** (2) TRANSACTION:
TRANSACTION 9003, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 952, OS thread handle 281471960891136, query id 33485 172.18.0.1 root updating
/* update for com.sparta.impostor.commerce.backend.domain.product.entity.Product
*/update product set category='TOP',description='Aberg',member_id=2,modified_at='2025-01-02 16:39:54.29322',name='Mazda3',price=8526,status='ON_SALE',stock_quantity=299589,sub_category='T_SHIRT' where id=1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 26 page no 4 n bits 80 index PRIMARY of table `impostor`.`product` trx id 9003 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 26 page no 4 n bits 80 index PRIMARY of table `impostor`.`product` trx id 9003 lock_mode X locks rec but not gap
waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0

*** WE ROLL BACK TRANSACTION (2)

 

 

DeadLock 발생 원인

코드를 보면서 DeadLock이 발생한 원인을 순차적으로 설명드리겠습니다.

createOrder() 메소드는 하나의 트랜잭션으로 이를 락 명령으로 표현하면 다음과 같습니다.

  • LS(Q) : 데이터 Q에 대한 공유 락을 요청한다.
  • LX(Q) : 데이터 Q에 대한 배타 락을 요청한다.
  • UN(Q) : 데이터 Q에 대한 공유/배타 락을 반납한다.
트랜잭션 A 트랜잭션 B 코드
LS(Product) LS(Product) Product product = productRepository.findById(orderRequest.productId())
                .orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));
UN(Product) UN(Product)
LS(Product) LS(Product) Orders order = Orders.create(
                product.getName(),
                quantity,
                totalPrice,
                OrderStatus.CONFIRMED,
                DeliveryStatus.NOT_SHIPPED,
                "",   // trackingNumber 초기 값
                product,
                b2CMember,
                product.getId()
        );
orderRepository.save(order);
LX(Order) LX(Order)
LX(Product) LX(Product) product.updateQuantity(updatedQuantity);
UN(Order) UN(Order)  
UN(Product) UN(Product)  

 

Order 테이블에 행을 삽입할 때, FK인 Product 테이블의 참조 무결성을 확인합니다. 이 과정에서 Product 테이블의 관련 행에 대해 S Lock이 걸립니다. 이 상태에서 Product 테이블에 업데이트가 일어날 때 X Lock을 요청하게 되는데 여러 트랜잭션이 X Lock을 요청하게 되면서 데드락이 발생했습니다.

 

 

saveAndFlush를 통한 DeadLock 해결

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final B2CMemberRepository b2cMemberRepository;

    @Transactional
    public OrderResponse createOrder(Long memberId, OrderRequest orderRequest) {

        B2CMember b2CMember = b2cMemberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));

        Product product = productRepository.findById(orderRequest.productId())
                .orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));

        // 상품의 수량이 주문 수량의 이상인지 검증
        int quantity = orderRequest.quantity();
        if (quantity > product.getStockQuantity() && product.getStockQuantity() <= 0) {
            throw new IllegalArgumentException("재고 수량이 부족합니다.");
        }

        int updatedQuantity = product.getStockQuantity() - quantity;
        product.updateQuantity(updatedQuantity);

        productRepository.saveAndFlush(product);

        Long totalPrice = Long.valueOf(product.getPrice() * quantity);

        Orders order = Orders.create(
                product.getName(),
                quantity,
                totalPrice,
                OrderStatus.CONFIRMED,
                DeliveryStatus.NOT_SHIPPED,
                "",   // trackingNumber 초기 값
                product,
                b2CMember,
                product.getId()
        );

        orderRepository.save(order);
        return OrderResponse.from(order);

    }

}

 

Product 테이블에 saveAndFlush()메소드를 사용하게 되면 락 명령이 다음과 같이 변화하게 됩니다.

트랜잭션 A 트랜잭션 B 코드
LS(Product) LS(Product) Product product = productRepository.findById(orderRequest.productId())
                .orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));
UN(Product) UN(Product)
LX(Product) LX(Product) product.updateQuantity(updatedQuantity);
productRepository.saveAndFlush(product);
UN(Product) UN(Product)
LS(Product) LS(Product) orderRepository.save(order);
LX(Order) LX(Order)
UN(Product) UN(Product)  
UN(Order) UN(Order)  

 

saveAndFlush() 메소드는 JPA의 save와 달리 변경된 엔티티를 영속성 컨텍스트에 저장하고 즉시 데이터베이스에 반영(Flush) 합니다.

이를 통해 Product 테이블의 X Lock을 먼저 확보하고 반납하게 한 뒤, 공유 락(S)을 사용하게 하면 락의 양립이 가능하여 데드락 발생을 방지합니다.

 

 

DeadLock 이슈 해결 후 동시성 테스트

이제는 수정된 로직으로 테스트를 재실행 해 보겠습니다. 현재는 재고가 299,584개 이고 위와 동일하게 5명의 사용자가 5개의 상품 주문을 동시에 날려보겠습니다.

 

요청 자체는 fail 없이 모두 성공했습니다.

 

테스트 완료 후 재고를 살펴보면 25개가 아닌 5개 재고만 줄어든 것을 확인할 수 있습니다. 즉 데이터 정합성이 깨졌음을 의미합니다.

 

이는 동시성 제어를 통해 해결해야 하는데 다음 글에서 이를 어떤 식으로 해결하였는지 설명드리겠습니다.

 

참고

https://mslim8803.tistory.com/74

 

Redisson을 이용한 재고 관리

예제에 대한 전체 코드는 https://github.com/minseokLim/practice/tree/main/redisson-practice 에서 확인할 수 있다. 1. 서론 인터넷에서 제품을 구매할 땐 '재고'라는 게 있다. 근데 만약 쇼핑몰에서 10개의 상품을

mslim8803.tistory.com

https://velog.io/@legowww/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%98%88%EC%99%B8CannotAcquireLockException-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0