Spring Cache — Caffeine과 Redis 공급자 완전 정복

Spring Cache 추상화 위에서 Caffeine 로컬 캐시와 Redis 분산 캐시를 각각 설정하는 방법, 직렬화 전략, TTL 관리, 그리고 두 공급자를 조합하는 멀티 캐시 패턴까지 실전 코드로 정리합니다.

· 8 min read · PALDYN Team

지난 글에서 Spring Cache 추상화 레이어와 @Cacheable, @CacheEvict, @CachePut 어노테이션의 동작 원리를 살펴봤다. 이번 글에서는 실제 캐시 저장소로 가장 많이 쓰이는 CaffeineRedis를 어떻게 연결하고 튜닝하는지 상세히 다룬다.

왜 공급자를 신중히 골라야 하나

Spring Cache의 강점은 CacheManager 인터페이스 뒤로 구현을 숨긴다는 것이다. @Cacheable을 붙인 비즈니스 코드는 어떤 공급자를 쓰든 바뀌지 않는다. 그러나 잘못된 공급자 선택은 GC 부담 증가, 네트워크 레이턴시 오버헤드, 인스턴스 간 캐시 불일치 같은 문제로 이어진다.

Caffeine vs Redis 공급자 비교

핵심 판단 기준은 하나다. 모든 애플리케이션 인스턴스가 같은 캐시 데이터를 봐야 하는가? 단일 인스턴스이거나 읽기 전용 설정 값 정도라면 Caffeine이 충분하다. 수평 확장 환경에서 캐시 일관성이 필요하다면 Redis가 필요하다.

Caffeine 설정

Caffeine은 구글 Guava Cache를 대체하기 위해 만들어진 고성능 JVM 로컬 캐시다. W-TinyLFU 알고리즘 기반으로 메모리 효율이 높고, 네트워크 없이 나노초 단위 응답이 가능하다.

의존성 추가

<!-- Maven -->
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
</dependency>

Spring Boot의 spring-boot-starter-cache와 함께 사용하면 자동 구성이 활성화된다.

application.yml 방식 (간단한 설정)

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=500,expireAfterWrite=10m

spec 문자열은 Caffeine의 CaffeineSpec 포맷을 따른다. expireAfterWrite=10m은 마지막 쓰기 후 10분, expireAfterAccess=5m은 마지막 접근 후 5분에 만료된다.

Java Config 방식 (세밀한 제어)

@Configuration
@EnableCaching
public class CaffeineConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .recordStats());   // 캐시 히트율 통계 활성화
        return manager;
    }
}

recordStats()를 활성화하면 cache.stats().hitRate(), missCount() 등 상세 지표를 얻을 수 있다. 운영 환경에서는 Micrometer와 연동해 Prometheus로 내보내는 것을 권장한다.

캐시별 개별 설정

@Bean
public CacheManager cacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    // 기본 설정
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(500)
        .expireAfterWrite(10, TimeUnit.MINUTES));

    // 특정 캐시에 별도 Caffeine 인스턴스 등록
    manager.registerCustomCache("users",
        Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build());
    return manager;
}

Redis 설정

Redis는 네트워크를 통해 접근하는 외부 인메모리 저장소다. 모든 애플리케이션 인스턴스가 동일한 Redis에 연결하므로 수평 확장 환경에서도 캐시 일관성이 유지된다.

의존성 추가

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.yml

spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms

spring.cache.type=redis를 지정하면 Spring Boot 자동 구성이 RedisCacheManager를 생성한다.

Java Config — 직렬화와 TTL 설정

Redis는 값을 바이트 배열로 직렬화해서 저장하므로 직렬화 전략이 중요하다. 기본값인 Java 직렬화는 클래스 변경에 취약하기 때문에 JSON 직렬화를 권장한다.

@Bean
public RedisCacheManager redisCacheManager(
        RedisConnectionFactory factory) {

    RedisCacheConfiguration defaultConfig =
        RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .disableCachingNullValues()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(
                        new GenericJackson2JsonRedisSerializer()));

    // 캐시별 TTL 오버라이드
    Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
        "products", defaultConfig.entryTtl(Duration.ofHours(1)),
        "sessions", defaultConfig.entryTtl(Duration.ofMinutes(5))
    );

    return RedisCacheManager.builder(factory)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(cacheConfigs)
        .build();
}

