Skip to content

Latest commit

 

History

History
192 lines (153 loc) · 11.6 KB

File metadata and controls

192 lines (153 loc) · 11.6 KB

01 — 캐시 어댑터: Caffeine · JCache · 트랜잭션 인지 캐시

무엇을 / 왜

spring-context의 캐시 추상화는 두 개의 인터페이스로 이뤄진다. CacheManager(이름으로 Cache를 찾아주는 레지스트리)와 Cache(get/put/evict를 가진 저장소)다. 코어는 이 인터페이스만 정의하고, 실제 저장 엔진은 모른다.

이 챕터는 그 두 인터페이스를 실제 캐시 라이브러리에 연결하는 어댑터를 다룬다. spring-context-support는 두 가지 백엔드 어댑터를 제공한다.

  • Caffeine — JVM 인메모리 고성능 캐시. CaffeineCacheManager + CaffeineCache.
  • JCache(JSR-107)javax.cache 표준 API. JCacheCacheManager + JCacheCache.

여기에 더해, 캐시 쓰기를 트랜잭션 커밋 이후로 미루는 cache.transaction 패키지를 함께 본다. 이는 특정 백엔드가 아니라 모든 Cache에 씌울 수 있는 데코레이터다.

참고: JSR-107 표준 애너테이션(@CacheResult 등)을 AOP로 가로채는 인프라(cache.jcache.interceptor / cache.jcache.config)는 분량이 커서 챕터 02에서 따로 다룬다. 이 챕터는 "저장소 어댑터"에 집중한다.

핵심 타입

        org.springframework.cache (spring-context 코어)
        ┌──────────────┐        ┌──────────────┐
        │ CacheManager │        │    Cache     │
        └──────┬───────┘        └──────┬───────┘
               │ implements             │ implements
   ┌───────────┴───────────┐    ┌───────┴──────────┐
   ▼                       ▼    ▼                  ▼
CaffeineCacheManager   AbstractCacheManager   CaffeineCache  JCacheCache
                         ▲ (코어 추상 베이스)
                         │ extends
        AbstractTransactionSupportingCacheManager   ◀── cache.transaction
                         ▲
                         │ extends
                   JCacheCacheManager

   데코레이터(백엔드 무관):
   Cache ──wrap──▶ TransactionAwareCacheDecorator ──delegate──▶ 실제 Cache

두 갈래의 설계 차이에 주목하자.

  • CaffeineCacheManager는 코어의 CacheManager직접 구현한다. 캐시를 요청 시점에 즉석에서(lazy) 만들어 내는 "동적 매니저"를 기본으로 한다.
  • JCacheCacheManager는 코어가 제공하는 AbstractCacheManager(정적 등록·초기화 골격)를 상속하는 AbstractTransactionSupportingCacheManager를 거쳐 구현한다. 즉 트랜잭션 인지 기능을 베이스 클래스로부터 물려받는다.

동작 흐름 1 — CaffeineCacheManager의 동적 캐시 생성

CaffeineCacheManager는 두 가지 모드를 갖는다. 생성자에 이름을 주지 않으면 dynamic 모드(요청마다 없는 캐시를 즉석 생성), setCacheNames(...)로 이름 집합을 고정하면 static 모드(그 이름들만, 런타임 추가 없음)다.

getCache("books")
        │
        ▼
┌────────────────────────────────────────────────┐
│ Cache cache = cacheMap.get("books")             │
│   존재? ──예──▶ 그대로 반환                       │
│   없음 + dynamic == true ?                       │
│        └─▶ cacheMap.computeIfAbsent(            │
│              "books", this::createCaffeineCache) │
│   없음 + static 모드 ? ──▶ null 반환             │
└────────────────────────────────────────────────┘
        │ createCaffeineCache
        ▼
asyncCacheMode 분기:
  false ─▶ cacheBuilder.build()        (native Caffeine Cache)
  true  ─▶ cacheBuilder.buildAsync()   (AsyncCache, retrieve 지원)
        │
        ▼
