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를 거쳐 구현한다. 즉 트랜잭션 인지 기능을 베이스 클래스로부터 물려받는다.
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의 동시성 처리다. cacheMap은 ConcurrentHashMap이고, 캐시 생성은 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을 감싸 저장한다.
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()하도록 오버라이드되어 있다.
캐시 쓰기는 보통 즉시 일어난다. 그런데 트랜잭션 안에서 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에 명시돼 있다.
데코레이터를 캐시마다 손으로 감싸지 않아도 된다. AbstractTransactionSupportingCacheManager가 decorateCache 훅을 오버라이드해, transactionAware 플래그가 켜져 있으면 매니저가 노출하는 모든 캐시를 자동으로 감싼다.
@Override
protected Cache decorateCache(Cache cache) {
return (isTransactionAware() ? new TransactionAwareCacheDecorator(cache) : cache);
}JCacheCacheManager는 이 클래스를 상속하므로 setTransactionAware(true) 한 줄로 모든 JCache 캐시가 트랜잭션 인지 캐시가 된다. (TransactionAwareCacheManagerProxy는 임의의 다른 CacheManager를 감싸 같은 효과를 주는 프록시 버전이다.)
- 어댑터 패턴:
CaffeineCache/JCacheCache는 외부 라이브러리 캐시를 SpringCache로 변환하는 전형적 어댑터다. 매니저의adaptCaffeineCache는protected라, 중앙에서 모든 캐시에 추가 데코레이션을 입히도록 오버라이드할 수 있다. - 동적 vs 정적: Caffeine 매니저는 "요청 시 생성(dynamic)"이 기본이고, JCache 매니저는 백엔드에 이미 정의된 캐시를 따라가는 "정적 로딩"이 기본이다. 두 라이브러리의 성격(인메모리 임시 vs 외부 구성된 캐시)을 반영한 의도적 차이다.
- 데코레이터로서의 트랜잭션 인지: 트랜잭션 동기화는 캐시 백엔드와 완전히 직교한다. 그래서 별도 데코레이터로 빼서 어떤
Cache에도 적용 가능하게 했다. 즉시 연산은 위임만 한다는 한계도 명확히 문서화돼 있다. null값 정책: 두 백엔드 모두 네이티브로는null을 거부한다. Spring 어댑터가allowNullValues로 홀더 래핑을 제공해, 캐시 추상화 사용자가null결과도 캐싱할 수 있게 한다.
이 챕터의 캐시 어댑터들은 "Spring Cache/CacheManager 인터페이스 ↔ 실제 캐시 라이브러리"를 잇는 얇은 변환 계층이다. CaffeineCacheManager는 동적 생성을 computeIfAbsent로 동시성 안전하게 처리하고, JCacheCacheManager는 JSR-107 표준 디스커버리와 정적 로딩 골격을 따른다. 그 위에 cache.transaction의 데코레이터가 캐시 쓰기를 트랜잭션 커밋 이후로 미뤄, DB와 캐시의 일관성을 지킨다.