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를 통한 UPDATE나 DELETE는 일반적인 엔티티 조작과 다르게 동작한다.
- 영속성 컨텍스트 무시: 벌크 연산은 1차 캐시를 거치지 않고 DB에 직접 쿼리를 날린다.
- 쓰기 지연의 함정: 테스트 코드 내에서 save()된 데이터는 아직 DB에 반영되지 않고 메모리에 머물러 있는데, 벌크 삭제 쿼리가 DB로 먼저 날아가 버린다.
- 결과: 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)를 고민하자!!!!
'Project > 사이드 프로젝트 기록' 카테고리의 다른 글
| [Spring 트래픽 제한] 인증 없는 API에 안전장치 달기: Bucket4j로 Rate Limiting 구현 (3) | 2026.01.15 |
|---|