조회 성능을 끌어올리겠다고 야심 차게 집계 컬럼을 추가했다가, 며칠 뒤 "팔로워 수가 왜 안 맞지?"라며 머리를 싸매본 적 있으신가요? 저는 있습니다. 오늘은 JPA의 더티 체킹(변경 감지)을 쓰다가 만나게 되는 동시성과 정합성 문제, 그리고 이걸 어떻게 풀어나갈 수 있는지를 차근차근 정리해보려고 합니다.
문제의 발단: 조회 성능을 위해 추가한 집계 컬럼
읽기 성능을 개선하려다 보면 흔히 만나는 갈림길이 있습니다. 매번 COUNT 쿼리로 통계를 계산할 것이냐, 아니면 반정규화를 해서 집계 값을 컬럼으로 박아둘 것이냐.
캐시 계층이나 조회 전용 NoSQL을 따로 둘 여력이 없다면, 후자가 꽤 매력적입니다. 예를 들어 제품 테이블에는 리뷰 개수, 리뷰 총점, 평균 리뷰 점수를, 회원 테이블에는 팔로워 수를 컬럼으로 둡니다. 조회할 때 조인이나 집계 함수 없이 컬럼 하나만 읽으면 되니 빠릅니다.
대신 대가가 있습니다. 데이터가 바뀔 때마다 이 집계 컬럼을 손수 갱신해줘야 한다는 것이죠. 흐름으로 보면 이렇습니다.
- 리뷰 작성: 제품 조회 → 이미 등록한 리뷰가 있는지 확인 → 리뷰 작성 → 제품 통계 갱신
- 팔로우: 대상 회원 조회 → 이미 팔로우했는지 확인 → 팔로잉 추가 → 회원의 팔로워 수 갱신
별 문제 없어 보이죠? 저도 처음엔 그랬습니다. 그런데 여기에 동시성이라는 복병이 숨어 있었습니다.
더티 체킹이 만든 함정
집계 컬럼 갱신을 JPA의 더티 체킹으로 처리한다고 해봅시다. 더티 체킹(변경 감지)은 트랜잭션이 커밋되거나 영속성 컨텍스트가 flush 되는 시점에, 엔티티의 스냅샷과 현재 상태를 비교해서 바뀐 컬럼이 있으면 UPDATE 쿼리를 날려주는 기능입니다. 쿼리를 직접 쓰지 않아도 도메인 객체의 값만 바꾸면 알아서 반영되니, 비즈니스 로직을 도메인 안에 응집시키기 딱 좋습니다.
문제는 더티 체킹을 쓰려면 반드시 기존 엔티티를 DB에서 조회해 와서 필드를 수정해야 한다는 점입니다. 그런데 엔티티를 조회한 시점과 트랜잭션이 커밋되어 UPDATE가 날아가는 시점 사이에, 다른 트랜잭션이 같은 레코드를 바꿔버린다면 어떻게 될까요?
한눈에 보는 사고 현장
회원 c를 두 사람이 동시에 팔로우하는 상황을 따라가 봅시다.
- 트랜잭션 A와 B가 거의 동시에 시작합니다.
- 둘 다 c를 조회합니다. 이 시점 c의 팔로워 수는 둘 다 0으로 읽었습니다.
- A가 먼저 로직을 끝내고 커밋합니다. 더티 체킹으로 c의 팔로워 수가 1이 됩니다.
- 이어서 B가 커밋합니다. B는 처음에 읽은 0에서 +1을 했으므로 자기 기준으론 1. 더티 체킹은 "팔로워 수 = 1"로 UPDATE를 날립니다.
결과를 보면, 실제로 생성된 팔로우는 2개인데 c의 팔로워 수는 1입니다. 한 건이 증발했습니다. 이게 바로 정합성이 깨지는 순간입니다. 더티 체킹은 "최종 상태를 통째로 덮어쓰는" 방식이라, 누가 중간에 끼어들어도 자기가 읽은 값 기준으로 결과를 밀어붙이거든요.
정합성을 지키기 위한 네 가지 고민
그럼 이걸 어떻게 막을까요? 떠올린 방법은 크게 네 가지였습니다.
1. 트랜잭션 격리 수준을 Serializable로 올리기
가장 단순한 발상은 격리 수준을 최고 단계인 Serializable로 올리는 것입니다. "동시 접근을 막으면 되잖아?" 싶죠. 하지만 여기엔 데드락이라는 함정이 있습니다.
MySQL(InnoDB) 기준으로 Serializable에서는 평범한 SELECT에도 공유락(Shared Lock, S-Lock)이 걸리고, UPDATE 시점엔 배타락(Exclusive Lock, X-Lock)이 필요합니다. 그런데 공유락끼리는 서로 호환되기 때문에 A와 B가 동시에 c에 대한 공유락을 잡을 수 있습니다.
진짜 문제는 커밋 직전입니다.
- A가 UPDATE를 위해 배타락을 얻으려는데, B가 공유락을 쥐고 있어 대기합니다.
- B도 UPDATE를 위해 배타락을 얻으려는데, A가 공유락을 쥐고 있어 대기합니다.
서로 상대가 락을 놓기만 기다리는, 교과서적인 락 업그레이드 데드락입니다. DB가 데드락을 감지해 한쪽을 롤백시키니 예외 처리로 어찌어찌 막을 수는 있겠지만, 근본적인 해결책이라고 보긴 어렵습니다.
2. 비관적 락(Pessimistic Lock)
조회 메서드에 비관적 락을 걸면 조회 시점부터 배타락을 잡습니다. A가 c를 비관적 락으로 읽어오면, A가 끝날 때까지 다른 트랜잭션은 공유락도 배타락도 못 얻습니다. 한 번에 하나씩만 들어오니 정합성도, 데드락도 깔끔하게 해결됩니다.
문제는 대기 시간입니다. 트랜잭션 시작점부터 배타락을 잡아버리니, 사실상 트랜잭션 하나가 레코드를 통째로 점유하는 셈이 됩니다. 정합성은 확실하지만 동시 접근이 늘어날수록 API 응답 대기 시간도 같이 늘어납니다.
게다가 DB는 애플리케이션이 모르는 내부 락을 알아서 걸기 때문에, 애플리케이션 레벨에서 명시적으로 락을 거는 게 어떤 부작용을 낳을지 예측하기 까다롭습니다. 트랜잭션과 락을 이제 막 깊게 파보는 입장이라면 더더욱 조심스럽죠. 실제로 현업 경험이 있는 분들도 비관적 락은 가능하면 피하라고 조언하는 경우가 많습니다. 이 두 가지 이유로 비관적 락은 후보에서 내렸습니다.
3. 낙관적 락(Optimistic Lock)
데드락도 없고, 레코드를 통째로 잠그지도 않고, JPA에서는 버전 컬럼만 두면 손쉽게 구현됩니다. @Version 한 줄이면 끝이니 매력적이죠.
하지만 낙관적 락은 정합성을 지키는 대가로 비즈니스 로직을 희생시킵니다. 버전이 일치할 때만 커밋하고, 안 맞으면 롤백하는 방식이거든요. 앞의 시나리오에 대입하면 a → c 팔로우는 통과하지만, b → c 팔로우는 "팔로워 수를 맞추는 로직에서 버전이 안 맞아서" 통째로 실패해버립니다. 정작 실패하지 말아야 할 팔로우 자체가 통계 갱신 충돌 때문에 엎어지는 거죠.
"그럼 팔로잉 생성과 팔로워 수 갱신의 트랜잭션을 분리하면 되잖아?"라고 할 수 있는데, 그러면 다시 처음의 정합성 문제로 돌아옵니다. 결국 낙관적 락도 이 케이스엔 잘 맞지 않았습니다.
4. 더티 체킹을 포기하고 쿼리를 직접 날리기 (채택)
마지막 방법은 더티 체킹을 과감히 버리고, 레코드의 현재 값을 기준으로 직접 계산 쿼리를 날리는 것입니다. 도메인 객체의 값을 바꾸는 대신 서비스 레이어에서 레포지토리 메서드를 직접 호출해야 해서, 도메인 안에 로직을 모으는 객체지향적 이상과는 살짝 멀어집니다. 하지만 정합성은 확실하게 잡힙니다.
핵심은 "조회해서 더하기"가 아니라 "DB에게 더하라고 시키기"입니다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Member m set m.followerCount = m.followerCount + 1 where m.id = :followingMemberId")
void increaseFollowerCount(Long followingMemberId);
이 JPQL은 "지금 저장된 팔로워 수에 1을 더하라"고 DB에 직접 지시합니다. 애플리케이션이 값을 읽어와 더하는 게 아니라, DB가 자기 레코드 값을 기준으로 증가시키죠. 이렇게 하면 COUNT 쿼리로 매번 세는 것보다 훨씬 빠르고, UPDATE 시 DB가 자체적으로 거는 배타락 덕분에 정합성도 보장됩니다. 먼저 들어온 트랜잭션이 UPDATE를 끝내고 커밋/롤백할 때까지 다음 트랜잭션은 락 획득을 기다리니까요. 회원 c에 별도의 공유락·배타락을 거는 다른 로직만 없다면 데드락도 피할 수 있습니다.
참고로 특정 DB에 종속되지 않으려고 네이티브 쿼리 대신 JPQL 문법만으로 풀 수 있는지 고민했는데, 다행히 JPQL이 지원하는 범위 안에서 해결할 수 있었습니다.
직접 쿼리를 쓸 때 반드시 짚어야 할 함정
여기서 @Modifying의 두 옵션을 정확히 이해하는 게 중요합니다. JPQL은 실행 직전에 영속성 컨텍스트를 flush 합니다. 즉 쓰기 지연 저장소(write-behind)에 쌓여 있던 쿼리들이 나가는데, 주의할 점은 "모든" 쿼리가 나가는 게 아니라는 것입니다.
위 JPQL은 Member에 대한 쿼리이므로, Hibernate의 자동 flush는 쿼리가 건드리는 테이블(query space)을 기준으로 판단해서 Member 관련 변경분만 flush 합니다. Following에 대한 INSERT나 DELETE는 같은 테이블이 아니니 그대로 쓰기 지연 저장소에 남아 있을 수 있습니다.
그런데 @Modifying 메서드에는 보통 clearAutomatically = true를 답니다. 벌크성 쿼리 실행 후 영속성 컨텍스트와 DB가 어긋나는 걸 막기 위해 컨텍스트를 비워버리는 옵션이죠. 문제는 이 상황에서 컨텍스트를 그냥 비워버리면, 아직 flush 되지 않고 남아 있던 Following의 INSERT/DELETE 쿼리가 통째로 유실된다는 점입니다.
예를 들어 식별자 생성 전략이 IDENTITY가 아니어서 INSERT가 지연되거나, 언팔로우라 DELETE가 지연 저장소에 쌓인 경우, 팔로워 수는 바뀌었는데 정작 팔로잉 추가/삭제는 사라지는 황당한 상황이 벌어집니다.
그래서 어떤 엔티티 관련 쿼리든 상관없이 지연 저장소의 모든 쿼리를 먼저 내보내야 합니다. 바로 이때 flushAutomatically = true가 등판합니다. 이 옵션은 JPQL 실행 전에 영속성 컨텍스트 전체를 flush 하므로, Following의 INSERT/DELETE까지 모두 반영된 뒤에 clearAutomatically가 컨텍스트를 안전하게 비웁니다. 두 옵션을 짝으로 켜야 데이터가 새지 않습니다.
한눈에 보는 네 가지 방법 비교

