
@Cacheable은 스프링에서 제공하는 캐싱 추상화 기법이며 Spring Transation 과 비슷한 형태의 추상화 레이어로 구성되어있다.Spring Aop기반의CacheInterceptor,CacheAspectSupport,Cache 인터페이스 구현체순서로 캐시값을 탐색하여 캐시 여부를 판단한다.CacheInterceptor에서 원본 함수 호출을 위한 invoke 람다 구현체를 생성한다.CacheAspectSupport에서 실제 캐시 탐색과@Cacheable옵션을 처리한다.Cache 인터페이스 구현체에서 캐시 저장소에 접근하여 캐시값을 꺼내오고 저장한다.
스프링에서 @Cacheable 을 사용하였을때 어떤 원리로 실제 Redis를 거쳐서 캐시를 관리하는지 알아보고 동작 순서를 코드를 눈으로 보면서 직접 확인해보자.
스프링 공식문서에서 @Cacheable을 다음과 같이 안내하고 있다.
캐싱 선언을 위해 Spring의 캐싱 추상화는 일련의 Java 어노테이션을 제공합니다.

(출처 : 스프링 공식문서)
@Cacheable 말고도 다른 위 사진에 나와있는 에너테이션들이 존재하며, 해당 에너테이션들은 스프링에서 제공하는 추상화 레벨에서 캐싱을 제공하기 위한 기능을 제공한다.
@Cacheable(cacheNames = ["products"], key = "#id")
fun getProduct(id: Long): ProductDto {
val product = productRepository.findById(id)
.orElseThrow { NoSuchElementException("Product not found: $id") }
return product.toDto()
}
@Cachable을 통해 Product라는 엔티티를 조회하는 메서드를 하나 작성했다.
위 함수는 다음과 같이 동작할 것이다.
- 메서드 호출시 캐시를 확인하여 캐시에 데이터가 있으면 해당 캐시 데이터 리턴(Cache Hit)
- 캐시에 데이터가 없으면 메서드 내부 호출하여 DB에서 데이터 조회후 캐시 등록(Cache Miss)
이 과정을 요약하여 한눈에 보기쉽게 시퀀스 다이어그램으로 그리면 아래와 같다.

해당 그림의 구조를 조금 더 자세히 들여다 보자.
결론부터 설명하자면, 스프링에서 AOP 기반으로 메서드를 가로채서 캐시 공간의 데이터를 확인하고 해당 데이터의 존재 여부에 따라 캐시 히트/미스 상태를 파악후 캐시 등록 혹은 갱신 등의 동작을 하게 된다.
해당 메커니즘을 위한 핵심 클래스들로 다음과 같은 클래스들이 있다.

- Configuration Layer : Spring 캐시 및 AOP 관련 빈 설정 등록
@EnableCaching: Spring 캐시 기능 활성화 애노테이션. 내부적으로CachingConfigurationSelector를 import 하여 캐시 인프라 빈 등록을 트리거함- Spring AOP Layer : 함수 호출을 가로챈 후 AOP 로직 실행
CGLIB Proxy: 실제 서비스 빈 대신 주입되는 프록시 객체.메서드 호출을 가로채 Advisor 체인 실행 후 타깃 메서드를 호출함CacheInterceptor: MethodInterceptor 구현체. AOP 체인에서 캐시 처리의 진입점. 실제 로직은 부모 클래스인CacheAspectSupport에 위임CacheAspectSupport: 캐시 핵심 로직이 구현된 추상 클래스.
-HIT→ 타깃 메서드 호출 없이 즉시 반환
-MISS→ 메서드 실행 후 CachePutRequest.apply()로 저장- Spring Cache 추상화 Layer : CacheAspectSupport 로부터 캐시 이름을 전달받아 실제 캐시 저장소에 접근
CacheManager: 캐시 저장소를 관리하는 팩토리 역할.캐시 이름으로 Cache 객체를 조회·생성함.RedisCacheManager(Spring Data Redis패키지에서 제공하는 레디스 캐시 매니저 클래스) 등으로 교체하여 캐시 등록 가능Cache (interface): get · put · evict 연산을 정의한 인터페이스. 구현체를 교체해도CacheAspectSupport코드는 변경 불필요. 추상화의 핵심- Cache 구현체 / Infrastructure : 실제 캐시 구현체 기반으로 캐싱 동작
RedisCache: Cache 인터페이스의 Redis 구현체. 내부적으로 RedisTemplate을 통해 Redis 서버와 통신.CaffeineCache: Cache 인터페이스의 로컬 메모리 구현체. JVM 내부 메모리에 저장하므로 네트워크 비용 없음.
스프링 AOP 기반으로 @Cachable 이 있는 메서드를 가로챈 이후 Cache Hit/Miss 의 일련의 과정을 거치게 된다. 스프링 트랜잭션과 거의 비슷한 형태로 추상화가 되어있다고 생각하면 이해하기 더 쉽다.
Proxy 빈 인스턴스 -> AOP Interceptor -> AspectSupport -> 캐시 혹은 트랜잭션 Manager -> 실제 캐싱 혹은 트랜잭션 관련 동작 -> 원본 클래스 메서드
이번에는 실제로 해당 서비스 코드를 테스트 코드를 통해 실행한 후 Intelij 의 Profiler 기능을 통해 함수 콜 스택을 직접 확인해보자.

