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

빌드노트

실패 결과를 캐시에 넣지 않으려면 condition 대신 unless

Spring @Cacheable의 인자 조건은 condition, 반환값에 따른 저장 거부는 unless로 나눈다. 실패·성공 반환값의 실제 본문 실행 수와 캐시 엔트리를 비교하고, 이미 저장된 실패 값에는 새 unless가 적용되지 않는 범위도 확인한다.

@Cacheable에서 condition = "#result.success()"로 성공 결과만 저장하려 하면 판단 시점이 맞지 않는다. condition은 캐시에서 값을 찾기 전에 평가하므로 메서드 반환값을 볼 수 없다. 반환값을 보고 새 저장을 막으려면 unless를 쓴다.

여기서 실패 결과는 메서드가 정상 반환한 success=false 객체다. 예외를 던지는 흐름은 이번 비교에 포함하지 않았다. Spring이 객체 안의 성공 여부를 알아서 해석하는 것은 아니므로, 실패를 값으로 돌려주는 서비스에서는 저장 제외 조건을 직접 정해야 한다.

인자 조건과 결과 조건을 나누어 적는다

lookup(String id, boolean useCache)가 Reply를 반환한다고 하자. Reply는 boolean success와 실행 번호인 int run을 가진 record이고, success()로 성공 여부를 읽는다. useCache는 조회 결과를 바꾸지 않고 이번 호출에서 캐시를 사용할지만 정하는 인자다.

이 메서드에서 캐시 사용을 허용한 호출 중 성공 결과만 저장하려면 애너테이션을 다음처럼 바꾼다.

@Cacheable(cacheNames = "lookups", key = "#p0",
    condition = "#p1",
    unless = "#result == null || !#result.success()")

#p0는 첫 번째 인자 id, #p1은 두 번째 인자 useCache다. 컴파일된 매개변수 이름 없이도 인덱스로 참조할 수 있다. 이 예제의 키는 id 하나이며, 결과를 바꾸는 다른 인자가 있는 실제 메서드라면 그 정보도 키에 반영해야 한다.

condition이 true면 캐시 조회를 진행한다. 저장된 값이 없어서 본문을 실행한 경우에는 반환 후 unless를 평가한다. 위 식은 null이거나 success()가 false면 true가 되어 저장을 거부한다. condition의 true는 캐시 적용 허용이고, unless의 true는 새 결과의 저장 거부다. 인자로 캐시 적용을 제한할 정책이 없다면 condition을 생략하고 unless만 둘 수 있다. 이 평가 시점과 인자 참조 방법은 Spring 6.2.12 Cacheable API (새 탭에서 열림)에 설명돼 있다.

잘못된 condition = "#result.success()"도 따로 실행했다. 같은 입력을 두 번 넣자 두 번 모두 SpelEvaluationException의 EL1011E가 발생했고 본문 진입 수는 0이었다. 이 식은 본문을 실행하기 전, 아직 없는 결과에 success()를 호출하려다 실패했다. 반환 후 저장 여부를 가려내는 조건이 되지 못했다.

두 번째 호출에서 본문이 다시 실행되는가

Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.12에서 독립된 최소 Maven 프로젝트를 실행했다. @EnableCaching(proxyTargetClass = true)와 ConcurrentMapCacheManager를 사용했고, 각 조건마다 새 Spring 컨텍스트와 빈 캐시로 시작했다. 호출은 컨텍스트에서 얻은 프록시의 public 메서드로 외부에서 들어갔으며, sync는 기본값 false로 두었다.

본문은 외부 조회 대신 진입 카운터를 1 늘리고 합성 Reply를 반환한다. 아래의 OK@1은 성공 결과와 본문 실행 번호 1, FAIL@2는 실패 결과와 실행 번호 2를 줄여 쓴 표기다. 각 조건에서 입력을 바꾸지 않고 두 번 호출한 결과를 정리했다.

  • condition=false, 성공 반환: lookup("item-1", false) 두 번의 반환은 OK@1 → OK@2였다. 본문은 2회 실행됐고 캐시 키는 생기지 않았다.
  • condition=true, unless 없음, 실패 반환: lookup("item-1", true)의 반환은 FAIL@1 → FAIL@1이었다. 본문은 1회 실행됐고 캐시에 FAIL@1이 남았다.
  • condition=true, unless 추가, 실패 반환: 같은 true 입력에서 FAIL@1 → FAIL@2가 반환됐다. 본문은 2회 실행됐고 캐시 키는 없었다.
  • condition=true, unless 추가, 성공 반환: 같은 true 입력에서 OK@1 → OK@1이 반환됐다. 본문은 1회 실행됐고 캐시에 OK@1이 남았다.

