JPA 낙관적 락이 새는 3가지 지점

목차

이 글의 코드는 Java 21, Spring Boot 3.5, Hibernate 6.x 기준입니다.

도서관 대출 시스템에서 같은 책을 두 사람이 동시에 빌리지 못하게 하려고 @Version을 붙였습니다. 한 줄이면 끝날 줄 알았는데, 락은 세 군데에서 샜습니다.

  • 저장 방식 때문에 락이 아예 동작하지 않았습니다
  • 락이 동작해도 서비스 안에서 예외를 잡을 수 없었습니다
  • 동시성 테스트를 돌렸는데 락 충돌이 한 번도 나지 않았습니다

1. 락은 Book에 건다

엔티티는 두 개입니다.

  • BookInfo: 도서 메타정보(ISBN, 제목, 저자). 거의 안 바뀜
  • Book: 실제 책 한 권. AVAILABLE ↔ ON_LOAN 상태가 바뀜

같은 ISBN의 책이 여러 권 있을 수 있고, 대출 가능 여부는 Book 한 권의 status로 판단합니다. 동시에 바뀌는 건 Book.status뿐이니 락도 Book에 겁니다.

두 엔티티를 나눠둔 덕도 봤습니다. 하나로 합쳤다면 “도서 정보 수정”과 “대출”이 같은 row에서 부딪쳤을 겁니다. 엔티티를 쪼개면 락의 범위도 같이 좁아집니다.

낙관적 락과 비관적 락 중에서는 낙관적 락을 골랐습니다.

  • 낙관적 락: 커밋 시점에 version을 비교. 평소 비용 0, 충돌하면 예외
  • 비관적 락: 조회 시점에 row를 잠금. 항상 대기 비용, 데드락 위험

같은 책 한 권을 같은 순간에 두 명이 빌리는 일은 드뭅니다. 드문 충돌 때문에 매번 대기 비용을 낼 이유가 없습니다. 비관적 락은 좌석 예약이나 재고 차감처럼 경합이 잦고 실패 비용이 큰 곳에 어울립니다.

2. @Version이 하는 일은 WHERE 조건 하나다

@Version은 필드에 붙입니다.

@Version
private Long version;   // INSERT 때 0, UPDATE마다 +1 (Hibernate가 관리)

@Version이 붙으면 UPDATE에 조건이 하나 더 붙습니다.

UPDATE book SET status = ?, version = ?
WHERE id = ? AND version = ?

다른 트랜잭션이 먼저 커밋해 version이 올라갔다면 이 UPDATE는 0건을 갱신합니다. Hibernate는 flush 시점에 갱신된 row 수를 보고 OptimisticLockException을 던집니다. 낙관적 락의 실체는 이 AND version = ? 하나입니다.

3. 함정 ① - 매퍼로 새로 만든 엔티티를 save()하면 version이 사라진다

영속성 어댑터의 updateBook()을 매퍼로 새 엔티티를 만들어 save()하도록 짜면 이렇습니다.

public void updateBook(Book book) {
    bookJpaRepository.save(bookMapper.mapToJpaEntity(book));  // 새로 만든 엔티티, version = null
}

도메인 Book에는 version이 없으니 매퍼가 만든 엔티티의 version은 null입니다. Spring Data JPA의 save()는 version 필드가 Long 같은 래퍼 타입이면 그 값이 null인지로 새 엔티티를 판단합니다. id가 채워져 있어도 version이 null이면 새 엔티티로 보고 persist()를 부릅니다. 그런데 id가 이미 있는 엔티티라 Hibernate는 새로 저장하지 못하고 에러가 납니다. 동시 요청이 없어도 대출 한 번에 바로 실패합니다.

이 방식으로는 조회한 version이 UPDATE까지 이어지지 않습니다. 락이 동작할 기회 자체가 없습니다.

Dirty Checking으로 바꾸자 조회 시점의 version이 살아남았다

public void updateBook(Book book) {
    BookJpaEntity entity = bookJpaRepository.findById(book.getId())
            .orElseThrow(() -> new BookNotFoundException("도서를 찾을 수 없습니다."));
    entity.changeStatus(book.getStatus());
    // save() 없음. managed 엔티티라 커밋 때 dirty checking으로 UPDATE
}

