Spring Data JPA에서 `@Query`와 Derived Query의 차이

kergosdyr·2026년 2월 8일
post-thumbnail

flush 타이밍과 @Modifying이 필요한 이유

Spring Data JPA를 사용하다 보면 어느 순간부터 비슷한 의문이 생깁니다. 조회는 자연스럽게 동작하는데, @Query로 delete나 update를 작성하는 순간 @Modifying이 필요해지고 bulk 쿼리 이후 조회 결과가 어색하게 보이기 시작합니다. flush 타이밍도 예상과 다르게 느껴집니다. 처음에는 단순한 설정 문제처럼 보이지만, 실제로는 JPA와 Hibernate의 실행 흐름을 이해해야 설명되는 영역입니다.

이 글에서는 이 흐름을 하나의 이야기로 연결해 보려고 합니다. 핵심 질문은 단순합니다. 왜 조회에서는 아무 문제 없던 @Query가 수정 쿼리에서는 @Modifying을 요구할까요? 그리고 bulk 쿼리 이후 왜 영속성 컨텍스트가 꼬인 것처럼 보일까요?

이 질문은 결국 flush 이야기로 이어집니다.


flush는 commit이 아니라 “동기화 시점”입니다

flush를 commit과 같은 개념으로 이해하면 이후의 모든 동작이 헷갈리기 시작합니다. flush는 트랜잭션을 끝내는 동작이 아니라, 영속성 컨텍스트의 변경 사항을 SQL로 만들어 DB와 동기화하는 시점입니다. 트랜잭션 내부에서는 엔티티 변경이 메모리에만 존재할 수 있고 DB에는 아직 반영되지 않았을 수 있습니다. flush는 이 두 상태를 맞추는 과정입니다.

Hibernate는 flush 시점을 FlushMode로 관리하며 기본값은 AUTO입니다. 이 모드에서는 flush가 특정 시점에 자동으로 발생합니다. 개발자가 직접 flush를 호출할 때, 트랜잭션 커밋 직전, 그리고 SELECT 실행 직전(필요한 경우)입니다. 특히 마지막 규칙이 Spring Data JPA를 이해하는 핵심이 됩니다.


SELECT 실행 전 flush는 “필요할 때만” 발생합니다

Hibernate 문서는 FlushMode.AUTO를 “sometimes flush before query execution”이라고 설명합니다. 즉 SELECT 전에 flush가 항상 발생하는 것은 아닙니다. 쿼리 결과가 영속성 컨텍스트의 변경으로 인해 달라질 수 있을 때만 flush가 수행됩니다.

Hibernate 공식 예제를 보면 이 동작이 직관적으로 이해됩니다.

먼저 flush가 발생하지 않는 경우입니다.

Session session = entityManager.unwrap(Session.class);
session.setHibernateFlushMode(FlushMode.AUTO);

Person person = new Person("John Doe");
entityManager.persist(person);

entityManager.createQuery(
    "select a from Advertisement a"
).getResultList();

Person을 persist 했지만 flush는 발생하지 않습니다. 쿼리가 조회하는 테이블이 Advertisement이기 때문입니다. Person 변경은 쿼리 결과에 영향을 주지 않습니다.

반대로 같은 테이블을 조회하면 flush가 발생합니다.

Session session = entityManager.unwrap(Session.class);
session.setHibernateFlushMode(FlushMode.AUTO);

Person person = new Person("John Doe");
entityManager.persist(person);

entityManager.createQuery(
    "select p from Person p"
).getResultList();

이번에는 SELECT 전에 INSERT가 먼저 실행됩니다. flush가 없다면 SELECT 결과가 틀려지기 때문입니다. Hibernate는 쿼리가 stale한 DB 상태를 보지 않도록 자동으로 flush를 수행합니다.

이 규칙을 이해하면 조회 동작이 명확해집니다.


그래서 조회에서는 @Query와 Derived Query 차이가 거의 없습니다