실제 캐싱 과정은 다음과 같은 순서로 실행된다.
CglibAopProxy.intercept(): 프록시 객체가 메서드 호출 가로챔CacheInterceptor.invoke(): AOP로 가로챈 메서드 호출을 캐시 처리 로직에 위임하고, 발생한 예외를 원본 그대로 복원해서 던지는 캐시 인터셉터의 진입점CacheAspectSupport.execute(CacheOperationInvoker, Object, Method ,Object[]): 초기화 상태 확인 → 해당 메서드에 캐시 오퍼레이션(@Cacheable 등)이 존재하면 캐시 처리 로직 실행, 없으면 그냥 원래 메서드 호출CacheAspectSupport.execute(CacheOperationInvoker, Method, CacheAspectSupport$CacheOperationContexts): 캐시 동기화 확인 → Early Evict → 캐시 조회 → 결과 평가(Put 포함)
4-1.CacheAspectSupport.findCachedValue():@Cacheable로 등록된CacheOperationContext들을 순회하며 각 컨텍스트의 Cache(RedisCache, CaffeineCache 등)에서 캐시 값 조회
4-2.CacheAspectSupport.evaluate(): 캐시 히트 여부에 따라 캐시 미스면 실제 메서드를 호출하고, 결과를 캐시에 저장(CachePut)하는 최종 결과 처리
참고 :
CacheOperationContext는@Cachable에너테이션으로 등록한 캐시 정보를 담고 있는 메타데이터 클래스이다.
캐시 네임, 캐시 키 등 캐시와 관련된 메타데이터를 담고 있다.
그렇다면 Cache Hit/Miss 에 따라 다르게 동작하는 과정은 구체적으로 어떻게 되는건지 조금 더 자세히 살펴보자.
@Override
public @Nullable Object invoke(final MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
// 원본 함수 호출의 제어를 담은 Invoker 클래스를 람다로 생성하여 CacheAspectSupport에게 넘겨줌
CacheOperationInvoker aopAllianceInvoker = () -> {
try {
// 원본 함수(ex - service method) 호출 : invocation.proceed();
return invocation.proceed();
}
// 원본 함수 예외가 발생했을때 해당 예외 핸들링 후
// CacheOperationInvoker.ThrowableWrapper 로 throw
catch (Throwable ex) {
throw new CacheOperationInvoker.ThrowableWrapper(ex);
}
};
Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
try {
// CacheAspectSupport 에 정의된 execute() 메서드
return execute(aopAllianceInvoker, target, method, invocation.getArguments());
}
catch (CacheOperationInvoker.ThrowableWrapper th) {
throw th.getOriginal();
}
}
해당 메서드 내부에서 CacheOperationInvoker 라는 람다 클래스를 만든후 해당 클래스를 execute() 함수의 인자로 넘긴다.
여기서
execute()함수는 부모로 상속받은CacheAspectSupport에 정의되어있는execute()함수이다.
// CacheInterceptor 는 CacheAspectSupport 를 상속받고 있다.
public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor, Serializable {
...
즉, 해당 메서드에서 정의한 CacheOperationInvoker 를 통해 CacheAspectSupport 에서 캐시 관련 작업을 수행하고 원본 메서드를 호출할 수 있게 된다.
CacheAspectSupport.execute(): 캐시 옵션 처리CacheAspectSupport.findCachedValue(): CacheContext 순회CacheAspectSupport.findInCaches(): CacheContext에 담긴 캐시 저장소에서 캐시값 가져옴
CacheAspectSupport.execute() : @Cachable 에너테이션 관련 설정에 맞게 캐싱 관련 동작 시작 (sync 옵션, early evict 옵션)
private @Nullable Object execute(CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
// 캐시 동기화 관련 설정 @Cacheable(sync=true) 옵션
// 해당 옵션이 true이면 캐시 저장소에 접근시 여러요청을 순차적으로 하나씩 처리
if (contexts.isSynchronized()) {
// Special handling of synchronized invocation
return executeSynchronized(invoker, method, contexts);
}
// Process any early evictions
processCacheEvicts(
contexts.get(CacheEvictOperation.class),
true,
CacheOperationExpressionEvaluator.NO_RESULT
);
// Check if we have a cached value matching the conditions
// 여기서 캐시 저장소에서 캐시 값 탐색
Object cacheHit = findCachedValue(invoker, method, contexts);
if (cacheHit == null || cacheHit instanceof Cache.ValueWrapper) {
// null → Miss → 원본 메서드 실행 후 캐시 저장
// ValueWrapper → Hit이지만 evaluate()를 통해 언래핑
return evaluate(cacheHit, invoker, method, contexts);
}
return cacheHit; // CompletableFuture/Reactive 타입은 바로 반환
}
CacheAspectSupport.findCachedValue() : CacheOperationContext 를 순회하면서 캐시 값 탐색
private @Nullable Object findCachedValue(CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
// CacheOperationContext : 캐시 관련 정보를 담은 메타데이터 클래스
for (CacheOperationContext context : contexts.get(CacheableOperation.class)) {
// isConditionPassing : @Cacheable(condition = "...") SpEL 표현식 평가
// 예시) @Cacheable(value = "users", condition = "#id > 0") 에서 캐시 조건이 users::1 인 키가 오면 해당 조건 true
if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {
Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
// 여기서 CacheOperationContext 안에 담긴 캐시 저장소에서 캐시 값 탐색
Object cached = findInCaches(context, key, invoker, method, contexts);
if (cached != null) {
if (logger.isTraceEnabled()) {
logger.trace("Cache entry for key '" + key + "' found in cache(s) " + context.getCacheNames());
}
return cached;
}
else {
if (logger.isTraceEnabled()) {
logger.trace("No cache entry for key '" + key + "' in cache(s) " + context.getCacheNames());
}
}
}
}
return null;
}
CacheAspectSupport.findInCaches() : CacheOperationContext 에 담긴 캐시 저장소에서 실제 캐시 값 탐색 및 NonBlocking 설정일때 비동기로 탐색
private @Nullable Object findInCaches(
CacheOperationContext context,
Object key,
CacheOperationInvoker invoker,
Method method,
CacheOperationContexts contexts
) {
for (Cache cache : context.getCaches()) {
if (CompletableFuture.class.isAssignableFrom(context.getMethod().getReturnType())) {
// 비동기 처리를 위한 ComuputableFuture 옵션의 캐싱 값 탐색
// 편의상 생략 ...
}
// Reactive 타입 클래스 처리
if (this.reactiveCachingHandler != null) {
Object returnValue = this.reactiveCachingHandler.findInCaches(context, cache, key, invoker, method, contexts);
if (returnValue != ReactiveCachingHandler.NOT_HANDLED) {
return returnValue;
}
}
// 실제 캐시 탐색
Cache.ValueWrapper result = doGet(cache, key);
if (result != null) {
return result;
}
}
return null;
}
AbstractCacheInvoker.doGet() : Cache 인터페이스 구현체(Redis, Caffine, EhCacheCache 등) 에서 캐시 값 가져옴
protected Cache.@Nullable ValueWrapper doGet(Cache cache, Object key) {
try {
return cache.get(key); // Cache 인터페이스 구현체에서 캐시값 get
}
catch (RuntimeException ex) {
getErrorHandler().handleCacheGetError(ex, cache, key);
return null; // If the exception is handled, return a cache miss
}
}
참고) Cache 인터페이스의 구현체의 예시로 RedissonCache 등이 있다.
// RedissonCache.get() 메서드 구현체
@Override
public ValueWrapper get(Object key) {
Object value;
if (mapCache != null && config.getMaxIdleTime() == 0 && config.getMaxSize() == 0) {
value = mapCache.getWithTTLOnly(key);
} else {
value = map.get(key);
}
if (value == null) {
addCacheMiss();
} else {
addCacheHit();
}
return toValueWrapper(value);
}
CacheAspectSupport.evaulte() : doGet()의 결과를 받아 Hit/Miss 분기 처리 + 캐시 저장/제거 후처리를 담당
private @Nullable Object evaluate(@Nullable Object cacheHit, CacheOperationInvoker invoker, Method method,
CacheOperationContexts contexts) {
// Reactive 파이프라인에서 재호출된 경우 이미 처리된 결과 바로 반환
if (contexts.processed) {
return cacheHit;
}
Object cacheValue;
Object returnValue;
// [Hit] cacheHit != null : doGet()에서 캐시 값을 찾은 경우
// [Miss] cacheHit == null OR hasCachePut : 캐시 값 없거나 @CachePut으로 강제 갱신 필요한 경우
if (cacheHit != null && !hasCachePut(contexts)) {
// [Hit 처리] Cache.ValueWrapper에서 실제 값 언래핑
cacheValue = unwrapCacheValue(cacheHit);
// 메서드 반환 타입에 맞게 값 래핑 (ex. Optional, CompletableFuture 등)
returnValue = wrapCacheValue(method, cacheValue);
}
else {
// [Miss 처리] CacheInterceptor에서 주입받은 invoker 람다로 원본 메서드 호출
returnValue = invokeOperation(invoker);
// 반환값에서 실제 캐시에 저장할 값 추출 (ex. Optional unwrap)
cacheValue = unwrapReturnValue(returnValue);
}
// [Miss 후처리] @Cacheable Miss인 경우 원본 메서드 실행 결과를 캐시 저장 목록에 추가
List<CachePutRequest> cachePutRequests = new ArrayList<>(1);
if (cacheHit == null) {
collectPutRequests(contexts.get(CacheableOperation.class), cacheValue, cachePutRequests);
}
// @CachePut은 Hit/Miss 무관하게 항상 캐시 저장 목록에 추가
collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);
// 수집된 캐시 저장 요청 실행 (@Cacheable Miss 저장 + @CachePut 갱신)
for (CachePutRequest cachePutRequest : cachePutRequests) {
Object returnOverride = cachePutRequest.apply(cacheValue);
if (returnOverride != null) {
returnValue = returnOverride;
}
}
// beforeInvocation=false 인 @CacheEvict 처리 (메서드 실행 후 캐시 제거)
// cf. beforeInvocation=true 인 @CacheEvict 는 execute()에서 이미 처리됨
Object returnOverride = processCacheEvicts(
contexts.get(CacheEvictOperation.class), false, returnValue
);
if (returnOverride != null) {
returnValue = returnOverride;
}
// 처리 완료 표시 (Reactive 재호출 시 중복 처리 방지)
contexts.processed = true;
return returnValue;
}
여태까지 정리한 내용을 기반으로 처음에 작성한 ProductCacheService.getProduct() 에서 캐시여부에 따라 어떤식으로 함수가 호출되어 동작할지 정리해보자.
메서드를 처음 호출하면 RedisCache.get() 구현체에 캐시값이 없을것이므로 원본 메서드를 호출하여 캐시값을 저장하고 리턴할것이다.
CacheInterceptor.invoke() ← AOP 인터셉터 진입점
└── CacheAspectSupport.execute() ← 캐시 흐름 조율
└── findCachedValue() ← 캐시 탐색 시작
└── findInCaches() ← Cache 구현체 순회
└── doGet() → RedisCache.get() ← Redis 조회 (null 반환)
└── evaluate() ← null이므로 Miss 처리
└── invokeOperation() ← 원본 메서드 호출
└── ProductCacheService.getProduct() ← 실제 DB 조회
└── doPut() → RedisCache.put() ← 결과를 Redis에 저장
메서드를 다시 호출하면 RedisCache.get() 구현체에 캐시값이 있을것이므로 원본 메서드를 호출하지 않고 캐시값을 언래핑 한 후 호출을 종료할것이다.
CacheInterceptor.invoke() ← AOP 인터셉터 진입점
└── CacheAspectSupport.execute() ← 캐시 흐름 조율
└── findCachedValue() ← 캐시 탐색 시작
└── findInCaches() ← Cache 구현체 순회
└── doGet() → RedisCache.get() ← Redis 조회 (ValueWrapper 반환)
└── evaluate() ← ValueWrapper이므로 Hit 처리
└── unwrapCacheValue() ← ValueWrapper에서 실제 값 추출 후 반환
(invokeOperation() 호출 없음) ← 원본 메서드 호출 안함
Spring Abstract Layer 에 대해 큰 그림을 그려볼 수 있었고, Spring Cache 추상화 도 Spring Transation 추상화 와 거의 비슷한 형태를 띄는게 인상적이었습니다.
특히 원본 함수 호출을 위한 Invoke 함수형 인터페이스 람다 구현체를 Interceptor 에서 생성하고 AspectSupport 는 캐싱이나 트랜잭션과 같은 비즈니스 로직만 다루면서 원본 함수 호출 및 제어를 컨트롤 할 수 있는게 잘 만들어진 구조로 보입니다.