Skip to content

Latest commit

 

History

History
191 lines (154 loc) · 11.3 KB

File metadata and controls

191 lines (154 loc) · 11.3 KB

02 — JSR-107 캐시 애너테이션 인터셉션

무엇을 / 왜

JSR-107(JCache)은 javax.cache.annotation 패키지에 표준 캐시 애너테이션을 정의한다. @CacheResult, @CachePut, @CacheRemove, @CacheRemoveAll이 그것이다. 이는 Spring 고유의 @Cacheable/@CacheEvict와는 별개의 "표준" 애너테이션이다.

이 챕터가 다루는 cache.jcache.interceptor/cache.jcache.config 패키지는, 이 표준 애너테이션이 붙은 메서드를 AOP로 가로채서 Spring 캐시 추상화 위에서 실행하는 인프라다. 핵심 메시지는 Javadoc에 명확하다 — "표준 JSR-107 애너테이션을 처리하는 데 javax.cache.Cachejavax.cache.CacheManager는 필요 없다. 캐시 연산은 Spring의 캐시 추상화로 수행한다." 즉 애너테이션 규격만 JSR-107을 따르고, 실제 저장은 Spring Cache/CacheResolver가 한다.

이 인프라는 보통 @EnableCaching을 켜면 Spring 캐시 인프라와 나란히 자동 등록된다. 그 진입점이 cache.jcache.config@Configuration 클래스들이다.

핵심 타입

@EnableCaching → ProxyJCacheConfiguration (extends AbstractJCacheConfiguration)
   │  registers as infrastructure beans:
   ├── JCacheOperationSource  (= DefaultJCacheOperationSource)   "메서드 → 연산" 매핑
   ├── JCacheInterceptor      (MethodInterceptor)                런타임 가로채기
   └── BeanFactoryJCacheOperationSourceAdvisor                   Pointcut + Advice 묶음

   AOP 매칭:
   JCacheOperationSourcePointcut.matches(method, class)
        └─▶ cacheOperationSource.hasCacheOperation(method, class)

   인터셉션 코어(애스펙트):
   JCacheInterceptor ──extends──▶ JCacheAspectSupport
        │ operation 종류에 따라 디스패치
        ├── CacheResultOperation    ─▶ CacheResultInterceptor
        ├── CachePutOperation       ─▶ CachePutInterceptor
        ├── CacheRemoveOperation    ─▶ CacheRemoveEntryInterceptor
        └── CacheRemoveAllOperation ─▶ CacheRemoveAllInterceptor

   연산 메타데이터 계층:
   JCacheOperation<A> ──┬── AbstractJCacheKeyOperation ── CacheResult/CachePut/CacheRemoveOperation
                        └── CacheRemoveAllOperation

크게 세 덩어리로 나뉜다.

  1. 연산 소스(OperationSource) — 메서드를 보고 어떤 캐시 연산인지(JCacheOperation) 알아내고 캐싱한다.
  2. 포인트컷·어드바이저 — 캐시 연산이 있는 메서드만 골라 프록시를 건다.
  3. 애스펙트·인터셉터 — 실제로 캐시 조회/저장/제거를 수행한다.

동작 흐름 1 — 메서드 → 연산 매핑과 캐싱

AbstractFallbackJCacheOperationSource는 "메서드 한 번 분석하면 결과를 캐싱"하는 핵심 골격이다. AOP는 같은 메서드를 수없이 호출하므로, 매 호출마다 애너테이션을 리플렉션으로 뒤지면 느리다. 그래서 MethodClassKey(메서드+대상클래스)별로 JCacheOperationConcurrentHashMap에 보관한다.

getCacheOperation(method, targetClass)
   │
   ▼
ReflectionUtils.isObjectMethod? ─예─▶ null (Object의 메서드는 무시)
   │ 아니오
   ▼
cacheKey = MethodClassKey(method, targetClass)
cached = operationCache.get(cacheKey)
   │
   ├─ cached != null
   │     └─▶ NULL_CACHING_MARKER 면 null, 아니면 그 연산 반환
   │
   └─ cached == null
         └─▶ operation = computeCacheOperation(method, targetClass)
               operation != null ? 캐시에 저장 후 반환
                               : NULL_CACHING_MARKER 저장 후 null