조회에서는 flush 판단을 Hibernate가 수행합니다. Spring Data는 쿼리를 생성할 뿐입니다. Derived Query는 메서드 이름을 파싱하여 JPQL을 생성하고, @Query는 개발자가 JPQL을 직접 작성합니다. 실행 단계에서는 동일한 flush 규칙을 따릅니다. 조회에서 두 방식의 차이가 거의 느껴지지 않는 이유가 여기에 있습니다.

진짜 차이는 수정 쿼리에서 시작됩니다.


@Modifying은 실행 경로를 바꾸는 애노테이션입니다

이제 이야기의 흐름이 자연스럽게 “수정 쿼리”로 넘어옵니다. 조회에서는 문제가 없던 @Query가 왜 수정 쿼리에서는 갑자기 동작하지 않을까요. 이 지점에서 Spring Data JPA의 기본 실행 경로를 이해할 필요가 있습니다.

Repository 메서드는 기본적으로 조회 실행 경로를 사용합니다. 내부적으로는 getResultList() 또는 getSingleResult()가 호출됩니다. 즉 Spring Data는 @Query가 붙어 있더라도 기본적으로 SELECT라고 가정하고 실행을 시도합니다.

이 가정은 조회에서는 자연스럽게 동작하지만, DELETE나 UPDATE에서는 바로 예외로 이어집니다.

@Query("delete from User u where u.roleId = :roleId")
void deleteUsers(Long roleId);

이 코드는 문법적으로는 문제 없어 보이지만 Intellij 에서 작성하게 되면 Warning 을 발생시킵니다. DELETE와 UPDATE는 조회 API가 아니라 executeUpdate()로 실행해야 하기 때문입니다. Spring Data는 JPQL 문자열만 보고 이를 자동으로 판단하지 않습니다. 그래서 개발자가 직접 실행 경로를 명시해야 합니다.

@Modifying
@Query("delete from User u where u.roleId = :roleId")
int deleteUsers(Long roleId);

여기서 @Modifying은 쿼리의 의미를 바꾸는 애노테이션이 아니라, 실행 방식을 바꾸는 애노테이션입니다. 이 한 줄이 붙는 순간 Spring Data는 더 이상 SELECT 실행 경로를 사용하지 않고 executeUpdate()를 호출합니다.

이 지점부터 중요한 변화가 시작됩니다. 이제 JPA가 제공하던 “엔티티 중심 실행 흐름”을 벗어나 DB를 직접 수정하는 경로로 진입하게 됩니다.


수정 쿼리는 JPA의 안전한 흐름을 건너뜁니다

이 차이를 이해하려면 Derived delete와 bulk delete를 나란히 보는 것이 가장 쉽습니다.

먼저 Derived delete입니다.

void deleteByRoleId(Long roleId);

이 메서드는 내부적으로 다음 흐름을 따릅니다. 먼저 SELECT로 삭제 대상 엔티티를 조회하고, 조회된 엔티티를 하나씩 remove 하며, 영속성 컨텍스트에 변경이 반영된 뒤 트랜잭션 종료 시 flush가 수행됩니다. 즉 JPA가 제공하는 “엔티티 생명주기”가 그대로 유지됩니다.

이제 bulk delete를 보겠습니다.

@Modifying
@Query("delete from User u where u.roleId = :roleId")
int deleteInBulkByRoleId(Long roleId);

이 쿼리는 완전히 다른 경로로 실행됩니다. SELECT는 발생하지 않으며 영속성 컨텍스트도 거치지 않습니다. DB에 DELETE가 바로 실행됩니다. 이 순간부터 flush 타이밍과 영속성 동기화는 Hibernate가 아니라 개발자의 책임이 됩니다.

이 변화가 바로 @Modifying 이후에 등장하는 옵션들의 출발점입니다.


bulk 쿼리를 실행하면 두 가지 문제가 동시에 발생합니다

JPA의 안전한 실행 흐름을 건너뛰면 두 가지 문제가 자연스럽게 따라옵니다. 하나는 실행 전에 발생하고, 다른 하나는 실행 후에 발생합니다.

첫 번째 문제는 아직 flush되지 않은 변경입니다.

다음 코드를 보면 상황이 쉽게 이해됩니다.