condition=false여도 본문은 실행된다. 그 호출에서는 캐시 조회와 새 저장을 건너뛴다. 반대로 condition=true만으로는 실패 객체의 저장을 막지 못했다. unless를 추가한 실패 조건에서 실행 번호가 1에서 2로 바뀐 것은, 첫 실패를 저장하지 않아 다음 호출이 본문까지 다시 들어갔기 때문이다.

처음에는 실패하고 다음 실행부터 성공하도록 바꾼 비교도 했다. 동일 입력 세 번의 반환은 FAIL@1 → OK@2 → OK@2였고 본문은 총 2회 실행됐다. 실패 뒤 다음 호출에서 다시 실행하고, 얻은 성공 결과부터 재사용했다. null만 반환하는 비교에서는 두 번 모두 본문이 실행됐으며 키가 생기지 않았다.

자기 코드에서 확인할 때도 반환값과 본문 진입 수, 캐시 엔트리를 함께 본다. 다음 블록은 위 애너테이션을 붙인 테스트용 lookupService가 항상 실패 Reply를 반환할 때의 확인 코드다. lookupService와 cacheManager는 같은 Spring 컨텍스트에서 얻은 빈이고, lookups는 비어 있는 테스트용 로컬 캐시여야 한다. entries()는 본문 첫 부분에서 증가시킨 카운터를 돌려주는 메서드다.

var first = lookupService.lookup("item-1", true);
var second = lookupService.lookup("item-1", true);
var stored = cacheManager.getCache("lookups").get("item-1");
System.out.printf("success=%s,%s entries=%d present=%s%n",
    first.success(), second.success(), lookupService.entries(), stored != null);

이 블록도 실제로 실행했고 다음 출력을 얻었다. Cache.get으로 저장 상태를 직접 읽으므로, 확인 도중 조회 메서드를 한 번 더 실행하지 않는다.

success=false,false entries=2 present=false

실패 응답이 두 번 나왔다는 사실만으로는 적용 여부를 알 수 없다. entries=1, present=true라면 이전 실패가 재사용됐는지 확인한다. 성공 결과에서도 매번 본문이 실행된다면 condition 값과 캐시 활성화, 호출 경로를 먼저 본다. 기본 프록시 모드의 자기 호출은 캐시 처리를 거치지 않는다고 Spring 공식 캐시 문서 (새 탭에서 열림)가 설명한다.

이미 저장된 실패 값도 걸러질까

빈 캐시에서 성공한 비교와 별개로, 테스트 캐시에 FAIL@0을 미리 넣었다. unless를 붙인 서비스가 본문에 들어가면 성공을 반환하도록 해도, lookup("item-1", true) 두 번은 모두 FAIL@0을 반환했다. 본문 진입 수는 0이었고 기존 값도 남았다. 캐시 적중으로 본문을 실행하지 않았으므로 새 반환값에 적용할 unless 검사까지 진행되지 않았다.

따라서 애너테이션 수정 후에도 같은 실패가 나온다면 기존 엔트리의 존재를 확인해야 한다. 이미 들어간 값의 만료나 대상 키 제거는 별도로 판단할 일이다. 같은 사전 입력에서 useCache=false로 호출한 비교에서는 본문이 두 번 실행돼 새 성공 결과를 돌려줬지만, 기존 FAIL@0은 캐시에 그대로 남았다. condition=false도 기존 값을 지우지는 않는다.

이 설정은 sync=false인 일반 동기 반환 메서드에 맞춘 것이다. 실험 버전의 sync API (새 탭에서 열림)는 sync=true에서 unless를 지원하지 않는다고 명시한다. 실패 결과를 제외하려고 위 식을 쓸 때는 현재 메서드의 sync 설정도 함께 확인한다.

이번 실행은 한 JVM에서 순서대로 호출한 로컬 메모리 캐시 비교다. Redis의 직렬화·TTL, 여러 서버의 동시 호출, 비동기 반환형, 트랜잭션과의 순서는 검증하지 않았다. 실패를 저장하지 않으면 반복 실패 동안에도 본문이 다시 실행된다는 비용까지 포함해 이 정책을 선택해야 한다.

← 전체 글 보기