낙관적 락 테스트가 실패하던 이유
들어가며
동시성 제어를 검증하기 위해
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 updated→ Optimistic 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으로 커밋을 시도했는지가 핵심이다
✔ 반드시 지켜야 할 조건
- 트랜잭션은 스레드별로 분리되어야 한다
- 커밋 시점 충돌을 의도적으로 만들어야 한다
- “동시 실행”이 아니라 “동시 커밋 조건”을 보장해야 한다
마무리
낙관적 락은 구현보다 테스트가 더 어렵다. 그리고 테스트가 정확하지 않으면,
“락이 안 걸린다” → 사실은 테스트가 잘못된 것일 가능성이 높다.
이번 경험을 통해 “정말 락이 안 걸린 것인지, 아니면 테스트가 잘못된 것인지” 를 구분하는 기준을 명확히 세울 수 있었다.