@Version 하나로 충돌이 잡히는 게 이상했다엔티티에 @Version 필드 하나를 붙였을 뿐인데, 두 트랜잭션이 같은 행을 동시에 수정하면 한쪽이 OptimisticLockException으로 튕겨 나온다. 그런데 DB에 SELECT ... FOR UPDATE 같은 락을 거는 것도 아니다. 락 없이 어떻게 "내가 읽은 뒤 남이 바꿨다"를 감지할까? 이 질문을 SQL 수준까지 따라가 보니, 낙관적 락은 결국 조건부 UPDATE 한 문장의 영향받은 행 수(row count)로 판정하는 compare-and-set이었다. 이 글은 그 한 문장이 어디서 만들어지고, 비교가 자바가 아니라 DB에서 일어난다는 점, 그리고 흔한 오해 몇 가지를 정리한다.
낙관적 락(optimistic locking)은 "충돌이 드물다"는 낙관적 가정 아래, 미리 잠그지 않고 커밋 시점에 충돌을 사후 감지하는 방식이다. 비관적 락이 "읽을 때부터 잠가서 남을 막는" 것과 정반대다.
낙관적 락은
UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?한 문장의 영향받은 행 수로 "내가 읽은 뒤 아무도 안 바꿨는가"를 원자적으로 판정한다.
@Version이 붙는 필드는 정수 계열(int/long/Integer/Long/short)이나 java.sql.Timestamp/java.time.Instant를 쓸 수 있고, 엔티티의 "세대 번호" 역할을 한다. Jakarta Persistence 3.2 스펙 §3.4.2에 따르면 이 값은 영속성 제공자(Hibernate)가 관리하며, 애플리케이션이 임의로 손대면 안 된다. 조회할 때 함께 읽히고, flush 때 자동으로 증가한다.
flush가 일어나면 Hibernate는 dirty checking으로 바뀐 엔티티를 찾아 UPDATE 문을 만든다. 버저닝 엔티티의 UPDATE는 평범한 UPDATE와 정확히 두 군데가 다르다.
version = ?(기존값 + 1)를 넣는다.AND version = ?(읽어온 옛 버전)를 붙인다.즉 "나는 3번 세대를 읽었으니, 지금도 3번일 때만 4번으로 올린다"는 조건부 갱신이다.
-- version=3 상태에서 name을 바꾸면 flush 때 이런 문장이 나간다
UPDATE product
SET name = ?, version = 4
WHERE id = 1 AND version = 3;
여기서 놓치기 쉬운 점 하나. 비교(WHERE version = 3)와 증가(version = 4)가 하나의 UPDATE 문 안에 있으므로, read-modify-write 사이에 끼어들 틈이 없다. 즉 DB 레벨에서 원자적으로 처리되는 compare-and-set이다.
가장 헷갈렸던 지점이다. "버전을 비교하는 if 문이 자바 어딘가에 있겠지"라고 생각했는데, 아니었다. 비교는 전적으로 DB가 WHERE ... AND version = 3을 평가하면서 수행한다. 그 사이 다른 트랜잭션이 이미 version을 4로 올려버렸다면 매칭되는 행이 없어 UPDATE의 affected rows = 0이 된다.
Hibernate가 하는 일은 JDBC PreparedStatement.executeUpdate()의 반환값을 검사하는 것뿐이다. 기대 행 수(보통 1)와 다르면 예외로 전환한다.
expected rows: 1
actual rows : 0 → StaleStateException
→ (JPA 경계) OptimisticLockException
Hibernate 내부에서 Expectation.ExpectedRowCount가 이 검사를 담당한다고 알려져 있다. 비교가 DB 한 곳에서 일어나기 때문에, 애플리케이션 서버가 여러 노드로 떠 있어도 판정은 일관되다 — 각 노드의 자바 메모리에서 버전을 비교했다면 불가능했을 일이다.
두 트랜잭션이 lost update로 이어지지 않고 한쪽이 튕기는 순간을 그려보면 이렇다.
tx1 tx2
read v=3 read v=3
│ │
name="A1" name="A2"
│ │
commit ──► UPDATE ... WHERE v=3
rows=1, DB now v=4
│
commit ──► UPDATE ... WHERE v=3
rows=0 ✗ → OptimisticLockException
락 기반이라면 tx2가 tx1의 커밋까지 블로킹됐겠지만, 낙관적 락은 둘 다 자유롭게 진행하다가 늦게 커밋한 쪽이 진다. 블로킹을 없앤 대신 충돌을 예외로 떠넘긴 설계다.
기본(암묵적) 버저닝은 실제로 바뀐 컬럼이 있어야 UPDATE가 나가고 버전이 오른다. 그런데 "이 엔티티를 읽고 검증만 하는데, 그 사이 값이 안 바뀌었음을 보장"하고 싶을 때가 있다. 스펙은 LockModeType으로 이를 구분한다.
| LockModeType | 동작 |
|---|---|
OPTIMISTIC (=READ) | 커밋 시 버전을 다시 읽어(SELECT version) 변하지 않았는지 검증. 값 변경은 안 함 |
OPTIMISTIC_FORCE_INCREMENT (=WRITE) | 엔티티가 안 바뀌었어도 버전을 강제로 +1 시켜 UPDATE |
OPTIMISTIC은 flush 끝에 EntityVerifyVersionProcess가 현재 버전이 읽었던 값과 같은지 확인하고 다르면 예외를 던진다. OPTIMISTIC_FORCE_INCREMENT는 EntityIncrementVersionProcess로 버전을 밀어올려, 논리적으로 연관된 애그리거트 전체의 무결성을 한 버전선에 묶을 때 쓴다(예: 자식 컬렉션을 바꿨을 때 부모의 버전도 올리기).
detached 엔티티와 merge()의 조합도 짚어둘 만하다. 화면(폼)에 실려 클라이언트까지 갔다 온 엔티티를 merge()하면, 그 안에 실린 version이 그대로 WHERE 절에 쓰인다. 그래서 화면을 열어둔 채 한참 붙들고 있다가 저장하면, 그 사이 남이 수정한 경우 version 불일치로 충돌이 잡힌다. 낙관적 락이 HTTP 요청-응답처럼 트랜잭션 경계를 넘는 "긴 대화"에서 특히 유용한 이유다.
따라가 보면서 바로잡은 오해들을 요약한다.
PESSIMISTIC_WRITE → SELECT ... FOR UPDATE)의 몫이다.if로 한다" → 아니다. 비교는 WHERE version = ?로 DB가 한다.OptimisticLockException은 던져질 뿐, 다시 읽고 다시 적용하는 재시도는 애플리케이션 책임이다.다음에 더 파고들 만한 주제로는 비관적 락(PESSIMISTIC_WRITE)이 만드는 FOR UPDATE와 낙관적 락의 처리량·데드락 트레이드오프, 그리고 OPTIMISTIC 검증 SELECT가 트랜잭션 격리 수준(REPEATABLE READ 스냅샷)과 어떻게 상호작용하는지가 있다.
LockModeType)Versioning, EntityVerifyVersionProcess, EntityIncrementVersionProcess, Expectation.ExpectedRowCount