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

빌드노트

서로 다른 Spring 메서드가 같은 캐시 값을 돌려줄 때

같은 캐시를 쓰는 두 @Cacheable 메서드의 기본 키에는 메서드 식별자가 없다. Spring 프록시와 로컬 캐시에서 반환값·본문 호출 수·키 타입을 비교하고, 캐시 이름 분리와 종류를 담은 명시 키 중 수정 방법을 고른다.

@Cacheable이 붙은 고객 조회와 공급자 조회에 같은 ID를 넣었는데, 공급자 조회에서도 고객 이름이 나온다. 두 메서드가 같은 캐시를 쓰고 기본 키를 사용한다면 생길 수 있는 일이다. 기본 키에는 클래스나 메서드 이름이 들어가지 않는다. 같은 캐시에서 같은 키를 찾으면 먼저 저장된 결과를 반환한다.

먼저 두 메서드의 cacheNames와 key, keyGenerator를 나란히 확인한다. 클래스의 @CacheConfig나 공통 CachingConfigurer에서 설정을 물려받는지도 본다. 아래 설명은 두 빈이 같은 CacheManager의 같은 캐시를 사용하고, 별도 키 생성기나 CacheResolver를 지정하지 않은 경우다.

반환값과 함께 캐시 이름·키 타입을 본다

Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.12로 독립된 작은 앱을 실행했다. @EnableCaching을 켜고 ConcurrentMapCacheManager를 사용했다. 외부 Redis나 DB 없이 고객 조회는 customer:41, 공급자 조회는 supplier:41이라는 합성 문자열을 반환하게 했다. 서로 다른 두 빈의 public 메서드에 모두 @Cacheable(cacheNames = "sharedLookup")을 붙였다.

기존 코드에서 비교할 때도 두 서비스를 직접 new로 만들지 말고 Spring이 주입한 빈을 사용한다. 다음 코드는 두 빈이 각각 customer(long id), supplier(long id)를 제공하는 테스트에서 실행한다. customerLookup, supplierLookup, cacheManager는 주입받은 변수이고, AopUtils와 ConcurrentMapCache는 각각 org.springframework.aop.support, org.springframework.cache.concurrent 패키지에서 import한다. 값이 비어 있는 테스트용 로컬 캐시를 대상으로 한다.

이 블록은 호출이 프록시를 거치는지와 두 반환값이 어느 키 하나에서 나왔는지를 확인한다.

System.out.println("customer proxy=" + AopUtils.isAopProxy(customerLookup));
System.out.println("supplier proxy=" + AopUtils.isAopProxy(supplierLookup));
System.out.println("customer=" + customerLookup.customer(41L));
System.out.println("supplier=" + supplierLookup.supplier(41L));
var cache = (ConcurrentMapCache) cacheManager.getCache("sharedLookup");
cache.getNativeCache().forEach((key, value) ->
    System.out.printf("cache=%s key=%s keyType=%s value=%s%n",
        cache.getName(), key, key.getClass().getName(), value));

실제로 얻은 출력이다. 두 메서드의 반환 타입을 모두 String으로 맞췄으므로 잘못된 값이 타입 오류 없이 반환됐다.

customer proxy=true
supplier proxy=true
customer=customer:41
supplier=customer:41
cache=sharedLookup key=41 keyType=java.lang.Long value=customer:41

proxy=true만으로 캐시 적용까지 증명되는 것은 아니다. 이 실험에서는 두 프록시에 CacheInterceptor가 있는지도 확인했다. 이어서 각 메서드를 같은 인자로 두 번씩 호출하고 본문 안의 카운터를 읽었다. 고객 본문은 1회, 공급자 본문은 0회 실행됐다. 새 컨텍스트에서 공급자를 먼저 호출하자 양쪽 결과가 supplier:41이 됐고, 카운터도 고객 0회·공급자 1회로 바뀌었다. 공급자에 다른 인자 42L를 넣으면 supplier:42가 반환되고 본문이 한 번 실행됐다.

이 관찰은 캐시를 거치지 않는 자기 호출과 구별된다. 여기서는 프록시의 캐시 조회가 적용됐고, 공급자 본문을 실행하기 전에 고객의 저장값을 찾았다. 위 캐스팅은 ConcurrentMapCache 전용이다. 다른 구현을 쓰면 해당 구현의 조회 수단으로 이름·키·값을 확인한다. 프록시가 아니거나 캐시 엔트리가 생기지 않는다면 호출 경로와 캐시 활성화부터 확인하고, 실제 생성 키가 다르면 이 사례의 충돌로 단정하지 않는다.