findById로 가져온 엔티티는 managed(영속성 컨텍스트가 추적하는) 상태이고, 조회한 순간의 version을 그대로 들고 있습니다. 상태만 바꾸면 커밋 때 JPA가 바뀐 필드를 찾아(dirty checking) UPDATE를 만듭니다. save()는 필요 없습니다.

-- updateBook() 호출 시
SELECT id, book_info_id, status, version FROM book WHERE id = ?

-- 커밋 시
UPDATE book SET status = ?, version = ? WHERE id = ? AND version = ?

이제 WHERE version = ?에 조회 시점의 version이 들어갑니다.

findById가 쿼리를 한 번 더 내지 않을까 싶었는데, 같은 트랜잭션 안이면 1차 캐시에서 꺼내므로 추가 쿼리가 없습니다. 다른 트랜잭션에서 온 도메인이라면 SELECT가 한 번 더 나가지만, 그걸 아끼려고 도메인 모델을 건드릴 만한 비용은 아니라고 판단했습니다.

도메인 Book에 version 필드를 두고 매퍼로 주고받는 방법도 있습니다. 다만 도메인이 @Version이나 jakarta.persistence.*를 import하기 시작하면 의존 방향이 깨집니다. 여기서는 도메인에 version을 두지 않았습니다.

4. 함정 ② - 서비스에서 잡으려던 예외가 catch에 닿지 않았다

락이 동작하니 예외를 도메인 예외로 바꾸고 싶었습니다. 처음엔 서비스 메서드 안에서 잡으려고 했습니다.

@Transactional
public Loan loanBook(LoanBookCommand command) {
    try {
        updateBookPort.updateBook(book.checkout());
    } catch (OptimisticLockException e) {
        throw new BookNotAvailableException("이미 대출 중인 도서입니다.");
    }
    return loan;
}

이 catch는 실행되지 않습니다. dirty checking은 바뀐 내용을 바로 내보내지 않고 모아뒀다가 보통 커밋할 때 한꺼번에 flush합니다. @Transactional은 Spring AOP 프록시가 메서드를 감싸 트랜잭션을 열고 닫으므로, UPDATE는 메서드가 return한 뒤 프록시의 커밋에서 나갑니다. 예외도 그때 try 바깥에서 터집니다.

예외는 어디서 터지는가

loanBook()의 try 안에서 엔티티를 바꿔도 UPDATE는 바로 나가지 않습니다. 메서드가 return한 뒤 트랜잭션 프록시가 커밋할 때 UPDATE가 나가고, 0건이 갱신되어 OptimisticLockException이 try 밖에서 터집니다. catch는 실행되지 않고 호출자는 ObjectOptimisticLockingFailureException을 받습니다. try 안에서 em.flush()를 부르면 UPDATE가 그 자리에서 나가 catch가 실행되고 호출자는 BookNotAvailableException을 받습니다. 이때 트랜잭션은 이미 rollback-only로 표시되어 롤백됩니다.

예외가 터진 곳
-
catch
-
호출자가 받는 예외
-

메서드 안에서 em.flush()를 부르면 UPDATE가 그 자리에서 나가니 catch가 실행되고, 도메인 예외로 바꿔 던질 수도 있습니다. 다만 OptimisticLockException이 나면 현재 트랜잭션은 롤백 대상으로 표시됩니다. 잡고 이어서 진행할 수는 없고, 삼키고 정상 return해도 커밋 단계에서 롤백되며 예외가 납니다.

선택지는 셋이었습니다.

  • 서비스에서 em.flush() 후 바꿔 던지기: 되지만 서비스가 flush 시점을 직접 챙겨야 함
  • REQUIRES_NEW로 새 트랜잭션을 열고 그 바깥에서 잡기: 되지만 구조가 복잡해짐
  • 컨트롤러까지 올라온 예외를 @RestControllerAdvice에서 바꾸기: 가장 단순

서비스가 영속성 계층의 flush 시점까지 알아야 할 이유는 없다고 보고 세 번째를 골랐습니다.

