빌드노트
캐시에 값이 있는데도 @CachePut 메서드가 실행되는 이유
CachePut은 캐시에 같은 키가 있어도 본문을 실행하고 반환값으로 갱신한다. Spring 프록시와 메모리 캐시에서 실행 횟수·반환값·저장값을 비교하고, 조회 생략이 목적이면 Cacheable로 바꾸는 기준을 확인한다.
같은 키의 값이 캐시에 있는데도 조회 본문이 매번 실행된다면, 먼저 해당 메서드에 @CachePut이 붙어 있는지 확인한다. CachePut은 메서드를 실행하고 그 결과로 캐시를 갱신한다. 캐시에 값이 있을 때 조회를 생략하려면 @Cacheable을 선택한다.
이 차이는 설정 오류가 아니다. Spring 6.2.12 CachePut API (새 탭에서 열림)는 메서드 실행을 생략하지 않는다고 명시한다. 아래 비교는 정상적으로 문자열을 반환하는 동기 메서드이며, condition과 unless는 지정하지 않았다. 조건식으로 결과 저장을 제한하더라도 CachePut이 캐시 적중을 이유로 본문 실행을 생략하는 기능으로 바뀌지는 않는다.
조회를 생략할 목적이면 Cacheable로 교체한다
예를 들어 상품 조회 find(String id)에 @CachePut(cacheNames = "readProducts", key = "#p0")을 붙였다면, 조회 결과를 재사용하려는 의도와 맞지 않는다. 그 메서드의 애너테이션을 다음으로 교체한다. 기존 캐시 이름과 키는 유지한다.
@Cacheable(cacheNames = "readProducts", key = "#p0")
#p0는 첫 번째 인자인 id다. Cacheable은 해당 캐시와 키에 값이 있으면 그 값을 반환하고 본문을 건너뛴다. 값이 없으면 본문을 실행해 결과를 저장한다. 갱신 작업까지 건너뛰어도 된다는 뜻은 아니므로, 먼저 이 메서드의 책임이 조회인지 확인해야 한다.
같은 키를 미리 채운 뒤 두 번씩 호출했다
Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.12에서 별도 Spring 컨텍스트와 ConcurrentMapCacheManager로 비교했다. @EnableCaching(proxyTargetClass = true)를 켰고, 외부 호출자가 컨텍스트에서 얻은 public 메서드를 호출했다. 프록시 여부와 CacheInterceptor 적용을 함께 확인했다.
비교용 ProductService에는 @CachePut을 붙인 loadAndPut(String id)와 @Cacheable을 붙인 find(String id)를 따로 뒀다. 서로 간섭하지 않도록 캐시는 각각 putProducts, readProducts를 썼다. 두 메서드 모두 키는 #p0이며, 본문에 들어올 때 자기 카운터를 1 늘려 id + ":run-" + 실행번호를 반환한다. DB나 외부 API는 호출하지 않았다.
두 캐시에 문자열 키 sku-7과 값 seed를 미리 넣고, 각 메서드를 같은 인자로 두 번 호출했다. 다음은 실제 출력 중 네 호출에 해당하는 줄이다. entries는 해당 메서드 본문이 실행된 누적 횟수, stored는 호출 직후 Cache.get으로 직접 읽은 값이다.
put call=1 returned=sku-7:run-1 entries=1 stored=sku-7:run-1
put call=2 returned=sku-7:run-2 entries=2 stored=sku-7:run-2
read-hit call=1 returned=seed entries=0 stored=seed
read-hit call=2 returned=seed entries=0 stored=seed
CachePut은 기존 seed를 반환하지 않았다. 첫 실행 결과를 저장한 뒤, 두 번째 호출에서도 다시 본문을 실행해 sku-7:run-2로 덮어썼다. 캐시가 채워졌다는 사실과 호출 횟수가 줄었다는 사실은 다르다.
Cacheable은 미리 넣은 seed를 두 번 반환했고 본문은 한 번도 실행하지 않았다. 이어서 대조용 readProducts의 해당 키만 비우고 두 번 더 호출했다. 이번에는 첫 호출이 sku-7:run-1을 반환하고 저장했으며, 다음 호출도 같은 값을 반환했다. 본문 실행 수는 총 1이었다. 빈 캐시에서만 실행되는 경로도 함께 확인한 것이다.
반환값만 같다고 캐시 적중으로 판단하지 않는다
실제 조회가 매번 같은 상품을 돌려주면 반환값만으로는 본문 실행 여부를 알기 어렵다. 로컬 테스트에서 본문 진입 카운터나 조회 의존성의 호출 수를 함께 확인한다. 아래 코드는 위 find에 Cacheable을 적용한 경우를 확인한다. productService와 cacheManager는 같은 Spring 컨텍스트의 빈이고, readProducts는 실서비스와 연결되지 않은 테스트용 메모리 캐시여야 한다. 새 서비스의 카운터는 0에서 시작하며, readEntries()는 find 본문에서만 증가하는 카운터를 반환한다.
Cache는 org.springframework.cache, Objects는 java.util에서 import한다. 미리 넣은 값의 재사용 여부와 본문 실행 수를 한 번에 읽는 코드다.
Cache cache = Objects.requireNonNull(cacheManager.getCache("readProducts"));
String id = "sku-7";
cache.put(id, "seed");
String first = productService.find(id);
String second = productService.find(id);
System.out.printf("returns=%s,%s entries=%d stored=%s%n",
first, second, productService.readEntries(), cache.get(id, String.class));
이 코드도 새 컨텍스트에서 실행해 다음 출력을 얻었다. 캐시를 확인할 때 find를 다시 부르지 않으므로 확인 행위가 조회 횟수를 바꾸지 않는다.
returns=seed,seed entries=0 stored=seed
교체 후에도 본문이 실행된다면, 같은 CacheManager의 같은 캐시 이름과 키를 보고 있는지부터 확인한다. 이 예제의 키는 문자열 sku-7이므로 타입이나 값이 달라지면 같은 키가 아니다. 캐시 활성화, 적용 중인 condition, 실제 호출 경로도 살핀다. Spring 6.2 공식 문서 (새 탭에서 열림)의 기본 프록시 모드에서는 외부에서 프록시를 통과한 호출에 캐시 처리가 적용된다. new로 만든 객체나 같은 객체 안의 자기 호출이라면 애너테이션만 교체해도 기대한 적중 동작이 나오지 않을 수 있다.
반대로 상품을 실제로 갱신하고 반환된 상품으로 캐시도 바꿔야 하는 메서드라면 CachePut의 실행이 필요하다. 조회에는 Cacheable, 실행 결과를 캐시에 반영할 작업에는 CachePut을 선택한다. 같은 메서드에 두 애너테이션을 겹쳐 붙여 해결하려 하지 않는다. 공식 문서의 CachePut 절 (새 탭에서 열림)도 두 동작의 충돌 때문에 일반적인 병용을 권하지 않는다.
이번 관찰은 단일 JVM에서 순서대로 호출한 정상 문자열 반환과 로컬 메모리 캐시에 한정된다. 실제 DB 호출 수나 성능, Redis·분산 캐시, 동시 호출, 비동기 반환형, 예외 및 트랜잭션 순서는 검증하지 않았다. 여기서 확인한 선택 기준은 캐시가 있을 때 실행을 생략할 것인지, 이번 실행의 결과로 캐시를 갱신할 것인지다.