Spring @Cacheable Deep Dive

jonghyun.log·2026년 3월 7일

deep-dive

목록 보기
1/1
post-thumbnail

N줄 요약

  1. @Cacheable 은 스프링에서 제공하는 캐싱 추상화 기법이며 Spring Transation 과 비슷한 형태의 추상화 레이어로 구성되어있다.
  2. Spring Aop 기반의 CacheInterceptor, CacheAspectSupport,Cache 인터페이스 구현체 순서로 캐시값을 탐색하여 캐시 여부를 판단한다.
  3. CacheInterceptor 에서 원본 함수 호출을 위한 invoke 람다 구현체를 생성한다.
  4. CacheAspectSupport 에서 실제 캐시 탐색과 @Cacheable 옵션을 처리한다.
  5. Cache 인터페이스 구현체 에서 캐시 저장소에 접근하여 캐시값을 꺼내오고 저장한다.

스프링에서 @Cacheable 을 사용하였을때 어떤 원리로 실제 Redis를 거쳐서 캐시를 관리하는지 알아보고 동작 순서를 코드를 눈으로 보면서 직접 확인해보자.

@Cacheable 이란?

스프링 공식문서에서 @Cacheable을 다음과 같이 안내하고 있다.

캐싱 선언을 위해 Spring의 캐싱 추상화는 일련의 Java 어노테이션을 제공합니다.

  • @Cacheable: 캐시 채우기를 시작합니다.

(출처 : 스프링 공식문서)

@Cacheable 말고도 다른 위 사진에 나와있는 에너테이션들이 존재하며, 해당 에너테이션들은 스프링에서 제공하는 추상화 레벨에서 캐싱을 제공하기 위한 기능을 제공한다.

@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라는 엔티티를 조회하는 메서드를 하나 작성했다.
위 함수는 다음과 같이 동작할 것이다.

  1. 메서드 호출시 캐시를 확인하여 캐시에 데이터가 있으면 해당 캐시 데이터 리턴(Cache Hit)
  2. 캐시에 데이터가 없으면 메서드 내부 호출하여 DB에서 데이터 조회후 캐시 등록(Cache Miss)

이 과정을 요약하여 한눈에 보기쉽게 시퀀스 다이어그램으로 그리면 아래와 같다.

해당 그림의 구조를 조금 더 자세히 들여다 보자.

Spring AOP 를 통한 캐싱 추상화 구조

결론부터 설명하자면, 스프링에서 AOP 기반으로 메서드를 가로채서 캐시 공간의 데이터를 확인하고 해당 데이터의 존재 여부에 따라 캐시 히트/미스 상태를 파악후 캐시 등록 혹은 갱신 등의 동작을 하게 된다.

해당 메커니즘을 위한 핵심 클래스들로 다음과 같은 클래스들이 있다.

  1. Configuration Layer : Spring 캐시 및 AOP 관련 빈 설정 등록
  2. Spring AOP Layer : 함수 호출을 가로챈 후 AOP 로직 실행
    • CGLIB Proxy : 실제 서비스 빈 대신 주입되는 프록시 객체.메서드 호출을 가로채 Advisor 체인 실행 후 타깃 메서드를 호출함
    • CacheInterceptor : MethodInterceptor 구현체. AOP 체인에서 캐시 처리의 진입점. 실제 로직은 부모 클래스인 CacheAspectSupport 에 위임
    • CacheAspectSupport : 캐시 핵심 로직이 구현된 추상 클래스.
      - HIT → 타깃 메서드 호출 없이 즉시 반환
      - MISS → 메서드 실행 후 CachePutRequest.apply()로 저장
  3. Spring Cache 추상화 Layer : CacheAspectSupport 로부터 캐시 이름을 전달받아 실제 캐시 저장소에 접근
    • CacheManager : 캐시 저장소를 관리하는 팩토리 역할.캐시 이름으로 Cache 객체를 조회·생성함. RedisCacheManager(Spring Data Redis 패키지에서 제공하는 레디스 캐시 매니저 클래스) 등으로 교체하여 캐시 등록 가능
    • Cache (interface) : get · put · evict 연산을 정의한 인터페이스. 구현체를 교체해도 CacheAspectSupport 코드는 변경 불필요. 추상화의 핵심
  4. Cache 구현체 / Infrastructure : 실제 캐시 구현체 기반으로 캐싱 동작
    • RedisCache : Cache 인터페이스의 Redis 구현체. 내부적으로 RedisTemplate을 통해 Redis 서버와 통신.
    • CaffeineCache : Cache 인터페이스의 로컬 메모리 구현체. JVM 내부 메모리에 저장하므로 네트워크 비용 없음.

스프링 AOP 기반으로 @Cachable 이 있는 메서드를 가로챈 이후 Cache Hit/Miss 의 일련의 과정을 거치게 된다. 스프링 트랜잭션과 거의 비슷한 형태로 추상화가 되어있다고 생각하면 이해하기 더 쉽다.

Proxy 빈 인스턴스 -> AOP Interceptor -> AspectSupport -> 캐시 혹은 트랜잭션 Manager -> 실제 캐싱 혹은 트랜잭션 관련 동작 -> 원본 클래스 메서드

@Cachable의 실제 동작

이번에는 실제로 해당 서비스 코드를 테스트 코드를 통해 실행한 후 Intelij 의 Profiler 기능을 통해 함수 콜 스택을 직접 확인해보자.

실제 캐싱 과정은 다음과 같은 순서로 실행된다.

  1. CglibAopProxy.intercept() : 프록시 객체가 메서드 호출 가로챔
  2. CacheInterceptor.invoke() : AOP로 가로챈 메서드 호출을 캐시 처리 로직에 위임하고, 발생한 예외를 원본 그대로 복원해서 던지는 캐시 인터셉터의 진입점
  3. CacheAspectSupport.execute(CacheOperationInvoker, Object, Method ,Object[]) : 초기화 상태 확인 → 해당 메서드에 캐시 오퍼레이션(@Cacheable 등)이 존재하면 캐시 처리 로직 실행, 없으면 그냥 원래 메서드 호출
  4. 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 에 따른 동작

그렇다면 Cache Hit/Miss 에 따라 다르게 동작하는 과정은 구체적으로 어떻게 되는건지 조금 더 자세히 살펴보자.

1.CacheInterceptor.invoke() : 원본 메서드 호출을 위한 람다 생성

CacheInterceptor.invoke()

    @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 에서 캐시 관련 작업을 수행하고 원본 메서드를 호출할 수 있게 된다.

2.CacheAspectSupport.execute() : 캐시 옵션 처리 및 CacheContext를 순회하면서 캐시 값 탐색

  1. CacheAspectSupport.execute() : 캐시 옵션 처리
  2. CacheAspectSupport.findCachedValue() : CacheContext 순회
  3. 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);
}

3.CacheAspectSupport.evaulte() : Cache.doGet() 으로 가져온 값 기반으로 Hit/Miss 처리

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;
}

캐시 Hit/Miss 에 따라 호출되는 메서드 흐름 정리

여태까지 정리한 내용을 기반으로 처음에 작성한 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 는 캐싱이나 트랜잭션과 같은 비즈니스 로직만 다루면서 원본 함수 호출 및 제어를 컨트롤 할 수 있는게 잘 만들어진 구조로 보입니다.

0개의 댓글