충돌이 잦을 거라고 가정하고, 다른 트랜잭션이 해당 데이터를 동시에 변경하지 못하도록 락을 획득하고, 필요한 경우 다른 트랜잭션이 락이 해제될 때까지 대기하게 한다.
SELECT ... FOR UPDATE처럼 조회 시점에 row를 잠근다.
이는 낙관적 락과 반대로 "선 차단, 후 처리"하는 방식이다.
이번에는 상품을 구매하면 재고가 차감되는 상황에서의 동시성을 구현해봤다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
적용하고자 하는 객체의 조회 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE)를 지정하면 JPA/Hibernate가 해당 조회를 비관적 쓰기 락을 획득하는 쿼리로 처리하며, MySQL에서는 일반적으로 SELECT ... FOR UPDATE 형태로 실행된다.
구현하고자 한 것은 상품을 구매(purchase)하면 주문을 생성하고, 재고를 차감하는 상황이다.
findById) 돌려본 결과 — Lost Update// 락 없이 조회 (동시성 문제 재현용)
@Transactional
public OrderResponse purchaseWithoutLock(Long productId, CreateOrderRequest dto) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));
product.purchase(dto.getQuantity());
Order order = Order.builder()
.product(product)
.username(dto.getUsername())
.quantity(dto.getQuantity())
.build();
orderRepository.save(order);
return OrderResponse.from(order);
}

findById로 조회할 경우, 즉 비관적 락이 적용하지 않았을 때, 재고 10개, 스레드 100개로 테스트한 결과, 최종 재고는 0으로 "그럴듯하게" 나왔다.
알고보니 이는 구매 메서드의 애플리케이션 레벨 검증이 있어서, IllegalStateException: 재고 부족 이 발생한 것이었다.
public void purchase(int quantity) {
if (stock < quantity) {
throw new IllegalStateException("재고 부족");
}
this.stock -= quantity;
}
하지만 Order 테이블을 확인해보니 실제 성공 건수는 18건으로 재고보다 초과된 결과나 나왔다. 즉, 동시성 제어에 실패했음을 알 수 있다.
재고가 10인 상태에서 여러 트랜잭션이 동시에 조회하면, 여러 트랜잭션이 모두 "재고가 10"이라고 읽을 수 있다.
각 트랜잭션이 자신의 계산 결과를 DB에 반영하면서 서로의 변경을 덮어쓸 수 있기 때문에 Lost Update가 발생한다.
이때 Deadlock도 발생했다. 비관적 락을 명시적으로 사용하지 않았더라도 InnoDB는 UPDATE를 수행할 때 필요한 행에 락을 획득한다. 여러 트랜잭션이 동일한 데이터를 동시에 변경하려는 과정에서 서로 락을 기다리는 교착 상태가 발생할 수 있다.
실제 테스트에서도 다음과 같은 예외가 발생했다.

MySQL의 InnoDB는 Deadlock을 감지하면 관련 트랜잭션 중 하나를 rollback하여 교착 상태를 해소한다. 따라서 이 경우 애플리케이션에서는 해당 예외를 전달받게 된다.
findByIdForUpdate) 결과@Transactional
public OrderResponse purchase(Long productId, CreateOrderRequest dto) {
Product product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));
product.purchase(dto.getQuantity());
Order order = Order.builder()
.product(product)
.username(dto.getUsername())
.quantity(dto.getQuantity())
.build();
orderRepository.save(order);
return OrderResponse.from(order);
}
비관적 락을 적용한 findByIdForUpdate 메서드를 사용해 같은 조건에서 테스트한 결과 정확히 재고 0, 주문 10건으로 성공할 수 있었다.
또, 예외(PessimisticLockException, CannotAcquireLockException) 처리가 없을 때도 문제 없이 실행되었었는데, 비관적 락은 다른 트랜잭션이 락을 보유하고 있는 경우 즉시 충돌 예외를 발생시키기보다는, 기본적으로 락이 해제될 때까지 대기한다. 다만 락 획득에 제한 시간을 설정한 경우 timeout이 발생하면 예외가 발생할 수 있다. 비관적 락은 충돌이 발생한 뒤 데이터를 되돌리는 방식이 아니라, 락을 획득한 트랜잭션이 작업을 끝낼 때까지 다른 트랜잭션의 해당 row에 대한 락 획득을 대기시켜 동시 변경을 직렬화한다.
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
이후에는 락에 타임아웃을 걸었는데, 기본값인 무한 대기로 두면, 극단적인 상황에서 요청이 지나치게 오랫동안 대기할 수 있기 때문에 위험하다.
타임아웃이 걸리면 PessimisticLockException / CannotAcquireLockException 발생하게 되어 이때부터 핸들러가 필요해진다.
이번 프로젝트에서는 선착순 구매 상황을 가정했기 때문에 동일한 상품의 재고를 여러 요청이 동시에 변경할 가능성이 높았고, 재고와 주문 수량의 정합성이 중요했다. 따라서 충돌 발생 후 재시도하는 낙관적 락보다, 조회 시점에 해당 상품을 잠그고 동시 변경을 직렬화하는 비관적 락을 적용했다.