로컬 캐시에서 Redis 캐시로 전환할 때, 비즈니스 코드를 하나도 안 바꿀 수 있을까요?

Spring의 캐시 추상화가 빛을 발하는 순간이 바로 이때입니다. CacheManager만 교체하면 @Cacheable 코드는 그대로 유지하면서 캐시 구현체를 자유롭게 바꿀 수 있습니다.

CacheManager란

CacheManager는 Cache 인스턴스를 생성하고 관리하는 팩토리 인터페이스입니다.

JAVA
public interface CacheManager {
    Cache getCache(String name);           // 이름으로 Cache 조회
    Collection<String> getCacheNames();    // 관리 중인 캐시 이름 목록
}

@Cacheable("products")가 실행되면 내부적으로 cacheManager.getCache("products")를 호출하여 Cache 인스턴스를 가져오고, 그 Cache에 데이터를 조회/저장합니다.

ConcurrentMapCacheManager — 가장 단순한 로컬 캐시

JDK의 ConcurrentHashMap을 기반으로 하는 인메모리 캐시입니다.

JAVA
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        ConcurrentMapCacheManager manager = new ConcurrentMapCacheManager();
        manager.setCacheNames(List.of("products", "users"));
        return manager;
    }
}

특징:

  • 별도 의존성 없이 바로 사용 가능
  • TTL(만료 시간) 설정 불가
  • 최대 크기 제한 없음 (메모리 누수 위험)
  • 단일 JVM에서만 유효

** 용도:** 개발/테스트 환경, 프로토타이핑

Caffeine — 고성능 로컬 캐시

Caffeine은 Google Guava Cache의 후속 프로젝트로, 높은 적중률과 성능을 자랑하는 로컬 캐시 라이브러리입니다.

의존성 추가

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

기본 설정

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

캐시별 개별 설정

실무에서는 캐시마다 다른 정책이 필요합니다. 자주 변하는 데이터는 짧은 TTL, 거의 변하지 않는 데이터는 긴 TTL을 설정합니다.

JAVA
@Configuration
@EnableCaching
public class CaffeineCacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager manager = new SimpleCacheManager();
        manager.setCaches(List.of(
                buildCache("products", 500, 10, TimeUnit.MINUTES),
                buildCache("users", 1000, 30, TimeUnit.MINUTES),
                buildCache("config", 100, 1, TimeUnit.HOURS)
        ));
        return manager;
    }

buildCache() 헬퍼 메서드에서 Caffeine 빌더로 캐시별 최대 크기, TTL, 통계 수집 여부를 설정합니다.

JAVA
    private CaffeineCache buildCache(String name, int maxSize,
                                       long duration, TimeUnit unit) {
        return new CaffeineCache(name,
                Caffeine.newBuilder()
                        .maximumSize(maxSize)
                        .expireAfterWrite(duration, unit)
                        .recordStats()  // 캐시 통계 수집
                        .build()
        );
    }
}

Caffeine의 주요 설정:

설정설명
maximumSize최대 엔트리 수 (초과 시 LRU 방식으로 퇴거)
expireAfterWrite쓰기 후 만료 시간
expireAfterAccess마지막 접근 후 만료 시간
refreshAfterWrite쓰기 후 갱신 트리거 (비동기 리프레시)
recordStats히트율, 미스율 등 통계 수집

RedisCacheManager — 분산 캐시

여러 서버가 동일한 캐시를 공유해야 하는 분산 환경에서는 Redis를 사용합니다.

의존성과 기본 설정

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
YAML
spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379

커스텀 RedisCacheManager 설정

JAVA
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // 기본 캐시 설정
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues();  // null 값 캐싱 방지

기본 설정을 기반으로 캐시별 TTL을 개별 지정하고, transactionAware()로 트랜잭션과 동기화합니다.

JAVA
        // 캐시별 개별 설정
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
                "products", defaultConfig.entryTtl(Duration.ofMinutes(30)),
                "users", defaultConfig.entryTtl(Duration.ofHours(1)),
                "sessions", defaultConfig.entryTtl(Duration.ofMinutes(5))
        );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .transactionAware()  // 트랜잭션과 동기화
                .build();
    }
}

직렬화 설정의 중요성

Redis는 외부 저장소이므로 객체를 직렬화해서 저장해야 합니다. 직렬화 방식에 따라 성능, 호환성, 디버깅 편의성이 달라집니다.

