본문 바로가기
빌드노트 검색

빌드노트

@CacheEvict 메서드가 예외를 던지면 캐시가 남는 이유

@CacheEvict는 기본적으로 정상 종료 뒤에 제거하므로 예외 호출에서는 키가 남는다. 실제 Spring 프록시와 로컬 캐시에서 정상·예외, 기본값·beforeInvocation=true, 자기 호출을 비교하고 실패해도 제거할 정책인지 판단한다.

@CacheEvict를 붙인 메서드를 호출했는데, 예외가 난 뒤에도 기존 캐시 값이 남아 있다. 기본 설정이라면 정상적인 동작일 수 있다. beforeInvocation의 기본값은 false이며, 메서드가 정상 종료해야 제거가 실행된다. 호출이 실패해도 지울지는 설정을 바꾸기 전에 결정해야 할 정책이다.

먼저 정상 호출에서도 같은 키가 남는지 확인한다. 정상 호출은 제거되고 예외 호출에서만 남는다면 제거 시점이 유력하다. 두 경우 모두 남는다면 실제 호출이 캐시 프록시를 통과하는지, 조회와 제거가 같은 캐시 이름·키를 쓰는지부터 본다.

예외를 어디서 잡았는지가 중요하다

Spring 공식 캐시 문서 (새 탭에서 열림)는 기본 설정에서 정상 완료 후 제거하고, 예외를 던지면 제거하지 않는다고 설명한다. 호출한 쪽에서 예외를 잡아 화면에 오류를 보여 주더라도, 캐시가 적용된 메서드 자체가 예외로 끝났다는 사실은 바뀌지 않는다.

다음은 운영 데이터 대신 새 합성 키를 넣는 로컬 테스트 캐시용 확인 코드다. profileService와 cacheManager는 Spring에서 주입받은 객체이며, 캐시 이름은 profiles다. 대상 public 메서드 evictAfter(String id, boolean fail)에는 @CacheEvict(cacheNames = "profiles", key = "#p0")가 붙어 있고, fail이 true면 IllegalStateException("synthetic failure")를 던지게 했다. #p0는 첫 번째 인자이므로 제거 대상은 문자열 id 하나다. 실제 코드에서도 조회 쪽과 제거 쪽의 키가 같아야 한다.

Cache는 org.springframework.cache, AopUtils는 org.springframework.aop.support, Objects는 java.util에서 import한다. 이 블록으로 실제 주입 객체의 프록시 여부와 예외 호출 전후의 키 존재를 확인한다.

System.out.println("proxy=" + AopUtils.isAopProxy(profileService));
Cache cache = Objects.requireNonNull(cacheManager.getCache("profiles"));
String id = "cache-evict-check-01";
cache.put(id, "old-profile");
System.out.println("before=" + (cache.get(id) != null));
try {
    profileService.evictAfter(id, true);
} catch (IllegalStateException error) {
    System.out.println(error.getMessage());
}
System.out.println("after=" + (cache.get(id) != null));

Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.12에서 새 컨텍스트와 ConcurrentMapCacheManager로 실행한 출력이다. @EnableCaching(proxyTargetClass = true)를 사용했다.

proxy=true
before=true
synthetic failure
after=true

after=true는 캐시에 키가 남았다는 뜻이다. 존재 여부를 읽을 때는 캐시를 다시 채울 수 있는 @Cacheable 조회 메서드를 부르지 않고 Cache.get(id)를 사용했다. 자기 코드에 적용할 때는 서비스 변수·캐시 이름·키 타입·호출 인자·예외 타입을 실제 대상에 맞춘다. 대상 메서드가 정상 종료한다면 이 예외 비교가 되지 않는다. 캐시 이름을 찾지 못하거나 호출 전 값부터 없다면 제거 시점을 판정할 준비가 되지 않은 것이다.

메서드 진입 시점에도 키를 읽어 비교했다

별도 비교에서는 매번 새 컨텍스트를 만들고, 서로 다른 합성 키에 old-profile을 넣었다. 기본 설정 메서드와 beforeInvocation=true 메서드는 제거 설정만 다르고 본문은 같게 했다. 둘 다 반환값 없는 동기 메서드이며, 본문에 들어오면 키 존재를 기록한 뒤 정상 종료하거나 같은 예외를 던진다. 캐시 조건식과 트랜잭션은 없다.

