
프로젝트를 하다 보면,
다른 엔티티의 상태 변화에 따라 특정 값을 함께 갱신해야 하는 요구사항을 종종 마주하게 됩니다.
예를 들어 리뷰가 등록되면 Book의 reviewCount를,
댓글이 달리면 Review의 commentCount를 함께 갱신해야 하는 식이죠.
이번 글은 코드잇 스프링 부트캠프에서 실제로 멘토링 중 나왔던 이와 같은 고민에서 출발해,
JPQL UPDATE와 락 전략, 그리고 동시성 문제에 대해 정리해보았습니다.
reviewCount는 리뷰가 생성될 때마다 어떻게 갱신할까?commentCount는 댓글이 생성될 때마다 어떻게 관리해야 할까?네모님은 다음 세 가지 방식 사이에서 동시성과 정합성 측면에서 어떤 전략이 최선일지 깊이 고민하셨습니다:
book.reviewCount++ SELECT COUNT(*) FROM Review WHERE book_id = ?로 매번 다시 세기 그중 네모님은 3번 방식, 즉 JPQL UPDATE 쿼리로 reviewCount와 rating을 함께 갱신하는 방식을 선택하셨어요.
구체적으로 락이나 JPQL의 동작 방식에 대해 언급하시진 않았지만,
그 선택만으로도 실무적인 고민이 엿보였기 때문에
👉 이 글에서는 그 흐름을 따라
락(Lock), JPQL UPDATE의 특성,
그리고 비관적 락과 낙관적 락 중 어떤 전략을 선택할 수 있는지까지 함께 정리해보고자 합니다.
@Modifying
@Query("""
UPDATE Book b
SET
b.reviewCount = (SELECT COUNT(r) FROM Review r WHERE r.book.id = :bookId),
b.rating = (SELECT COALESCE(AVG(r.rating), 0) FROM Review r WHERE r.book.id = :bookId)
WHERE b.id = :bookId
""")
void recalcStats(@Param("bookId") UUID bookId);
@Modifying과 함께 JPQL UPDATE 사용그치만 ..!!!
JPQL UPDATE는 JPA의 영속성 컨텍스트를 무시하고 DB에 바로 반영되기 때문에,
같은 트랜잭션 내에서 조회 결과가 최신 값과 불일치할 수 있습니다.
이 문제를 방지하려면 @Modifying(clearAutomatically = true) 옵션을 반드시 사용하는 것이 좋아요.
@Modifying(clearAutomatically = true)
@Query("UPDATE Book b SET b.reviewCount = :count WHERE b.id = :bookId")
void updateCount(@Param("count") long count, @Param("bookId") UUID bookId);
JPQL로 직접 UPDATE를 수행하면, JPA는 해당 row에 대해 쓰기 락(write lock)을 요청해요.
이는 DB 차원에서 다른 트랜잭션의 접근을 차단하는 비관적 락(Pessimistic Lock)의 대표적인 예시입니다.
| 구분 | 비관적 락 (Pessimistic) | 낙관적 락 (Optimistic) |
|---|---|---|
| 전략 | "다른 트랜잭션이 수정할 수도 있으니, 미리 잠그자"(부정핑) | "설마 동시에 수정하겠어? 나중에 충돌 검사하자"(긍정?핑) |
| 구현 방식 | DB 레벨 락 (예: SELECT FOR UPDATE, JPQL UPDATE) | 버전 필드(@Version) 기반 충돌 체크 |
| 사용 예 | JPQL UPDATE, SELECT FOR UPDATE | 엔티티 merge 시 버전 체크 |
| 장점 | 정합성 강력 보장 | 성능 우수, 락 없음 |
| 단점 | 성능 저하, 블로킹 발생 가능 | 충돌 시 예외, 로직 복잡도 증가 |

graph TD
A[리뷰 생성 요청] --> B{락 전략 선택}
B -->|비관적 락| C[JPQL UPDATE → row 잠금]
B -->|낙관적 락| D[@Version 기반 충돌 검사]
C --> E[동시 수정 방지, 성능 부담]
D --> F[충돌 시 예외 발생, 성능 우위]
리뷰 생성 시 book.reviewCount와 rating을 어떻게 갱신할지 고민하던 네모님은,
JPQL UPDATE 쿼리를 통해 두 값을 한 번에 갱신하는 방법을 선택하셨어요.
이 방식이 야무졌던 이유는 다음과 같아요:
++ 연산은 동시성 이슈에 쉽게 노출될 수 있기 때문이죠.복잡한 조건을 고려한 결정이라기보단, 현실적인 상황에서 자연스럽게 나온 선택이었지만,
결과적으로는 실무에서도 자주 사용하는 안전한 방식과 맞닿아 있었습니다.
이 선택에 대해서는 9팀 멘토링 시간에 직접 들을 수 있었고,
그때 떠올랐던 기술적 배경과 함께 알아두면 좋을 내용을
큐레이션 형식으로 정리해보면 좋겠다는 생각에 이 글을 작성하게 되었습니다.
한편, 꼭 JPQL UPDATE를 쓰지 않아도 JPA에서 비관적 락을 명시적으로 걸 수 있는 방법이 있습니다.
예를 들어 findById와 함께 PESSIMISTIC_WRITE를 사용하면,
JPQL 없이도 아래처럼 트랜잭션 레벨에서 락을 적용할 수 있어요:
Book book = em.find(Book.class, bookId, LockModeType.PESSIMISTIC_WRITE);
// 이후 book.reviewCount = ... 로직 수행
이 방식은 DB 락을 안전하게 걸면서도 엔티티를 수정하는 흐름을 그대로 유지할 수 있어,
JPQL UPDATE보다 더 유연하고 타입 안전한 구조로 이어질 수 있습니다.
→ 관련 예제 보기
reviewCount++처럼 단순히 값을 증가시키는 방식은