Spring Data JPA 벌크 쿼리의 함정
요약: Spring Data JPA의 벌크 UPDATE·DELETE는 성능 면에서는 강력하지만, 영속성 컨텍스트를 우회한다는 특성 때문에 같은 트랜잭션 안에서 1차 캐시와 DB 상태가 어긋나는 문제를 만들 수 있습니다. 이 글은
@Modifying가 왜 필요한지,clearAutomatically와flushAutomatically가 각각 어떤 문제를 해결하는지, 그리고 실무에서 어떤 조합을 기준으로 선택해야 하는지 정리한 기록입니다.
시작하며
JPA를 쓰다 보면 누구나 한 번쯤 이런 고민을 하게 됩니다.
"엔티티를 하나씩 조회해서 수정하는 건 안전한데, 너무 느리지 않나?"
실제로 Dirty Checking 기반 변경은 객체 상태와 DB 상태를 자연스럽게 맞춰 주기 때문에 매우 안전합니다. 하지만 대량 변경이 필요한 순간에는 성능 부담이 커집니다. 그래서 많은 경우 벌크 UPDATE·DELETE를 떠올리게 되죠.
문제는 여기서부터 시작됩니다.
벌크 쿼리는 일반적인 엔티티 변경과 다르게 영속성 컨텍스트를 거치지 않고 DB에 직접 반영됩니다. 즉, DB는 이미 최신 상태인데 애플리케이션은 여전히 예전 엔티티를 들고 있는 상황이 생길 수 있습니다.
처음에는 단순히 이런 식으로 보입니다.
- 분명 업데이트를 했는데 다시 조회하면 값이 안 바뀐 것처럼 보임
- 변경 직후 로직이 최신 상태가 아니라 예전 상태를 기준으로 동작함
- 운영에서는 간헐적으로 "업데이트가 안 됐다"는 제보가 올라옴
이번 글에서는 Spring Data JPA의 @Modifying, clearAutomatically, flushAutomatically를 중심으로, 벌크 쿼리를 왜 조심해서 써야 하는지, 그리고 언제 어떤 옵션을 붙여야 안전한지를 실무 관점에서 정리해보겠습니다.
TL;DR
| 항목 | 내용 |
|---|---|
| 문제 | 벌크 UPDATE·DELETE는 영속성 컨텍스트를 우회하므로 1차 캐시와 DB 상태가 어긋날 수 있음 |
| 핵심 어노테이션 | @Modifying는 해당 리포지토리 메서드가 조회가 아니라 변경 쿼리라는 점을 Spring Data JPA에 알려줌 |
clearAutomatically 역할 | 벌크 쿼리 직후 영속성 컨텍스트를 비워 재조회 시 최신 DB 상태를 보장함 |
flushAutomatically 역할 | 벌크 쿼리 직전에 미반영 변경을 먼저 DB에 반영해 유실을 방지함 |
| 가장 안전한 조합 | 선행 변경 보존과 재조회 정합성이 모두 중요하다면 flushAutomatically = true, clearAutomatically = true 조합이 유리함 |
| 주의할 점 | 항상 둘 다 켜는 것이 정답은 아니며, 같은 트랜잭션 안의 흐름을 기준으로 선택해야 함 |
왜 벌크 쿼리를 조심해야 할까
JPA에서 일반적인 엔티티 변경은 영속성 컨텍스트를 중심으로 이뤄집니다. 엔티티를 조회하고 값을 바꾼 뒤 트랜잭션을 커밋하면, JPA는 변경 감지를 통해 필요한 SQL을 실행합니다. 이 방식은 객체 상태와 DB 상태를 자연스럽게 맞춰 주기 때문에 안전합니다.
그런데 벌크 쿼리는 다릅니다.
JPQL의 UPDATE·DELETE는 엔티티를 하나씩 수정하지 않고, 조건에 맞는 행을 DB에서 직접 변경합니다. 이미 영속성 컨텍스트에 올라와 있는 엔티티는 그대로인데, DB만 먼저 바뀌는 구조가 만들어지는 것이죠.
이 특성 때문에 같은 트랜잭션 안에서 아래 같은 문제가 발생할 수 있습니다.
- 재조회했는데 값이 안 바뀐 것처럼 보인다
- 후속 분기 로직이 예전 엔티티 상태를 기준으로 실행된다
- 테스트에서는 잘 지나가는데 운영에서만 간헐적으로 이상 현상이 발생한다
즉, 벌크 쿼리는 단순히 "빠른 UPDATE"가 아니라, 영속성 컨텍스트와의 정합성을 개발자가 직접 관리해야 하는 기능으로 이해해야 합니다.
flowchart TB
subgraph Tx["하나의 트랜잭션"]
direction TB
APP["서비스 로직"] --> PC["영속성 컨텍스트<br>1차 캐시"]
APP --> BULK["벌크 UPDATE / DELETE"]
end
subgraph DB["Database"]
TABLE["테이블"]
end
BULK -->|"DB 직접 반영"| TABLE
PC -. "자동 동기화 아님<br>부정합 발생 지점" .-> TABLE
@Modifying는 왜 필요한가
Spring Data JPA에서 @Query는 기본적으로 조회 쿼리, 즉 SELECT를 기대합니다. 그런데 UPDATE나 DELETE 같은 변경 쿼리를 실행하려면, Spring Data JPA에게 "이건 조회가 아니라 DML이다" 라고 분명히 알려줘야 합니다.
그 역할을 하는 것이 바로 @Modifying입니다.
즉, @Modifying는 단순한 문법 장식이 아니라, 해당 리포지토리 메서드가 데이터를 읽는 메서드가 아니라 변경하는 메서드라는 점을 선언하는 장치입니다.
보통 반환값은 영향받은 row 수를 의미하는 int를 사용합니다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
@Modifying
@Query("update Member m set m.name = :name where m.id = :id")
int updateName(@Param("id") Long id, @Param("name") String name);
}이 코드는 DB 행을 직접 수정합니다. 하지만 여기서 중요한 포인트가 있습니다.
이 시점에 영속성 컨텍스트 안에 이미 Member 엔티티가 올라와 있었다면, 그 객체 상태는 자동으로 최신 값으로 바뀌지 않습니다.
즉, @Modifying는 벌크 쿼리를 실행하게 해 주지만, 정합성 문제까지 해결해 주지는 않습니다.
진짜 문제는 어디서 발생할까
벌크 쿼리의 핵심 위험은 아주 단순합니다.
DB는 바뀌었는데, 1차 캐시는 그대로 남아 있다.
같은 트랜잭션 안에서 이미 조회한 엔티티가 있다면, 이후 findById() 같은 재조회는 DB보다 먼저 영속성 컨텍스트를 확인합니다. 그래서 실제 DB에는 최신 값이 들어갔는데도 애플리케이션은 여전히 예전 객체를 읽게 됩니다.
sequenceDiagram
participant App as 서비스 로직
participant PC as 영속성 컨텍스트
participant DB as Database
App->>PC: 1. findById(1) → Member(name="old")
PC-->>App: Member(name="old") 캐시 적재
App->>DB: 2. @Modifying UPDATE name='new' WHERE id=1
DB-->>App: 1 row affected
Note over PC: 1차 캐시: name="old" 그대로 유지
Note over DB: 실제 DB: name="new"
App->>PC: 3. findById(1) 재조회
PC-->>App: Member(name="old") ← 부정합 발생
겉으로 보기에는 단순히 "업데이트가 안 된 것 같다"는 현상처럼 보이지만, 실제 원인은 벌크 쿼리가 영속성 컨텍스트를 우회했다는 점에 있습니다.
이 지점부터 clearAutomatically와 flushAutomatically를 함께 이해해야 합니다.
clearAutomatically는 무엇을 해결할까
이 문제를 해결하기 위한 대표 옵션이 clearAutomatically = true입니다.
@Modifying(clearAutomatically = true)를 사용하면, 벌크 쿼리 실행 직후 영속성 컨텍스트를 비웁니다. 그러면 이후 조회는 더 이상 1차 캐시를 사용할 수 없으므로 DB에서 최신 값을 다시 읽어 오게 됩니다.
@Modifying(clearAutomatically = true)
@Query("update Member m set m.name = :name where m.id = :id")
int updateName(@Param("id") Long id, @Param("name") String name);이 옵션이 특히 유용한 상황은 다음과 같습니다.
- 벌크 쿼리 직후 같은 트랜잭션 안에서 동일 엔티티를 다시 조회하는 경우
- 변경된 상태를 기준으로 후속 비즈니스 로직이 이어지는 경우
- "DB 최신 값"을 반드시 기준으로 삼아야 하는 경우
즉, clearAutomatically는 재조회 정합성을 해결하는 옵션입니다.
하지만 여기에도 비용이 있습니다.
영속성 컨텍스트 전체를 비우기 때문에, 현재 관리 중이던 엔티티는 모두 detach됩니다. 이후 로직에서 다시 엔티티를 사용하려면 재조회가 필요할 수 있고, 그만큼 비용도 생깁니다.
clearAutomatically는 벌크 쿼리 이후의 부정합을 해결하지만, 영속성 컨텍스트에 남아 있던 상태를 모두 버린다는 점까지 함께 이해해야 합니다.
clearAutomatically만 쓰면 왜 위험할까
처음에는 clearAutomatically만 켜면 문제가 다 해결되는 것처럼 보일 수 있습니다. 그런데 실제로는 또 다른 위험이 생깁니다.
바로 아직 flush되지 않은 변경이 유실될 수 있다는 점입니다.
예를 들어 같은 트랜잭션 안에서 어떤 엔티티를 수정했지만 아직 DB에 반영되지 않은 상태라고 가정해 보겠습니다. 이때 벌크 쿼리를 실행하면서 곧바로 clear를 해 버리면, 영속성 컨텍스트가 비워지면서 그 미반영 변경 역시 함께 사라질 수 있습니다.
즉, clearAutomatically는 벌크 이후 재조회 정합성에는 도움이 되지만, 그 전에 쌓여 있던 변경 보존까지 책임져 주지는 않습니다.
그래서 실무에서는 clear만 단독으로 붙이기보다, 그 전에 flush가 필요한 구조인지를 항상 함께 봐야 합니다.
flushAutomatically는 무엇을 해결할까
이때 등장하는 옵션이 flushAutomatically = true입니다.
이 옵션은 벌크 쿼리를 실행하기 직전에 영속성 컨텍스트의 변경 사항을 DB에 먼저 반영하라는 의미입니다. 쉽게 말해, "아직 DB에 나가지 않은 변경이 있다면 먼저 안전하게 저장하고, 그 다음 벌크 쿼리를 실행하라" 는 것입니다.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from Member m where m.status = 'INACTIVE'")
int deleteInactiveMembers();이 조합이 실행되면 흐름은 다음과 같습니다.
- 영속성 컨텍스트의 미반영 변경을 먼저 flush한다
- 벌크 UPDATE·DELETE를 실행한다
- 영속성 컨텍스트를 clear한다
- 이후 재조회는 DB 최신 값을 기준으로 한다
sequenceDiagram
participant App as 서비스 로직
participant PC as 영속성 컨텍스트
participant DB as Database
App->>PC: 1. 엔티티 변경 누적
Note over PC: Dirty 엔티티 존재
App->>PC: 2. 벌크 쿼리 호출
rect rgb(230, 245, 255)
PC->>DB: 2-1. flushAutomatically → flush
Note over DB: 누적 변경 반영 완료
end
rect rgb(255, 245, 230)
App->>DB: 2-2. 벌크 UPDATE/DELETE 실행
Note over DB: 벌크 변경 반영
end
rect rgb(245, 230, 255)
PC->>PC: 2-3. clearAutomatically → clear
Note over PC: 1차 캐시 비움
end
App->>DB: 3. 재조회 → DB에서 최신 값 로드
정리하면 역할 분담은 명확합니다.
flushAutomatically는 미반영 변경 유실 방지를 담당합니다.clearAutomatically는 1차 캐시 부정합 해소를 담당합니다.
두 옵션은 비슷해 보여도 해결하는 문제가 다릅니다.
어떤 조합을 선택해야 할까
결국 선택 기준은 하나입니다.
같은 트랜잭션 안에서 무슨 일이 벌어지는가?
옵션 조합을 정리하면 다음과 같습니다.
| 조합 | flush 전 | clear 후 | 적합한 상황 | 주의할 점 |
|---|---|---|---|---|
| 기본값 (둘 다 false) | X | X | 벌크 쿼리 후 재조회가 전혀 없음 | 재조회 시 1차 캐시 부정합 가능 |
clearAutomatically = true | X | O | 벌크 쿼리 전 누적 변경이 없고, 이후 재조회가 필요함 | 미반영 변경 유실 가능 |
flushAutomatically = true | O | X | 선행 변경 보존만 중요하고, 이후 재조회는 없음 | 재조회 시 부정합 가능 |
| 둘 다 true | O | O | 선행 변경 보존과 재조회 정합성이 모두 중요함 | flush + clear 비용 발생 |
실무에서는 가장 안전한 기본 선택으로 flushAutomatically = true와 clearAutomatically = true 조합을 많이 고려합니다. 다만 이 조합이 항상 정답은 아닙니다. 벌크 쿼리 이후에 같은 트랜잭션에서 해당 엔티티를 다시 조회하지 않는다면, 굳이 flush와 clear 비용을 매번 치를 필요는 없습니다.
결국 중요한 것은 옵션을 습관적으로 켜는 것이 아니라, 트랜잭션 안에서 어떤 상태를 보장해야 하는지 계약처럼 명확히 정하는 것입니다.
- 벌크 쿼리 전에 아직 DB에 반영되지 않은 변경이 남아 있는가
- 벌크 쿼리 후 같은 트랜잭션 안에서 최신 상태를 다시 읽어야 하는가
실무에서 특히 조심해야 하는 상황
벌크 쿼리는 서비스 로직이 조금만 복잡해져도 예상치 못한 문제를 만들 수 있습니다.
예를 들어 회원 상태를 벌크 업데이트한 뒤, 같은 트랜잭션에서 다시 회원을 조회해 알림을 보내는 로직이 있다고 가정해 보겠습니다.
clearAutomatically가 없으면 재조회 결과가 1차 캐시에서 반환될 수 있습니다.- 그러면 실제 DB에는 반영된 최신 상태가 아니라, 변경 전 상태를 기준으로 알림이 발송될 수 있습니다.
- 반대로
clearAutomatically만 켜 두고, 그 전에 다른 엔티티 변경이 누적되어 있었다면 그 변경이 유실될 위험도 있습니다.
이런 문제를 피하려면 보통 다음 중 하나를 선택해야 합니다.
- 벌크 쿼리 전후의 flush·clear 타이밍을 명확히 설계한다
- 같은 트랜잭션에서 벌크 쿼리와 후속 조회를 섞지 않는다
- 대량 변경 로직 자체를 별도 배치 트랜잭션으로 분리한다
즉, 벌크 쿼리는 단순히 쿼리 한 줄의 문제가 아니라 트랜잭션 경계 설계의 문제로 보는 편이 더 정확합니다.
flowchart TD
ROOT["벌크 쿼리에서 자주 터지는 문제"]
ROOT --> A["DB만 먼저 변경됨"]
ROOT --> B["1차 캐시가 예전 상태 유지"]
ROOT --> C["후속 로직이 잘못된 상태를 참조"]
ROOT --> D["clear 시 미반영 변경 유실 가능"]
운영 체크리스트
벌크 쿼리를 추가하기 전에 아래 질문을 먼저 점검해 보면 많은 문제를 예방할 수 있습니다.
- 벌크 쿼리 이후 같은 트랜잭션에서 동일 엔티티를 다시 조회하는가
- 벌크 쿼리 전에 아직 flush되지 않은 변경이 쌓일 수 있는 구조인가
- clear 이후에도 로직이 안전하게 이어지는가
- 재조회 비용이나 Lazy 로딩 문제를 감당할 수 있는가
- 대량 변경 로직을 별도 배치 작업으로 분리할 수 있는가
- 벌크 쿼리의 영향 row 수를 로깅하거나 모니터링하고 있는가
- Dirty Checking 기반 변경과 벌크 쿼리가 같은 트랜잭션에서 무분별하게 섞이지 않도록 설계했는가
마치며
Spring Data JPA의 벌크 쿼리는 성능 면에서 매우 유용한 도구입니다. 하지만 영속성 컨텍스트를 우회한다는 특성 때문에, 일반적인 엔티티 변경과는 완전히 다른 방식으로 다뤄야 합니다.
핵심은 단순합니다.
벌크 쿼리는 빠르지만, 정합성은 자동으로 보장되지 않습니다.
@Modifying는 변경 쿼리라는 의도를 명확히 선언해 주고, clearAutomatically는 벌크 쿼리 이후 1차 캐시 부정합을 정리하며, flushAutomatically는 그 전에 누적된 변경이 유실되지 않도록 돕습니다.
결국 세 옵션은 각각 따로 외우기보다, 같은 트랜잭션 안에서 어떤 상태를 보장해야 하는지를 기준으로 함께 판단해야 합니다.
실무에서는 벌크 쿼리를 도입할 때 단순히 "성능이 더 좋아진다"는 관점만 볼 것이 아니라, 이후 로직이 어떤 엔티티 상태를 믿고 움직이는지까지 함께 설계해야 합니다. 이 기준만 명확히 세워 두면, 벌크 쿼리는 위험한 지름길이 아니라 성능과 안정성을 함께 챙길 수 있는 강력한 도구가 됩니다.
@Modifying는 변경 쿼리임을 선언합니다.clearAutomatically는 벌크 이후 재조회 정합성을 보장합니다.flushAutomatically는 벌크 직전 미반영 변경 유실을 막아 줍니다.- 그리고 어떤 조합을 쓸지는 트랜잭션 안의 흐름을 기준으로 결정해야 합니다.
