포스트

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

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

들어가며

동시성 제어를 검증하려고 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으로 떨어졌다. 최종 포인트는 정확히 한 번만 차감됐다.

핵심 정리

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

낙관적 락 테스트의 본질

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

반드시 지켜야 할 조건

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

마무리

낙관적 락은 구현보다 테스트가 더 어렵다. 테스트가 정확하지 않으면 “락이 안 걸린다”고 결론짓기 쉽지만, 실제로는 테스트가 잘못됐을 가능성이 높다. 이번 경험 덕분에 정말 락이 안 걸린 것인지, 아니면 테스트가 잘못된 것인지를 구분하는 기준을 분명히 세울 수 있었다.

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