[친구하자] JPA @Modifying(clearAutomatically = true)의 함정
@Modifying(clearAutomatically = true) 사용 시 영속성 컨텍스트가 비워지면서 엔티티가 detached 되어, 상태 변경이 DB에 반영되지 않았던 버그 원인과 해결 과정을 정리합니다.
정기 예약 취소 API에서
CallReservation은 정상적으로 취소되는데RecurringReservation상태만ACTIVE로 남는 버그를 겪었다. 원인은@Modifying(clearAutomatically = true)로 인해 영속 엔티티가 detached 된 뒤 상태를 변경한 것이었다.
들어가며
정기 예약 취소 API를 개발한 뒤 테스트 코드는 모두 통과했지만, 실제 DB를 조회해보니 recurring_reservations.status가 여전히 ACTIVE로 남아 있었다. CallReservation 데이터는 정상적으로 CANCELLED가 되었는데, 정작 정기 예약 상태는 바뀌지 않은 것이다.
원인을 추적해보니 JPA의 영속성 컨텍스트(Persistence Context), AUTO flush 모드, 그리고 @Modifying(clearAutomatically = true)가 맞물려 발생한 문제였다. 이 글에서는 왜 이런 현상이 생겼는지와 실제 해결 방법을 정리한다.
1. 배경 지식
영속성 컨텍스트(Persistence Context)
JPA에서 엔티티는 단순한 Java 객체가 아니라, EntityManager가 관리하는 1차 캐시(영속성 컨텍스트) 안에서 상태가 추적된다.
[트랜잭션 시작]
↓
EntityManager 생성
↓
영속성 컨텍스트 활성화
↓
findById(id) → 엔티티 로드 (managed 상태)
↓
entity.setXxx(...) → dirty-check 대상 등록
↓
[트랜잭션 커밋] → flush 시점에 UPDATE SQL 반영
- 영속 컨텍스트에 있는 엔티티는
managed상태이며, 변경사항이 자동 추적된다. - 컨텍스트에서 분리되면
detached상태가 되고, 필드를 바꿔도 DB 반영 대상이 아니다.
Flush와 AUTO 모드
flush는 영속성 컨텍스트의 변경사항을 DB에 동기화하는 동작이다. Spring Data JPA 기본 flush 모드는 AUTO이며, 다음 시점에 자동으로 동작한다.
- 트랜잭션 커밋 시점
- JPQL 쿼리 실행 직전
이 버그에서는 2번이 핵심이었다.
@Modifying(clearAutomatically = true)
벌크 UPDATE/DELETE JPQL은 영속성 컨텍스트를 거치지 않고 DB에 직접 반영된다.
@Modifying(clearAutomatically = true)
@Query("UPDATE CallReservation r SET r.status = :cancelStatus ...")
int cancelFuturePendingByRecurringId(...);
clearAutomatically = true를 쓰면 벌크 쿼리 실행 후 영속성 컨텍스트를 비운다. 이때 기존 managed 엔티티들이 전부 detached 상태가 된다.
2. 버그 발생 과정
문제가 발생한 코드를 요약하면 다음과 같다.
@Transactional
public void cancelRecurringReservation(Long userId, Long recurringReservationId) {
// 1) rr 로드 (managed)
RecurringReservation rr = recurringReservationRepository.findById(recurringReservationId)
.orElseThrow(...);
// 2) 미래 ENQUEUED 예약 취소
List<CallReservation> futureEnqueued = callReservationRepository.findFutureEnqueuedByRecurringId(...);
for (CallReservation reservation : futureEnqueued) {
reservation.cancel(); // managed라 dirty-check 대상
eventPublisher.publishEvent(...);
}
// 3) 벌크 UPDATE + clearAutomatically
callReservationRepository.cancelFuturePendingByRecurringId(...);
// 4) 이미 detached 된 rr 변경
rr.cancel(); // DB에 반영되지 않음
}
실제 실행 흐름은 다음과 같았다.
rr는 처음엔 managed 상태- 벌크 쿼리 실행 직전에
AUTO flush가 동작해 일부 변경 반영 - 벌크 쿼리 후
clearAutomatically로 영속성 컨텍스트 초기화 rr가 detached 상태가 된 뒤rr.cancel()호출- 메모리 값은 바뀌지만 DB에는 UPDATE가 발생하지 않음
그래서 CallReservation은 취소되지만 RecurringReservation은 ACTIVE로 남았다.
3. 수정 방법
핵심은 간단하다. rr.cancel() 호출을 벌크 UPDATE 이전으로 옮긴다.
@Transactional
public void cancelRecurringReservation(Long userId, Long recurringReservationId) {
RecurringReservation rr = recurringReservationRepository.findById(recurringReservationId)
.orElseThrow(...);
// ... 기타 로직
// managed 상태일 때 먼저 상태 변경
rr.cancel();
// 이후 벌크 UPDATE 실행
callReservationRepository.cancelFuturePendingByRecurringId(...);
}
수정 후 흐름:
rr.cancel() 호출 (managed)
↓
벌크 쿼리 직전 AUTO flush
↓
rr 상태 변경 UPDATE 반영
↓
벌크 UPDATE 실행
↓
clearAutomatically (이미 필요한 변경 반영 완료)
이제 RecurringReservation 상태도 정상적으로 CANCELLED로 저장된다.
4. 교훈
clearAutomatically = true는 기존 엔티티를 모두 detached 상태로 만든다.- 벌크 UPDATE 이후에는 기존 엔티티를 상태 변경 대상으로 가정하면 안 된다.
- 엔티티 상태 변경은 벌크 쿼리보다 먼저 수행하는 것이 안전하다.
- Mockito 기반 단위 테스트만으로는 이런 문제를 놓치기 쉽다.
@DataJpaTest,@SpringBootTest같은 통합 테스트가 필요하다.
마치며
JPA의 영속성 컨텍스트는 강력하지만, 내부 동작을 정확히 이해하지 않으면 예상 못 한 버그가 생긴다. 특히 벌크 UPDATE와 dirty-check를 같은 트랜잭션에서 함께 사용할 때는 실행 순서와 엔티티 상태(managed/detached)를 반드시 의식해야 한다.