[백엔드] 비관적 락과 영속성 컨텍스트 동기화

비관적 락을 획득해도 이전에 조회한 엔티티의 상태가 남을 수 있는 이유와 refresh() 사용 시 주의할 점을 정리합니다.

JavaJPAHibernateMySQL

개요

동일한 재고 데이터를 여러 트랜잭션에서 변경하는 상황을 가정해보겠습니다.

데이터 정합성을 위해 비관적 락을 적용하면 다른 트랜잭션의 변경과 충돌하는 접근을 제어할 수 있습니다.

Q) 그렇다면 락을 획득한 이후에는 애플리케이션에서 사용하는 엔티티도 최신 상태일까요?

A) 이미 영속성 컨텍스트에서 관리하고 있는 엔티티라면 락 획득과 엔티티의 상태 갱신을 따로 확인해야 합니다.

비관적 락과 영속성 컨텍스트

비관적 락은 DB 레코드에 대한 동시 접근을 제어합니다.

영속성 컨텍스트는 같은 식별자를 가진 엔티티를 하나의 객체로 관리합니다.

이미 조회한 엔티티가 있다면, 같은 데이터를 다시 조회하더라도 기존 객체를 사용할 수 있습니다. 락을 획득했다는 이유만으로 객체의 필드까지 DB 값으로 갱신된다고 가정하면 안 됩니다.

구분역할
비관적 락다른 트랜잭션과 충돌하는 DB 접근 제어
영속성 컨텍스트엔티티의 식별자와 객체 상태 관리
refresh()DB에서 다시 읽은 상태를 관리 중인 엔티티에 반영

조회 결과와 객체 상태가 어떻게 반영되는지는 JPA 구현체, 쿼리 방식, 격리 수준에 따라 확인해야 합니다.

이전 상태로 재고를 변경하는 경우

다음은 동작을 설명하기 위한 예시입니다. 수량은 설명용 값이며, A가 락을 획득하는 시점에 B의 커밋을 읽을 수 있는 환경을 가정합니다.

순서트랜잭션 A트랜잭션 B
1수량 100인 엔티티를 조회하여 영속성 컨텍스트에서 관리
2락 획득 후 수량을 50으로 변경하고 커밋
3락을 획득하지만 엔티티에는 수량 100이 남아 있음
4수량 100에서 10을 차감하여 90으로 변경

DB에는 수량이 50으로 변경되어 있지만 A는 이전에 조회한 100을 기준으로 계산합니다.

최신 수량에서 10을 차감하려던 의도와 다른 결과가 됩니다.

Q) 락을 획득했는데도 왜 이런 문제가 발생할까요?

A) 다른 트랜잭션의 접근을 제어하는 것과 이미 조회한 객체를 갱신하는 것은 서로 다른 동작이기 때문입니다.

EntityManager.refresh() 적용

EntityManager.refresh()를 사용하면 DB에서 다시 읽은 값을 영속 엔티티에 반영할 수 있습니다.

잠금 모드를 함께 전달하여 상태 갱신 시 비관적 락을 요청할 수도 있습니다.

다음은 resource-local 트랜잭션에서 조회 → 락 및 상태 갱신 → 수량 변경 → 커밋 순서를 보여주는 예시입니다. Stock은 설명용 도메인입니다.

EntityTransaction tx = entityManager.getTransaction();
tx.begin();
try {
    Stock stock = entityManager.find(Stock.class, stockId);
    entityManager.refresh(stock, LockModeType.PESSIMISTIC_WRITE);
    stock.decrease(amount);
    tx.commit();
} catch (RuntimeException error) {
    if (tx.isActive()) {
        tx.rollback();
    }
    throw error;
}

Q) 엔티티를 변경한 다음에 refresh()를 호출해도 될까요?

A) refresh()는 아직 DB에 반영하지 않은 엔티티의 변경을 덮어쓸 수 있습니다. 따라서 상태를 갱신한 이후에 도메인 값을 변경해야 합니다.

또한 잠금 대기나 데드락이 없어지는 것은 아닙니다. 동일한 데이터를 변경하는 다른 경로도 함께 확인해야 합니다.

동작 확인 시점

엔티티가 언제 조회되었는지 확인하려면 다음 순서를 나눠서 살펴보면 됩니다.

  1. 엔티티가 처음 로딩되는 시점
  2. 다른 트랜잭션이 변경을 커밋하는 시점
  3. 락을 획득하고 엔티티 상태를 갱신하는 시점
  4. 도메인 값을 변경하는 시점

연관관계를 탐색하는 과정에서 엔티티가 예상보다 먼저 로딩되는지도 확인해야 합니다.

재현 테스트에서는 서로 다른 영속성 컨텍스트와 트랜잭션을 사용하여 순서를 제어합니다. 최종 DB 값과 함께 수량을 계산하기 직전 엔티티의 값도 확인해야 합니다.

Learning Point

  1. 비관적 락 획득과 영속 엔티티의 상태 갱신은 구분해야 한다는 점
  2. refresh()는 미반영 변경을 덮어쓸 수 있으므로 호출 순서가 중요하다는 점
  3. 최종 DB 값뿐 아니라 엔티티 조회 및 변경 시점도 함께 확인해야 한다는 점

참고 자료