Redis 키 네이밍 전략

기본 키 형식은 cacheName::cacheKey다. @Cacheable(value = "users", key = "#id")이면 users::42 형태로 저장된다. prefix를 변경하려면 RedisCacheConfiguration.prefixCacheNameWith("myapp:")를 설정한다.

Caffeine & Redis CacheManager 설정 코드

멀티 캐시 매니저 패턴

로컬 캐시(Caffeine)와 분산 캐시(Redis)를 동시에 운용하고, @Cacheable에서 선택적으로 사용하려면 @Primary + @Qualifier 패턴을 활용한다.

@Bean
@Primary
public CacheManager caffeineCacheManager() {
    // 빠른 응답 필요 캐시: 기본값
    return new CaffeineCacheManager("config", "roles");
}

@Bean("redisCacheManager")
public CacheManager redisCacheManager(
        RedisConnectionFactory factory) {
    return RedisCacheManager.create(factory);
}
// 기본 (Caffeine) 사용
@Cacheable(value = "config", key = "#key")
public String getConfig(String key) { ... }

// Redis 명시 사용
@Cacheable(
    value = "sessions",
    key   = "#userId",
    cacheManager = "redisCacheManager"
)
public UserSession getSession(Long userId) { ... }

Cache-Aside 패턴과 갱신 전략

Spring Cache의 @CacheableCache-Aside 패턴을 구현한다. 캐시 히트면 DB를 건너뛰고, 미스면 DB에서 읽어 캐시에 저장한다. 데이터 갱신 시에는 두 가지 전략이 있다.

// 전략 1: Write-Invalidate — 갱신 후 캐시 삭제
@CacheEvict(value = "users", key = "#user.id")
@Transactional
public void updateUser(User user) {
    userRepository.save(user);
}

// 전략 2: Write-Through — 갱신 후 캐시도 즉시 업데이트
@CachePut(value = "users", key = "#result.id")
@Transactional
public User updateUser(User user) {
    return userRepository.save(user);
}

Write-Invalidate는 구현이 단순하고 Cache Stampede(동시 캐시 미스로 DB 부하 폭증)에 주의해야 한다. Write-Through는 캐시 데이터가 항상 최신이지만 캐시와 DB 사이 정합성 관리가 더 복잡하다.

Caffeine 통계 모니터링

운영 환경에서는 캐시 히트율을 추적해야 한다. recordStats()를 설정하면 Actuator + Micrometer를 통해 자동으로 지표가 노출된다.

// CaffeineCache에서 직접 통계 접근
Cache cache = cacheManager.getCache("users");
CaffeineCache caffeineCache = (CaffeineCache) cache;
CacheStats stats = caffeineCache.getNativeCache().stats();

double hitRate    = stats.hitRate();    // 0.0 ~ 1.0
long   missCount  = stats.missCount();
long   evictions  = stats.evictionCount();

히트율이 80% 미만이면 maximumSize가 너무 작거나 TTL이 너무 짧다는 신호다. 반대로 100%에 가까우면 캐시 데이터가 너무 오래 살아 있어 갱신이 지연될 수 있다.

정리

선택 기준CaffeineRedis
단일 인스턴스✅ 최적오버스펙
수평 확장❌ 비권장✅ 필수
응답 속도나노초마이크로초
직렬화 비용없음JSON/바이트 변환
TTL 정밀 제어제한적밀리초 단위

대부분의 Spring Boot 프로젝트는 로컬 설정 값 → Caffeine, 사용자 세션·공유 데이터 → Redis 조합으로 시작하는 것이 실용적이다. 두 공급자를 동시에 쓸 때는 캐시 이름을 명확히 분리하고 어노테이션에 cacheManager를 명시하자.


지난 글: Spring Cache 추상화 — @Cacheable · @CacheEvict 완전 정복

다음 글: Spring HTTP 캐시 헤더 — Cache-Control과 ETag 완전 정복


읽어주셔서 감사합니다. 😊