-
[JPA] 낙관적 락(Optimistic Lock)과 예제 코드Backend/jpa 2024. 10. 14. 15:44
1. 낙관적 락(Optimistic Lock)이란?
낙관적 락은 동시성 문제가 발생할 가능성이 낮다고 가정하고, 여러 트랜잭션이 동시에 데이터에 접근할 수 있도록 허용한다. 그러나 실제로 데이터를 변경할 때 충돌이 발생하면, 해당 트랜잭션은 롤백되고 예외를 발생시킨다.
JPA에서는 @Version 필드를 사용하여, 해당 엔티티가 변경될 때마다 버전 값이 증가한다. 트랜잭션이 종료되기 전에 다른 트랜잭션이 이 엔티티를 변경하여 버전이 증가한 것을 감지하면, OptimisticLockException이 발생하고 해당 트랜잭션은 롤백된다. 또한 데이터베이스의 물리적 락 기능을 사용하지 않고, 엔티티의 버전을 통해 동시성을 제어한다.
2. OptimisticLockingFailureException 예외란?
OptimisticLockingFailureException은 SpringDataJPA에서 낙관적 락(Optimistic Lock)을 사용할 때 발생하는 예외이다.
2-1. Entity.class
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Stock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int amount; @Version private Long version; @Builder private Stock(String name, int amount) { this.name = name; this.amount = amount; } public static Stock create(String stockName, int amount) { return new Stock(stockName, amount); } public void decrease(int amount) { isEnoughAmount(amount); this.amount -= amount; } // 주문 개수가 현재 재고량 보다 많은가? private void isEnoughAmount(int orderAmount) { if (this.amount < orderAmount) { throw new IllegalArgumentException("재고량이 부족합니다."); } } }
2-2. 낙관적 락(Optimistic Lock) 동작 원리
- 1. 데이터 조회 : 트랜잭션이 데이터베이스에서 엔티티를 조회할 때, 엔티티의 현재 version 값을 함께 조회한다.
select s1_0.id, s1_0.amount, s1_0.name, s1_0.version from stock s1_0 where s1_0.id=1
- 2. 데이터 수정 : 트랜잭션이 데이터를 수정할 때, 해당 트랜잭션이 처음에 조회한 version 값과 데이터베이스에 저장된 version 값을 비교한다.
update stock set amount=0, name=NULL, version=1 where id=1 and version=0
- 3. version 비교
- 트랜잭션이 가지고 있는 version 값과 데이터베이스에 저장된 version 값이 동일하면, 수정에 성공하며 version 값이 증가한다.
- 만약 version 값이 다르다면, 다른 트랜잭션에서 해당 데이터를 수정했다는 의미이다. 이때, JPA는 OptimisticLockException을 발생시킨다.
3. 낙관적 락, LockModeType
JPA에서 @Lock 애너테이션을 사용하여 락 모드를 설정할 수 있다. 낙관적 락은 3가지 모드를 제공하는데, 낙관적 락의 경우 데이터베이스에서 제공하는 물리적 락을 사용하는 것이 아닌, 엔티티의 버전을 통해 동시성을 제어한다.
순수 JPA와 SpringDataJPA에서 발생하는 낙관적 락 예외는 다르다.
순수 JPA : OptimisticLockException
SpringDataJPA : OptimisticLockingFailureException → SpringDataJpa을 사용하는 경우 OptimisticLockException를 감싼 OptimisticLockingFailureException를 던진다.
본 글에서는 버전 정보가 달라 충돌이 발생할 때 발생하는 예외를 OptimisticLockException로 통합하여 설명한다.3-1. LockModeType 종류
- LockModeType.NONE
- 기본적으로 사용되는 모드이다.
- 조회한 엔티티를 수정할 때, 조회 시점의 버전과 데이터베이스의 버전을 비교하여 다른 트랜잭션에 의해 변경되었는지 확인한다.
- 버전 정보가 서로 다르다는 것은 다른 트랜잭션에서 데이터 변경이 일어났다는 뜻이므로, OptimisticLockException 예외가 발생하며 트랜잭션이 롤백된다.
- 결론: 엔티티를 변경하려고 할 때만 버전 비교가 발생한다.
- LockModeType.OPTIMISTIC
- 엔티티를 조회하는 시점에 버전 값을 조회한다.
- 트랜잭션이 끝날 때까지 처음 조회한 버전 값을 유지하며, 커밋 시점에 조회한 버전과 데이터베이스의 버전을 비교한다. 즉, 트랜잭션 도중에 엔티티를 수정하더라도 즉시 버전이 올라가는 것이 아니며, 커밋되는 시점에 버전이 증가한다.
- 결론 : 트랜잭션 커밋 시점에 버전 비교가 발생한다.
- LockModeType.OPTIMISTIC_FORCE_INCREMENT
- 엔티티를 조회하는 시점에 버전 값이 강제로 증가한다.
- 트랜잭션 내에서 엔티티를 조회만 하더라도 버전이 증가하며, 이는 수정 작업이 없더라도 발생한다. (단, 같은 트랜잭션 내에서는 동일한 엔티티를 반복 조회해도 최초 1회에 한해 버전이 증가한다.)
- 먼저 엔티티를 조회한 트랜잭션 A와 이후에 동일한 엔티티를 조회한 트랜잭션 B가 있을 때, 트랜잭션 B에 의해 버전 값이 증가하였으므로, 트랜잭션 A가 커밋하는 시점에 버전 값 충돌로 인하여 롤백이 발생한다.
- 결론 : 엔티티를 처음 조회할 때만 버전이 강제로 증가하고, 이를 통해 다른 트랜잭션이 동일한 엔티티의 데이터를 수정하지 못하도록 방지한다.
4. 테스트 코드
- Given:
- 재고가 1개 남은 Stock 엔티티를 생성하고 이를 데이터베이스에 저장한다.
- 3개의 스레드를 생성한 후, 각 스레드가 stockService.decreaseAmount() 메서드를 실행하도록 설정한다.
- When:
- 각 스레드는 decreaseAmount() 메서드를 동시에 호출하여, Stock 엔티티의 재고를 1씩 감소시킨다.
- future.get() 메서드를 사용해 스레드의 실행 결과를 기다리고, 실행 중 ExecutionException이 발생하면 해당 예외를 thrownException에 저장한다.
- Then:
- ExecutionException이 발생한 경우, 그 원인이 OptimisticLockingFailureException인지 검증한다.
@RequiredArgsConstructor @Service public class StockService { private final StockRepository stockRepository; @Transactional public StockResponse createStock(StockServiceRequest request) { Stock stock = request.toEntity(); Stock savedStock = stockRepository.save(stock); return new StockResponse(savedStock.getName(), savedStock.getAmount()); } @Transactional public StockDecreaseResponse decreaseAmount(StockDecreaseServiceRequest request) { Stock stock = stockRepository.findById(request.getStockId()) .orElseThrow(() -> new IllegalArgumentException("상품을 조회할 수 없습니다.")); stock.decrease(request.getDecreaseAmount()); return new StockDecreaseResponse(stock.getId(), stock.getName(), stock.getAmount()); } }
@SpringBootTest class StockServiceTest { @Autowired private StockService stockService; @Autowired private StockRepository stockRepository; @Test @DisplayName("Optimistic Lock Test") void decreaseAmount() { // given Stock stock = createStock(1); stockRepository.save(stock); ExecutorService executorService = Executors.newFixedThreadPool(3); Future<?> future1 = executorService.submit( () -> stockService.decreaseAmount(new StockDecreaseServiceRequest(stock.getId(), 1)) ); Future<?> future2 = executorService.submit( () -> stockService.decreaseAmount(new StockDecreaseServiceRequest(stock.getId(), 1)) ); Future<?> future3 = executorService.submit( () -> stockService.decreaseAmount(new StockDecreaseServiceRequest(stock.getId(), 1)) ); // when Exception thrownException = null; try { future1.get(); future2.get(); future3.get(); } catch (ExecutionException ex) { thrownException = (Exception) ex.getCause(); } catch (InterruptedException ex) { } // then assertTrue(thrownException instanceof ObjectOptimisticLockingFailureException); } private Stock createStock(int amount) { return Stock.builder() .amount(amount) .build(); } }
- 가장 먼저 엔티티를 수정하는 경우, 조회 시점의 버전과 데이터베이스의 버전이 일치하므로 트랜잭션 커밋이 된다.
- 트랜잭션 1, 2, 3이 모두 동일한 버전 값을 가지고 있었는데, 트랜잭션 1이 UPDATE에 성공하면서 버전 값이 증가하였다. 따라서 트랜잭션 2와 3은 UPDATE 시점에 데이터베이스와 버전 값이 일치하지 않아 ROLLBACK이 발생한다.