adaptCaffeineCache(name, nativeCache)
        ▼
new CaffeineCache(name, nativeCache, allowNullValues)  ← Spring Cache로 포장

핵심은 getCache의 동시성 처리다. cacheMapConcurrentHashMap이고, 캐시 생성은 computeIfAbsent로 원자적으로 이뤄진다. 같은 이름을 동시에 요청해도 캐시는 하나만 만들어진다.

@Override
public @Nullable Cache getCache(String name) {
    Cache cache = this.cacheMap.get(name);
    if (cache == null && this.dynamic) {
        cache = this.cacheMap.computeIfAbsent(name, this::createCaffeineCache);
    }
    return cache;
}

공통 설정과 커스텀 캐시의 분리

setCaffeine/setCaffeineSpec/setCacheSpecification/setCacheLoader/setAsyncCacheMode/setAllowNullValues 같은 세터로 공통 설정을 바꾸면, 내부적으로 refreshCommonCaches()가 호출되어 이미 만들어진 공통 캐시들을 재생성한다. 단, registerCustomCache(name, cache)로 직접 등록한 캐시(customCacheNames에 기록됨)는 재생성 대상에서 제외된다 — 캐시별 개별 설정을 보존하기 위함이다.

private void refreshCommonCaches() {
    for (Map.Entry<String, Cache> entry : this.cacheMap.entrySet()) {
        if (!this.customCacheNames.contains(entry.getKey())) {
            entry.setValue(createCaffeineCache(entry.getKey()));
        }
    }
}

allowNullValues가 기본 true인 이유도 짚을 만하다. Caffeine 자체는 null 값을 저장하지 못하므로, Spring 어댑터(CaffeineCache)가 내부 홀더 객체로 사용자 레벨 null을 감싸 저장한다.

동작 흐름 2 — JCacheCacheManager의 정적 로딩과 미싱 캐시 탐색

JCacheCacheManager는 코어 AbstractCacheManager의 골격을 따른다. 초기화(afterPropertiesSet) 시 loadCaches()로 백엔드에 이미 존재하는 캐시를 한 번에 끌어와 등록하고, 이후 없는 이름이 요청되면 getMissingCache()로 한 번 더 백엔드를 조회한다.

afterPropertiesSet()
   │ cacheManager == null ? ─▶ Caching.getCachingProvider().getCacheManager()
   │                            (JSR-107 ServiceLoader로 프로바이더 탐색)
   ▼
super.afterPropertiesSet()  (AbstractCacheManager)
   │
   ▼
loadCaches()
   for (name : javaxCacheManager.getCacheNames())
       caches += new JCacheCache(jcache, allowNullValues)
   │
   ▼ (런타임에 새 캐시가 백엔드에 추가된 경우)
getMissingCache(name)
   jcache = javaxCacheManager.getCache(name)
   jcache != null ? ─▶ new JCacheCache(jcache, allowNullValues)
                  : ─▶ null

afterPropertiesSet에서 백엔드 매니저가 주입되지 않았다면 Caching.getCachingProvider().getCacheManager()로 JSR-107 표준 디스커버리를 수행한다. 즉 클래스패스에 있는 JCache 프로바이더(Ehcache, Hazelcast 등)를 자동으로 집어 온다.

resetCaches()는 닫히지 않은(!isClosed()) 백엔드 캐시들을 순회하며 clear()하도록 오버라이드되어 있다.

동작 흐름 3 — 트랜잭션 인지 캐시

캐시 쓰기는 보통 즉시 일어난다. 그런데 트랜잭션 안에서 DB에 쓴 뒤 캐시도 갱신했는데 트랜잭션이 롤백되면, DB는 되돌아가지만 캐시에는 잘못된 값이 남는다. 이를 막는 것이 TransactionAwareCacheDecorator다.