5. 예외 변환은 @RestControllerAdvice에서 409로

OptimisticLockException을 그대로 내보내면 문제가 셋입니다.

  • Hibernate의 영어 기술 메시지가 노출됨
  • JPA 엔티티 클래스명이 응답에 섞임(내부 구조 누설)
  • 같은 “이미 대출 중”인데 BookNotAvailableException을 던졌을 때와 응답 형식이 다름

그래서 핸들러에서 도메인 예외로 바꿔 409로 응답합니다. ProblemDetail을 만드는 헬퍼는 다른 도메인 예외 핸들러와 같이 씁니다.

@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ProblemDetail optimisticLockHandler(HttpServletRequest request) {
    return createProblemDetail(
            new BookNotAvailableException("이미 대출 중인 도서입니다."),
            HttpStatus.CONFLICT, request);
}

잡는 예외가 JPA 표준 OptimisticLockException이 아니라 Spring의 ObjectOptimisticLockingFailureException인 점이 중요합니다. 커밋에서 난 예외는 JpaTransactionManager가 Spring의 DataAccessException 계층으로 바꿔 던집니다. em.flush()처럼 직접 부른 호출에서는 JPA 표준 예외가 그대로 올라올 수 있으니 둘 다 잡아두는 편이 안전합니다.

409 Conflict는 동시성 충돌의 표준 코드입니다. ETag와 If-Match 같은 사전 조건 방식이라면 412 Precondition Failed를 쓰기도 합니다.

6. 함정 ③ - 10개를 동시에 던졌는데 락 충돌은 0건이었다

락이 실제로 동작하는지는 동시성 테스트로 확인해야 합니다. MockMvc는 서블릿 컨테이너의 커넥터, 스레드 풀, 필터 체인을 거치지 않아서 운영의 동시 요청 경로를 재현하지 못합니다. 실제 톰캣을 띄우는 @SpringBootTest(webEnvironment = RANDOM_PORT)와 TestRestTemplate으로 테스트했습니다.

처음 테스트는 스레드 10개에 요청을 던지기만 했습니다.

ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
    executor.submit(() -> 대출요청());
}

이렇게 하면 진짜 충돌이 안 날 수 있습니다. 스케줄러가 스레드를 조금씩 엇갈려 시작시키면, 첫 요청이 커밋된 뒤에야 두 번째 요청이 책을 조회합니다.

  • 첫 트랜잭션: 대출 성공, version 0 → 1
  • 두 번째 트랜잭션: 조회해보니 이미 ON_LOAN → 비즈니스 가드(if (book.isOnLoan()) throw ...)에서 차단
  • OptimisticLockException이 날 기회가 없음

비즈니스 가드와 낙관적 락은 충돌을 잡는 시점이 다른 두 겹의 방어선입니다. 가드는 트랜잭션 시작 직후에, 락은 커밋 시점에 잡습니다. 락을 검증하려면 두 트랜잭션이 모두 가드를 통과해 커밋에서 부딪쳐야 하고, 그러려면 출발선을 맞춰야 합니다.

같은 책에 대출 요청 10개

스레드 10개가 같은 책을 대출합니다. 출발선 없이 엇갈려 출발하면 첫 스레드가 커밋해 version이 0에서 1, 상태가 ON_LOAN이 되고, 나머지 9개는 조회 시점에 이미 ON_LOAN을 보고 비즈니스 가드에서 막힙니다. OptimisticLockException은 한 번도 나지 않습니다. CountDownLatch로 출발선을 통일해 10개 모두 첫 커밋 전에 version 0을 읽으면, 모두 가드를 통과한 뒤 커밋에서 부딪쳐 1건만 201이고 9건은 409입니다. latch는 이런 충돌이 나올 확률을 높일 뿐 보장하지는 않습니다.

201 성공
-
409 락 충돌
-
가드 차단
-
최종 version
-

CountDownLatch로 출발선을 맞추자 커밋 충돌이 났다

CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(10);