같은 인자는 메서드가 달라도 같은 키다

공식 캐시 문서 (새 탭에서 열림)는 기본 키를 인자로 구성한다고 설명한다. 실험 버전의 SimpleKeyGenerator API (새 탭에서 열림)와 6.2.12 구현 (새 탭에서 열림)도 확인했다.

  • 인자가 없으면 SimpleKey.EMPTY다.
  • 인자가 하나이며 null도 배열도 아니면 그 인자 자체를 쓴다. 이 예제의 41L은 Long 키다.
  • 나머지는 인자들을 담은 SimpleKey를 만든다. 여러 인자뿐 아니라 단일 null·배열 인자도 여기에 해당한다.

어느 경우에도 기본 키에 메서드 식별자가 자동으로 붙지는 않는다. 실험의 두 조회는 모두 sharedLookup에서 Long(41)을 찾았다. 서로 다른 키의 해시값이 우연히 겹친 문제가 아니라, 같은 저장 공간에 같은 논리 키를 사용한 것이다. 빈을 서로 다른 클래스로 나누거나 메서드 이름만 바꾸어도 이 조건은 바뀌지 않는다.

결과의 의미가 다르면 캐시나 키를 분리한다

고객 ID 41과 공급자 ID 41이 서로 다른 대상을 뜻한다면 캐시 이름을 분리할 수 있다. 아래는 각 조회 메서드에 붙일 애너테이션을 바꾸는 예다.

// 고객 조회 메서드
@Cacheable(cacheNames = "customerLookup")

// 공급자 조회 메서드
@Cacheable(cacheNames = "supplierLookup")

사용하는 CacheManager에도 두 이름의 캐시가 준비돼 있어야 한다. 별도로 실행한 이 조건에서는 각 캐시에 Long(41)이 하나씩 저장됐다. 같은 인자로 두 번씩 호출해도 결과는 각각 customer:41, supplier:41이었고 본문 호출은 양쪽 모두 1회였다.

캐시 하나를 함께 써야 한다면 두 조회의 의미가 키에서도 구분되게 한다. 다음 변경은 숫자 ID 앞에 고정된 종류 접두사를 붙인다.

// 고객 조회 메서드
@Cacheable(cacheNames = "sharedLookup", key = "'customer:' + #p0")

// 공급자 조회 메서드
@Cacheable(cacheNames = "sharedLookup", key = "'supplier:' + #p0")

#p0는 첫 번째 인자다. Cacheable API (새 탭에서 열림)에서 설명하는 인덱스 접근이므로 컴파일된 매개변수 이름에 의존하지 않는다. 이 조건의 실제 키는 Long(41)이 아니라 서로 다른 String인 customer:41, supplier:41이었다. 양쪽 본문은 한 번씩 실행됐으며, 호출 순서를 뒤집어도 각자 올바른 값을 반환했다.

key = "#p0"만 추가한 비교도 실행했다. 키가 여전히 Long(41) 하나라서 기본 설정과 같은 잘못된 결과가 나왔다. 명시 키라는 형식보다 결과가 다른 두 조회를 구별하는 정보가 키에 있는지가 중요하다. 위 접두사는 숫자 ID 하나로 결과가 정해지는 예에 맞춘 것이다. 실제 결과가 다른 인자에도 의존한다면 그 인자를 키에서 빠뜨리지 않아야 한다. 이미 keyGenerator를 지정했다면 key와 동시에 사용하지 않고 생성 규칙을 조정한다.

수정 후에는 새 테스트 캐시에서 같은 ID를 양쪽 순서로 호출하고, 두 번째 호출이 각자의 결과를 재사용하는지까지 확인한다. 키만 바꾸었다면 해당 데이터를 갱신·제거하는 @CachePut, @CacheEvict도 같은 규칙을 쓰는지 함께 살핀다.

이번 비교는 한 JVM의 동기 호출과 로컬 ConcurrentMapCache에서 확인했다. Redis의 직렬화·TTL·여러 서버 사이 동작, 기존 저장값의 전환은 검증하지 않았다. 운영 캐시를 비우는 실험도 하지 않았다. 여기서 확인한 것은 서로 다른 두 빈의 조회가 캐시 이름과 생성 키를 함께 공유할 때 상대 결과를 반환할 수 있으며, 둘 중 하나를 구분하면 이 예제의 충돌이 사라진다는 점이다.

← 전체 글 보기