Spring Cache 추상화란?
Spring Framework는 @Cacheable, @CacheEvict, @CachePut 등의 어노테이션으로 캐시를 추상화합니다. 하지만 실전에서는 단일 캐시만으로 부족합니다. 로컬 캐시(Caffeine)와 분산 캐시(Redis)를 조합한 다계층(Multi-tier) 캐시 전략이 필요합니다. 이 글에서는 Spring Cache의 내부 동작부터 다계층 캐시 구현, TTL 관리, 캐시 동기화까지 실전 패턴을 다룹니다.
Spring Cache 핵심 어노테이션
Spring Cache의 기본 어노테이션 4가지를 먼저 정리합니다.
| 어노테이션 | 역할 | 주요 속성 |
|---|---|---|
@Cacheable |
캐시 조회 → 미스 시 메서드 실행 후 저장 | value, key, condition, unless |
@CachePut |
항상 메서드 실행 후 캐시 갱신 | value, key |
@CacheEvict |
캐시 제거 | value, key, allEntries, beforeInvocation |
@Caching |
여러 캐시 연산 조합 | cacheable, put, evict |
CacheManager 구현체 비교
Spring은 다양한 CacheManager 구현체를 제공합니다. 각각의 특성을 이해해야 올바른 계층 설계가 가능합니다.
// 1. ConcurrentMapCacheManager — 기본 제공, 프로토타이핑용
@Bean
public CacheManager simpleCacheManager() {
return new ConcurrentMapCacheManager("users", "products");
}
// 2. CaffeineCacheManager — 고성능 로컬 캐시
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats());
return manager;
}
// 3. RedisCacheManager — 분산 캐시
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(
SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
왜 다계층 캐시인가?
단일 캐시의 한계는 명확합니다. 로컬 캐시(Caffeine)는 빠르지만 인스턴스 간 동기화가 안 되고, 분산 캐시(Redis)는 네트워크 호출 비용이 있습니다. 다계층 캐시는 L1(로컬) → L2(분산) 순서로 조회하여 두 장점을 결합합니다.
| 구분 | Caffeine (L1) | Redis (L2) | 다계층 |
|---|---|---|---|
| 응답 속도 | ~1μs | ~1ms | L1 히트 시 ~1μs |
| 인스턴스 공유 | 불가 | 가능 | 가능 |
| 장애 영향 | 없음 | Redis 다운 시 전체 미스 | Redis 다운 시 L1 폴백 |
| 메모리 | JVM 힙 사용 | 외부 메모리 | 핫 데이터만 L1 |
다계층 CacheManager 구현
Spring의 CompositeCacheManager는 여러 CacheManager를 순서대로 탐색하지만, 진정한 계층적 조회(L1 미스 → L2 조회 → L1 저장)를 하지 않습니다. 커스텀 구현이 필요합니다.
public class TieredCache implements Cache {
private final Cache l1; // Caffeine
private final Cache l2; // Redis
private final String name;
public TieredCache(String name, Cache l1, Cache l2) {
this.name = name;
this.l1 = l1;
this.l2 = l2;
}
@Override
public String getName() { return name; }
@Override
public Object getNativeCache() { return this; }
@Override
public ValueWrapper get(Object key) {
// 1단계: L1 조회
ValueWrapper value = l1.get(key);
if (value != null) return value;
// 2단계: L2 조회
value = l2.get(key);
if (value != null) {
// L2 히트 → L1에 승격(promote)
l1.put(key, value.get());
}
return value;
}
@Override
public void put(Object key, Object value) {
// 양쪽 모두에 저장
l1.put(key, value);
l2.put(key, value);
}
@Override
public void evict(Object key) {
l1.evict(key);
l2.evict(key);
}
@Override
public void clear() {
l1.clear();
l2.clear();
}
}
TieredCacheManager 설정
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisFactory) {
// L1: Caffeine
CaffeineCacheManager caffeineManager = new CaffeineCacheManager();
caffeineManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofMinutes(2)));
// L2: Redis
RedisCacheConfiguration redisConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(
SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
RedisCacheManager redisManager = RedisCacheManager.builder(redisFactory)
.cacheDefaults(redisConfig)
.build();
return new TieredCacheManager(caffeineManager, redisManager);
}
}
public class TieredCacheManager implements CacheManager {
private final CacheManager l1Manager;
private final CacheManager l2Manager;
private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>();
public TieredCacheManager(CacheManager l1, CacheManager l2) {
this.l1Manager = l1;
this.l2Manager = l2;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, n ->
new TieredCache(n, l1Manager.getCache(n), l2Manager.getCache(n)));
}
@Override
public Collection<String> getCacheNames() {
Set<String> names = new HashSet<>();
names.addAll(l1Manager.getCacheNames());
names.addAll(l2Manager.getCacheNames());
return names;
}
}
캐시별 TTL 차별화
모든 캐시에 동일한 TTL을 적용하는 것은 비효율적입니다. 데이터 특성에 따라 캐시별 TTL을 분리해야 합니다. Spring Actuator로 캐시 히트율을 모니터링하며 TTL을 튜닝할 수 있습니다.
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10));
// 캐시별 TTL 설정
Map<String, RedisCacheConfiguration> perCacheConfig = Map.of(
"users", defaultConfig.entryTtl(Duration.ofHours(1)),
"products", defaultConfig.entryTtl(Duration.ofMinutes(30)),
"sessions", defaultConfig.entryTtl(Duration.ofMinutes(5)),
"config", defaultConfig.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(perCacheConfig)
.build();
}
Redis Pub/Sub로 L1 동기화
다중 인스턴스 환경에서 가장 큰 문제는 L1 캐시 불일치입니다. 인스턴스 A에서 데이터를 변경하면 인스턴스 B의 L1에는 여전히 오래된 데이터가 남아 있습니다. Redis Pub/Sub로 이 문제를 해결합니다.
@Component
public class CacheInvalidationPublisher {
private final RedisTemplate<String, String> redisTemplate;
private static final String CHANNEL = "cache:invalidate";
public CacheInvalidationPublisher(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publishEviction(String cacheName, String key) {
String message = cacheName + "::" + key;
redisTemplate.convertAndSend(CHANNEL, message);
}
}
@Component
public class CacheInvalidationSubscriber implements MessageListener {
private final CaffeineCacheManager localCacheManager;
private final String instanceId = UUID.randomUUID().toString();
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
String[] parts = payload.split("::", 2);
if (parts.length == 2) {
Cache cache = localCacheManager.getCache(parts[0]);
if (cache != null) {
cache.evict(parts[1]); // L1에서만 제거
}
}
}
}
// RedisMessageListenerContainer 등록
@Bean
public RedisMessageListenerContainer listenerContainer(
RedisConnectionFactory factory,
CacheInvalidationSubscriber subscriber) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(subscriber,
new ChannelTopic("cache:invalidate"));
return container;
}
@CacheEvict + 이벤트 통합
캐시 무효화를 서비스 로직과 분리하면 유지보수가 쉬워집니다. Spring의 이벤트 시스템과 결합한 패턴입니다.
// 도메인 이벤트 정의
public record UserUpdatedEvent(Long userId) {}
// 서비스: 캐시 무효화 없이 깔끔하게
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository repository;
private final ApplicationEventPublisher eventPublisher;
@Cacheable(value = "users", key = "#id")
public UserDto getUser(Long id) {
return repository.findById(id)
.map(UserDto::from)
.orElseThrow();
}
@Transactional
public void updateUser(Long id, UpdateUserRequest request) {
User user = repository.findById(id).orElseThrow();
user.update(request);
eventPublisher.publishEvent(new UserUpdatedEvent(id));
}
}
// 이벤트 리스너: 트랜잭션 커밋 후 캐시 무효화
@Component
@RequiredArgsConstructor
public class CacheEvictionListener {
private final CacheInvalidationPublisher publisher;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserUpdated(UserUpdatedEvent event) {
publisher.publishEviction("users", event.userId().toString());
}
}
Cache Stampede 방지
캐시가 만료되는 순간 다수의 요청이 동시에 DB를 호출하는 Cache Stampede(Thundering Herd) 문제는 장애의 주요 원인입니다. 두 가지 해결 전략을 소개합니다.
// 전략 1: @Cacheable의 sync 속성 — 단일 스레드만 로딩
@Cacheable(value = "products", key = "#id", sync = true)
public ProductDto getProduct(Long id) {
return productRepository.findById(id)
.map(ProductDto::from)
.orElseThrow();
}
// 전략 2: Caffeine의 refreshAfterWrite — 만료 전 백그라운드 갱신
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.refreshAfterWrite(Duration.ofMinutes(8))); // 만료 2분 전에 갱신
// CacheLoader 필요
manager.setCacheLoader(key -> loadFromSource(key));
return manager;
}
// 전략 3: 분산 락으로 중복 로딩 방지 (Redisson)
public ProductDto getProductWithLock(Long id) {
String cacheKey = "products::" + id;
ProductDto cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return cached;
RLock lock = redissonClient.getLock("lock:" + cacheKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// Double-check after acquiring lock
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return cached;
ProductDto product = productRepository.findById(id)
.map(ProductDto::from).orElseThrow();
redisTemplate.opsForValue().set(cacheKey, product,
Duration.ofMinutes(30));
return product;
}
} finally {
lock.unlock();
}
throw new RuntimeException("Cache load timeout");
}
캐시 모니터링 설정
운영 환경에서 캐시 효율을 지속적으로 관찰해야 합니다. Caffeine의 recordStats()와 Micrometer를 연동하면 히트율, 미스율, 축출 수를 Prometheus/Grafana로 시각화할 수 있습니다.
# application.yml
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=300s,recordStats
# Actuator에서 캐시 메트릭 노출
management:
endpoints:
web:
exposure:
include: caches,metrics
metrics:
tags:
application: my-app
// 커스텀 히트율 알림
@Scheduled(fixedRate = 60_000)
public void checkCacheHitRate() {
CaffeineCache cache = (CaffeineCache) cacheManager.getCache("users");
CacheStats stats = cache.getNativeCache().stats();
double hitRate = stats.hitRate();
if (hitRate < 0.5) {
log.warn("Cache 'users' 히트율 저조: {:.1f}% — TTL 또는 maxSize 조정 검토",
hitRate * 100);
}
}
실전 체크리스트
다계층 캐시 도입 시 반드시 확인해야 할 항목입니다.
- 직렬화 호환성: Redis에 저장할 객체는 반드시 역직렬화 가능해야 합니다. 클래스 변경 시 기존 캐시가 깨질 수 있으므로
@JsonIgnoreProperties(ignoreUnknown = true)추가를 권장합니다. - L1 TTL < L2 TTL: 로컬 캐시는 항상 분산 캐시보다 짧은 TTL을 설정해야 불일치 윈도우를 최소화할 수 있습니다.
- maxSize 산정: L1의 maxSize는
(힙 여유 공간 × 0.1) / 평균 객체 크기로 계산합니다. - Redis 장애 대비:
CacheErrorHandler를 구현하여 Redis 연결 실패 시 L1만으로 동작하도록 폴백 처리합니다. - 키 설계: 복합 키는
@Cacheable(key = "#root.method.name + '_' + #id")형태로 충돌을 방지합니다.
// CacheErrorHandler: Redis 장애 시 조용히 미스 처리
@Configuration
public class CacheErrorConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException e,
Cache cache, Object key) {
log.warn("Redis cache get 실패 [{}:{}]: {}",
cache.getName(), key, e.getMessage());
// 예외를 삼켜서 L1 또는 DB 폴백
}
};
}
}
정리
Spring Cache의 다계층 전략은 성능(L1 로컬)과 일관성(L2 분산)의 균형을 잡는 핵심 아키텍처 패턴입니다. Caffeine + Redis 조합으로 L1 히트 시 마이크로초 응답을, Pub/Sub로 인스턴스 간 일관성을, sync와 분산 락으로 Stampede 방지를 구현할 수 있습니다. 캐시는 도입보다 운영과 모니터링이 더 중요합니다. 히트율을 지속 관찰하고 TTL을 데이터 특성에 맞게 튜닝하세요.