Spring Cache Abstraction이란?
Spring의 Cache Abstraction은 캐시 구현체(Redis, Caffeine, EhCache 등)에 독립적인 선언적 캐싱 레이어를 제공합니다. @Cacheable, @CacheEvict, @CachePut 어노테이션만으로 메서드 결과를 캐싱하고, 캐시 구현체를 바꿔도 비즈니스 코드를 수정할 필요가 없습니다. Spring AOP 기반으로 동작하며, 프록시를 통해 메서드 호출을 인터셉트합니다.
기본 설정과 캐시 매니저
@EnableCaching으로 캐싱을 활성화하고, CacheManager 빈으로 캐시 구현체를 설정합니다.
@Configuration
@EnableCaching
public class CacheConfig {
// Caffeine 캐시 매니저 (로컬 캐시)
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats() // 히트율 통계
);
return manager;
}
// Redis 캐시 매니저 (분산 캐시)
@Bean
public RedisCacheManager redisCacheManager(
RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(
SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
// 캐시별 TTL 개별 설정
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"users", defaultConfig.entryTtl(Duration.ofHours(1)),
"products", defaultConfig.entryTtl(Duration.ofMinutes(15)),
"config", defaultConfig.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.transactionAware() // 트랜잭션과 동기화
.build();
}
}
@Cacheable: 조회 캐싱
@Cacheable은 메서드 결과를 캐시에 저장하고, 동일 파라미터로 재호출 시 캐시에서 반환합니다. 메서드가 실행되지 않으므로 부작용이 없는 조회 메서드에 사용합니다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 기본: 파라미터가 캐시 키
@Cacheable("users")
public User findById(Long id) {
return userRepository.findById(id).orElseThrow();
}
// 커스텀 키: SpEL로 키 생성
@Cacheable(value = "users", key = "#email")
public User findByEmail(String email) {
return userRepository.findByEmail(email).orElseThrow();
}
// 복합 키: 여러 파라미터 조합
@Cacheable(value = "user-search", key = "#department + '-' + #role")
public List<User> findByDepartmentAndRole(String department, String role) {
return userRepository.findByDepartmentAndRole(department, role);
}
// 조건부 캐싱: condition (캐시 저장 조건)
@Cacheable(value = "users", condition = "#id > 0")
public User findByIdSafe(Long id) {
return userRepository.findById(id).orElseThrow();
}
// unless: 결과 기반 캐시 제외
@Cacheable(value = "users", unless = "#result == null")
public User findByIdOrNull(Long id) {
return userRepository.findById(id).orElse(null);
}
// unless: 특정 조건의 결과 캐싱 제외
@Cacheable(value = "users", unless = "#result.status == 'INACTIVE'")
public User findActive(Long id) {
return userRepository.findById(id).orElseThrow();
}
}
@CacheEvict: 캐시 무효화
데이터가 변경되면 캐시를 무효화해야 합니다. @CacheEvict는 특정 키 또는 전체 캐시를 삭제합니다.
@Service
public class UserService {
// 특정 키 삭제
@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
// 캐시 전체 삭제
@CacheEvict(value = "users", allEntries = true)
public void deleteAll() {
userRepository.deleteAll();
}
// beforeInvocation: 메서드 실행 전에 캐시 삭제
// (메서드가 예외를 던져도 캐시는 이미 삭제됨)
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void deleteUser(Long id) {
userRepository.deleteById(id); // 예외 발생해도 캐시는 삭제됨
}
// 여러 캐시 동시 무효화
@Caching(evict = {
@CacheEvict(value = "users", key = "#user.id"),
@CacheEvict(value = "user-search", allEntries = true),
@CacheEvict(value = "user-stats", allEntries = true),
})
public User updateUserProfile(User user) {
return userRepository.save(user);
}
}
@CachePut: 캐시 갱신
@CachePut은 메서드를 항상 실행하고 결과를 캐시에 저장합니다. @Cacheable과 달리 캐시가 있어도 메서드가 실행됩니다. 데이터 변경 후 캐시를 최신 상태로 유지할 때 사용합니다.
@Service
public class ProductService {
// 저장 후 캐시도 갱신
@CachePut(value = "products", key = "#result.id")
public Product createProduct(ProductDto dto) {
Product product = Product.from(dto);
return productRepository.save(product);
}
// 업데이트 후 캐시 갱신
@CachePut(value = "products", key = "#id")
public Product updateProduct(Long id, ProductDto dto) {
Product product = productRepository.findById(id).orElseThrow();
product.update(dto);
return productRepository.save(product);
}
// @Cacheable + @CachePut 조합은 피하세요!
// 두 어노테이션의 동작이 충돌합니다
}
@Caching: 복합 캐시 연산
하나의 메서드에서 여러 캐시 연산을 조합할 때 @Caching을 사용합니다.
@Service
public class OrderService {
@Caching(
put = {
@CachePut(value = "orders", key = "#result.id"),
},
evict = {
@CacheEvict(value = "order-list", allEntries = true),
@CacheEvict(value = "user-orders", key = "#dto.userId"),
@CacheEvict(value = "order-stats", allEntries = true),
}
)
public Order createOrder(OrderDto dto) {
Order order = Order.from(dto);
return orderRepository.save(order);
}
}
커스텀 KeyGenerator
복잡한 키 생성 로직이 반복되면 커스텀 KeyGenerator를 빈으로 등록합니다.
@Component("tenantKeyGenerator")
public class TenantKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
// 현재 테넌트 + 메서드명 + 파라미터 조합
String tenant = TenantContext.getCurrentTenant();
String methodName = method.getName();
String paramKey = Arrays.stream(params)
.map(String::valueOf)
.collect(Collectors.joining("-"));
return tenant + ":" + methodName + ":" + paramKey;
}
}
// 사용
@Cacheable(value = "products", keyGenerator = "tenantKeyGenerator")
public List<Product> findByCategory(String category) {
return productRepository.findByCategory(category);
}
// 캐시 키: "tenantA:findByCategory:electronics"
멀티 레벨 캐시: Caffeine + Redis
로컬 캐시(Caffeine)로 초고속 조회를 제공하고, 분산 캐시(Redis)로 다중 인스턴스 간 일관성을 유지하는 2단계 캐시 구조입니다.
@Component
public class MultiLevelCacheManager implements CacheManager {
private final CaffeineCacheManager l1Cache;
private final RedisCacheManager l2Cache;
public MultiLevelCacheManager(
CaffeineCacheManager l1Cache,
RedisCacheManager l2Cache) {
this.l1Cache = l1Cache;
this.l2Cache = l2Cache;
}
@Override
public Cache getCache(String name) {
return new MultiLevelCache(
l1Cache.getCache(name),
l2Cache.getCache(name)
);
}
@Override
public Collection<String> getCacheNames() {
Set<String> names = new HashSet<>();
names.addAll(l1Cache.getCacheNames());
names.addAll(l2Cache.getCacheNames());
return names;
}
}
// L1 → L2 순서로 조회, L1 미스 시 L2 조회 후 L1에 저장
public class MultiLevelCache implements Cache {
private final Cache l1;
private final Cache l2;
@Override
public ValueWrapper get(Object key) {
// L1 조회
ValueWrapper value = l1.get(key);
if (value != null) return value;
// L1 미스 → L2 조회
value = l2.get(key);
if (value != null) {
l1.put(key, value.get()); // L1에 채움
}
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);
}
// ... 나머지 메서드 구현
}
캐시 모니터링과 통계
캐시 히트율을 Micrometer로 수집하여 캐시 효율을 모니터링합니다.
// Caffeine + Micrometer 통합
@Bean
public CacheManager caffeineCacheManager(MeterRegistry registry) {
CaffeineCacheManager manager = new CaffeineCacheManager("users", "products");
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats() // 통계 활성화 필수
);
// Micrometer에 캐시 메트릭 등록
manager.getCacheNames().forEach(name -> {
Cache cache = manager.getCache(name);
if (cache != null) {
CaffeineCacheMetrics.monitor(registry,
(com.github.benmanes.caffeine.cache.Cache<?, ?>)
cache.getNativeCache(),
name);
}
});
return manager;
}
// Prometheus 메트릭 예시:
// cache_gets_total{cache="users",result="hit"} 9500
// cache_gets_total{cache="users",result="miss"} 500
// cache_evictions_total{cache="users"} 200
// cache_size{cache="users"} 8500
운영 베스트 프랙티스
- self-invocation 주의: 같은 클래스 내부 호출에서는 AOP 프록시가 적용되지 않아 캐시가 동작하지 않습니다
- null 캐싱 전략:
unless = "#result == null"로 null 캐싱을 방지하거나, cache penetration 방어를 위해 의도적으로 null을 캐싱하세요 - TTL 설계: 데이터 변경 빈도에 따라 적절한 TTL을 설정하고, 캐시별 TTL을 개별 관리하세요
- 캐시 키 충돌: 캐시 이름(value)을 명확히 지정하고, 키가 겹치지 않도록 네이밍 규칙을 수립하세요
- 트랜잭션 동기화:
transactionAware()로 트랜잭션 커밋 후에만 캐시가 갱신되도록 설정하세요 - 히트율 모니터링: Micrometer로 캐시 히트율을 추적하고, 70% 미만이면 TTL/사이즈를 재검토하세요