캐시에 "연산 없음"도 명시적으로 기록하는 점이 포인트다. NULL_CACHING_MARKER라는 표식 객체를 넣어, 두 번 다시 분석하지 않도록 한다(부정 캐싱).

computeCacheOperation은 폴백 정책을 구현한다. AOP 프록시 대상 클래스의 가장 구체적인 메서드(AopUtils.getMostSpecificMethod)를 먼저 보고, 거기 없으면 원래 선언 메서드(인터페이스 메서드 등)를 본다.

Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);
JCacheOperation<?> operation = findCacheOperation(specificMethod, targetClass);  // 1순위
if (operation != null) return operation;
if (specificMethod != method) {
    operation = findCacheOperation(method, targetClass);  // 폴백: 선언 메서드
    if (operation != null) return operation;
}
return null;

실제 애너테이션 파싱은 추상 메서드 findCacheOperation이며, 구현체 AnnotationJCacheOperationSource(그 위에 기본 빈을 만드는 DefaultJCacheOperationSource)가 @CacheResult 등을 읽어 구체 JCacheOperation을 만든다.

Pointcut에서의 재사용

JCacheOperationSourcePointcut.matches는 이 OperationSource를 그대로 빌린다. "이 메서드에 캐시 연산이 있나?"를 곧 "프록시를 걸까?"의 판단으로 쓴다. 내부 ClassFilterCacheManager 구현 클래스 자체는 후보에서 제외해, 캐시 매니저 빈에 캐시 프록시가 잘못 씌워지는 것을 막는다.

@Override
public boolean matches(Method method, Class<?> targetClass) {
    return (this.cacheOperationSource == null ||
            this.cacheOperationSource.hasCacheOperation(method, targetClass));
}

동작 흐름 2 — 호출 가로채기와 연산 디스패치

프록시된 메서드가 호출되면 JCacheInterceptor(=MethodInterceptor)를 거쳐 JCacheAspectSupport.execute로 들어온다. 여기서 연산 종류를 보고 네 개의 전용 인터셉터 중 하나로 분기한다.

proxy.findBook(id)
   │
   ▼ JCacheInterceptor.invoke(MethodInvocation)
JCacheAspectSupport.execute(invoker, target, method, args)
   │ initialized?  (afterPropertiesSet에서 네 인터셉터 생성)
   ▼
operation = cacheOperationSource.getCacheOperation(method, targetClass)
   │ operation == null ? ─▶ 그냥 invoker.invoke() (캐시 미적용)
   ▼
context = new DefaultCacheInvocationContext(operation, target, args)
   │
   ▼ execute(context, invoker)
operation instanceof ...
   ├ CacheResultOperation    → cacheResultInterceptor.invoke(...)
   ├ CachePutOperation       → cachePutInterceptor.invoke(...)
   ├ CacheRemoveOperation    → cacheRemoveEntryInterceptor.invoke(...)
   └ CacheRemoveAllOperation → cacheRemoveAllInterceptor.invoke(...)

네 인터셉터는 afterPropertiesSet에서 한 번 생성된다. initialized 플래그는 AspectJ 등으로 애스펙트가 자동 편입됐지만 아직 초기화되지 않은 경우 캐싱을 건너뛰고 원본 메서드만 실행하기 위한 안전장치다.

JCacheOperation<?> operation = getCacheOperationSource().getCacheOperation(method, targetClass);
if (operation != null) {
    CacheOperationInvocationContext<?> context =
            createCacheOperationInvocationContext(target, args, operation);
    return execute(context, invoker);
}
return invoker.invoke();

동작 흐름 3 — @CacheResult의 실제 처리(예외 캐싱 포함)

CacheResultInterceptor는 JSR-107의 가장 특징적인 기능인 예외 캐싱까지 구현하므로 대표 예시로 좋다. 흐름은 다음과 같다.

1. cacheKey = generateKey(context)
2. cache          = resolveCache(context)
   exceptionCache = resolveExceptionCache(context)   (선택)
3. operation.isAlwaysInvoked() == false 면:
      cached = doGet(cache, cacheKey)
      cached != null ? ─▶ 즉시 cached.get() 반환 (캐시 히트)
      checkForCachedException(exceptionCache, cacheKey)  ← 캐싱된 예외 있으면 그대로 throw
