Spring Cache 다계층 전략

Spring Cache 추상화란?

Spring Framework는 @Cacheable, @CacheEvict, @CachePut 등의 어노테이션으로 캐시를 추상화합니다. 하지만 실전에서는 단일 캐시만으로 부족합니다. 로컬 캐시(Caffeine)와 분산 캐시(Redis)를 조합한 다계층(Multi-tier) 캐시 전략이 필요합니다. 이 글에서는 Spring Cache의 내부 동작부터 다계층 캐시 구현, TTL 관리, 캐시 동기화까지 실전 패턴을 다룹니다.

Spring Cache 핵심 어노테이션

Spring Cache의 기본 어노테이션 4가지를 먼저 정리합니다.

어노테이션 역할 주요 속성
@Cacheable 캐시 조회 → 미스 시 메서드 실행 후 저장 value, key, condition, unless
@CachePut 항상 메서드 실행 후 캐시 갱신 value, key
@CacheEvict 캐시 제거 value, key, allEntries, beforeInvocation
@Caching 여러 캐시 연산 조합 cacheable, put, evict

CacheManager 구현체 비교

Spring은 다양한 CacheManager 구현체를 제공합니다. 각각의 특성을 이해해야 올바른 계층 설계가 가능합니다.

// 1. ConcurrentMapCacheManager — 기본 제공, 프로토타이핑용
@Bean
public CacheManager simpleCacheManager() {
    return new ConcurrentMapCacheManager("users", "products");
}

// 2. CaffeineCacheManager — 고성능 로컬 캐시
@Bean
public CacheManager caffeineCacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .recordStats());
    return manager;
}

// 3. RedisCacheManager — 분산 캐시
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory factory) {
    RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(30))
        .serializeValuesWith(
            SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    return RedisCacheManager.builder(factory)
        .cacheDefaults(config)
        .build();
}

왜 다계층 캐시인가?

단일 캐시의 한계는 명확합니다. 로컬 캐시(Caffeine)는 빠르지만 인스턴스 간 동기화가 안 되고, 분산 캐시(Redis)는 네트워크 호출 비용이 있습니다. 다계층 캐시는 L1(로컬) → L2(분산) 순서로 조회하여 두 장점을 결합합니다.

구분 Caffeine (L1) Redis (L2) 다계층
응답 속도 ~1μs ~1ms L1 히트 시 ~1μs
인스턴스 공유 불가 가능 가능
장애 영향 없음 Redis 다운 시 전체 미스 Redis 다운 시 L1 폴백
메모리 JVM 힙 사용 외부 메모리 핫 데이터만 L1

다계층 CacheManager 구현

Spring의 CompositeCacheManager는 여러 CacheManager를 순서대로 탐색하지만, 진정한 계층적 조회(L1 미스 → L2 조회 → L1 저장)를 하지 않습니다. 커스텀 구현이 필요합니다.

public class TieredCache implements Cache {
    private final Cache l1; // Caffeine
    private final Cache l2; // Redis
    private final String name;

    public TieredCache(String name, Cache l1, Cache l2) {
        this.name = name;
        this.l1 = l1;
        this.l2 = l2;
    }

    @Override
    public String getName() { return name; }

    @Override
    public Object getNativeCache() { return this; }

    @Override
    public ValueWrapper get(Object key) {
        // 1단계: L1 조회
        ValueWrapper value = l1.get(key);
        if (value != null) return value;

        // 2단계: L2 조회
        value = l2.get(key);
        if (value != null) {
            // L2 히트 → L1에 승격(promote)
            l1.put(key, value.get());
        }
        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);
    }

    @Override
    public void clear() {
        l1.clear();
        l2.clear();
    }
}

TieredCacheManager 설정

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisFactory) {
        // L1: Caffeine
        CaffeineCacheManager caffeineManager = new CaffeineCacheManager();
        caffeineManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(5_000)
            .expireAfterWrite(Duration.ofMinutes(2)));

        // L2: Redis
        RedisCacheConfiguration redisConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .serializeValuesWith(
                SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()));
        RedisCacheManager redisManager = RedisCacheManager.builder(redisFactory)
            .cacheDefaults(redisConfig)
            .build();

        return new TieredCacheManager(caffeineManager, redisManager);
    }
}

