Spring Cache Abstraction 내부 동작 — @Cacheable이 AOP 프록시 위에서 동작하는 방법
@Cacheable을 메서드에 붙이는 일은 쉽습니다. 하지만 캐시가 적용되지 않거나, 키가 의도와 다르게 만들어지거나,sync=true를 켰는데도 동시 호출이 두 번 실행되는 순간 추상화 안쪽을 들여다봐야 합니다. 이 글은 Spring Cache Abstraction의 컴포넌트 지도부터CacheInterceptor한 번 호출이 끝나기까지의 실행 흐름을 풀어냅니다.
1. 캐시 추상화가 풀어주는 문제
서비스 메서드 한 줄을 캐시로 감싸는 일은 어렵지 않습니다. 직접 작성한다면 대략 이런 모양입니다.
public Order findOrder(long id) {
Order cached = cache.get(id);
if (cached != null) return cached;
Order loaded = repository.findById(id);
cache.put(id, loaded);
return loaded;
}
문제는 동일한 패턴이 메서드마다 반복된다는 점, 그리고 캐시 백엔드를 ConcurrentHashMap에서 Caffeine으로, 다시 Redis로 바꿀 때마다 모든 호출 사이트를 손봐야 한다는 점입니다.
Spring Cache Abstraction은 이 두 가지를 분리합니다. 메서드에는 "이 결과는 캐시 가능하다"라는 선언만 남기고, 실제 캐시 조회와 저장은 AOP 인터셉터가 가로채서 처리합니다. 그리고 캐시 백엔드는 CacheManager 빈을 갈아끼우는 것으로 교체할 수 있습니다.
@Cacheable("orders")
public Order findOrder(long id) {
return repository.findById(id);
}
이 한 줄이 동작하려면 다음의 컴포넌트들이 맞물려야 합니다.
2. 컴포넌트 지도
추상화는 크게 세 층으로 나뉩니다.
flowchart TB
subgraph annotation [Annotation Layer]
A1["@Cacheable"]
A2["@CachePut"]
A3["@CacheEvict"]
A4["@Caching"]
end
subgraph aop [AOP Layer]
B1[BeanFactoryCacheOperationSourceAdvisor]
B2[CacheInterceptor]
B3[CacheAspectSupport]
B4[AnnotationCacheOperationSource]
end
subgraph runtime [Runtime Layer]
C1[CacheOperation]
C2[KeyGenerator]
C3[CacheResolver]
C4[CacheManager]
C5[Cache]
end
A1 --> B4
A2 --> B4
A3 --> B4
A4 --> B4
B1 --> B2
B2 --> B3
B3 --> C1
B3 --> C2
B3 --> C3
C3 --> C4
C4 --> C5
가장 윗단은 사용자가 직접 만지는 어노테이션입니다. 중간단은 어노테이션을 메타데이터로 바꾸고 메서드 호출을 가로채는 AOP 인프라입니다. 맨 아래는 실제 캐시를 들고 있는 빈들입니다.
인터페이스 두 개만 기억하면 됩니다
org.springframework.cache.Cache는 단일 캐시의 의미를 정의합니다.
public interface Cache {
String getName();
Object getNativeCache();
ValueWrapper get(Object key);
<T> T get(Object key, Callable<T> valueLoader);
void put(Object key, Object value);
ValueWrapper putIfAbsent(Object key, Object value);
void evict(Object key);
void clear();
}
org.springframework.cache.CacheManager는 이름으로 Cache 인스턴스를 찾아주는 레지스트리입니다.
public interface CacheManager {
Cache getCache(String name);
Collection<String> getCacheNames();
}
이 두 인터페이스 위에 ConcurrentMapCacheManager, CaffeineCacheManager, Spring Data Redis의 RedisCacheManager, JSR-107 어댑터인 JCacheCacheManager 등이 구현체로 올라가 있습니다. 어떤 구현체를 쓰든 추상화의 인터페이스는 동일하기 때문에 비즈니스 코드는 변경되지 않습니다.
3. @EnableCaching이 빈 그래프에 무엇을 등록하는가
Spring Boot에서는 spring-boot-starter-cache 의존성이 있으면 CacheAutoConfiguration이 자동으로 @EnableCaching을 활성화합니다. 직접 구성한다면 다음과 같이 켭니다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("orders", "users");
}
}
@EnableCaching은 메타 어노테이션으로 @Import(CachingConfigurationSelector.class)를 달고 있습니다. 셀렉터는 mode 속성을 보고 두 갈래 중 하나를 골라 등록합니다.
AdviceMode.PROXY(기본값):AutoProxyRegistrar+ProxyCachingConfigurationAdviceMode.ASPECTJ:AspectJCachingConfiguration(별도spring-aspectsJAR 필요)
기본 경로인 ProxyCachingConfiguration은 다음 세 빈을 등록합니다.
flowchart LR
A["@EnableCaching"] --> B[CachingConfigurationSelector]
B --> C[ProxyCachingConfiguration]
C --> D[BeanFactoryCacheOperationSourceAdvisor]
C --> E[CacheInterceptor]
C --> F[AnnotationCacheOperationSource]
D --> E
D --> F
각각의 역할은 다음과 같습니다.
- AnnotationCacheOperationSource: 클래스/메서드를 받아
@Cacheable같은 어노테이션을 파싱하여CacheOperation목록을 만듭니다. 내부적으로SpringCacheAnnotationParser가 실제 파싱을 담당합니다. - CacheInterceptor: AOP Alliance의
MethodInterceptor를 구현한, 실제로 메서드 호출을 가로채는 어드바이스입니다. - BeanFactoryCacheOperationSourceAdvisor: Advisor는 "어떤 메서드에" + "무엇을" 적용할지를 묶은 객체입니다. 여기서 포인트컷은 "메서드 또는 클래스에 캐시 어노테이션이 붙어 있는가"이고, 어드바이스는 위의
CacheInterceptor입니다.
AutoProxyRegistrar는 InfrastructureAdvisorAutoProxyCreator를 등록합니다. 이 빈 후처리기가 컨테이너 안의 모든 빈을 훑어보다가, 위 Advisor의 포인트컷과 매칭되는 빈을 발견하면 그 자리에 프록시를 끼워넣습니다. 결과적으로 @Cacheable이 붙은 메서드를 가진 빈의 주입 타깃은 원본이 아니라 프록시가 됩니다.
이 매커니즘은 @Transactional과 동일합니다. 프록시 자체의 동작 원리는 이전 글 (Spring AOP 프록시의 두 얼굴)에서 다뤘기 때문에, 여기서는 캐시 인터셉터가 실제로 무엇을 하는지에 집중하겠습니다.
4. CacheInterceptor.invoke()부터 메서드 호출까지
CacheInterceptor는 짧은 클래스입니다. 본체는 CacheAspectSupport에 위임됩니다.
public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
CacheOperationInvoker aopAllianceInvoker = () -> {
try {
return invocation.proceed();
} catch (Throwable ex) {
throw new CacheOperationInvoker.ThrowableWrapper(ex);
}
};
Object target = invocation.getThis();
return execute(aopAllianceInvoker, target, method, invocation.getArguments());
}
}
핵심은 두 가지입니다. 첫째, invocation.proceed() (실제 메서드 호출)를 람다로 감싸 CacheOperationInvoker에 묶습니다. 캐시 미스가 났을 때만 이 람다를 호출하여 원본 메서드를 실행합니다. 둘째, 모든 결정 로직은 부모 클래스 CacheAspectSupport.execute()로 위임합니다.
CacheAspectSupport.execute()의 의사 코드
복잡한 부분을 떼어내고 핵심 흐름만 추리면 다음과 같습니다.
protected Object execute(CacheOperationInvoker invoker, Object target,
Method method, Object[] args) {
if (this.initialized) {
Class<?> targetClass = getTargetClass(target);
CacheOperationSource source = getCacheOperationSource();
if (source != null) {
Collection<CacheOperation> operations =
source.getCacheOperations(method, targetClass);
if (!CollectionUtils.isEmpty(operations)) {
return execute(invoker, method,
new CacheOperationContexts(operations, method, args, target, targetClass));
}
}
}
return invoker.invoke();
}
캐시 어노테이션이 하나도 없으면 그냥 원본 메서드를 호출하고 끝납니다. 어노테이션이 있으면 CacheOperationContexts를 만들어 핵심 분기로 들어갑니다.
캐시 작업의 실행 순서
execute(invoker, method, contexts)는 다음 순서를 따릅니다.
flowchart TB
Start([Start]) --> Sync{sync = true?}
Sync -->|Yes| SyncPath[executeSynchronized: single op only]
SyncPath --> End([Return])
Sync -->|No| EarlyEvict[Process @CacheEvict beforeInvocation=true]
EarlyEvict --> FindHit[findCachedValue from @Cacheable]
FindHit --> Hit{Cache hit?}
Hit -->|Yes| CollectPut1[Skip cache lookup, gather @CachePut requests]
Hit -->|No| InvokeMethod[invoker.invoke = real method call]
InvokeMethod --> CollectPut2[Gather @CachePut + miss put]
CollectPut1 --> PerformPut[Perform all put requests]
CollectPut2 --> PerformPut
PerformPut --> LateEvict[Process @CacheEvict beforeInvocation=false]
LateEvict --> End
흐름의 핵심을 풀어보면 다음과 같습니다.
- 조기 제거(early evict):
@CacheEvict(beforeInvocation=true)로 표시된 작업이 먼저 실행됩니다. 메서드가 예외를 던지더라도 캐시를 비워야 하는 경우입니다. - 캐시 조회:
@Cacheable작업의 키를 만들어 캐시에서 값을 찾습니다. 적중하면 그 값을 보관해 둡니다. - 메서드 실행 또는 적중값 반환: 미스면
invoker.invoke()를 호출해 실제 메서드를 실행하고, 적중이면 보관해 둔 값을 반환 후보로 씁니다. - CachePut 수집:
@CachePut작업과, 캐시 미스로 새로 채워야 하는@Cacheable작업을 한 곳에 모아 일괄로cache.put()합니다. 둘 다 메서드 실행 결과를 반드시 캐시에 써야 하기 때문에 처리 경로가 같습니다. - 사후 제거(late evict):
@CacheEvict(beforeInvocation=false)로 표시된 작업이 메서드가 정상 종료된 다음에 실행됩니다.
이 순서가 중요합니다. 같은 메서드에 @Cacheable과 @CacheEvict를 함께 붙이면 동작 순서가 헷갈리기 쉬운데, 흐름도가 알려주듯 beforeInvocation 플래그가 가르고 나머지는 모두 메서드 호출 이후입니다.
@Caching은 별도 처리 단계가 아니라, 한 메서드에 여러 캐시 어노테이션을 결합하기 위한 컨테이너일 뿐입니다. 위 흐름은 그대로 적용됩니다.
5. 키 생성: SimpleKeyGenerator와 SpEL
캐시 키 생성은 추상화에서 가장 자주 디버깅 대상이 되는 부분입니다. 동작은 단순합니다. key 속성이 비어 있으면 KeyGenerator 빈을 호출하고, SpEL 표현식이 있으면 그것을 평가합니다.
SimpleKeyGenerator의 정적 메서드
기본 키 생성기는 SimpleKeyGenerator입니다. 핵심은 정적 메서드 generateKey(Object... params)입니다.
public static Object generateKey(Object... params) {
if (params.length == 0) {
return SimpleKey.EMPTY;
}
if (params.length == 1) {
Object param = params[0];
if (param != null && !param.getClass().isArray()) {
return param;
}
}
return new SimpleKey(params);
}
세 가지 규칙으로 정리됩니다.
- 파라미터 0개: 상수
SimpleKey.EMPTY를 키로 사용합니다. 메서드를 어떻게 호출하든 항상 같은 키이므로 결과는 캐시 슬롯 하나에 저장됩니다. - 파라미터 1개 (배열 아님): 그 파라미터 자체를 키로 사용합니다.
findOrder(123L)의 키는 박싱된Long값 123입니다. 다만 자기 자신의equals/hashCode가 안정적이지 않은 객체를 그대로 키로 쓰면 캐시 적중률이 사라질 수 있습니다. - 그 외: 모든 파라미터를 담은
SimpleKey를 만듭니다.
SimpleKey가 hashCode를 한 번만 계산하는 이유
SimpleKey의 생성자는 다음을 수행합니다.
elements.clone()으로 파라미터 배열을 방어적 복사합니다 (외부 수정 차단).Arrays.deepHashCode(this.params)로 해시를 계산한 뒤, MurmurHash3 finalizer 비트 믹싱을 적용해transient int hashCode필드에 저장합니다.
private static int calculateHash(Object[] params) {
int hash = Arrays.deepHashCode(params);
hash = (hash ^ (hash >>> 16)) * 0x85ebca6b;
hash = (hash ^ (hash >>> 13)) * 0xc2b2ae35;
return hash ^ (hash >>> 16);
}
equals는 Arrays.deepEquals를 사용합니다. 같은 파라미터 시퀀스로 만들어진 두 SimpleKey는 같습니다. hashCode는 미리 계산된 필드를 반환하므로 매번 재계산되지 않습니다. 직렬화 후 역직렬화될 때는 readObject에서 다시 계산하여 일관성을 유지합니다.
SimpleKey 자체는 캐시 키로 쓰기 위해 충돌 가능성을 줄이도록 의도된 클래스이며, 분포가 균등하게 퍼지도록 finalizer를 한 번 더 적용한 것이 비결입니다.
SpEL 평가 컨텍스트
key, condition, unless 속성에 문자열을 적으면 SpEL로 평가됩니다. 평가는 CacheEvaluationContext 위에서 일어나며, 다음 변수가 항상 노출됩니다.
#root.method— 호출 중인Method객체#root.target— 호출 대상 빈 (프록시 아래의 실제 객체)#root.caches— 이 작업이 다루는Cache컬렉션#root.args— 메서드 인자 배열#p0,#p1, ... — 위치 기반 인자 (#root.args[0]과 동일)#a0,#a1, ... —#p변수의 별칭<파라미터명>— 디버그 정보가 보존되어 있다면 (-parameters컴파일 옵션) 인자 이름으로 접근 가능#result—unless와@CachePut의unless,@CacheEvict(condition)등 사후 평가에서만 사용 가능.Optional/CompletableFuture같은 래퍼는 자동으로 풀려서 실제 값이 들어옵니다.
예를 들어 다음과 같이 사용합니다.
@Cacheable(value = "users", key = "#userId")
public User getUser(long userId) { ... }
@Cacheable(value = "users", key = "#user.id")
public User refresh(User user) { ... }
@Cacheable(value = "orders",
condition = "#amount > 1000",
unless = "#result == null")
public Order placeOrder(Long userId, Long amount) { ... }
condition은 메서드 호출 이전에 평가되므로 #result에 접근할 수 없고, unless는 호출 이후에 평가되므로 가능합니다. 둘 다 boolean이며, condition이 false면 캐시 자체를 건너뛰고, unless가 true면 결과를 캐시에 쓰지 않습니다.
6. sync=true는 무엇을 보장하는가
여러 스레드가 동시에 같은 키로 메서드를 호출하면 어떻게 될까요. 기본 동작에서는 N개 스레드가 모두 캐시 미스를 발견하고, N번 메서드를 실행하고, N번 cache.put()을 호출합니다. 결과적으로 캐시는 채워지지만 비싼 메서드가 N번 실행됩니다. 이른바 cache stampede 문제입니다.
sync=true는 이를 방지합니다.
@Cacheable(value = "reports", sync = true)
public Report generate(long reportId) { ... }
내부에서 executeSynchronized()는 Cache.get(key, Callable<T> valueLoader) 오버로드를 사용합니다. 이 메서드는 캐시 구현체가 키 단위 락을 걸어, 동일 키에 대해 하나의 호출자만 valueLoader를 실행하도록 보장합니다. 다른 스레드는 첫 번째 호출이 끝나기를 기다린 뒤 그 결과를 받습니다.
다만 sync=true는 제약이 큽니다.
- 한 메서드에 하나의 캐시 작업만 허용됩니다. 다른
@Cacheable이나@CachePut/@CacheEvict와 결합할 수 없습니다. cacheNames속성에 캐시를 하나만 지정할 수 있습니다.unless를 지원하지 않습니다.
또한 락 동작은 캐시 구현체의 Cache.get(key, Callable) 구현에 달려 있습니다. ConcurrentMapCacheManager나 CaffeineCacheManager는 키 단위 잠금을 보장하지만, 일부 분산 캐시 구현체는 단순히 락 없이 호출자를 직렬화할 수도 있습니다. 분산 환경에서 진짜로 "한 노드 한 번"을 보장하려면 별도의 분산 락이 필요합니다.
7. 흔히 마주치는 함정
자기 호출은 가로채지지 않는다
다음 코드는 예상대로 동작하지 않습니다.
@Service
public class OrderService {
public Order findOrCompute(long id) {
return findOrder(id);
}
@Cacheable("orders")
public Order findOrder(long id) { ... }
}
findOrCompute()가 외부에서 호출되면 그 호출은 프록시를 거치지만, 내부에서 findOrder(id)를 호출하는 시점에는 이미 프록시 안쪽으로 들어와 있는 상태(this 객체)입니다. this.findOrder()는 프록시를 거치지 않고 원본 메서드로 직행하므로 캐시 인터셉터가 가로챌 기회를 얻지 못합니다.
해결책은 익숙합니다. 자기 자신을 프록시로 주입받거나, 메서드를 다른 빈으로 분리합니다. 또는 AOP 모드를 AspectJ로 바꿔 위빙 기반으로 동작시키면 자기 호출도 가로채지지만, 운영 부담이 늘어납니다.
접근 제어자
프록시 기반 모드에서 캐시 어노테이션은 public 메서드에만 효과가 있습니다. JDK Dynamic Proxy는 인터페이스 메서드만 가로챌 수 있고, CGLIB도 final이 아닌 public/protected를 다룰 수 있지만 Spring AOP는 일관성을 위해 public만 처리합니다. private/package-private/protected에 어노테이션을 붙여도 오류는 나지 않고 그냥 무시됩니다.
key=""의 함정
@Cacheable("foo")로 키를 비워두면 모든 인자가 들어간 SimpleKey를 키로 사용합니다. 인자가 없는 메서드라면 SimpleKey.EMPTY 하나가 키이므로 메서드 결과는 캐시 한 슬롯에 저장되고, 호출자가 누구든 같은 값을 반환받습니다. 사용자별 결과를 캐싱하려는데 키를 명시하지 않으면, 첫 번째 호출의 결과가 다른 사용자에게도 새어 나갈 수 있습니다.
컬렉션 반환과 unless
@Cacheable에 unless="#result == null"을 자주 적습니다. 그런데 메서드가 List<Order>를 반환할 때 "빈 리스트는 캐시하고 싶지 않다"면 다음처럼 작성해야 합니다.
@Cacheable(value = "orders", unless = "#result == null or #result.isEmpty()")
public List<Order> findByUser(long userId) { ... }
unless는 메서드가 정상 반환된 직후에 평가되므로 #result 접근이 가능하지만, 메서드가 예외를 던지면 평가 자체가 일어나지 않고 캐시 쓰기도 건너뜁니다.
8. CacheManager 구현체 살펴보기
같은 추상화 위에 어떤 구현체를 끼우느냐에 따라 동작 특성이 달라집니다.
ConcurrentMapCacheManager
이름 그대로 ConcurrentHashMap을 백엔드로 사용합니다. 클래스패스에 다른 의존성이 없으면 Spring Boot가 기본으로 등록하는 구현체입니다.
- TTL 없음, 크기 제한 없음, 통계 없음
- 단일 JVM 내에서만 동작
- 테스트나 정말 작은 캐시에 적합
CaffeineCacheManager
Caffeine을 백엔드로 사용합니다. 클래스패스에 com.github.ben-manes.caffeine:caffeine이 있고 Redis 같은 다른 캐시 구현체가 없으면 Spring Boot가 우선 선택합니다.
- W-TinyLFU 기반 admission policy로 적중률이 우수
- TTL, 크기 제한, 통계 모두 지원
- 단일 JVM 내에서만 동작
RedisCacheManager
Spring Data Redis가 제공합니다. spring-boot-starter-data-redis와 spring.cache.type=redis 또는 클래스패스 자동 감지로 선택됩니다.
- 다중 노드 간 공유 캐시
- TTL, 키 prefix, serializer 설정 풍부
- 네트워크 호출이 끼므로 로컬 캐시 대비 latency가 높음
- 직렬화 형식을 잘못 정하면 운영 중 호환성 문제 발생
CompositeCacheManager와 TransactionAwareCacheManagerProxy
다수의 CacheManager를 묶고 싶을 때 CompositeCacheManager를 씁니다. getCache(name) 호출 시 등록된 매니저들을 차례로 조회합니다.
TransactionAwareCacheManagerProxy는 트랜잭션 동기화 기능을 덧붙입니다. 이 프록시를 거치면 cache.put()이 트랜잭션 커밋 이후로 지연됩니다. @Transactional 메서드 내부에서 @CachePut을 사용할 때 롤백되면 캐시도 함께 되돌리고 싶을 때 유용합니다.
9. 정리
@Cacheable 한 줄 뒤에는 다음의 사슬이 있습니다.
flowchart LR
A[Caller] --> B[Proxy: JDK or CGLIB]
B --> C[CacheInterceptor.invoke]
C --> D[CacheAspectSupport.execute]
D --> E[CacheOperationSource: parse annotations]
D --> F[KeyGenerator: build key]
D --> G[CacheResolver: find Cache]
G --> H[CacheManager.getCache]
H --> I[Cache.get / put / evict]
D --> J[Real method invocation on miss]
이 흐름을 머릿속에 그려두면, 캐시가 동작하지 않는 상황의 90%는 빠르게 진단됩니다. 자기 호출인지, 접근 제어자가 맞는지, 키 SpEL이 의도대로 평가되는지, sync 제약을 위반했는지, 어느 CacheManager가 실제로 주입되었는지를 차례로 확인하면 됩니다.
추상화는 백엔드를 교체하기 위해서만 존재하는 것이 아닙니다. 동작 모델을 단순화해서 디버깅 표면을 좁히는 데 더 큰 가치가 있습니다. 위 사슬에 익숙해진 다음에는 @Cacheable 한 줄이 더 이상 마법처럼 보이지 않을 것입니다.
참고자료
- Spring Framework Reference — Cache Abstraction: https://docs.spring.io/spring-framework/reference/integration/cache.html
- Spring Framework Reference — Declarative Annotation-based Caching: https://docs.spring.io/spring-framework/reference/integration/cache/annotations.html
- Spring Framework Javadoc — CacheAspectSupport: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/interceptor/CacheAspectSupport.html
- Spring Framework Javadoc — CacheInterceptor: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/interceptor/CacheInterceptor.html
- Spring Framework Javadoc — CacheManager: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/CacheManager.html
- Spring Framework Source — CacheAspectSupport.java: https://github.com/spring-projects/spring-framework/blob/main/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java
- Spring Framework Source — SimpleKeyGenerator.java: https://github.com/spring-projects/spring-framework/blob/main/spring-context/src/main/java/org/springframework/cache/interceptor/SimpleKeyGenerator.java
- Spring Framework Source — SimpleKey.java: https://github.com/spring-projects/spring-framework/blob/main/spring-context/src/main/java/org/springframework/cache/interceptor/SimpleKey.java
- Spring Framework Source — EnableCaching.java: https://github.com/spring-projects/spring-framework/blob/main/spring-context/src/main/java/org/springframework/cache/annotation/EnableCaching.java
- Spring Boot Reference — Caching: https://docs.spring.io/spring-boot/reference/io/caching.html
- Spring Data Redis — Redis Cache: https://docs.spring.io/spring-data/redis/reference/redis/redis-cache.html
- Caffeine: https://github.com/ben-manes/caffeine

