JPQL UPDATE와 비관적 락, 낙관적 락 차이: 동시성 문제를 실무처럼 고민하기

Nova | 김인후·2025년 5월 6일
5
post-thumbnail

프로젝트를 하다 보면,
다른 엔티티의 상태 변화에 따라 특정 값을 함께 갱신해야 하는 요구사항을 종종 마주하게 됩니다.

예를 들어 리뷰가 등록되면 Book의 reviewCount를,
댓글이 달리면 Review의 commentCount를 함께 갱신해야 하는 식이죠.

이번 글은 코드잇 스프링 부트캠프에서 실제로 멘토링 중 나왔던 이와 같은 고민에서 출발해,
JPQL UPDATE와 락 전략, 그리고 동시성 문제에 대해 정리해보았습니다.


🤔 고민의 시작은 이랬습니다

  • Book 엔티티의 reviewCount는 리뷰가 생성될 때마다 어떻게 갱신할까?
  • Review 엔티티의 commentCount는 댓글이 생성될 때마다 어떻게 관리해야 할까?

네모님은 다음 세 가지 방식 사이에서 동시성과 정합성 측면에서 어떤 전략이 최선일지 깊이 고민하셨습니다:

  1. 리뷰 생성 시, 단순히 book.reviewCount++
  2. 리뷰 생성 시, SELECT COUNT(*) FROM Review WHERE book_id = ?로 매번 다시 세기
  3. JPQL UPDATE를 통해 DB에서 직접 집계 + 갱신

그중 네모님은 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 사용
  • 리뷰 수와 평점을 한 번의 쿼리로 동시 갱신
  • 이때 발생하는 락(lock) 동작을 이해하는 것이 핵심 포인트입니다.

JPQL UPDATE 사용할 때 꼭 알아야 할 점 ⚠️: clearAutomatically

그치만 ..!!!
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는 락🔒을 건다? Yes, 비관적 락🔒입니다

JPQL로 직접 UPDATE를 수행하면, JPA는 해당 row에 대해 쓰기 락(write lock)을 요청해요.
이는 DB 차원에서 다른 트랜잭션의 접근을 차단하는 비관적 락(Pessimistic Lock)의 대표적인 예시입니다.

락 전략 비교

구분비관적 락 (Pessimistic)낙관적 락 (Optimistic)
전략"다른 트랜잭션이 수정할 수도 있으니, 미리 잠그자"(부정핑)"설마 동시에 수정하겠어? 나중에 충돌 검사하자"(긍정?핑)
구현 방식DB 레벨 락 (예: SELECT FOR UPDATE, JPQL UPDATE)버전 필드(@Version) 기반 충돌 체크
사용 예JPQL UPDATE, SELECT FOR UPDATE엔티티 merge 시 버전 체크
장점정합성 강력 보장성능 우수, 락 없음
단점성능 저하, 블로킹 발생 가능충돌 시 예외, 로직 복잡도 증가

락 전략 시각화 (Mermaid)

락 전략

graph TD
    A[리뷰 생성 요청] --> B{락 전략 선택}
    B -->|비관적 락| C[JPQL UPDATE → row 잠금]
    B -->|낙관적 락| D[@Version 기반 충돌 검사]
    C --> E[동시 수정 방지, 성능 부담]
    D --> F[충돌 시 예외 발생, 성능 우위]

네모님의 선택, 실무에서도 충분히 고려할 수 있는 접근이었습니다

리뷰 생성 시 book.reviewCountrating을 어떻게 갱신할지 고민하던 네모님은,
JPQL UPDATE 쿼리를 통해 두 값을 한 번에 갱신하는 방법을 선택하셨어요.

이 방식이 야무졌던 이유는 다음과 같아요:

  • 리뷰는 자주 생성되고,
  • Book은 여러 사용자가 동시에 접근할 가능성이 높으며,
  • 단순 ++ 연산은 동시성 이슈에 쉽게 노출될 수 있기 때문이죠.

복잡한 조건을 고려한 결정이라기보단, 현실적인 상황에서 자연스럽게 나온 선택이었지만,
결과적으로는 실무에서도 자주 사용하는 안전한 방식과 맞닿아 있었습니다.

이 선택에 대해서는 9팀 멘토링 시간에 직접 들을 수 있었고,
그때 떠올랐던 기술적 배경과 함께 알아두면 좋을 내용을
큐레이션 형식으로 정리해보면 좋겠다는 생각에 이 글을 작성하게 되었습니다.


💬 꼭 JPQL UPDATE를 써야 할까? 비관적 락은 JPA에서도 가능해요

한편, 꼭 JPQL UPDATE를 쓰지 않아도 JPA에서 비관적 락을 명시적으로 걸 수 있는 방법이 있습니다.

예를 들어 findById와 함께 PESSIMISTIC_WRITE를 사용하면,
JPQL 없이도 아래처럼 트랜잭션 레벨에서 락을 적용할 수 있어요:

Book book = em.find(Book.class, bookId, LockModeType.PESSIMISTIC_WRITE);
// 이후 book.reviewCount = ... 로직 수행

이 방식은 DB 락을 안전하게 걸면서도 엔티티를 수정하는 흐름을 그대로 유지할 수 있어,
JPQL UPDATE보다 더 유연하고 타입 안전한 구조로 이어질 수 있습니다.
관련 예제 보기


🔗 참고 자료


마무리하며

  • JPQL UPDATE는 비관적 락을 유발합니다
  • reviewCount++처럼 단순히 값을 증가시키는 방식은
    여러 트랜잭션이 동시에 접근할 경우, 마지막 값만 반영되는 문제가 발생할 수 있습니다
    → 즉, 중복 업데이트나 값 손실로 이어질 수 있어 동시성 충돌에 매우 취약합니다
  • 동시성이 중요한 필드라면 쿼리 기반 갱신락 전략 고려가 필요합니다
  • 실무에서는 데이터 특성과 트래픽 패턴을 고려해 적절한 락을 선택해야 합니다
profile
SoftwareEngineer

0개의 댓글