직렬화 방식장점단점
JdkSerializationRedisSerializer기본값, 별도 설정 불필요가독성 낮음, 클래스 변경 시 역직렬화 실패
GenericJackson2JsonRedisSerializerJSON 형태로 가독성 좋음타입 정보 포함으로 용량 약간 증가
Jackson2JsonRedisSerializer지정 타입만 직렬화캐시마다 개별 설정 필요
StringRedisSerializer키 직렬화에 적합값 직렬화에는 부적합

실무에서는 키는 StringRedisSerializer, 값은 GenericJackson2JsonRedisSerializer를 사용하는 것이 일반적입니다.

다중 CacheManager

로컬 캐시와 분산 캐시를 동시에 사용하는 경우가 있습니다. 변경이 거의 없는 설정 데이터는 로컬 캐시로, 여러 서버가 공유해야 하는 데이터는 Redis로 관리하는 패턴입니다.

JAVA
@Configuration
@EnableCaching
public class MultiCacheConfig {

    @Bean
    @Primary
    public CacheManager caffeineCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(500)
                .expireAfterWrite(5, TimeUnit.MINUTES));
        return manager;
    }

@Primary가 붙은 Caffeine이 기본 CacheManager가 되고, Redis는 명시적으로 지정할 때만 사용합니다.

JAVA
    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30)))
                .build();
    }
}
JAVA
@Service
public class ProductService {

    // 기본(@Primary) CacheManager 사용 → Caffeine
    @Cacheable("localConfig")
    public AppConfig getConfig() { ... }

    // 명시적으로 Redis CacheManager 지정
    @Cacheable(value = "products", cacheManager = "redisCacheManager")
    public Product findById(Long id) { ... }
}

캐시 통계 모니터링

Caffeine의 recordStats()와 Spring Boot Actuator를 연동하면 캐시 통계를 모니터링할 수 있습니다.

YAML
management:
  endpoints:
    web:
      exposure:
        include: caches, metrics

/actuator/metrics/cache.gets로 히트율/미스율을 확인할 수 있습니다. 캐시 히트율이 낮다면 TTL이나 캐시 키 전략을 재검토해야 합니다.

구현체 선택 가이드

상황추천 구현체
개발/테스트ConcurrentMapCacheManager
단일 서버, 고성능 필요Caffeine
분산 환경, 서버 간 공유Redis
로컬 + 분산 혼합Caffeine + Redis (다중 CacheManager)

주의할 점

1. 로컬 캐시에서 Redis 캐시로 전환할 때 직렬화 문제가 발생한다

Caffeine 같은 로컬 캐시는 객체 참조를 그대로 저장하지만, Redis는 직렬화가 필수입니다. 기본 JdkSerializationRedisSerializer는 클래스 구조가 변경되면 역직렬화에 실패합니다. 캐시 구현체를 교체할 때는 저장 대상 객체의 직렬화 호환성을 반드시 점검해야 합니다.

2. 다중 CacheManager 환경에서 @Primary를 지정하지 않으면 빈 충돌이 발생한다

CacheManager 빈이 2개 이상이면 Spring이 어떤 것을 기본으로 사용할지 알 수 없어 NoUniqueBeanDefinitionException이 발생합니다. 반드시 하나에 @Primary를 붙이고, 나머지는 @Cacheable(cacheManager = "빈이름")으로 명시적으로 지정해야 합니다.

3. ConcurrentMapCacheManager를 운영 환경에서 사용하면 메모리 누수가 발생한다

ConcurrentMapCacheManager는 TTL과 최대 크기 제한이 없습니다. 캐시 데이터가 계속 쌓여 OOM(OutOfMemoryError)이 발생할 수 있습니다. 개발/테스트에서만 사용하고, 운영 환경에서는 반드시 Caffeine이나 Redis로 교체해야 합니다.

정리

  • CacheManager 는 Cache 인스턴스를 관리하는 팩토리로, 교체만으로 캐시 구현체를 변경할 수 있습니다
  • Caffeine 은 로컬 환경에서 TTL, 최대 크기, 통계 수집을 지원하는 고성능 캐시입니다
  • Redis 는 분산 환경에서 캐시를 공유할 수 있으며, 직렬화 설정이 핵심입니다
  • 다중 CacheManager로 로컬/분산 캐시를 함께 사용할 수 있고, @CacheablecacheManager 속성으로 지정합니다
댓글 로딩 중...