public class TieredCacheManager implements CacheManager {
    private final CacheManager l1Manager;
    private final CacheManager l2Manager;
    private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>();

    public TieredCacheManager(CacheManager l1, CacheManager l2) {
        this.l1Manager = l1;
        this.l2Manager = l2;
    }

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, n ->
            new TieredCache(n, l1Manager.getCache(n), l2Manager.getCache(n)));
    }

    @Override
    public Collection<String> getCacheNames() {
        Set<String> names = new HashSet<>();
        names.addAll(l1Manager.getCacheNames());
        names.addAll(l2Manager.getCacheNames());
        return names;
    }
}

캐시별 TTL 차별화

모든 캐시에 동일한 TTL을 적용하는 것은 비효율적입니다. 데이터 특성에 따라 캐시별 TTL을 분리해야 합니다. Spring Actuator로 캐시 히트율을 모니터링하며 TTL을 튜닝할 수 있습니다.

@Bean
public CacheManager redisCacheManager(RedisConnectionFactory factory) {
    RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10));

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

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

Redis Pub/Sub로 L1 동기화

다중 인스턴스 환경에서 가장 큰 문제는 L1 캐시 불일치입니다. 인스턴스 A에서 데이터를 변경하면 인스턴스 B의 L1에는 여전히 오래된 데이터가 남아 있습니다. Redis Pub/Sub로 이 문제를 해결합니다.

@Component
public class CacheInvalidationPublisher {
    private final RedisTemplate<String, String> redisTemplate;
    private static final String CHANNEL = "cache:invalidate";

    public CacheInvalidationPublisher(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void publishEviction(String cacheName, String key) {
        String message = cacheName + "::" + key;
        redisTemplate.convertAndSend(CHANNEL, message);
    }
}

@Component
public class CacheInvalidationSubscriber implements MessageListener {
    private final CaffeineCacheManager localCacheManager;
    private final String instanceId = UUID.randomUUID().toString();

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String payload = new String(message.getBody());
        String[] parts = payload.split("::", 2);
        if (parts.length == 2) {
            Cache cache = localCacheManager.getCache(parts[0]);
            if (cache != null) {
                cache.evict(parts[1]);  // L1에서만 제거
            }
        }
    }
}

// RedisMessageListenerContainer 등록
@Bean
public RedisMessageListenerContainer listenerContainer(
        RedisConnectionFactory factory,
        CacheInvalidationSubscriber subscriber) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(subscriber,
        new ChannelTopic("cache:invalidate"));
    return container;
}

@CacheEvict + 이벤트 통합

캐시 무효화를 서비스 로직과 분리하면 유지보수가 쉬워집니다. Spring의 이벤트 시스템과 결합한 패턴입니다.

// 도메인 이벤트 정의
public record UserUpdatedEvent(Long userId) {}

// 서비스: 캐시 무효화 없이 깔끔하게
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository repository;
    private final ApplicationEventPublisher eventPublisher;

    @Cacheable(value = "users", key = "#id")
    public UserDto getUser(Long id) {
        return repository.findById(id)
            .map(UserDto::from)
            .orElseThrow();
    }

    @Transactional
    public void updateUser(Long id, UpdateUserRequest request) {
        User user = repository.findById(id).orElseThrow();
        user.update(request);
        eventPublisher.publishEvent(new UserUpdatedEvent(id));
    }
}

// 이벤트 리스너: 트랜잭션 커밋 후 캐시 무효화
@Component
@RequiredArgsConstructor
public class CacheEvictionListener {
    private final CacheInvalidationPublisher publisher;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onUserUpdated(UserUpdatedEvent event) {
        publisher.publishEviction("users", event.userId().toString());
    }
}

Cache Stampede 방지

캐시가 만료되는 순간 다수의 요청이 동시에 DB를 호출하는 Cache Stampede(Thundering Herd) 문제는 장애의 주요 원인입니다. 두 가지 해결 전략을 소개합니다.

// 전략 1: @Cacheable의 sync 속성 — 단일 스레드만 로딩
@Cacheable(value = "products", key = "#id", sync = true)
public ProductDto getProduct(Long id) {
    return productRepository.findById(id)
        .map(ProductDto::from)
        .orElseThrow();
}