주입받은 프록시를 외부에서 직접 호출한 네 조건의 실제 결과는 다음과 같다. 아래의 ‘진입’은 메서드 본문 첫 부분, ‘종료 뒤’는 호출자가 정상 반환 또는 예외를 받은 뒤다. 시작 전에는 네 조건 모두 키가 있었다.

  • 기본값·정상: 진입 때 있음 → 종료 뒤 없음.
  • 기본값·예외: 진입 때 있음 → 종료 뒤 있음.
  • beforeInvocation=true·정상: 진입 때 없음 → 종료 뒤 없음.
  • beforeInvocation=true·예외: 진입 때 없음 → 종료 뒤 없음.

네 조건 모두 본문은 정확히 한 번 실행됐고, 예외 조건의 예외는 호출자까지 전달됐다. 즉 beforeInvocation=true는 예외를 없애는 설정이 아니다. 제거가 본문보다 먼저 일어나므로 이후 본문이 실패해도 제거된 상태가 남는다. 실험 버전의 CacheEvict 선언 (새 탭에서 열림)과 캐시 인터셉터 기반 구현 (새 탭에서 열림)에서도 기본값과 앞·뒤 제거 처리 위치를 확인했다.

proxy=true 하나만으로 캐시 처리가 적용됐다고 단정할 수는 없다. 다른 기능 때문에 프록시가 생겼을 수도 있다. 이번 비교에서는 프록시의 어드바이저에 CacheInterceptor가 있는지도 확인했다. 이어서 같은 빈의 다른 메서드가 this.evictAfter(...) 또는 this.evictBefore(...)로 호출하게 했다. 이 자기 호출에서는 정상·예외와 두 설정을 조합한 네 조건 모두 키가 남았다. 객체는 여전히 프록시였지만, 내부 호출은 제거 처리를 거치지 않았다.

공식 문서의 프록시 모드 설명 (새 탭에서 열림)은 프록시를 통과하는 호출에만 적용된다고 명시한다. 자기 호출이라면 beforeInvocation만 바꾸어도 해결되지 않는다. 캐시 제거 책임을 별도 Spring 빈으로 옮기고 그 빈을 주입받아 호출하는 등, 애너테이션이 붙은 메서드로 들어가는 경계를 확인해야 한다. 캐시 기능 활성화 여부와 public 메서드인지도 함께 살핀다.

실패한 요청도 캐시를 버려야 한다면

변경 작업이 성공했을 때만 기존 값을 버릴 정책이라면 기본값을 유지한다. 반대로 실패 여부와 관계없이 기존 값을 더는 신뢰할 수 없다면 호출 전에 제거하도록 바꿀 수 있다. 아래는 첫 번째 인자 id를 키로 쓰는 기존 메서드의 애너테이션 수정이다.

@CacheEvict(cacheNames = "profiles", key = "#p0", beforeInvocation = true)

이는 성공할지 알기 전에 제거하는 선택이다. 조건식을 썼다면 그 조건도 충족돼야 하며, 키나 조건 계산에 메서드 결과인 #result를 쓰던 코드는 그대로 옮길 수 없다. 호출 전에는 결과가 없으므로 인자처럼 미리 알 수 있는 정보로 계산해야 한다.

수정 후에는 로컬 테스트에서 매번 키를 새로 채우고 정상 호출과 예외 호출을 다시 비교한다. 위 비교와 달리 값이 계속 남는다면 프록시 경계, condition, 실제 캐시 이름·키가 일치하는지 확인한다. 값이 잠시 사라졌다가 다시 생긴다면 다른 조회가 재적재했는지도 구분해야 한다. 호출 전 제거만으로 이후 갱신 완료까지의 재적재를 막는 것은 아니다.

이번 실행은 한 JVM의 동기 void 메서드와 로컬 캐시에서 제거 시점을 확인한 것이다. DB 갱신과 트랜잭션 rollback·commit에 대한 제거 순서, Redis 같은 외부 캐시의 동작, 동시 조회의 재적재, 비동기 반환값은 검증하지 않았다. beforeInvocation=true에서 로컬 키가 사라졌다는 결과를 DB와 캐시의 정합성 보장으로 확대해서는 안 된다.

← 전체 글 보기