@Transactional
public void scenario() {
    User user = new User("kim");
    userRepository.save(user);

    userRepository.deleteInBulkByRoleId(roleId);
}

save는 호출되었지만 INSERT가 아직 DB에 반영되지 않았을 수 있습니다. Hibernate는 SELECT 실행 전에만 flush 필요성을 판단하기 때문입니다. 하지만 bulk delete는 SELECT가 아니므로 flush가 발생하지 않을 수 있습니다. 결국 방금 저장한 데이터가 삭제되지 않는 상황이 생길 수 있습니다.

이 문제를 해결하기 위해 등장한 옵션이 flushAutomatically입니다.

@Modifying(flushAutomatically = true)

이 옵션은 bulk 쿼리 실행 전에 강제로 flush를 수행합니다. 실행 순서를 명확하게 보장하는 역할을 합니다.


두 번째 문제는 더 자주 발생합니다. 바로 영속성 컨텍스트의 상태 불일치입니다.

@Transactional
public void scenario() {
    List<User> users = userRepository.findByRoleId(roleId);

    userRepository.deleteInBulkByRoleId(roleId);

    System.out.println(users.size());
}

DB에는 데이터가 삭제되었습니다. 그러나 users 리스트에는 여전히 엔티티가 남아 있습니다. bulk 쿼리는 DB만 수정하고 영속성 컨텍스트는 변경 사실을 알지 못하기 때문입니다. 이 상태를 stale 상태라고 부릅니다.

이 문제를 해결하는 옵션이 clearAutomatically입니다.

@Modifying(clearAutomatically = true)

bulk 쿼리 실행 후 EntityManager를 초기화하여 DB 상태와 영속성 컨텍스트를 다시 동기화합니다.


bulk UPDATE에서는 문제가 더 크게 드러납니다

delete보다 update에서 이 문제가 더 쉽게 드러납니다.

@Modifying(clearAutomatically = true)
@Query("update User u set u.active = false where u.lastLogin < :date")
int deactivateUsers(LocalDate date);

이미 로딩된 엔티티는 업데이트되지 않습니다.

User user = userRepository.findById(1L).get();
userRepository.deactivateUsers(now);

user.isActive(); // 여전히 true

DB와 메모리 상태가 달라집니다. 그래서 bulk update에서도 clearAutomatically 사용이 사실상 필수에 가깝습니다.


실행 경로가 바뀌면 책임도 함께 바뀝니다

이제 전체 흐름이 연결됩니다. Derived delete는 JPA 생명주기를 따릅니다. bulk 쿼리는 그 흐름을 건너뜁니다. 그래서 flush와 영속성 동기화를 개발자가 직접 관리해야 합니다.

flushAutomatically는 실행 전에 DB 상태를 맞추는 역할을 합니다. clearAutomatically는 실행 후 영속성 컨텍스트를 보호합니다. 그리고 이 모든 흐름은 @Modifying이 실행 경로를 바꾸는 순간부터 시작됩니다.


정리

조회 쿼리는 Hibernate의 기본 flush 규칙 안에서 동작합니다. FlushMode.AUTO에서는 트랜잭션 커밋 직전에 flush가 발생하고, SELECT 결과가 달라질 수 있는 경우에만 쿼리 실행 전에 flush가 수행됩니다. 그래서 조회에서는 Derived Query와 @Query의 차이가 거의 드러나지 않습니다.

반면 수정 쿼리는 실행 경로가 달라집니다. @Modifying이 붙는 순간 SELECT 실행 경로를 건너뛰고 DB를 직접 수정하는 흐름으로 진입합니다. 이때부터 flush와 영속성 동기화는 자동으로 보장되지 않습니다.

그래서 두 가지 옵션이 필요해집니다. flushAutomatically는 실행 전에 DB 상태를 먼저 맞추고, clearAutomatically는 실행 후 영속성 컨텍스트를 다시 동기화합니다. 이 흐름을 이해하면 Spring Data JPA에서 bulk 쿼리 사용 시 발생하는 대부분의 혼란을 설명할 수 있습니다.

profile
이것저것 하는 개발자입니다.

0개의 댓글