@Modifying의 문제점을 알고 사용하자

2025. 9. 24. 00:44·프로젝트

이전 게시글에서는 삭제의 용도로 사용하던 양방향 매핑을 제거하게 된 계기와 그로 인해서 deleteAllInBatch와 JPQL 삭제 메서드를 사용하게 된 이유를 소개했습니다.

 

https://coding-self-study.tistory.com/31

 

양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화

최근 여러 프로젝트를 하면서 @OneToMany를 충분히 잘 알고 사용하고 있다고 생각했습니다.양방향 매핑과 Cascade를 통해서 삭제를 편리하게 하려고 했는데 또 쿼리를 까보니 양방향 매핑은 그렇게

coding-self-study.tistory.com

 

이번 게시물에서는 삭제 관련 기능들을 구현할 때 발생하는 문제점과 주의 사항에 대해서 다뤄보겠습니다.

✅ 영속성 컨텍스트 동기화 

가장 먼저 이 개념에 대해서 이야기를 해보고 싶습니다.

 

예전에 @Modifying이 붙어있는 JPQL 기반 삭제 메서드를 @DataJpaTest(트랜잭션이 탑재되어 있음)로 테스트하던 와중 문제가 생긴 적이 있습니다.

 

예를 들자면 다음과 같은 JPQL 기반 삭제 메서드가 있다고 해봅시다

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying
    @Query("delete from Member m where m.name = :name")
    void deleteByName(String name);
}

 

그리고 다음과 같은 테스트를 진행합니다.

@DataJpaTest 
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void deleteByName_삭제후조회_void리턴() {
        // given
        Member m1 = new Member("AAA");
        memberRepository.save(m1);

        // when
        memberRepository.deleteByName("AAA");

        // then
        Optional<Member> found = memberRepository.findById(m1.getId());
        assertThat(found).isEmpty(); // 실패 !!
    }
}

 

결과는?  테스트는 실패합니다. 

  • 그 이유가 무엇일까요?

바로 우리의 Member m1은 여전히 @Transaction 내부의 영속성 컨텍스트(1차 캐시) 내부에서 살아있기 때문입니다.

JPA는 DB를 조회하기 이전에 영속성 컨텍스트에 존재하는 정보라면 꺼내서 사용하게 됩니다.

- deleteAllInBatch
- deleteByName(JPQL + @Modifying)

이런 벌크 메서드들은 1차 캐시를 동기화 하지 않고 트랜잭션이 끝나는 대로 즉시 DB에 반영합니다.

- delete
- deleteAll
이런 메서드들만이 삭제도 하나씩 하고 ~ 트랜잭션의 동기화를 맞춰 주게 됩니다.

 

그런데, 사실 위의 문제는 큰 문제가 아닙니다.

가장 큰 맥락에서

  • 이미 삭제한 것을 다시 조회할 일도 크게 없을 뿐만 아니라
  • 로직 자체는 잘 작동하는데 위의 예시는 테스트에서만 문제가 됩니다.

위 문제를 한 번 해결해 볼까요 ? 

 

1️⃣ @DataJpaTest를 하지 마세요.

말 그대로 그냥 Test에서 트랜잭션으로 묶이지 않으면 그만 아닐까요? @SpringBootTest을 쓰면서 트랜잭션 롤백을 쓰지 않는 테스트 조합을 고려해볼 수 있겠습니다.

  • 단 이렇게 한다면, 여전히 Test는 실패하게 될 겁니다. 
  • JPQL 기반으로 만들어진 메서드는 별도의 Transaction이 붙지 않으니 
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Transactional
    @Modifying
    @Query("delete from Member m where m.name = :name")
    void deleteByName(String name);
}

 

이렇게 까지 해주면 성공하겠네요.

 

비추 ->  트랜잭션은 서비스 영역에서 관리하는 게 맞다고 생각하고 테스트 만을 위해서 저기에 @Transactional을 붙이는 건 부적절해 보임

2️⃣ @Modifying(clearAutomatically = true) 영속성 컨텍스트 클리어 조건을 붙여준다.

영속성 캐시를 날려줍니다. 그럼 트랜잭션 기반 테스트를 사용한다고 해도 통과하게 될 것 입니다.

  • 괜찮아 보이죠? 잘만 쓰면 괜찮습니다. (발생할 수 있는 문제는 추후 언급)

3️⃣ (Good) 테스트 코드 중간에 clear를 해준다.

@Test
void deleteByName_삭제후조회_void리턴() {
    // given
    Member m1 = new Member("AAA");
    memberRepository.save(m1);

    // when
    memberRepository.deleteByName("AAA");
    em.clear(); // 영속성 컨텍스트 비우기

    // then
    Optional<Member> found = memberRepository.findById(m1.getId());
    assertThat(found).isEmpty(); // 성공!
}

이게 가장 편해 보입니다.

 

모든 것을 유지하면서 테스트 코드도 성공시킬 수 있어요.

  • 다만 서비스 로직을 작성할 때 1차 캐시가 동기화되지 않는다는 것을 인지하고 있어야 할 것.
추가적으로 flush와 clear에 대해서 알아보도록 하죠.

 

Flush는 현재 영속성 캐시의 내용을 DB에 반영합니다.

 

위의 예시를 그대로 보죠

@Test
void deleteByName_삭제후조회_void리턴() {
    // given
    Member m1 = new Member("AAA");
    memberRepository.save(m1);

    // when
    memberRepository.deleteByName("AAA");
    em.flush(); // 현재 영속성 컨텍스트에 존재하는 m1을 DB에 반영합니다. -> 다시 저장한다는 말씀

    // then
    Optional<Member> found = memberRepository.findById(m1.getId());
    assertThat(found).isEmpty(); // 실패!
}

 

 

Clear는 그냥 현재 영속성 컨텍스트에 있는 내용을 지워줍니다.

@Test
void deleteByName_삭제후조회_void리턴() {
    // given
    Member m1 = new Member("AAA");
    memberRepository.save(m1);

    // when
    memberRepository.deleteByName("AAA");
    em.clear(); // 현재 영속성 컨텍스트에 존재하는 m1을 없애줍니다.

    // then
    Optional<Member> found = memberRepository.findById(m1.getId());
    // 영속성 컨텍스트에 m1이 없어졌기 때문에 DB에서 조회를 시도할 것이고. 삭제가 되었으니 찾지 못했겠죠.
    assertThat(found).isEmpty(); // 성공!
}

 

이제 영속성 컨텍스트의 동기화 개념에 대해서 감이 잡히셨을까요?

그럼 이제 다음 단계로 넘어가 보겠습니다.

✅ @Modifying의 파라미터

@Modifying 어노테이션은 @Query를 이용하여 INSERT, UPDATE, DELETE쿼리를 작성할 경우 사용해줘야 하는 어노테이션입니다. 사용하지 않으면 QueryExecutionRequestException 이 발생합니다.

 

Modifying의 파라미터는 두 종류가 있습니다.

1️⃣ @Modifying(clearAutomatically = true)

  • 해당 어노테이션을 붙여주게 되면, 현재 영속성 컨텍스트를 없애줍니다.

2️⃣ @Modifying(flushAutomatically = true)

  • 해당 어노테이션을 붙여주게 되면, 현재 영속성 컨텍스트를 DB에 반영해 줍니다.

전 목차에서의 내용을 보면,

"그냥 clearAutomatically만 잘 쓰면 되는 것 아닌가요?"

라고 생각할 수도 있습니다. 그런데, flush가 왜 필요할 수도 있는지 생각해 보죠.

 

만약, 

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying(clearAutomatically = true)
    @Query("delete from Member m where m.name = :name")
    void deleteByName(String name);
}

이렇게 메서드를 만들고 사용하고 있었다고 생각해 보겠습니다.

@Test
void deleteByName_삭제후조회_void리턴() {
    // given
    Member m1 = new Member("AAA");
    memberRepository.save(m1);

	
    (이 과정에서 딴짓을 합니다) 
    ex) 
    Team t1 = teamRepository.findById(1L).orElseThrow();
    t1.changeName("newName");
	

    // when
    memberRepository.deleteByName("AAA");
   

    // then
    Optional<Member> found = memberRepository.findById(m1.getId());
    assertThat(found).isEmpty(); //성공
    Team t1 = teamRepository.findById(1L).orElseThrow();
    assertThat(t1.getName).isEqualTo("newName"); // 실패!
}

위의 코드와 같이 진행을 하게 된다면 Dirty Checking 되고 있는 사항들이 모두 삭제되어 Service 로직이 망가져 버릴 수도 있습니다.

 

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("delete from Member m where m.name = :name")
    void deleteByName(String name);
}

그래서 이렇게 바꾼다면 모든 테스트가 성공하게 될 것입니다.

 

Dirty Checking 되고 있는 사항들이 반영이 될 테니까요!

✅ 결론 : 그래서 어떻게 사용하라고요?

1️⃣ @Modifying(clearAutomatically = true, flushAutomatically = true)

가장 쉬운 방법은 저 두 개의 조합을 함께 사용하는 것입니다. 하지만, 이는 기능상 문제는 없겠지만 완벽한 방법은 아닙니다.

  • 영속성 컨텍스트가 모두 반영되고 + 지워진다는 점 
  • 하나의 트랜잭션이 끝나고 모아서 반영이 되는 구조를 사용할 수 없게 된다.

2️⃣ @Modifying + Test에서 em.flush와 em.clear를 활용한다

저는 이 방법을 추천합니다. 이와 같이 사용하는 대신 다음 사항만 고려하여 서비스 로직을 작성하면 됩니다.

  • 현재 영속성 컨텍스트가 동기화되지 않았음을 인지한다.
  • +플젝할 때 사실 Repository Level 테스트는 작성하지 않았습니다. 
    대부분 JPA 기본 메서드를 사용하기 + 저런 간단한 쿼리문은 테스트할 필요성을 크게 못 느꼈습니다.

✅  (참고) :  Hibernate의 FlushModyType = auto

사실 저는 위의 정보들을 다 알고 있었음에도 flushAutomatically = true를 사용하지 않았습니다. 

"아니 방금 영속성 컨텍스트를 반영하려면 쓰라고 하시지 않았나요?"

결론은 이게 맞습니다. 그런데 조금 상충되는 듯한 내용을 말씀드리고 싶어서 마지막 참고에 넣었어요...

이미 상당히 글이 두서가 없는 것 같은데 더 혼란을 드릴까 봐 말이죠.

 

JPA 표준 FlushModeType은 두 가지가 존재합니다.

  • AUTO (default)
  • COMMIT

Hibernate는 JPA의 구현체이며 또한 기본 flush 모드도 AUTO입니다. 이게 무슨 소리냐면,

Hibernate는 자동적으로 JPQL/HQL 실행 직전에 영속성 컨텍스트를 flush 해서 DB 상태와 맞추는 기능이 있다는 말입니다.

 

이런 이유로 저는 앞전에 보여드렸던 예시인 아래의 코드에서...

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying(clearAutomatically = true)
    @Query("delete from Member m where m.name = :name")
    void deleteByName(String name);
}
@Test
void deleteByName_삭제후조회_void리턴() {
    // given
    Member m1 = new Member("AAA");
    memberRepository.save(m1);

	
    (이 과정에서 딴짓을 합니다) 
    ex) 
    Team t1 = teamRepository.findById(1L).orElseThrow();
    t1.changeName("newName");
	

    // when
    memberRepository.deleteByName("AAA");
   

    // then
    Optional<Member> found = memberRepository.findById(m1.getId());
    assertThat(found).isEmpty(); //성공
    Team t1 = teamRepository.findById(1L).orElseThrow();
    assertThat(t1.getName).isEqualTo("newName"); // 여기가 왜 실패하지??
}

마지막 줄이 당최 왜 실패하는지 이해하지 못했습니다.

"분명 flush를 내부적으로 호출을 할 텐데 왜 반영이 되지 않지?"

라고 생각을 하고 로그를 찍어보았습니다.

 

결과적으로, 로그에서는 Dirty Checking을 하며 변화를 감지했는데 update 쿼리가 나가지 않았습니다.

 

그 이유는 해당 flush도 JPQL과 관련 있다는 판단하에 실행되기 때문입니다.

공식 문서의 6.1을 발췌해 보겠습니다.

 

6.1. AUTO flush

By default, Hibernate uses the AUTO flush mode which triggers a flush in the following circumstances:

  • prior to committing a Transaction
  • prior to executing a JPQL/HQL query that overlaps with the queued entity actions
  • before executing any native SQL query that has no registered synchronization

그래서 Team의 경우에는 member를 삭제하는 JPQL과 상관이 없다고 판단되어 flush가 일어나지 않은 겁니다!

 

따라서, 몇몇 운 좋은 경우에는 flush가 될 수도 있다는 말입니다.

그렇지만 여전히 flush를 명시적으로 해주는 게 좋겠죠?

'프로젝트' 카테고리의 다른 글

모니터링 구축기 : Logback + PLG  (0) 2025.09.26
양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화  (0) 2025.09.23
테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI  (1) 2025.07.01
JPA 최적화는 어떻게 해야할까?  (0) 2025.05.28
배포의 모든 것 - 5. Blue-Green 무중단 배포 적용  (2) 2025.05.01
'프로젝트' 카테고리의 다른 글
  • 모니터링 구축기 : Logback + PLG
  • 양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화
  • 테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI
  • JPA 최적화는 어떻게 해야할까?
나는 정말 개발이 하고 싶었다
나는 정말 개발이 하고 싶었다
개발 혼자 공부하기
  • 나는 정말 개발이 하고 싶었다
    감자밭
    나는 정말 개발이 하고 싶었다
  • 전체
    오늘
    어제
    • 분류 전체보기 (32)
      • ETC (3)
      • 알고리즘 (0)
      • Java (0)
      • DB (2)
      • Spring (0)
      • 프로젝트 (18)
      • Server (3)
      • CS (0)
        • 운영체제 (0)
      • Infra (4)
        • IAC (1)
        • AWS (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • hELLO· Designed By정상우.v4.10.3
나는 정말 개발이 하고 싶었다
@Modifying의 문제점을 알고 사용하자
상단으로

티스토리툴바