2차 캐시에 READ_WRITE 전략을 걸어두면 Hibernate User Guide는 "동시 접근을 안전하게 관리하기 위해 캐시 항목에 락을 건다"고만 설명한다. 이 "락"이 DB 트랜잭션 락과 같은 것인지, 락을 쥔 트랜잭션이 죽으면 어떻게 되는지는 문서에 없다. 소스 레벨에서 따라가 보니 이 락은 DB에는 전혀 닿지 않는 캐시 계층 자체의 락이었고, 소유자가 사라져도 스스로 풀리도록 설계돼 있었다.
Item과 SoftLockImpl — 캐시가 들고 있는 두 가지 형태org.hibernate.cache.spi.support.AbstractReadWriteAccess는 캐시 리전에 값을 넣을 때 항상 Lockable 인터페이스로 감싼다. 구현은 두 가지뿐이다.
Item(value, version, timestamp) — 읽을 수 있는 상태. timestamp는 이 값이 캐시에 들어간 시각이다.SoftLockImpl(timeout, multiplicity, unlockTimestamp) — 잠긴 상태. multiplicity는 이 키를 동시에 잠근 트랜잭션 수, timeout은 락이 스스로 풀리는 시각이다.
READ_WRITE의 락은 DB 행 락이 아니라 캐시 접근 계층에만 존재하는 soft lock이다. 다른 세션의 DB 트랜잭션은 이 락과 무관하게 진행되고, 영향받는 건 "그 사이 이 키를 읽는 요청이 캐시 미스로 DB를 다시 탄다"는 것뿐이다.
UPDATE가 flush될 때 EntityUpdateAction.execute()는 실제 UPDATE SQL을 보내기 전에 lockCacheItem()을 호출해 캐시 키를 SoftLockImpl로 바꿔둔다. 이 순간부터 Item.isReadable()이 아니라 SoftLockImpl.isReadable()이 호출되는데, 이건 조건 없이 항상 false를 반환한다. 즉 SQL이 DB에 도달하기도 전에 캐시는 이미 "이 값은 못 믿는다"고 선언한다.
트랜잭션이 끝나면(doAfterTransactionCompletion) 결과에 따라 갈린다.
afterUpdate()로 새 Item을 채워 넣는다. 이때 락도 함께 풀린다.unlockItem()으로 락만 풀고 값은 비워둔다. 다음 읽기는 캐시 미스로 DB를 다시 탄다.헷갈리기 쉬운 지점이 multiplicity다. lockItem()이 이미 잠긴 키에 다시 호출되면 새 SoftLockImpl을 만드는 게 아니라 기존 항목의 multiplicity를 증가시킨다. 언락은 반대로 감소시키다가 0이 되는 순간에만 unlockTimestamp를 기록한다. 트랜잭션이 여러 개 겹쳐 같은 키를 잠갔다면, 그중 아무나 커밋한다고 캐시가 곧바로 다시 열리지 않는다.
isWriteable()은 txTimestamp > timeout(만료됐으니 덮어써도 됨)이거나 multiplicity == 0(모두 풀렸음)일 때만 허용한다. 소유자가 죽어서 unlock을 못 불러도 timeout이 지나면 이 조건이 스스로 만족돼 락이 풀린다 — 크래시가 캐시를 영구히 잠그지 않게 하는 안전장치다. 이 timeout은 SimpleTimestamper가 만드는데, 클래스 주석은 "단일 VM에서만 유효하다"고 명시한다. AtomicLong CAS로 밀리초 해상도보다 촘촘한 증가를 보장하는 구조라, 여러 JVM이 같은 캐시 리전을 공유하는 클러스터 구성에서는 이 타임스탬프를 그대로 신뢢할 수 없다.
노트에는 없던 예시를 하나 만들어 봤다. 같은 엔티티(키 E)를 T1, T2가 거의 동시에 갱신하는 상황이다.
| 시각 | 이벤트 | multiplicity | 캐시 상태 |
|---|---|---|---|
| t=0 | 캐시 미스, Item 채워짐 | - | Item |
| t=10 | T1이 lockItem() | 1 | SoftLockImpl |
| t=12 | T2가 같은 키에 lockItem() | 2 | SoftLockImpl (concurrent=true) |
| t=15 | T2 커밋, unlockItem() | 1 | 여전히 SoftLockImpl |
| t=20 | T1 커밋, afterUpdate() | 0 | Item (새 값) |
t=15에서 T2가 먼저 커밋해도 캐시는 열리지 않는다. multiplicity가 아직 1이라 isWriteable()이 false를 반환하기 때문이다. 여기서 곧바로 Item으로 되돌렸다면 T1의 UPDATE가 진행 중인데 다른 트랜잭션이 "확정된 값"인 것처럼 읽어갈 수 있었다. multiplicity가 카운터인 이유는 이 창을 막기 위해서다 — 마지막으로 언락하는 쪽만 캐시를 다시 연다.
// 실제 필드/메서드명을 유지한 축약형 재구성
class SoftLockImpl implements Lockable {
long timeout;
int multiplicity = 1;
long unlockTimestamp;
boolean isReadable(long txTimestamp) {
return false; // 잠긴 동안은 예외 없이 불가
}
boolean isWriteable(long txTimestamp, Object newVersion) {
if (txTimestamp > timeout) return true; // 락 만료 → 덮어쓰기 허용
if (multiplicity > 0) return false; // 아직 누가 쥐고 있음
return txTimestamp > unlockTimestamp; // 모두 풀린 뒤 시점만 허용
}
void lock() {
multiplicity++;
}
void unlock(long ts) {
if (--multiplicity == 0) unlockTimestamp = ts;
}
}
READ_WRITE 2차 캐시의 락은 DB에는 보이지 않는, 캐시 리전 안에서만 통용되는 soft lock이다. multiplicity로 중첩 잠금을 세고, timeout으로 소유자 생존과 무관하게 스스로 풀리도록 설계돼 있다는 게 핵심이다. 다음에 파고들 만한 지점은 NONSTRICT_READ_WRITE가 락 없이 무효화만으로 이 문제를 어떻게 더 느슨하게 처리하는지, 그리고 Infinispan 같은 분산 2차 캐시에서 SimpleTimestamper의 "단일 VM 전제"가 실제로 어떤 클록으로 대체되는지다.
AccessType, AbstractReadWriteAccess, SimpleTimestamper, EntityUpdateActionEntityUpdateAction이 flush 큐 안에서 실행 순서를 어떻게 재배열하는지 다룬 글