Project/사이드 프로젝트 기록

[Spring/JPA] refresh token 통합 테스트 중 데이터 불일치 문제 삽질기 (@Modifying의 flushAutomatically 옵션)

쉬지마 이굥진 2026. 1. 27. 00:15

1. 문제 상황: "분명히 지웠는데 왜 남아있지?"

최근 진행 중인 사이드 프로젝트의 access, refresh token 인증 로직을 검증하기 위해 통합 테스트 코드를 작성하고 있었다. 로그인, 토큰 갱신, 로그아웃으로 이어지는 전체 플로우를 테스트하던 중, 로그아웃 시 DB에서 Refresh Token이 삭제되지 않는 문제가 생겼다.

// 문제의 테스트 코드 일부
@Test
@DisplayName("로그아웃 -> RefreshToken 삭제 확인")
void logout_ShouldDeleteRefreshToken() throws Exception {
    // 1. 로그인하여 토큰 생성
    userService.login(loginRequest); 
    
    // 2. 로그아웃 수행 (내부적으로 deleteByExpiresAtBefore 실행)
    mockMvc.perform(post("/api/v1/users/logout")...);

    // 3. 검증: 토큰이 삭제되어 Optional.empty()여야 함
    Optional<RefreshToken> deletedTokenOpt = refreshTokenService.findByUserId(testUser.getId());
    assertThat(deletedTokenOpt).isEmpty(); // <--- 여기서 테스트 실패!
}

 

분명 로그아웃 로직 내에서 삭제 쿼리가 실행되었음에도 불구하고, 테스트 검증 단계에서는 토큰이 여전히 조회되어 테스트가 실패했던거다.

 

2. 원인 분석: JPA 벌크 연산과 영속성 컨텍스트의 속성

이 테스트 클래스 상단에는 @Transactional이 붙어 있다. 이는 테스트가 끝나면 데이터를 롤백하기 위함이지만, 동시에 테스트 코드 내의 모든 작업이 하나의 영속성 컨텍스트(1차 캐시) 안에서 일어난다는 뜻이기도 하다.

 

문제의 원인은 @Query를 이용해 직접 작성한 벌크 연산(Bulk Operation)에 있었다.

@Modifying
@Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :dateTime")
void deleteByExpiresAtBefore(@Param("dateTime") LocalDateTime dateTime);

 

JPA에서 @Query를 통한 UPDATEDELETE일반적인 엔티티 조작과 다르게 동작한다.

  1. 영속성 컨텍스트 무시: 벌크 연산은 1차 캐시를 거치지 않고 DB에 직접 쿼리를 날린다.
  2. 쓰기 지연의 함정: 테스트 코드 내에서 save()된 데이터는 아직 DB에 반영되지 않고 메모리에 머물러 있는데, 벌크 삭제 쿼리가 DB로 먼저 날아가 버린다.
  3. 결과: DB 입장에서는 아직 들어오지도 않은 데이터를 지우라고 하니 아무 일도 일어나지 않았고, 메모리에는 생성된 토큰이 그대로 남아있게 된다.

몰랐어요 몰랏다고요

 

3. 해결 방법: @Modifying 옵션 제대로 활용하기

이 문제를 해결하기 위해 @Modifying 어노테이션의 옵션을 조정했다.

수정된 코드

@Modifying(clearAutomatically = false, flushAutomatically = true)
@Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :dateTime")
void deleteByExpiresAtBefore(@Param("dateTime") LocalDateTime dateTime);

적용된 옵션 설명

  • flushAutomatically = true: 쿼리를 실행하기 직전에 영속성 컨텍스트의 변경사항을 DB에 먼저 반영(flush)한다. 덕분에 방금 생성된 토큰이 DB에 적재된 후 삭제 쿼리가 실행되어 정상적으로 지워지게 되는 것.
  • clearAutomatically = false: 쿼리 실행 후 1차 캐시를 비우지 않는다. 현재 테스트 코드에서는 동일한 트랜잭션 내에서 testUser 등 다른 객체들을 계속 참조해야 하므로, 불필요한 재조회를 막기 위해 false로 유지했다.

 

4. 마치며: 테스트 코드 필요성

처음에는 약간 주객전도가 된 느낌이었다. 어떻게 보면 테스트 코드 통과를 위해 레포지토리 코드를 수정한 거니 말 그대로 '통과만을 위한 코드 수정? 이 맞나 싶은 것..!

하지만 이번 경험을 통해 깨달은 점은 다음과 같다.

테스트 코드가 실패해서 내 코드의 잠재적 위험(여기선 데이터 정합성 문제)을 미리 알게된 것

 

단순히 save()만 믿고 있다가 운영 환경에서 벌크 연산으로 인한 데이터 불일치 버그를 만났다면 원인을 찾기 훨씬 힘들었을 거다. 나는 @Modifying에 이런 옵션이 있다는 것도 몰랐으니.. 🥲

 

이번 기회에 JPA의 벌크 연산 매커니즘과 영속성 컨텍스트의 관계를 확실히 정리할 수 있었다.

 

 

한 줄 요약:

JPA에서 @Query로 수정/삭제를 할 때는 반드시 현재 메모리 상태와 DB 상태의 동기화(flush, clear)를 고민하자!!!!