왜 캐시는 기능이 아니라 운영 설계인가

Spring Boot에서 캐시는 조회 성능을 올리는 가장 빠른 방법처럼 보이지만, 실제 운영에서는 데이터 정합성, 장애 전파, 트래픽 폭증, 배포 시점의 키 호환성까지 함께 다뤄야 합니다. 단순히 @Cacheable을 붙이면 평균 응답 시간은 줄어들 수 있지만, TTL이 너무 길면 사용자는 오래된 값을 보고, TTL이 너무 짧으면 Redis와 원본 데이터베이스를 동시에 때리는 구조가 됩니다. 특히 인기 상품, 공지 목록, 권한 메뉴, 환경 설정처럼 여러 사용자가 동시에 읽는 데이터는 캐시 만료 순간에 같은 쿼리가 몰리는 캐시 스탬피드가 생기기 쉽습니다.

실무에서 안전한 캐시 설계의 기준은 세 가지입니다. 첫째, 어떤 데이터가 얼마나 오래 틀려도 되는지 명확히 정합니다. 둘째, 키 이름과 TTL을 코드에서 추적 가능하게 관리합니다. 셋째, 캐시 실패가 서비스 전체 장애로 번지지 않게 만듭니다. 이 문서는 Spring Boot 애플리케이션에서 Redis 캐시를 사용할 때 바로 적용할 수 있는 설정, 코드 패턴, 점검 항목을 중심으로 정리합니다.

캐시 대상부터 분리하기

모든 조회 API가 캐시 대상은 아닙니다. 자주 읽히고, 계산 비용이 크며, 짧은 시간 동안 동일한 결과를 허용할 수 있는 데이터가 좋은 후보입니다. 반대로 사용자별 잔액, 결제 상태, 재고 차감 직후의 수량처럼 최신성이 핵심인 데이터는 캐시를 붙이기 전에 업무 규칙을 먼저 확인해야 합니다. 캐시 대상은 보통 세 그룹으로 나누면 관리가 쉽습니다.

  • 정적 또는 준정적 데이터: 코드 테이블, 카테고리, 약관 버전, 공통 설정처럼 변경 빈도가 낮은 데이터입니다. TTL을 길게 두되 배포나 관리자 변경 시 명시적으로 무효화합니다.
  • 인기 조회 데이터: 상품 상세, 게시글 상세, 랭킹, 대시보드 요약처럼 반복 조회가 많은 데이터입니다. TTL을 짧게 두고 만료 시 부하를 제어합니다.
  • 비싼 계산 결과: 통계 집계, 외부 API 응답, 복잡한 권한 메뉴처럼 매번 계산하기 부담되는 데이터입니다. 실패 시 기본값이나 부분 응답을 줄 수 있는지도 함께 설계합니다.

RedisCacheManager 기본 설정 예시

Spring Cache 추상화를 쓰면 서비스 코드는 간결해지지만, 캐시별 TTL을 분리하지 않으면 운영 중 조정이 어렵습니다. 아래 예시는 기본 TTL은 짧게 두고, 변경이 적은 카테고리와 통계 요약은 별도 TTL을 적용합니다. JSON 직렬화를 사용하면 사람이 Redis에서 값을 확인하기 쉽지만, 클래스 구조가 자주 바뀌는 DTO를 그대로 캐싱하면 역직렬화 문제가 생길 수 있으므로 캐시 전용 응답 객체를 두는 편이 안전합니다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> perCache = Map.of(
            "categoryTree", defaults.entryTtl(Duration.ofHours(6)),
            "productDetail", defaults.entryTtl(Duration.ofMinutes(10)),
            "dashboardSummary", defaults.entryTtl(Duration.ofMinutes(2))
        );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaults)
            .withInitialCacheConfigurations(perCache)
            .transactionAware()
            .build();
    }
}

키 규칙은 메서드 이름보다 업무 기준으로 잡기

@Cacheable의 기본 키 생성은 빠르게 시작하기 좋지만, 운영에서는 어떤 키가 어떤 데이터를 의미하는지 바로 읽혀야 합니다. 키에는 도메인, 식별자, 조회 조건, 버전 정보를 넣습니다. 버전은 DTO 구조나 계산 기준이 바뀌었을 때 전체 Redis를 비우지 않고도 새 키로 자연스럽게 전환하기 위한 장치입니다. 예를 들어 상품 상세라면 product:v2:detail:상품ID 형태로 만들고, 목록 조회는 page, size, sort, 필터 값을 빠뜨리지 않습니다.

@Service
@RequiredArgsConstructor
public class ProductQueryService {

    private final ProductRepository productRepository;

    @Cacheable(
        cacheNames = "productDetail",
        key = "'product:v2:detail:' + #productId",
        unless = "#result == null"
    )
    public ProductDetailResponse getProductDetail(Long productId) {
        return productRepository.findDetail(productId)
            .map(ProductDetailResponse::from)
            .orElse(null);
    }

    @CacheEvict(
        cacheNames = "productDetail",
        key = "'product:v2:detail:' + #productId"
    )
    public void evictProductDetail(Long productId) {
        // 관리자 수정, 가격 변경, 판매 상태 변경 직후 호출합니다.
    }
}

목록 캐시는 더 조심해야 합니다. 검색 조건 조합이 많으면 키가 무한히 늘어날 수 있고, 캐시 적중률도 낮아집니다. 운영에서는 첫 페이지, 대표 정렬, 고정 필터처럼 실제 트래픽이 몰리는 조건만 캐시하고 나머지는 데이터베이스 인덱스와 쿼리 튜닝으로 처리하는 편이 낫습니다.

