Spring Cache Abstraction의 구조: 프록시 기반 AOP
Spring의 캐시 추상화는 @Transactional과 동일한 프록시 기반 AOP로 동작합니다. @EnableCaching을 선언하면 Spring은 캐시 어노테이션이 붙은 빈에 프록시를 생성하고, 메서드 호출 시 캐시 인터셉터가 먼저 캐시를 조회합니다.
// 캐시 활성화
@Configuration
@EnableCaching
public class CacheConfig {
}
이 프록시 구조에서 반드시 이해해야 할 제약이 있습니다:
- Self-invocation 무시: 같은 클래스 내부에서
this.findById()를 호출하면 프록시를 거치지 않으므로 캐시가 동작하지 않습니다.@Transactional의 self-invocation 함정과 동일합니다. - public 메서드만: 기본 프록시 모드에서는 public 메서드에만 캐시 어노테이션이 유효합니다.
- 반환값이 있어야:
void메서드에@Cacheable을 붙이면 항상 캐시 미스로 동작합니다.
@Cacheable: 캐시 조회와 저장의 기본
@Cacheable은 메서드 결과를 캐시에 저장하고, 동일한 키로 호출 시 메서드를 실행하지 않고 캐시된 값을 반환합니다.
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
// DB 조회 — 캐시 히트 시 이 메서드 자체가 실행되지 않음
return productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found: " + id));
}
}
기본 키 생성 규칙 (SimpleKeyGenerator)
| 파라미터 수 | 생성되는 키 | 예시 |
|---|---|---|
| 0개 | SimpleKey.EMPTY |
getAll() |
| 1개 | 해당 파라미터 인스턴스 | findById(42L) → 키는 42L |
| 2개 이상 | SimpleKey(params...) |
find("A", 1) → SimpleKey("A", 1) |
주의: 기본 키 생성은 파라미터의 hashCode()와 equals()에 의존합니다. 커스텀 객체를 파라미터로 사용한다면 반드시 두 메서드를 올바르게 구현해야 합니다.
SpEL로 키 커스터마이징
// 파라미터 중 일부만 키로 사용
@Cacheable(value = "products", key = "#category + '-' + #page")
public List<Product> findByCategory(String category, int page, String sortBy) {
// sortBy는 캐시 키에 포함되지 않음
}
// 파라미터의 중첩 프로퍼티
@Cacheable(value = "users", key = "#request.userId")
public UserProfile getProfile(ProfileRequest request) { ... }
// 정적 메서드 호출
@Cacheable(value = "users", key = "T(java.util.Objects).hash(#name, #region)")
public User findUser(String name, String region) { ... }
@CacheEvict: 캐시 무효화 전략
데이터가 변경되면 캐시를 무효화해야 합니다. @CacheEvict는 지정한 캐시에서 엔트리를 제거합니다.
@CacheEvict(value = "products", key = "#id")
public void updateProduct(Long id, ProductUpdateDto dto) {
Product product = productRepository.findById(id).orElseThrow();
product.update(dto);
productRepository.save(product);
}
// 캐시 전체 삭제
@CacheEvict(value = "products", allEntries = true)
public void refreshAllProducts() { ... }
// 메서드 실행 전에 evict (기본값: 메서드 성공 후 evict)
@CacheEvict(value = "products", key = "#id", beforeInvocation = true)
public void deleteProduct(Long id) {
productRepository.deleteById(id);
// 메서드가 예외를 던져도 캐시는 이미 삭제됨
}
beforeInvocation 동작 차이
| 설정 | evict 시점 | 메서드 예외 시 | 사용 케이스 |
|---|---|---|---|
false (기본) |
메서드 성공 후 | 캐시 유지 | 일반적인 업데이트/삭제 |
true |
메서드 실행 전 | 캐시 이미 삭제됨 | 실패해도 stale 데이터를 제거해야 할 때 |
@CachePut: 캐시 갱신 (조회 없이 쓰기만)
@CachePut은 @Cacheable과 달리 항상 메서드를 실행하고, 결과를 캐시에 저장합니다. 캐시 조회를 하지 않으므로 “갱신” 용도로 사용합니다.
// 업데이트 후 캐시를 최신 값으로 교체
@CachePut(value = "products", key = "#id")
public Product updateAndReturn(Long id, ProductUpdateDto dto) {
Product product = productRepository.findById(id).orElseThrow();
product.update(dto);
return productRepository.save(product);
// 반환값이 캐시에 저장됨
}
주의: 같은 메서드에 @Cacheable과 @CachePut을 동시에 사용하면 예측하기 어려운 동작이 발생합니다. 공식 문서에서도 이 조합을 권장하지 않습니다. @CachePut은 항상 실행을 강제하고, @Cacheable은 실행을 건너뛰려 하기 때문입니다.
@Caching: 복합 캐시 연산
하나의 메서드에서 여러 캐시를 동시에 조작해야 할 때 @Caching을 사용합니다.
@Caching(
evict = {
@CacheEvict(value = "products", key = "#product.id"),
@CacheEvict(value = "productsByCategory", key = "#product.category"),
@CacheEvict(value = "productSearch", allEntries = true)
},
put = {
@CachePut(value = "products", key = "#product.id")
}
)
public Product updateProduct(Product product) {
return productRepository.save(product);
}
condition과 unless: 조건부 캐싱
모든 결과를 캐시하는 것이 항상 바람직한 것은 아닙니다. SpEL 기반 조건으로 캐싱 여부를 제어할 수 있습니다.
// condition: 메서드 실행 전 평가 — 캐시 조회 자체를 건너뜀
@Cacheable(value = "products", key = "#id", condition = "#id > 0")
public Product findById(Long id) { ... }
// unless: 메서드 실행 후 평가 — 결과값 기반으로 캐시 저장 여부 결정
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product findById(Long id) {
return productRepository.findById(id).orElse(null);
// null 결과는 캐시하지 않음
}
// 조합: 파라미터 조건 + 결과 조건
@Cacheable(
value = "products",
key = "#name",
condition = "#name.length() > 2", // 2자 이하 검색어는 캐시 안 함
unless = "#result == null || #result.isEmpty()" // 빈 결과도 캐시 안 함
)
public List<Product> searchByName(String name) { ... }
| 속성 | 평가 시점 | true일 때 | #result 사용 |
|---|---|---|---|
condition |
메서드 실행 전 | 캐시 로직 활성화 | 불가 |
unless |
메서드 실행 후 | 캐시 저장 안 함 | 가능 |
sync=true: 캐시 Stampede 방지
동시에 수백 개의 요청이 같은 키로 캐시 미스를 경험하면, 모든 요청이 동시에 DB를 조회하는 Cache Stampede(Thundering Herd)가 발생합니다. sync=true는 하나의 스레드만 값을 계산하고 나머지는 대기하게 합니다.
@Cacheable(value = "hotProducts", key = "#id", sync = true)
public Product findHotProduct(Long id) {
// 동시 100개 요청이 와도 DB 조회는 1번만 실행
return productRepository.findById(id).orElseThrow();
}
주의: sync=true는 모든 CacheManager가 지원하는 것은 아닙니다. Spring이 제공하는 ConcurrentMapCacheManager와 CaffeineCacheManager는 지원하지만, Redis 기반 캐시 매니저는 구현체에 따라 다릅니다. 사용 전 반드시 확인이 필요합니다.
CacheManager 구현체 비교: ConcurrentMap vs Caffeine vs Redis
| 구현체 | 저장소 | TTL 지원 | 분산 환경 | 적합한 상황 |
|---|---|---|---|---|
ConcurrentMapCacheManager |
JVM 힙 메모리 | 없음 | 불가 | 테스트, 프로토타입 |
CaffeineCacheManager |
JVM 힙 메모리 | TTL·TTI·최대크기 | 불가 | 단일 인스턴스, 고성능 로컬 캐시 |
RedisCacheManager |
외부 Redis | TTL (캐시별) | 가능 | 다중 인스턴스, K8s 환경 |
Caffeine 로컬 캐시 설정: 캐시별 TTL 분리
// build.gradle
implementation 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-cache'
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(List.of(
buildCache("products", 10, Duration.ofMinutes(30)),
buildCache("users", 5, Duration.ofMinutes(60)),
buildCache("config", 1, Duration.ofHours(6))
));
return manager;
}
private CaffeineCache buildCache(String name, int maxSizeMb, Duration ttl) {
return new CaffeineCache(name,
Caffeine.newBuilder()
.maximumWeight(maxSizeMb * 1024L * 1024L)
.weigher((key, value) -> estimateSize(value))
.expireAfterWrite(ttl)
.recordStats() // 히트율 모니터링용
.build()
);
}
private int estimateSize(Object value) {
// 실무에서는 직렬화 크기 추정 또는 고정값 사용
return 1024; // 기본 1KB로 추정
}
}
recordStats()를 활성화하면 Micrometer를 통해 캐시 히트율·미스율·eviction 수를 Prometheus/Grafana로 모니터링할 수 있습니다.
Redis CacheManager: 분산 환경 캐시 설정
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 기본 설정: 모든 캐시에 적용
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues(); // null 캐싱 방지
// 캐시별 TTL 오버라이드
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"products", defaultConfig.entryTtl(Duration.ofMinutes(10)),
"users", defaultConfig.entryTtl(Duration.ofHours(1)),
"config", defaultConfig.entryTtl(Duration.ofHours(6))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.transactionAware() // Spring 트랜잭션과 연동
.build();
}
}
Redis 직렬화 함정
GenericJackson2JsonRedisSerializer는 JSON에 @class 타입 정보를 포함합니다. 클래스 패키지를 변경하거나 필드를 제거하면 역직렬화 실패로 캐시 히트가 에러를 발생시킵니다. 대응 방법:
- 캐시 엔트리에 TTL을 반드시 설정하여 자연 만료로 마이그레이션
- 배포 시 영향받는 캐시 키를 수동 삭제하는 스크립트 추가
- Jackson
ObjectMapper에FAIL_ON_UNKNOWN_PROPERTIES = false설정
@CacheConfig: 클래스 레벨 공통 설정
모든 메서드에 value = "products"를 반복하는 대신 클래스 레벨에서 공통 설정을 선언할 수 있습니다.
@Service
@CacheConfig(cacheNames = "products", cacheManager = "redisCacheManager")
public class ProductService {
@Cacheable(key = "#id")
public Product findById(Long id) { ... }
@CacheEvict(key = "#id")
public void delete(Long id) { ... }
@CachePut(key = "#result.id")
public Product save(Product product) { ... }
}
L1 + L2 캐시 아키텍처: Caffeine + Redis 조합
실무에서는 Caffeine(L1, 로컬)과 Redis(L2, 분산)을 계층형으로 조합하여 Redis 네트워크 지연을 줄이면서도 다중 인스턴스 일관성을 유지합니다.
/**
* L1(Caffeine) → L2(Redis) 순서로 조회하는 CompositeCacheManager
* Spring의 CompositeCacheManager는 여러 CacheManager를 순서대로 탐색합니다.
*/
@Configuration
@EnableCaching
public class TwoLevelCacheConfig {
@Bean
@Primary
public CacheManager cacheManager(
CacheManager caffeineCacheManager,
CacheManager redisCacheManager) {
CompositeCacheManager composite = new CompositeCacheManager();
composite.setCacheManagers(List.of(
caffeineCacheManager, // L1: 먼저 조회
redisCacheManager // L2: L1 미스 시 조회
));
composite.setFallbackToNoOpCache(false);
return composite;
}
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeineSpec(
CaffeineSpec.parse("maximumSize=10000,expireAfterWrite=5m"));
return manager;
}
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory cf) {
return RedisCacheManager.builder(cf)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)))
.build();
}
}
주의: CompositeCacheManager는 읽기 시 L1 → L2 순서로 탐색하지만, 쓰기(evict/put)는 모든 매니저에 전파되지 않습니다. 같은 캐시 이름이 L1에 존재하면 L1에만 쓰입니다. 완전한 L1+L2 계층 구현은 커스텀 Cache/CacheManager를 작성하거나, multilevel-cache 같은 서드파티 라이브러리를 사용해야 합니다.
운영에서 자주 발생하는 캐시 문제와 대응
| 문제 | 증상 | 원인 | 대응 |
|---|---|---|---|
| Cache Stampede | TTL 만료 시 DB 부하 급증 | 동시 캐시 미스 | sync=true 또는 확률적 조기 갱신 |
| Cache Penetration | 존재하지 않는 키 반복 조회 | null 결과 미캐싱 | null도 짧은 TTL로 캐싱, 또는 Bloom Filter |
| Cache Avalanche | 다수 키가 동시 만료 | TTL 일괄 설정 | TTL에 랜덤 지터(jitter) 추가 |
| Stale 데이터 | DB 업데이트 후 캐시에 옛 값 | evict 누락 | @CacheEvict 또는 @CachePut 누락 점검 |
실무 설계 체크리스트
- 캐시 키 설계: 파라미터 전체를 키로 사용하면 캐시 효율이 떨어집니다.
keySpEL로 핵심 식별자만 지정하세요. - TTL 필수:
ConcurrentMapCacheManager는 TTL이 없어 메모리가 무한 증가합니다. 프로덕션에서는 Caffeine이나 Redis를 사용하세요. - null 캐싱 정책:
unless = "#result == null"로 null을 제외하거나, 의도적으로 짧은 TTL로 null을 캐싱하여 penetration을 방지하세요. - 직렬화 호환성: Redis 캐시에서 클래스 구조 변경 시 역직렬화 실패에 대비한 배포 절차를 마련하세요.
- 모니터링: Caffeine의
recordStats()+ Micrometer, Redis의INFO stats로 히트율을 추적하세요. 히트율 80% 미만이면 캐시 전략을 재검토해야 합니다. - self-invocation: 같은 빈 내부 호출은 캐시가 동작하지 않습니다. 별도 서비스로 분리하거나
@Lazyself-injection을 사용하세요.
참고 자료