[JPA] 낙관적 락(Optimistic Lock)과 예제 코드jpa2024. 10. 14. 15:44
Table of Contents
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이 발생한다.