// 전략 2: Caffeine의 refreshAfterWrite — 만료 전 백그라운드 갱신
@Bean
public CacheManager caffeineCacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofMinutes(8)));  // 만료 2분 전에 갱신
    // CacheLoader 필요
    manager.setCacheLoader(key -> loadFromSource(key));
    return manager;
}

// 전략 3: 분산 락으로 중복 로딩 방지 (Redisson)
public ProductDto getProductWithLock(Long id) {
    String cacheKey = "products::" + id;
    ProductDto cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) return cached;

    RLock lock = redissonClient.getLock("lock:" + cacheKey);
    try {
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            // Double-check after acquiring lock
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) return cached;

            ProductDto product = productRepository.findById(id)
                .map(ProductDto::from).orElseThrow();
            redisTemplate.opsForValue().set(cacheKey, product,
                Duration.ofMinutes(30));
            return product;
        }
    } finally {
        lock.unlock();
    }
    throw new RuntimeException("Cache load timeout");
}

캐시 모니터링 설정

운영 환경에서 캐시 효율을 지속적으로 관찰해야 합니다. Caffeine의 recordStats()와 Micrometer를 연동하면 히트율, 미스율, 축출 수를 Prometheus/Grafana로 시각화할 수 있습니다.

# application.yml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=300s,recordStats

# Actuator에서 캐시 메트릭 노출
management:
  endpoints:
    web:
      exposure:
        include: caches,metrics
  metrics:
    tags:
      application: my-app
// 커스텀 히트율 알림
@Scheduled(fixedRate = 60_000)
public void checkCacheHitRate() {
    CaffeineCache cache = (CaffeineCache) cacheManager.getCache("users");
    CacheStats stats = cache.getNativeCache().stats();

    double hitRate = stats.hitRate();
    if (hitRate < 0.5) {
        log.warn("Cache 'users' 히트율 저조: {:.1f}% — TTL 또는 maxSize 조정 검토",
            hitRate * 100);
    }
}

실전 체크리스트

다계층 캐시 도입 시 반드시 확인해야 할 항목입니다.

  • 직렬화 호환성: Redis에 저장할 객체는 반드시 역직렬화 가능해야 합니다. 클래스 변경 시 기존 캐시가 깨질 수 있으므로 @JsonIgnoreProperties(ignoreUnknown = true) 추가를 권장합니다.
  • L1 TTL < L2 TTL: 로컬 캐시는 항상 분산 캐시보다 짧은 TTL을 설정해야 불일치 윈도우를 최소화할 수 있습니다.
  • maxSize 산정: L1의 maxSize는 (힙 여유 공간 × 0.1) / 평균 객체 크기로 계산합니다.
  • Redis 장애 대비: CacheErrorHandler를 구현하여 Redis 연결 실패 시 L1만으로 동작하도록 폴백 처리합니다.
  • 키 설계: 복합 키는 @Cacheable(key = "#root.method.name + '_' + #id") 형태로 충돌을 방지합니다.
// CacheErrorHandler: Redis 장애 시 조용히 미스 처리
@Configuration
public class CacheErrorConfig implements CachingConfigurer {
    @Override
    public CacheErrorHandler errorHandler() {
        return new SimpleCacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException e,
                    Cache cache, Object key) {
                log.warn("Redis cache get 실패 [{}:{}]: {}",
                    cache.getName(), key, e.getMessage());
                // 예외를 삼켜서 L1 또는 DB 폴백
            }
        };
    }
}

정리

Spring Cache의 다계층 전략은 성능(L1 로컬)과 일관성(L2 분산)의 균형을 잡는 핵심 아키텍처 패턴입니다. Caffeine + Redis 조합으로 L1 히트 시 마이크로초 응답을, Pub/Sub로 인스턴스 간 일관성을, sync와 분산 락으로 Stampede 방지를 구현할 수 있습니다. 캐시는 도입보다 운영과 모니터링이 더 중요합니다. 히트율을 지속 관찰하고 TTL을 데이터 특성에 맞게 튜닝하세요.

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