Hibernate는 @Version 없이도 stale detection을 한다 — 좋아요 동시성 디버깅에서 만난 의외의 안전장치
들어가며
게시글 좋아요 토글(toggleLike) 기능을 보다가 동시성 문제가 있을 것 같아서 재현 테스트를 만들어 봤습니다. 처음 가설은 꽤 단순했어요.
"check-then-act 패턴이니까 동시 요청이 들어오면 like_count가 음수가 될 거다."
그런데 막상 실측해 보니 결과가 예상과 완전히 달랐습니다. 정합성은 멀쩡한데, 10건 중 9건이 500 에러로 실패하고 있었어요. 원인을 따라가다 보니 Hibernate가 @Version 없이도 자동으로 해주는 stale detection 메커니즘을 만나게 됐습니다. 이 글에서는 그 과정을 정리해 보려고 합니다.
1. 문제의 코드
좋아요 토글 서비스 코드는 아래와 같았습니다.
@Transactional
public LikeResponse toggleLike(UUID userId, int postId) {
Optional<PostLike> optionalPostLike =
likeRepository.findByUser_IdAndPost_Id(userId, postId);
// 좋아요 취소
if (optionalPostLike.isPresent()) {
likeRepository.delete(optionalPostLike.get());
postStatusRepository.decrementLikeCount(postId);
return new LikeResponse(false, getLikeCount(postId));
}
// 좋아요 등록
User userRef = userRepository.getReferenceById(userId);
Post postRef = postRepository.getReferenceById(postId);
likeRepository.save(new PostLike(userRef, postRef));
postStatusRepository.incrementLikeCount(postId);
return new LikeResponse(true, getLikeCount(postId));
}
여기서 decrementLikeCount는 원자적으로 동작하는 UPDATE 쿼리입니다.
@Modifying
@Query(value = """
UPDATE post_statuses
SET like_count = like_count - 1
WHERE post_id = :id
""", nativeQuery = true)
void decrementLikeCount(@Param("id") int id);
전형적인 check-then-act 구조입니다. 좋아요가 있는지 먼저 확인(check)하고, 그 결과에 따라 다른 동작(act)을 수행하는 방식이죠.
2. 가설 — 정합성이 깨질 것이다
같은 사용자가 좋아요가 눌린 상태에서 거의 동시에 두 번 클릭하면, 즉 동시 더블 취소 상황에서는 아래 같은 race condition이 생길 거라고 예상했습니다.
초기 상태: post_likes에 row 1개, like_count=1
Thread A: find → 있음 → delete(rowA) → decrement(-1)
Thread B: find → 있음 → delete(rowA, no-op) → decrement(-1)
기대 결과: like_count=-1, row=0 → 정합성 깨짐
delete()는 실제로 한 번만 일어나더라도, decrement는 두 스레드에서 모두 실행될 수 있으니 like_count가 두 번 깎일 거라고 생각했어요.
3. 재현 테스트
여기서 한 가지 주의할 점이 있었습니다. JPA 테스트에 @Transactional을 그대로 붙이면 모든 스레드가 하나의 트랜잭션을 공유하게 돼서 동시성이 사라집니다. 그래서 TransactionTemplate으로 setUp만 단일 트랜잭션으로 감싸고, 테스트 본문은 non-transactional 상태로 둔 다음 ExecutorService로 N개 스레드를 동시에 실행했습니다.
@SpringBootTest
class LikeConcurrencyTest {
@Autowired LikeService likeService;
@Autowired LikeRepository likeRepository;
@Autowired PostStatusRepository postStatusRepository;
// ... (생략)
@Test
void 동시_더블_취소() throws InterruptedException {
// given: 좋아요 1개 등록 상태
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
Map<String, AtomicInteger> exceptionCounts = new ConcurrentHashMap<>();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await();
likeService.toggleLike(userId, postId);
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
exceptionCounts
.computeIfAbsent(e.getClass().getName(), k -> new AtomicInteger())
.incrementAndGet();
} finally {
doneLatch.countDown();
}
});
}
startLatch.countDown();
doneLatch.await(10, TimeUnit.SECONDS);
int actualRows = likeRepository.countByPost_Id(postId);
int likeCount = postStatusRepository.findById(postId).orElseThrow().getLikeCount();
System.out.println("successCount=" + successCount + ", failCount=" + failCount);
System.out.println("actualRows=" + actualRows + ", likeCount=" + likeCount);
System.out.println("예외=" + exceptionCounts);
}
}
4. 실측 결과 — 예상과 완전히 달랐다
successCount=1, failCount=9
actualRows=0, likeCount=0
예외={org.springframework.orm.ObjectOptimisticLockingFailureException=9}
- 정합성은 멀쩡하다: actualRows == likeCount == 0
- 하지만 9개 스레드가 실패했다: 1개만 성공, 9개는 예외
가설은 완전히 빗나갔습니다. 정합성이 깨지는 게 아니라 9/10이 500 에러로 실패하고 있었어요. 그리고 예외 이름도 익숙하지 않았습니다. ObjectOptimisticLockingFailureException.
저는 낙관적 락이 @Version 어노테이션을 붙여야만 동작하는 줄 알고 있었습니다. 그런데 엔티티에는 @Version이 없습니다. 그럼 이 예외는 왜 발생한 걸까요?
5. 예외의 정체 — Hibernate의 자동 stale detection
스택 트레이스의 root cause를 따라가 보니 진짜 예외가 따로 있었습니다.
ObjectOptimisticLockingFailureException
-> StaleObjectStateException:
Row was updated or deleted by another transaction
(or unsaved-value mapping was incorrect): [PostLike#1]
StaleObjectStateException. 말 그대로 "내가 지우려던 row가 다른 트랜잭션에 의해 이미 사라졌다"는 뜻입니다.
이게 왜 가능한지 이해하려면 Hibernate가 delete()를 실제로 어떻게 처리하는지 먼저 봐야 합니다.
Hibernate의 지연 쓰기와 auto-flush
em.remove(entity)나 repository.delete(entity)는 즉시 DELETE SQL을 날리지 않습니다. JPA의 지연 쓰기(write-behind) 최적화 때문에, 일단 persistence context에 "이 엔티티는 지울 예정"이라고만 표시해 두고 실제 SQL은 flush 시점에 모아서 실행합니다.
flush는 보통 다음 시점 중 하나에서 일어납니다.
- 트랜잭션 commit 직전
- em.flush() 명시 호출
- JPQL 또는 네이티브 쿼리 실행 직전 (auto-flush)
decrementLikeCount는 @Modifying이 붙은 네이티브 UPDATE 쿼리입니다. Hibernate는 이 쿼리를 실행하기 직전에 "pending 상태인 변경이 native query 결과에 영향을 줄 수도 있으니 먼저 flush해야 한다"라고 판단하고 auto-flush를 트리거합니다.
그래서 toggleLike()는 실제로 아래 순서대로 흘러갑니다.
likeRepository.delete(rowA); // 1) persistence context에 "지울 예정" 등록만
postStatusRepository.decrementLikeCount(postId);
// ↓ auto-flush 트리거
// 2) DELETE FROM post_likes WHERE id=1 ← 진짜 SQL은 여기서 실행
// 3) UPDATE post_statuses SET like_count = like_count - 1
affected rows 기반 stale detection
DELETE 쿼리가 실행되면 DB는 "몇 개의 행이 영향받았는지(affected rows)"를 반환합니다. Hibernate는 이 값을 보고 상태를 판단합니다.
- affected rows = 1 → 정상. 내가 지우려던 row를 잘 지웠다.
- affected rows = 0 → "어? 내가 지우려던 row가 이미 없네" → StaleObjectStateException
JPA는 기본적으로 "managed 엔티티는 DB에 실제로 존재한다"고 가정합니다. em.remove()나 dirty checking으로 나가는 UPDATE도 전부 이 가정 위에서 돌아가죠. 그런데 다른 트랜잭션이 먼저 row를 지워버리면 이 전제가 깨집니다. Hibernate 입장에서는 "내가 알고 있던 상태와 DB 실제 상태가 다르다"는 신호이기 때문에 바로 예외를 던집니다.
이 검사는 @Version이 없어도 항상 동작합니다. 사용자가 따로 켜는 옵션이 아니라 Hibernate가 기본으로 제공하는 안전장치예요.
6. 두 스레드의 상세 흐름
그럼 실제로 두 스레드에서 무슨 일이 벌어졌는지 단계별로 그려보겠습니다.
초기 상태: post_likes에 row id=1, like_count=1
[Thread A] [Thread B]
findByUser_IdAndPost_Id findByUser_IdAndPost_Id
→ row id=1 발견 (managed) → row id=1 발견 (managed)
likeRepository.delete(rowA) likeRepository.delete(rowA)
↓ (실제 SQL 미발행) ↓ (실제 SQL 미발행)
decrementLikeCount(postId)
↓ auto-flush 트리거
↓ DELETE FROM post_likes WHERE id=1
→ affected rows = 1 ✅
↓
UPDATE post_statuses
SET like_count = like_count - 1
→ 정상
↓
commit 성공 decrementLikeCount(postId)
↓ auto-flush 트리거
↓ DELETE FROM post_likes WHERE id=1
→ affected rows = 0 ❌
↓
Hibernate:
"이미 지워진 row다!"
→ StaleObjectStateException
→ 트랜잭션 롤백
→ decrement 쿼리도 실행 안 됨
핵심은 여기입니다. B의 decrementLikeCount는 DELETE의 auto-flush 단계에서 예외가 터지기 때문에 아예 실행되지 못합니다. 그래서 like_count가 두 번 깎이는 상황은 발생하지 않습니다. 정합성이 "우연히" 보호된 이유가 바로 이 메커니즘이었습니다.
7. @Version 기반 낙관적 락과 무엇이 다른가
이름이 비슷해서 헷갈리기 쉬운데, 두 메커니즘은 보호하는 대상이 다릅니다.
항목 @Version 기반 자동 stale detection
| 활성화 | 명시적 어노테이션 필요 | 항상 작동 |
| 검사 방식 | version 컬럼 비교 | DELETE/UPDATE의 affected rows 검사 |
| 막을 수 있는 것 | Lost update | "사라진 row 삭제/수정" |
| 던지는 예외 | OptimisticLockException | StaleObjectStateException |
자동 stale detection은 "Lost update를 막아주는 진짜 낙관적 락"은 아닙니다. 그냥 "내가 지우려던 row나 수정하려던 row가 이미 사라졌네"를 감지해 주는 수준이에요. 진짜로 Lost update를 막으려면 @Version을 별도로 써야 합니다.
이번 케이스가 운 좋게 보호된 이유는 좋아요 취소 경로가 delete + UPDATE라는 두 단계 연산이었고, 첫 번째 단계가 stale detection 검사 대상에 정확히 걸렸기 때문입니다. 만약 SQL을 전부 직접 날렸거나, @Modifying 쿼리 두 개만 연달아 호출하는 구조였다면 이런 보호막은 없었을 가능성이 큽니다.
8. 그래서 진짜 문제는 무엇인가
처음에는 "정합성이 깨질 것이다"라고 생각했는데, 실측 결과를 보니 진짜 문제는 가용성이었습니다.
- 정합성: Hibernate의 stale detection으로 우연히 보호됨
- 가용성: 10건의 동시 요청 중 9건이 500 에러
사용자 입장에서는 "더블 클릭하면 대부분 실패한다"는 꽤 치명적인 UX 문제입니다. 그리고 이건 H2 같은 테스트 환경에서만 보이는 현상이 아니라 프로덕션 MySQL에서도 똑같이 발생합니다. Hibernate의 stale detection은 특정 DB가 아니라 ORM 레벨에서 동작하는 메커니즘이기 때문입니다.
해법도 분명합니다. 요청을 직렬화해야 합니다. 비관적 락(SELECT ... FOR UPDATE)이나 분산 락으로 check-then-act 전체를 임계 구역으로 묶으면, 모든 요청이 순차적으로 처리되면서 100% 성공하는 방향으로 가져갈 수 있습니다.
9. 배운 것
- @Version 없이도 Hibernate는 자동 stale detection을 수행합니다. managed 엔티티에 대한 delete()나 dirty checking UPDATE는 affected rows가 0이면 자동으로 StaleObjectStateException을 던집니다. 따로 설정하지 않아도 항상 동작하는 안전장치입니다.
- 정합성과 가용성은 완전히 다른 문제입니다. 데이터가 깨지지 않았다고 해서 코드가 건강하다는 뜻은 아닙니다. 동시성 문제를 볼 때는 정합성만 확인해서는 부족하고, 요청 성공률 같은 가용성 지표도 같이 봐야 합니다.
- 가설 검증에는 실측이 꼭 필요합니다. 코드를 머릿속으로만 시뮬레이션하면 ORM이나 DB가 이미 깔아둔 안전장치를 놓치기 쉽습니다. 부하 테스트나 동시성 테스트로 실제로 어떤 일이 벌어지는지 확인해야 진짜 문제가 드러납니다.
참고
- JPA 스펙: EntityManager.remove() semantics
- Hibernate User Guide: Flushing and dirty checking
- Spring Data JPA: @Modifying and auto-flush behavior