원리는 단순하다. 동기화가 활성화된 트랜잭션이 있으면 put/evict/clear를 즉시 실행하지 않고 TransactionSynchronization을 등록해 afterCommit 단계로 미룬다. 트랜잭션이 없으면 평소처럼 즉시 실행한다.

cache.put(key, value)
        │
        ▼
TransactionSynchronizationManager.isSynchronizationActive() ?
        │
   ┌────┴─────────────────────────┐
  예                              아니오
   │                              │
   ▼                              ▼
registerSynchronization(          targetCache.put(key, value)
  new TransactionSynchronization(){       (즉시 실행)
    afterCommit() {
      targetCache.put(key, value);  ← 커밋 성공 후에만 실제 반영
    }
  })
@Override
public void put(final Object key, final @Nullable Object value) {
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                TransactionAwareCacheDecorator.this.targetCache.put(key, value);
            }
        });
    }
    else {
        this.targetCache.put(key, value);
    }
}

주의점: putIfAbsent, evictIfPresent, invalidate, 그리고 모든 get/retrieve는 데코레이터가 그대로 위임만 한다. 이들은 즉시 결과(반환값)가 필요한 "즉시 연산"이라 커밋까지 미룰 수 없기 때문이다. 트랜잭션 환경에서 이런 즉시 연산은 주의해서 써야 한다는 경고가 클래스 Javadoc에 명시돼 있다.

매니저 차원에서 켜기

데코레이터를 캐시마다 손으로 감싸지 않아도 된다. AbstractTransactionSupportingCacheManagerdecorateCache 훅을 오버라이드해, transactionAware 플래그가 켜져 있으면 매니저가 노출하는 모든 캐시를 자동으로 감싼다.

@Override
protected Cache decorateCache(Cache cache) {
    return (isTransactionAware() ? new TransactionAwareCacheDecorator(cache) : cache);
}

JCacheCacheManager는 이 클래스를 상속하므로 setTransactionAware(true) 한 줄로 모든 JCache 캐시가 트랜잭션 인지 캐시가 된다. (TransactionAwareCacheManagerProxy는 임의의 다른 CacheManager를 감싸 같은 효과를 주는 프록시 버전이다.)

설계 포인트 / 확장점

  • 어댑터 패턴: CaffeineCache/JCacheCache는 외부 라이브러리 캐시를 Spring Cache로 변환하는 전형적 어댑터다. 매니저의 adaptCaffeineCacheprotected라, 중앙에서 모든 캐시에 추가 데코레이션을 입히도록 오버라이드할 수 있다.
  • 동적 vs 정적: Caffeine 매니저는 "요청 시 생성(dynamic)"이 기본이고, JCache 매니저는 백엔드에 이미 정의된 캐시를 따라가는 "정적 로딩"이 기본이다. 두 라이브러리의 성격(인메모리 임시 vs 외부 구성된 캐시)을 반영한 의도적 차이다.
  • 데코레이터로서의 트랜잭션 인지: 트랜잭션 동기화는 캐시 백엔드와 완전히 직교한다. 그래서 별도 데코레이터로 빼서 어떤 Cache에도 적용 가능하게 했다. 즉시 연산은 위임만 한다는 한계도 명확히 문서화돼 있다.
  • null 값 정책: 두 백엔드 모두 네이티브로는 null을 거부한다. Spring 어댑터가 allowNullValues로 홀더 래핑을 제공해, 캐시 추상화 사용자가 null 결과도 캐싱할 수 있게 한다.

정리

이 챕터의 캐시 어댑터들은 "Spring Cache/CacheManager 인터페이스 ↔ 실제 캐시 라이브러리"를 잇는 얇은 변환 계층이다. CaffeineCacheManager는 동적 생성을 computeIfAbsent로 동시성 안전하게 처리하고, JCacheCacheManager는 JSR-107 표준 디스커버리와 정적 로딩 골격을 따른다. 그 위에 cache.transaction의 데코레이터가 캐시 쓰기를 트랜잭션 커밋 이후로 미뤄, DB와 캐시의 일관성을 지킨다.