지난 글에 이어, 이번에는 CustomCache 구현 시 발견된 단점을 보완하기 위해
Spring Cache 기반 구조로 코드 변경 없이 적용하는 방식을 소개합니다.
보다 실용적이고 유지보수 가능한 방향에 대해 함께 고민해보려 합니다.
여기서 CompositeCache는 Spring에서 제공하는 클래스가 아닌, 제가 직접 구현한 캐시 조합 구조를 의미합니다.
Spring에서 여러 캐시 계층(L1/L2)을 조합하려면 CompositeCacheManager를 사용하는 것이 일반적입니다.
그러나 실제로 적용해보면 다음과 같은 한계가 존재합니다:
| 문제점 | 설명 |
|---|---|
❌ get()이 모든 캐시 계층에 호출됨 | L1(Caffeine)에 데이터가 있어도, L2(Redis)까지 불필요하게 접근함 |
| ❌ L2 조회 후 L1에 자동 동기화되지 않음 | L2에서 데이터를 가져와도 L1에 put되지 않음 |
| ❌ 계층별 메트릭 수집 어려움 | 히트율, 미스율을 계층별로 추적하거나 대시보드화하기 어려움 |
| ❌ 구조 제어 불가 | @Cacheable은 작동하지만 캐시 계층의 흐름을 제어할 수 없음 |
특히 가장 불편했던 점은 L2(Redis)에서 조회된 데이터를 L1(Caffeine)에 동기화해주지 않는 문제였습니다.
또한 Spring 기본 구조는 L1 → L2 순서를 보장하지 않으며,
단지 여러 CacheManager 중 하나를 선택하는 구조일 뿐입니다.
그래서 저는 CacheManager와 Cache 인터페이스를 직접 구현하여,
기존 코드에 영향을 주지 않고 의도한 캐시 전략(L1 → L2 → DB) 을 그대로 적용할 수 있는 구조를 만들었습니다.
CaffeineCacheManager와 CaffeineCache를 보면 각각 다음을 구현하고 있습니다:
public class CaffeineCacheManager implements CacheManager
public abstract class AbstractValueAdaptingCache implements Cache
여기서 아이디어를 얻어,
CacheManager와 Cache를 직접 구현해서 Spring에 빈으로 등록하면
기존 코드 변경 없이도 캐시 전략을 제어할 수 있겠다고 판단했습니다.
즉, CompositeCacheManager가 CompositeCache를 반환하도록 구성하면,
Spring 캐시 구조에서 원하는 동작을 그대로 구현할 수 있다는 개념적 설계를 세웠습니다.
CompositeCacheManager
@Override
public Cache getCache(String name) {
return cacheLookupCache.computeIfAbsent(name, this::buildCompositeCache);
}
/**
* 캐시가 없으면 빈 캐시라도 반환
* @param name
* @return
*/
private Cache buildCompositeCache(String name) {
List<Cache> caches = cacheManagers.stream()
.map(manager -> manager.getCache(name))
.filter(Objects::nonNull)
.toList();
if (caches.isEmpty()) {
return new NoOpCache(name); // 캐시가 없으면 빈 캐시라도 반환
}
return new CompositeCache(caches, updatableCacheManager);
}
CompositeCache
@Override
public ValueWrapper get(Object key) {
for (Cache cache : caches) { // redis, local에서 찾는거임
ValueWrapper valueWrapper = cache.get(key);
if (valueWrapper != null && valueWrapper.get() != null) {
updatableCacheManager.putIfAbsent(cache, key, valueWrapper.get()); // L1 채우기
return valueWrapper;
}
}
return null; // 모든 계층에서 못찾음
}
| 항목 | 설명 |
|---|---|
| 🎯 정확한 캐시 흐름 | L1 → L2 → DB 순서를 정확히 보장 |
| 🚀 성능 최적화 | L1에서 hit 시 Redis 접근 생략 → I/O 절감 |
| 📊 모니터링 가능 | Micrometer 연동 → L1/L2 캐시 히트율 시각화 |
| 🛠 코드 변경 없음 | @Cacheable 유지 → 적용 용이 |
| 🧩 장애 대응 확장성 | CircuitBreaker, fallback 삽입 가능 구조 |
숙제로 남은 부분은 Scale-out 환경에서 LocalCache(L1)를 어떻게 동기화할 것인가입니다.
이 부분은 어쩔 수 없는 한계에 가까우며, 완벽한 정합성이 필요한 경우에는 GlobalCache(L2, Redis)를 사용하고,
정합성보다 응답 속도나 부하 분산이 더 중요한 경우에는 CompositeCache 구조를 활용하는 것이 적절하다고 판단했습니다.
이때 LocalCache의 TTL(Time To Live)을 짧게 가져간다면,
정합성 문제를 어느 정도 완화하면서도 성능과 안정성 간의 균형을 유지할 수 있을 것이라 생각합니다.
이번 캐시 구조 개선은 “기존 시스템에 최소한의 코드 변경만으로 어떻게 안정성을 높일 수 있을까?”, “성능을 어떻게 더 끌어올릴 수 있을까?”
그리고 “다음 개발자가 와도 유지보수가 쉬운 구조를 만들 수 있을까?” 라는 고민에서 출발했습니다.
기존의 Spring CompositeCache 구조는 아쉬운 점이 많았고,
직접 CacheManager와 Cache를 커스터마이징해 정확한 L1 → L2 흐름 제어와 장애 대응이 가능하도록 만든 경험은
단순한 성능 개선을 넘어, 설계를 주도한 경험이었습니다.
현재 해당 구조는 실제 사내 서비스에 적용되어 운영 중이며,
Micrometer 기반의 모니터링을 연동해 캐시 히트율 70% 이상을 유지하고 있습니다.
히트율이 낮은 케이스에 대해서는 패턴 분석 및 캐시 전략 고도화 작업을 지속적으로 수행 중입니다.
단순히 도입된 기술을 사용하는 것이 아니라, 프레임워크의 한계를 직접 커스터마이징하며 극복했다는 점에서
이번 경험은 저에게 있어 작지만 실질적인 혁신이었고, 안정적인 서비스 운영에 기여한 사례인거 같았습니다
구현 코드: https://github.com/cwangg897/composite
망나니 개발자님의 아래 글을 참고했으며, 댓글을 통해 직접 소통도 나누었습니다. 감사합니다.
🔗 https://mangkyu.tistory.com/371 (망나니개발자 블로그)