for (int i = 0; i < 10; i++) {
    executor.submit(() -> {
        try {
            startLatch.await();     // 출발 신호까지 대기
            대출요청();
        } finally {
            endLatch.countDown();
        }
    });
}

startLatch.countDown();   // 대기 중이던 스레드를 한꺼번에 풀어줌
endLatch.await(10, TimeUnit.SECONDS);
  • startLatch: 출발선. 모든 스레드를 거의 같은 순간에 출발시킴
  • endLatch: 결승선. 모든 워커가 끝난 뒤에 검증 코드가 돌게 함
  • await(timeout): 데드락이 나도 테스트가 무한 대기하지 않게 함

latch가 보장하는 건 “한꺼번에 풀어준다”까지입니다. 풀려난 스레드가 실제로 조회하는 순서는 OS 스케줄러와 HTTP 처리 경로에 달려 있어서, 첫 커밋보다 늦게 조회한 요청은 여전히 가드에 걸릴 수 있습니다. 커밋 충돌이 나올 확률을 크게 높일 뿐 보장하지는 않으므로, 아래 “409 9건” 단정도 드물게 실패할 수 있습니다.

검증은 응답 코드 개수와 최종 version으로 했다

각 워커는 POST /api/v1/loans/{bookId}의 응답 코드를 AtomicInteger로 셉니다. 여러 스레드가 동시에 올리는 값이라 int의 ++로는 셀 수 없습니다.

int status = response.getStatusCode().value();
if (status == 201) successCount.incrementAndGet();
else if (status == 409) conflictCount.incrementAndGet();
else otherCount.incrementAndGet();

모든 워커가 끝나면 이렇게 확인합니다.

assertThat(otherCount.get()).isZero();          // 예상 밖 응답 코드
assertThat(successCount.get()).isEqualTo(1);
assertThat(conflictCount.get()).isEqualTo(9);
assertThat(book.getVersion()).isEqualTo(1L);    // UPDATE는 한 번만 성공

otherCount를 따로 둔 이유는 201도 409도 아닌 응답을 놓치지 않기 위해서입니다. 이게 없으면 엉뚱한 이유로 실패한 요청이 조용히 묻힙니다.

테스트 준비 코드에서 챙긴 건 세 가지입니다.

  • @BeforeEach에 @Transactional을 붙이지 않음: 준비 데이터가 메인 스레드의 트랜잭션에 갇혀 워커 스레드에서 안 보임
  • executor.shutdown() 뒤에 awaitTermination: shutdown()만으로는 실행 중인 작업이 끝나길 기다리지 않아 워커가 다음 테스트로 샘
  • H2와 운영 DB의 차이: 격리 수준과 MVCC 구현이 달라 락 동작이 다를 수 있음. 운영과 같은 DB로 한 번 더 돌리려면 Testcontainers

7. 충돌을 자동으로 재시도하려면 새 트랜잭션이어야 한다

도서관은 충돌을 409로 돌려주고 사용자가 다시 누르면 되는 도메인입니다. 재고 차감이나 포인트 적립처럼 결과가 같아야 하는 곳이라면 시스템이 자동으로 재시도하는 편이 낫습니다. Spring Retry로 붙일 수 있습니다.

@Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 50))
@Transactional
public Loan loanBook(LoanBookCommand command) { ... }

두 가지를 지켜야 합니다.

  • 재시도마다 새 트랜잭션: 기존 트랜잭션은 이미 rollback-only라 그 안에서 다시 해봐야 결과가 같음
  • 멱등성: 같은 호출이 여러 번 일어나도 결과가 같아야 재시도가 의미 있음

8. @Version은 시작이었다

@Version은 UPDATE에 조건 하나를 붙일 뿐이고, 그 조건이 의미를 가지려면 주변이 맞아야 했습니다.

  • 저장: 매퍼로 만든 새 엔티티는 version이 null이라 save()가 persist()로 가서 에러 → dirty checking
  • 예외: 커밋 때 터지는 예외는 서비스 메서드 안에서 못 잡음 → @RestControllerAdvice에서 409
  • 검증: 엇갈려 출발한 요청은 가드에서 막혀 락까지 오지 않음 → CountDownLatch로 출발선 맞추기