직접 쿼리 방식의 단점도 분명합니다. 더티 체킹을 포기하면서 코드가 덜 객체지향적이 되고, 도메인 로직이 서비스와 레포지토리로 흩어지면서 서비스가 비대해질 수 있습니다. 서비스 레이어를 모킹하는 테스트 구조라면 통합 검증이 까다로워지기도 하고요. 그래도 락을 최소화하면서 정합성을 가장 확실하게 보장한다는 점에서, 이 케이스에는 가장 합리적인 선택이었습니다.
번외: 배치 스케줄러로 나중에 맞추기
실시간 정합성을 포기하고, 스케줄러와 배치 쿼리로 주기적으로 통계를 다시 계산해 맞추는 방법도 있습니다. 동시성 문제가 자주 터지진 않을 테고(실제로 서비스 환경에서 의도적으로 재현하기도 꽤 어렵습니다), "잠깐 안 맞는 게 락으로 고생하는 것보단 낫다"고 볼 수도 있으니까요. 특히 팔로워 수처럼 정확한 숫자가 실시간으로 그렇게까지 중요하지 않은 통계라면 더 그렇습니다.
그런데 이 방식이 효율적이려면 애초에 팔로워 수 UPDATE 쿼리가 실행되지 않아야 합니다. 더티 체킹으로 카운트를 올리는 순간 어차피 UPDATE로 배타락이 걸리는데, 그럴 거면 직접 계산 쿼리를 날리는 것과 락 점유 시간 차이가 거의 없으면서 실시간 정합성만 깨지는 셈이거든요. 모든 리뷰 작성·수정·삭제, 팔로우·언팔로우마다 잠깐씩 숫자가 안 맞는 구간이 생기는 건데, 인스타그램처럼 팔로워가 수십만이라 K 단위로 표시되는 서비스가 아니라면 +1 한 건의 어긋남도 꽤 거슬립니다.
물론 적절한 캐시 레이어를 두면 UPDATE 없이도 사용자에게 보여주는 카운트를 실시간으로 올릴 수 있습니다. 캐시에 카운트를 동기화해두고 주기적으로 DB에 반영하는 식이죠. 다만 캐시 인프라를 구축하는 시간 비용을 감당하기 어려운 상황이라면, 이 방식은 다음을 기약하게 됩니다.
앞으로 개선하고 싶은 점
문제는 해결했지만 아쉬움은 남습니다. 정합성을 맞추느라 서비스 로직이 비대해졌다는 점이죠. 당장 손대긴 어려워 숙제로 남겨두지만, 구상은 이렇습니다.
- 이벤트 발행으로 팔로워 수 갱신 로직을 서비스에서 분리: 트랜잭션이 나뉘어 정합성이 어긋날 수 있지만, 매우 드문 경우이니 그 부분만 배치 스케줄러로 보완하는 절충안.
- 팔로워 수 전용 캐시 레이어 도입: 매 팔로우·언팔로우마다 DB에 한 번 더 접근하는 비용을, 디스크 I/O보다 빠른 인메모리 I/O로 줄이기.
마치며
정리하면, JPA의 더티 체킹은 편하지만 "조회한 값 기준으로 통째 덮어쓰기"라는 특성 때문에 동시성 환경에서 정합성을 깨뜨릴 수 있습니다. 격리 수준 상향은 데드락을, 비관적 락은 대기 시간과 부작용을, 낙관적 락은 정상 로직의 실패를 불러왔고, 결국 "DB에게 직접 더하라고 시키는" 쿼리 방식이 락은 최소화하면서 정합성을 가장 확실하게 잡아줬습니다.
그리고 그 과정에서 flushAutomatically와 clearAutomatically를 짝으로 켜야 데이터가 새지 않는다는, 작지만 치명적인 디테일도 챙겨야 했고요. 비슷한 고민을 하고 계신 분들께 작은 이정표가 되었으면 좋겠습니다.
'IT' 카테고리의 다른 글
| 2026년 6월 25일, 오늘 가장 핫한 IT 뉴스 5가지 총정리: 마이크론 어닝 서프라이즈부터 바이트댄스 AI 공습까지 (4) | 2026.06.25 |
|---|---|
| JPA 동시성 제어 완벽 정리: 낙관적 잠금(Optimistic Lock) vs 비관적 잠금(Pessimistic Lock) (3) | 2026.06.25 |
| 재고는 1개인데 주문이 2건? 동시성 이슈를 해결하는 모든 방법 총정리 (4) | 2026.06.24 |
| 2026년 6월 24일 IT 뉴스 톱5: 코스피 '검은 화요일'부터 AI 모델 대전, 램마겟돈 시즌2까지 (7) | 2026.06.24 |
| 2026년 6월 22일 가장 핫한 IT 뉴스 5선: 스페이스X의 60조원 베팅부터 클로드 셧다운까지 (5) | 2026.06.22 |