포스트

낙관적 락 테스트가 실패하던 이유

낙관적 락 테스트가 실패하던 이유

들어가며

동시성 제어를 검증하기 위해
Hibernate의 낙관적 락(Optimistic Lock) 테스트를 작성했다.

의도는 단순했다.

여러 요청이 동시에 포인트를 차감하면
단 1건만 성공하고 나머지는 실패해야 한다

하지만 테스트 결과는 계속 엇나갔다.

expected: 1 actual : 3

이번 글에서는

  • 왜 테스트가 계속 실패했는지
  • 그리고 어떤 차이로 인해 정확히 성공하게 되었는지

를 정리한다.


처음 접근

“멀티 스레드면 충분하지 않을까?”

초기 테스트는 다음과 같은 전제에서 시작했다.

  • ExecutorService로 여러 스레드를 생성
  • 각 스레드에서 동일한 사용자에 대해 포인트 차감 요청
  • 엔티티에 @Version 필드가 있으니 낙관적 락이 걸릴 것이라 기대

겉보기에는 충분히 “동시성 테스트”처럼 보였다.

하지만 실제 결과는:

  • 모든 요청이 성공하거나
  • 심지어 3번 모두 포인트가 차감되는 상황이 발생했다

실패 원인 ①

“동시에 실행”과 “동시에 커밋”은 다르다

가장 큰 착각은 이것이었다.

여러 스레드에서 동시에 메서드를 호출하면
자연스럽게 낙관적 락 충돌이 발생할 것이다

하지만 낙관적 락은 조회 시점이 아니라 커밋 시점에 동작한다.

Hibernate는 업데이트 시 다음과 같은 SQL을 생성한다.

1
2
3
update users
set points = ?, version = ?
where id = ? and version = ?

즉,

  • 같은 version으로 업데이트를 시도해야
  • 단 하나만 성공하고
  • 나머지는 0 row updatedOptimistic Lock 예외 발생

문제는 초기 테스트에서

  • 각 스레드가 서로 다른 시점에 조회하고
  • 서로 다른 version으로 커밋을 시도하고 있었다는 점이다

결과적으로 충돌 자체가 발생하지 않았다.


실패 원인 ②

트랜잭션 경계가 분리되지 않았다

또 하나의 문제는 트랜잭션 범위였다.

테스트 클래스 또는 메서드에 @Transactional이 붙어 있는 경우:

  • 여러 스레드가 같은 트랜잭션 컨텍스트를 공유할 수 있다

이 경우,

  • 각 스레드는 독립적인 DB 커밋을 수행하지 못하고
  • Hibernate가 내부적으로 flush 타이밍을 조정하면서
  • 의도한 낙관적 락 충돌이 발생하지 않는다

👉 낙관적 락 테스트에서 트랜잭션 분리는 필수 조건이다.


결정적 전환점

“동시 시작”을 명시적으로 보장하다

문제를 해결하기 위해 테스트를 다음과 같이 재구성했다.

핵심 전략

  • 모든 스레드가 준비될 때까지 대기

  • 동일한 시점에 조회를 시작

  • 각 스레드는 완전히 독립된 트랜잭션 사용

이를 위해 CountDownLatch를 두 개 사용했다.

1
2
CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
  • readyLatch
    • 모든 스레드가 대기 상태에 진입했는지 보장
  • startLatch
    • 모든 스레드를 한 번에 출발시킴

이로 인해 모든 스레드는

동일한 version 값을 읽은 상태에서 동시에 커밋을 시도하게 되었다.


트랜잭션 설정 변경

서비스 레이어에서는 다음과 같이 설정했다.

1
2
3
4
@Transactional(propagation = Propagation.REQUIRES_NEW)
public DeductPointResult execute(...) {
    ...
}

이 설정으로 인해:

  • 각 스레드는 완전히 독립된 트랜잭션
  • 커밋 타이밍이 명확히 분리됨
  • Hibernate가 낙관적 락 충돌을 정확히 감지

결과

의도한 낙관적 락이 정확히 동작

테스트 결과는 다음과 같이 안정적으로 수렴했다.

1
2
success = 1
fail    = 2
  • 단 1건만 포인트 차감 성공
  • 나머지는 ObjectOptimisticLockingFailureException
  • 최종 포인트는 정확히 1회만 차감

핵심 정리

이번 경험에서 얻은 교훈은 명확하다.

✔ 낙관적 락 테스트의 본질

  • 멀티 스레드가 중요한 것이 아니다
  • 같은 version으로 커밋을 시도했는지가 핵심이다

✔ 반드시 지켜야 할 조건

  • 트랜잭션은 스레드별로 분리되어야 한다
  • 커밋 시점 충돌을 의도적으로 만들어야 한다
  • “동시 실행”이 아니라 “동시 커밋 조건”을 보장해야 한다

마무리

낙관적 락은 구현보다 테스트가 더 어렵다. 그리고 테스트가 정확하지 않으면,

“락이 안 걸린다” → 사실은 테스트가 잘못된 것일 가능성이 높다.

이번 경험을 통해 “정말 락이 안 걸린 것인지, 아니면 테스트가 잘못된 것인지” 를 구분하는 기준을 명확히 세울 수 있었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.