캐시 스탬피드 줄이기

TTL이 동시에 끝나는 순간 여러 요청이 원본 DB로 몰리면 캐시가 오히려 장애 증폭기가 됩니다. 가장 쉬운 완화책은 캐시별 TTL에 약간의 흔들림을 주는 것입니다. Spring Cache 기본 설정만으로는 키마다 다른 TTL을 주기 어렵기 때문에, 트래픽이 매우 큰 데이터는 CacheManager 자동 캐시 대신 RedisTemplate으로 직접 제어하거나, 별도 로딩 잠금을 둡니다. 잠금은 짧아야 하며, 잠금을 잡지 못한 요청은 잠깐 기다린 뒤 캐시를 다시 확인해야 합니다.

public ProductDetailResponse getHotProduct(Long productId) {
    String cacheKey = "product:v2:hot:" + productId;
    ProductDetailResponse cached = redisValue.get(cacheKey);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:" + cacheKey;
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));

    if (Boolean.TRUE.equals(locked)) {
        try {
            ProductDetailResponse loaded = loadFromDatabase(productId);
            long jitter = ThreadLocalRandom.current().nextLong(30, 90);
            redisValue.set(cacheKey, loaded, Duration.ofMinutes(5).plusSeconds(jitter));
            return loaded;
        } finally {
            redisTemplate.delete(lockKey);
        }
    }

    sleepQuietly(80);
    ProductDetailResponse retry = redisValue.get(cacheKey);
    return retry != null ? retry : loadFromDatabase(productId);
}

이 패턴은 모든 캐시에 넣을 필요가 없습니다. 상위 트래픽 데이터, 외부 API를 호출하는 데이터, 집계 시간이 긴 데이터처럼 만료 순간의 비용이 큰 곳에만 적용합니다. 또한 잠금 시간이 너무 길면 장애 시 요청이 줄줄이 대기하므로 1초에서 5초 사이의 짧은 값으로 시작하고, 실제 처리 시간을 지표로 확인해야 합니다.

무효화는 저장 트랜잭션과 함께 생각하기

캐시 무효화는 데이터 저장 성공 이후에 실행되어야 합니다. 저장 트랜잭션이 롤백됐는데 캐시만 삭제되면 성능 손해 정도로 끝나지만, 저장 전 캐시를 갱신했다가 롤백되면 잘못된 데이터가 노출될 수 있습니다. RedisCacheManager의 transactionAware 설정은 트랜잭션 커밋 이후 캐시 작업이 실행되도록 도와줍니다. 더 복잡한 업무에서는 도메인 이벤트를 발행하고, 커밋 이후 이벤트 리스너에서 관련 캐시를 삭제하는 방식이 명확합니다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductChanged(ProductChangedEvent event) {
    cacheManager.getCache("productDetail")
        .evict("product:v2:detail:" + event.productId());
    cacheManager.getCache("dashboardSummary")
        .clear();
}

clear는 강력하지만 위험합니다. 작은 서비스에서는 편리하지만, 대형 서비스에서 전체 캐시 삭제는 다음 순간 대량의 DB 조회를 만들 수 있습니다. 가능한 한 단일 키 삭제를 우선하고, 목록이나 집계처럼 영향 범위가 넓은 캐시는 짧은 TTL과 백그라운드 재계산을 조합합니다.

운영 지표와 장애 대응

캐시는 붙이는 순간부터 관측 대상입니다. 최소한 캐시 적중률, Redis 응답 시간, 연결 풀 사용량, 직렬화 실패, 캐시 미스 이후 DB 쿼리 시간은 봐야 합니다. 적중률이 낮은 캐시는 메모리만 쓰고 운영 복잡도를 늘립니다. Redis 장애 때 서비스가 어떤 동작을 하는지도 미리 정해야 합니다. 핵심 조회는 일시적으로 DB 조회로 우회할 수 있지만, 외부 API 캐시처럼 원본 호출 비용이 높은 경우에는 짧은 기본 응답이나 이전 값을 유지하는 전략이 필요할 수 있습니다.

  • 캐시 이름별 TTL과 용도를 문서화합니다.
  • 키에는 도메인, 버전, 식별자, 조건을 포함합니다.
  • null 캐싱은 기본적으로 막고, 필요할 때만 짧은 TTL로 허용합니다.
  • 인기 키는 TTL 흔들림이나 짧은 로딩 잠금으로 스탬피드를 줄입니다.
  • 저장 트랜잭션 커밋 이후에 캐시를 삭제합니다.
  • clear 전체 삭제는 배포 직후나 장애 상황에서 DB 부하를 만들 수 있으므로 제한합니다.
  • 캐시 적중률이 낮은 항목은 제거하거나 키 조건을 다시 설계합니다.

마무리 체크리스트

Spring Boot Redis 캐시는 성능 개선 도구이지만, 운영 기준 없이 붙이면 오래된 데이터와 순간 부하라는 다른 문제를 만듭니다. 먼저 캐시 대상과 허용 가능한 지연 시간을 정하고, 캐시별 TTL과 키 규칙을 분리합니다. 인기 데이터에는 만료 시점 분산과 로딩 잠금을 적용하고, 데이터 변경 후에는 커밋 이후 무효화를 보장합니다. 마지막으로 적중률과 Redis 지연 시간을 지표로 보면서 효과 없는 캐시는 과감히 제거합니다. 캐시는 많이 붙이는 것이 아니라, 오래 운영해도 설명 가능한 곳에 정확히 붙이는 것이 핵심입니다.