이전 포스팅에서 Java와 JPA로 동시성 문제를 처리하는 방법에 대해서 알아봤습니다. 최근 한 면접에서 이 부분에 관련된 면접관님의 피드백을 받고 의문점이 하나 생겼습니다.
면접관 님 : 이런 상황에는 비관적 락을 사용한다. 비관적 락을 사용해도 레코드를 읽을 수 있고, 비즈니스 특성상 사용자의 요청이 유실되는 것이 더 안좋은 상황이기 때문에....(후략)
이 답변을 듣고, "아... PESSIMISTIC_WRITE
를 자주 사용하는게 아니라, 상황에 맞게 적절하게 사용해야겠구나" 라는 생각이 들었습니다. 이를 계기로 제가 이전 포스팅에서 사용했던 PESSIMISTIC_WRITE
가 아닌 PESSIMISTIC_READ
옵션을 걸어보았습니다.
JPA에서 비관적 락을 위해 사용하는 @Lock
어노테이션의 옵션은
PESSIMISTIC_WRITE
PESSIMISTIC_READ
PESSIMISTIC_FORCE_INCREMENT
가 있습니다. 데이터베이스로 h2를 사용할 경우, PESSIMISTIC_WRITE
or PESSIMISTIC_READ
옵션을 걸면 SELECT ... FOR UPDATE
구문이 포함된 쿼리가 실행됩니다.
그런데 PESSIMISTIC_WRITE
은 쓰기와 읽기가 모두 불가능하고 PESSIMISTIC_READ
는 쓰기작업에 대해서만 락을 거는 옵션입니다. 그렇다면 어째서 PESSIMISTIC_READ
옵션에서도 SELECT ... FOR UPDATE
구문이 실행되는걸까요?
여기서 선언하는 SQL문과는 별개로 하이버네이트의 다른 구현이 있는걸까? 내가 모르는 JPA의 추상화된 기능이 있는 것일까? 라는 의문이 들었습니다.
저는 JPA 구현체로 하이버네이트 6.3.1 버전을 사용하고 있었고, 공식문서에서 비관적 락 옵션별 구현에 대해서 찾아보니 다음과 같은 내용을 찾을 수 있었습니다.
공식문서 상에서 하이버네이트는 PESSIMISTIC_READ
옵션을 사용하면 S 락(공유 락)을 건다고 설명하고 있습니다. 쿼리상으로는 SELECT ... FOR SHARE
문을 실행해야하는 것이죠.
하지만 앞에서도 보았듯이, PESSIMISTIC_READ
옵션을 걸어도 SELECT ... FOR UPDATE
가 실행되는 상황입니다.
이와 관련된 자료로 h2는 X락(배타락)만 지원한다는 내용인데요, 그렇기 때문에 PESSIMISTIC_READ
옵션을 걸어도 SELECT ... FOR UPDATE
구문이 실행된 것입니다.
그런데 JPA는 어떻게 h2 데이터베이스의 구현 상태에 맞게 쿼리를 실행할 수 있는 것일까요?
💡 데이터베이스 방언(Dialect in database)이란?
데이터베이스 방언(Database Dialect)은 특정 데이터베이스 시스템이 SQL을 해석하고 실행하는 방식을 정의하는 것입니다. 데이터베이스는 모두 SQL을 사용하지만, 각각의 데이터베이스 시스템은 SQL을 조금씩 다르게 해석하고 실행합니다. 데이터베이스 방언은 이러한 차이를 추상화하여 애플리케이션 코드가 특정 데이터베이스에 종속되지 않고 여러 종류의 데이터베이스를 사용할 수 있도록 도와줍니다. 데이터베이스 방언은 주로 하이버네이트(Hibernate)와 같은 ORM에서 사용됩니다.
h2가 S락(공유 락)을 지원하지 않는 것은 h2의 고유한 특성이라고 할 수 있겠습니다. JPA 구현체들은 이에 맞게 SQL을 실행하는 클래스를 제공합니다.
하이버네이트 6 버전부터 dialect가 자동으로 설정된다는 내용입니다. 이로인해 제가 dialect의 존재를 몰랐음에도 하이버네이트가 h2에 맞게 방언으 적용하여 SQL을 실행한 것이죠.
따라서 사용하는 데이터베이스의 락 구현을 이해하고, 이에 맞는 @Lock
옵션을 설정할 필요가 있습니다.
이제 PESSIMISTIC_READ
옵션을 걸었을 때 SELECT ... FOR UPDATE
가 실행되는 이유를 알았습니다.
그렇다면 h2 + 하이버네이트를 사용할 경우, PESSIMISTIC_READ
옵션의 원래 의도대로 동작을 할까요?
테스트 코드
@Test
@DisplayName("비관적 락 'PESSIMISTIC_READ' 사용 시 다른 스레드의 읽기 가능 여 확인")
void testPessimisticReadLockBlockingRead() throws Exception {
// Given
Long productId = 1L;
Product product = Product.builder()
.id(productId)
.price(1000)
.stock(30)
.name("Test Product")
.build();
productRepository.save(product);
ExecutorService executorService = Executors.newFixedThreadPool(2);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(20);
AtomicInteger successfulReadCount = new AtomicInteger(0);
Runnable buyTask = () -> {
try {
startLatch.await();
productService.buyProduct(3, productId);
} catch (Exception e) {
System.out.println(e.getClass().getName());
} finally {
endLatch.countDown(); // Release other thread after lock is released
}
};
Runnable readTask = () -> {
try {
startLatch.await();
Product product1 = productFindService.findById(productId);
successfulReadCount.incrementAndGet();
} catch (Exception e) {
System.out.println("Failed to read product: " + e.getMessage());
} finally {
endLatch.countDown();
}
};
// Start the threads
for (int i=0; i<10; i++){
executorService.submit(buyTask);
executorService.submit(readTask);
}
startLatch.countDown();
endLatch.await();
executorService.shutdown();
assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS));
// Then
assertEquals(10, successfulReadCount.get(), "Should successfully read the product after lock is released.");
}
결과
읽기 작업을 수행하는 다른 스레드에서 데이터를 읽을 수 있었습니다. 사실 h2는 MVCC를 지원하기 때문에 레코드에 대한 락 획득 없이 데이터 조회가 가능합니다.
따라서 h2 데이터베이스 + JPA 비관적 락을 사용할 경우, 옵션에 상관 없이 for update
를 실행하면서 MVCC로 인해 읽기 작업 또한 문제없이 수행할 수 있습니다.
그렇다면 S락(공유 락)을 지원하는 데이터베이스에서는 PESSIMISTIC_READ
옵션을 걸었을 때, 어떤 식으로 동작하는지 궁금해졌습니다.
널리 사용되는 RDBMS 중 하나인 MySQL에서 PESSIMISTIC_READ
옵션을 걸고 테스트를 실행해보겠습니다.
for share
구문을 포함한 SQL이 실행되는 것을 확인할 수 있습니다.
테스트 결과
그런데 테스트는 통과하지 못합니다.
원인은 데드락입니다. 이로 인해 최초 커밋한 트랜잭션 외의 트랜잭션이 모두 롤백되고 3개의 재고만 차감되었습니다.
공식문서에 따르면, MySQL은 UPDATE ... WHERE ...
문을 실행할 때 대상 레코드에 X락을 걸도록 되어있습니다. 따라서 for share
로 인해 S락을 획드한 트랜잭션이 같은 레코드에 대해 X락을 요청하면, 이미 락을 보유하고 있는 다른 트랜잭션들과의 충돌로 인해 데드락이 발생할 수 있습니다.
이를 해결하기 위해서 PESSIMISTIC_WRITE
옵션을 통해 처음부터 X락을 획득하도록 유도할 수 있습니다.
테스트 결과
for update
구문이 실행되고, 데드락이 발생하지 않으면서 테스트를 통과했습니다.
읽기 작업에 대해서는 개발하고자 하는 서비스가 어느 정도 수준의 동시성을 필요로 하는지에 따라 격리 수준도 함께 조절하면서 트랜잭션이 읽을 데이터의 스냅샷 시점 또한 관리할 수 있겠습니다.
이번 포스팅에서 구축했던 상품 주문 서비스의 경우, 사용자가 다른 요청에 의해 변경된 재고 정보를 정확하게 받는 것이 중요하고, 사용자의 구매 요청이 유실되지 않는 것 또한 중요하기 때문에 READ COMMITED
+ PESSIMISTIC_WRITE
조합이 적절하다고 생각합니다.
이번 포스팅에서는 h2 + JPA(하이버네이트)를 사용하면서 @Lock
옵션을 통해 데이터베이스 방언에 대해 알아보았습니다.
이를 통해 각 데이터베이스 마다 동일한 추상화에 대해서도 지원하는 범위나 방식이 다르기 때문에, 기술을 선택할 때 서비스나 애플리케이션의 목표와 필요에 따라 어떤 데이터베이스가 가장 적합한지 제대로된 근거를 가지고 선택해아겠다고 생각했습니다.
이를 위해서는 각 데이터베이스가 어떤 작업을 지원하고, 그 작업을 어떻게 지원하는지를 이해해야 합니다. 이를 통해 정확한 기술 선택과 개발 방향을 결정할 수 있습니다. 따라서 ORM 뿐만 아니라, RDBMS에 대한 학습도 꾸준히 해야겠다고 느꼈습니다.