4. 캐시 미스 → 실제 메서드 실행:
      result = invoker.invoke()
      doPut(cache, cacheKey, result)   ← 결과 캐싱
      return result
5. 실행 중 예외 발생(ThrowableWrapper) →
      exceptionTypeFilter.match(예외) 면 doPut(exceptionCache, cacheKey, 예외)
      그리고 원래 예외 재던짐
if (!operation.isAlwaysInvoked()) {
    Cache.ValueWrapper cachedValue = doGet(cache, cacheKey);
    if (cachedValue != null) {
        return cachedValue.get();
    }
    checkForCachedException(exceptionCache, cacheKey);
}
try {
    Object invocationResult = invoker.invoke();
    doPut(cache, cacheKey, invocationResult);
    return invocationResult;
}
catch (CacheOperationInvoker.ThrowableWrapper ex) {
    Throwable original = ex.getOriginal();
    cacheException(exceptionCache, operation.getExceptionTypeFilter(), cacheKey, original);
    throw ex;
}

예외 캐싱에는 까다로운 디테일이 있다. 캐싱된 예외를 다시 던질 때, 그 예외의 스택트레이스는 "처음 캐싱됐을 때의 호출 경로"라서 지금 호출자에게는 어색하다. rewriteCallStack은 예외를 SerializationUtils.clone으로 복제한 뒤, 현재 호출 스택과 캐싱된 스택의 공통 조상 지점을 찾아 스택트레이스를 이어 붙인다. 직렬화가 불가능하거나 공통 조상을 못 찾으면 원본 예외를 그대로 쓴다.

핵심 메서드 / 타입 정리

  • JCacheAspectSupport.execute(...) — 연산 종류별 디스패처. 네 인터셉터로 가는 갈림길.
  • AbstractFallbackJCacheOperationSource.getCacheOperation(...) — 메서드별 연산을 캐싱(부정 캐싱 포함)하는 성능 핵심.
  • CacheResultInterceptor.invoke(...) — 히트/미스/예외캐싱을 모두 처리하는 대표 연산.
  • KeyGeneratorAdapter / CacheResolverAdapter — JSR-107의 CacheKeyGenerator/CacheResolver 표준 SPI를 Spring의 KeyGenerator/CacheResolver로 맞물리는 어댑터. "표준 규격 ↔ Spring 인프라"의 양방향 변환을 담당한다.
  • DefaultJCacheOperationSourceBeanFactoryAware/SmartInitializingSingleton. 명시 설정이 없으면 SimpleKeyGenerator/SimpleCacheResolver 같은 sensible default를 빈 팩토리에서 찾아 채운다.

설계 포인트 / 확장점

  • Spring 캐시 인프라와의 평행 구조: JCacheAspectSupport는 Spring 자체의 CacheAspectSupport와 의도적으로 같은 모양(OperationSource + Pointcut + Advisor + Interceptor)을 가진다. @Cacheable을 이해하면 이 구조도 바로 읽힌다.
  • 설정 진입점 분리: AbstractJCacheConfiguration(공통, OperationSource 빈 정의) ↔ ProxyJCacheConfiguration(프록시 모드용 Advisor/Interceptor 빈 정의). JCacheConfigurer로 사용자가 cacheManager/cacheResolver/exceptionCacheResolver/keyGenerator를 커스터마이즈한다.
  • 표준과 구현의 분리: 애너테이션은 JSR-107, 저장과 키 생성은 Spring. 그래서 어떤 Spring CacheManager(Caffeine, Redis 등)와도 조합할 수 있다.
  • 예외 캐싱: @CacheResult(exceptionCacheName=...)는 JSR-107 고유 기능이며, 스택트레이스 재작성까지 신경 쓴 정교한 구현이다.

정리

이 챕터의 인프라는 JSR-107 표준 캐시 애너테이션을 Spring AOP로 처리한다. OperationSource가 메서드를 연산으로 매핑(+캐싱)하고, Pointcut/Advisor가 대상 메서드를 골라 프록시를 걸며, JCacheAspectSupport가 연산 종류에 따라 네 전용 인터셉터로 디스패치한다. 저장은 전적으로 Spring 캐시 추상화 위에서 이뤄지므로, "표준 애너테이션 + Spring 캐시 백엔드"라는 조합이 자유롭게 성립한다.