Spring Boot Cache: @Cacheable

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이 제공하는 ConcurrentMapCacheManagerCaffeineCacheManager는 지원하지만, 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 ObjectMapperFAIL_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 누락 점검

실무 설계 체크리스트

  • 캐시 키 설계: 파라미터 전체를 키로 사용하면 캐시 효율이 떨어집니다. key SpEL로 핵심 식별자만 지정하세요.
  • TTL 필수: ConcurrentMapCacheManager는 TTL이 없어 메모리가 무한 증가합니다. 프로덕션에서는 Caffeine이나 Redis를 사용하세요.
  • null 캐싱 정책: unless = "#result == null"로 null을 제외하거나, 의도적으로 짧은 TTL로 null을 캐싱하여 penetration을 방지하세요.
  • 직렬화 호환성: Redis 캐시에서 클래스 구조 변경 시 역직렬화 실패에 대비한 배포 절차를 마련하세요.
  • 모니터링: Caffeine의 recordStats() + Micrometer, Redis의 INFO stats로 히트율을 추적하세요. 히트율 80% 미만이면 캐시 전략을 재검토해야 합니다.
  • self-invocation: 같은 빈 내부 호출은 캐시가 동작하지 않습니다. 별도 서비스로 분리하거나 @Lazy self-injection을 사용하세요.

참고 자료

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux