Redis 캐싱 전략 — Cache-Aside부터 Write-Behind까지, TTL 설계까지

DB가 같은 쿼리를 초당 수천 번 받는다. 결과는 매번 똑같은데도. 캐시는 이 반복을 메모리에서 끊어주는 장치다. 그런데 캐시를 한 번 끼우는 순간 “DB와 캐시 중 뭐가 진실인가”라는 문제가 따라온다. 전략을 잘못 고르면 빨라지는 대신 데이터가 틀린다. 각 전략이 내부에서 어떻게 동작하고 어디서 깨지는지부터 본다.


Q1. 캐시를 추가했는데 왜 데이터가 자꾸 틀리나?

Redis 캐시를 붙였다. 배포 직후 일부 사용자가 구버전 데이터를 본다. CS가 쌓인다. 왜 이런가?

원인은 네 가지로 분류된다. 전략을 고르기 전에 지금 어떤 유형의 문제인지 먼저 판별해야 한다.

원인 1: 업데이트했는데 캐시에 구버전이 살아있음

@CacheEvict 위치가 잘못됐거나, 트랜잭션 커밋보다 evict가 먼저 일어난 경우다.

// 잘못된 패턴
@Transactional
public void updateProduct(Long id, ProductUpdateRequest req) {
    Product product = productRepository.findById(id).orElseThrow();
    product.update(req);
    productRepository.save(product);
    // 여기서 evict가 일어남. 그런데 트랜잭션이 아직 커밋 안 됨.
    // 다른 스레드가 이 틈에 DB를 읽으면 구버전을 캐시에 다시 올려버림.
    cacheManager.getCache("products").evict(id);
}

트랜잭션이 커밋되기 전에 캐시를 지우면, 다른 스레드가 그 직후 DB를 읽고 구버전 데이터를 캐시에 다시 적재할 수 있다. 커밋 이후에 evict해야 한다.

원인 2: 멀티 인스턴스에서 한 인스턴스만 최신

인스턴스 A에서 업데이트 후 로컬 캐시(Caffeine 등)를 비웠다. 인스턴스 B의 로컬 캐시에는 구버전이 남아있다. Redis(L2)만 캐시로 쓰고 있다면 이 문제는 없다. 로컬 캐시(L1)가 끼어있을 때 발생한다.

원인 3: 트랜잭션 롤백 후에도 캐시에 값이 있음

@Transactional + @Cacheable 조합의 실행 순서 문제다. 메서드가 실행되고 캐시에 값이 올라간 뒤, 나중에 트랜잭션이 롤백되면 DB는 원상복구되지만 캐시는 그대로다. 캐시는 DB 트랜잭션 롤백의 영향을 받지 않는다.

원인 4: 가끔 틀리고 패턴이 없음

Cache Stampede 중 경쟁 조건(race condition)이다. 캐시 miss 후 여러 스레드가 동시에 DB를 읽고 캐시에 쓰는 과정에서 순서가 꼬여 구버전이 올라가는 경우다.


전략 선택보다 먼저, 어떻게 읽고 쓰는지 패턴을 정의하라. 전략이 좋아도 구현이 잘못되면 어떤 전략이든 데이터가 틀린다. 위 네 가지 원인을 각각 Q7, Q8, Q7, Q3에서 코드 레벨로 다룬다.


Q2. Cache-Aside가 가장 흔하다는데 어떻게 동작하나? 없으면 어떤 고통?

캐시 없이 상품 상세 API를 운영하고 있다. 초당 1,000건 요청이 전부 DB를 때린다.

DB 응답 시간이 50ms라면, DB가 동시에 처리할 수 있는 커넥션이 20개 있을 때 초당 처리량은 20 / 0.05 = 초당 400건이 최대다. 나머지 600건은 커넥션 풀 대기다. 응답 레이턴시는 폭발한다. 트래픽이 조금만 늘어도 즉시 장애다.

[WHY] 왜 캐시 저장소로 Redis인가. 로컬(프로세스 내) 캐시는 가장 빠르지만 인스턴스마다 따로 산다. 서버가 여러 대면 캐시가 공유되지 않고, A에서 갱신해도 B는 구버전을 들고 있어 정합성이 깨진다. Redis는 외부 공유 저장소라 모든 인스턴스가 같은 값을 본다. Memcached도 같은 역할을 하지만, Redis는 String 외에 Hash·Sorted Set·Stream 같은 자료구조, TTL 외 다양한 만료·eviction 정책, 영속성(RDB/AOF), 복제·클러스터를 함께 제공한다. 단순 문자열 캐시라면 Memcached로 충분하지만, 캐시 무효화 브로드캐스트(Pub/Sub)·랭킹·분산 락까지 한 저장소에서 풀려면 Redis가 맞는다. 로컬 캐시는 버리는 게 아니라 Redis 앞단(L1)으로 함께 쓴다(Q10).

Cache-Aside는 이 문제의 직접적인 해답이다. 동작 방식은 단순하다.

읽기 요청
  1. 캐시(Redis) 조회
  2-A. Hit: 캐시 값 반환 (DB 안 씀)
  2-B. Miss: DB 조회 → 결과를 캐시에 저장 → 반환

직접 구현하면 이렇다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;

    private static final Duration TTL = Duration.ofMinutes(10);

    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;

        // 1. 캐시 조회
        Product cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached; // Cache Hit
        }

        // 2. DB 조회
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));

        // 3. 캐시 저장
        redisTemplate.opsForValue().set(cacheKey, product, TTL);

        return product;
    }
}

Spring의 @Cacheable을 쓰면 위 로직이 AOP로 추상화된다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Cacheable(value = "products", key = "#productId")
    public Product getProduct(Long productId) {
        // 캐시 miss일 때만 이 코드가 실행됨
        return productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));
    }

    @CacheEvict(value = "products", key = "#productId")
    public void updateProduct(Long productId, ProductUpdateRequest req) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.update(req);
        productRepository.save(product);
    }
}

왜 Cache-Aside가 가장 흔한가. 처음부터 모든 데이터를 캐시에 올릴 필요가 없다. 실제로 조회된 데이터만 자동으로 올라간다. 메모리를 효율적으로 쓴다. 구현도 단순하다.

Cache-Aside의 약점. 캐시 miss 직후 DB를 읽고 캐시에 저장하기까지 수십~수백 ms의 공백이 생긴다. 이 공백에 수천 개의 동시 요청이 동시에 miss를 내면 전부 DB를 때린다. 이것이 Cache Stampede다.


Q3. Cache Stampede가 뭔가? 어떤 장애로 이어지나?

매일 밤 12시에 인기 상품 캐시가 만료된다. 그 순간 DB가 터진다.

시나리오를 구체적으로 보자.

23:59:59.999 - "product:1001" TTL 만료 예정
00:00:00.001 - 요청 500건이 동시에 캐시 miss
00:00:00.002 - 500건 전부 DB에 SELECT 쿼리 발송
00:00:00.003 - DB CPU 스파이크. 응답 지연 시작
00:00:00.050 - 응답 지연으로 대기 중인 요청이 1,000건으로 증가
00:00:00.500 - DB 커넥션 풀 고갈. 타임아웃 시작
00:00:01.000 - API 게이트웨이에서 503 응답

캐시가 없을 때보다 나쁜 상황이 발생한다. 평소에는 캐시가 트래픽을 전부 막아주다가, TTL 만료 순간 평소엔 한 번도 오지 않던 부하가 DB에 한꺼번에 쏟아진다. DB는 이 부하를 감당할 설계가 되어있지 않다.

해결 1: Mutex Lock (분산 락)

첫 번째 요청만 DB를 조회하고, 나머지는 캐시가 채워질 때까지 짧게 대기한다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;

    private static final Duration CACHE_TTL = Duration.ofMinutes(10);
    private static final Duration LOCK_TTL = Duration.ofSeconds(5);
    private static final long LOCK_RETRY_INTERVAL_MS = 50;
    private static final int LOCK_MAX_RETRIES = 100; // 5초 / 50ms

    public Product getProduct(Long productId) throws InterruptedException {
        String cacheKey = "product:" + productId;
        String lockKey = "lock:product:" + productId;

        // 1. 캐시 조회 (fast path)
        Product cached = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 2. 캐시 miss: 락 획득 시도
        for (int retry = 0; retry < LOCK_MAX_RETRIES; retry++) {
            Boolean acquired = stringRedisTemplate.opsForValue()
                    .setIfAbsent(lockKey, "1", LOCK_TTL);

            if (Boolean.TRUE.equals(acquired)) {
                // 3. 락 획득 성공: DB 조회 후 캐시 저장
                try {
                    // 다른 스레드가 이미 채웠을 수 있으니 다시 확인
                    cached = (Product) redisTemplate.opsForValue().get(cacheKey);
                    if (cached != null) {
                        return cached;
                    }

                    Product product = productRepository.findById(productId)
                            .orElseThrow(() -> new ProductNotFoundException(productId));
                    redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);
                    return product;
                } finally {
                    stringRedisTemplate.delete(lockKey);
                }
            }

            // 4. 락 획득 실패: 대기 후 재시도
            Thread.sleep(LOCK_RETRY_INTERVAL_MS);

            // 재시도 전 캐시 다시 확인 (이미 채워졌을 수 있음)
            cached = (Product) redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return cached;
            }
        }

        // 5. 락 획득 끝내 실패: 직접 DB 조회 (fallback)
        return productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));
    }
}

해결 2: TTL Jitter

모든 상품 캐시 TTL이 정확히 10분이면, 동시에 적재된 캐시들은 동시에 만료된다. ±10% 랜덤 편차로 만료 타이밍을 분산시킨다.

@Component
public class CacheConfig {

    private static final Random RANDOM = new Random();

    public Duration jitteredTtl(Duration baseTtl) {
        // ±10% 랜덤 편차
        double jitterFactor = 0.9 + RANDOM.nextDouble() * 0.2; // 0.9 ~ 1.1
        long jitteredSeconds = (long) (baseTtl.getSeconds() * jitterFactor);
        return Duration.ofSeconds(jitteredSeconds);
    }
}

// 사용
redisTemplate.opsForValue().set(cacheKey, product, cacheConfig.jitteredTtl(Duration.ofMinutes(10)));

해결 3: Probabilistic Early Expiry

잔여 TTL이 짧아질수록 비동기로 캐시를 미리 갱신한다. 만료 직전에 선제적으로 갱신해서 실제 만료를 방지한다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;

    // beta 값: 높을수록 더 일찍 갱신 (기본값 1.0)
    private static final double BETA = 1.0;
    private static final Duration BASE_TTL = Duration.ofMinutes(10);

    public Product getProduct(Long productId) throws Exception {
        String cacheKey = "product:" + productId;
        String ttlKey = "product:recompute_time:" + productId;

        String cachedJson = stringRedisTemplate.opsForValue().get(cacheKey);
        Long remainingTtl = stringRedisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
        String recomputeTimeStr = stringRedisTemplate.opsForValue().get(ttlKey);

        if (cachedJson != null && remainingTtl != null && recomputeTimeStr != null) {
            double recomputeTime = Double.parseDouble(recomputeTimeStr);
            double currentTime = System.currentTimeMillis() / 1000.0;

            // XFetch 알고리즘: 조기 갱신 여부 결정
            double earlyExpiry = currentTime - recomputeTime * BETA * Math.log(Math.random());

            if (earlyExpiry <= currentTime - (BASE_TTL.getSeconds() - remainingTtl)) {
                // 아직 갱신 불필요
                return objectMapper.readValue(cachedJson, Product.class);
            }
            // 갱신 필요: 아래로 fall-through
        }

        // DB 조회 + 캐시 갱신
        long start = System.currentTimeMillis();
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));
        double recomputeTime = (System.currentTimeMillis() - start) / 1000.0;

        String productJson = objectMapper.writeValueAsString(product);
        stringRedisTemplate.opsForValue().set(cacheKey, productJson, BASE_TTL);
        stringRedisTemplate.opsForValue().set(ttlKey, String.valueOf(recomputeTime), BASE_TTL);

        return product;
    }
}

실무 선택. Lock + Jitter 조합으로 시작한다. Lock은 Stampede를 막고, Jitter는 여러 키가 동시에 만료되는 것을 분산시킨다. Probabilistic Early Expiry는 TTL이 길고 DB 재조회 비용이 높을 때 추가로 고려한다.


Q4. Write-Through는 뭔가? Cache-Aside와 언제 다른 선택을 하나?

상품을 업데이트한 직후 상세 페이지를 열면 캐시 miss가 난다. 매번 DB를 때린다.

Cache-Aside의 경우 쓰기 시 @CacheEvict로 캐시를 지운다. 다음 조회 때 miss가 발생하고, 그때 DB를 읽어 캐시를 다시 채운다. 업데이트 직후 즉시 조회되는 데이터라면 miss가 반드시 한 번 발생한다.

Write-Through는 쓰기와 동시에 캐시를 업데이트한다.

쓰기 요청
  1. DB 업데이트
  2. 캐시 업데이트 (evict 대신 put)
  3. 완료

다음 읽기
  → 캐시 Hit 보장 (Miss 없음)

구현 코드는 다음과 같다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;

    private static final Duration TTL = Duration.ofMinutes(10);

    @Cacheable(value = "products", key = "#productId")
    public Product getProduct(Long productId) {
        return productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));
    }

    @Transactional
    @CachePut(value = "products", key = "#productId")  // evict 대신 put
    public Product updateProduct(Long productId, ProductUpdateRequest req) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.update(req);
        productRepository.save(product);
        return product; // 반환값이 캐시에 저장됨
    }
}

단, @CachePut@Transactional을 함께 쓸 때는 트랜잭션 커밋 전에 캐시가 갱신되는 문제가 있다. Q7에서 이 조합을 제대로 다룬다. 지금은 Write-Through의 개념과 선택 기준에 집중한다.

쓰기 레이턴시 증가. DB 업데이트 + Redis 업데이트 두 번의 I/O가 순차적으로 발생한다. Redis가 같은 리전에 있다면 RTT는 1~5ms 수준이라 대부분 감수할 만하다.

Write-Through가 맞는 경우.

  • 쓰기 직후 즉시 읽기가 발생하는 흐름 (업데이트 → 상세 페이지 리다이렉트)
  • 캐시 miss를 절대 허용할 수 없는 핫 데이터
  • 쓰기보다 읽기가 압도적으로 많은 데이터 (읽기 10,000 : 쓰기 1)

Write-Through의 함정. 거의 안 읽히는 데이터도 쓸 때마다 캐시에 올라간다. 100만 개 상품이 있고 하루에 한 번씩 배치로 가격이 업데이트된다면, 조회되지 않는 상품까지 전부 캐시에 올라가 메모리를 낭비한다.

현실적인 조합. 핫 데이터(조회 빈도 상위 5%)는 Write-Through, 나머지는 Cache-Aside. 어떤 데이터가 핫한지 모른다면 Cache-Aside로 시작해서 히트율이 높은 키를 식별한 뒤 Write-Through로 전환한다.


Q5. Write-Behind는 왜 위험한가?

조회수를 Redis에 먼저 쓰고 나중에 DB에 반영하는 패턴을 봤다. 주문에도 쓸 수 있지 않나?

Write-Behind의 동작은 이렇다.

쓰기 요청
  1. 캐시만 업데이트 (즉시 반환)
  2. [비동기] 나중에 DB 업데이트

결과: 쓰기 응답이 빠름. 단, DB와 캐시 사이에 시차가 존재.

주문에 적용하면 어떤 일이 생기나.

시나리오:
  T=0:00 - 주문 요청. 캐시에 저장. "주문 완료" 응답.
  T=0:01 - 비동기 DB 저장 스케줄러 실행 예정.
  T=0:00 ~ T=0:01 사이 Redis 장애 발생.

결과:
  - 사용자는 "주문 완료"를 봤음
  - DB에는 주문 없음
  - Redis에 있던 주문 데이터는 증발
  - 사용자: "주문했는데 내역이 없다"

이것은 비즈니스 손실이다. 환불 이슈, 재고 불일치, 고객 이탈로 이어진다.

Write-Behind가 허용되는 유일한 케이스: 약간의 유실이 허용되는 집계성 데이터.

조회수, 좋아요 수, 검색 클릭 수는 1~2개 유실돼도 비즈니스에 치명적이지 않다. 이런 데이터는 Write-Behind가 오히려 최적이다. 초당 1만 건의 조회수 증가 요청을 DB에 직접 쓰면 DB가 버티지 못한다.

@Service
@RequiredArgsConstructor
public class ViewCountService {

    private final StringRedisTemplate redisTemplate;
    private final ProductRepository productRepository;

    private static final String VIEW_COUNT_KEY_PREFIX = "view_count:";
    private static final String DIRTY_KEYS = "view_count:dirty_keys";

    // 조회수 증가 (캐시에만 씀, 즉시 반환)
    public void incrementViewCount(Long productId) {
        String key = VIEW_COUNT_KEY_PREFIX + productId;
        redisTemplate.opsForValue().increment(key);
        // 나중에 DB에 flush할 키 목록에 추가
        redisTemplate.opsForSet().add(DIRTY_KEYS, key);
    }

    public Long getViewCount(Long productId) {
        String key = VIEW_COUNT_KEY_PREFIX + productId;
        String count = redisTemplate.opsForValue().get(key);
        if (count != null) {
            return Long.parseLong(count);
        }
        // 캐시 없으면 DB에서 가져오고 캐시에 적재
        Long dbCount = productRepository.findViewCountById(productId);
        redisTemplate.opsForValue().set(key, String.valueOf(dbCount), Duration.ofHours(1));
        return dbCount;
    }

    // 5분마다 DB에 반영 (Write-Behind flush)
    @Scheduled(fixedDelay = 300_000)
    @Transactional
    public void flushViewCounts() {
        Set<String> dirtyKeys = redisTemplate.opsForSet().members(DIRTY_KEYS);
        if (dirtyKeys == null || dirtyKeys.isEmpty()) {
            return;
        }

        for (String key : dirtyKeys) {
            String countStr = redisTemplate.opsForValue().get(key);
            if (countStr == null) continue;

            Long productId = Long.parseLong(key.replace(VIEW_COUNT_KEY_PREFIX, ""));
            Long count = Long.parseLong(countStr);

            productRepository.updateViewCount(productId, count);
        }

        // flush 완료 후 dirty keys 초기화
        redisTemplate.delete(DIRTY_KEYS);
    }
}

Write-Behind를 쓸 때는 반드시 이 질문을 먼저 해야 한다. “Redis가 지금 죽으면, 여기 있는 데이터가 날아가도 괜찮은가?” 아니라면 Write-Behind는 선택지가 아니다.


Q6. TTL을 얼마로 설정해야 하나?

TTL을 5분으로 했더니 캐시 히트율이 60%밖에 안 된다. 너무 짧은 건가?

먼저 히트율을 정확히 측정한다.

# Redis 히트율 확인
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"

# keyspace_hits: 6000000
# keyspace_misses: 4000000
# 히트율 = 6000000 / (6000000 + 4000000) = 60%

# 실시간으로 보기
redis-cli INFO stats | grep keyspace
watch -n 1 'redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"'

TTL이 너무 짧으면 히트율이 낮다. DB 부하는 여전하고 캐시를 둔 의미가 없다. TTL이 너무 길면 stale 데이터 노출 시간이 길어진다. 업데이트가 되어도 사용자는 오래된 데이터를 본다.

데이터 유형별 TTL 기준.

데이터권장 TTL이유
상품 상세 (가격 제외)30분 ~ 1시간자주 안 바뀜. Miss 비용 높음
상품 가격 / 재고30초 ~ 2분실시간성 중요. 구버전 노출 = 민원
사용자 세션30분 (슬라이딩)활동 중에는 계속 갱신
인기 검색어5분 ~ 10분자주 조회. 약간 stale 허용
실시간 랭킹1분 이하빠른 변경 반영 필요
공지사항 / 카테고리1시간 ~ 24시간거의 안 바뀜

Jitter는 항상 붙여야 한다.

같은 TTL로 설정된 키들은 같은 시간에 만료된다. 동시에 적재된 인기 상품 1,000개가 전부 10분 뒤에 동시에 만료되면 Q3의 Stampede가 1,000배 규모로 발생한다.

@Component
public class JitteredTtlProvider {

    private static final Random RANDOM = new Random();

    /**
     * baseTtl의 ±10% 범위에서 랜덤 TTL 반환
     * 예: 600초 → 540초 ~ 660초 사이 값
     */
    public Duration get(Duration baseTtl) {
        long base = baseTtl.getSeconds();
        long jitter = (long) (base * 0.1); // 10%
        long actual = base - jitter + (long)(RANDOM.nextDouble() * jitter * 2);
        return Duration.ofSeconds(actual);
    }

    /**
     * 사용 예시
     */
    public Duration productTtl() {
        return get(Duration.ofMinutes(10)); // 9분 ~ 11분
    }
}

개별 키 TTL 확인.

# 특정 키의 TTL 확인 (초 단위)
redis-cli TTL product:1001

# 밀리초 단위
redis-cli PTTL product:1001

# 만료 예정 키 수 모니터링
redis-cli INFO keyspace
# db0:keys=10000,expires=9500,avg_ttl=300000

# 특정 패턴의 키 TTL 일괄 확인
redis-cli --scan --pattern "product:*" | head -10 | xargs -I {} redis-cli TTL {}

TTL이 지나면 키는 실제로 언제 지워지나.

TTL이 끝나는 순간 키가 즉시 삭제되는 게 아니다. Redis는 두 가지 방식을 함께 쓴다.

  • Lazy expiration(수동 만료): 누군가 그 키에 접근할 때 만료 여부를 검사한다. 이미 만료됐으면 그때 삭제하고 없는 것처럼 응답한다. 접근이 없으면 만료된 키가 메모리에 계속 남아있을 수 있다.
  • Active expiration(능동 만료): 백그라운드에서 주기적으로(기본 초당 10회) TTL 있는 키를 무작위로 샘플링한다. 샘플 중 만료된 비율이 높으면 같은 사이클을 반복해서 더 지운다. 한 번에 전체를 훑지 않으므로 메인 스레드 블로킹을 피한다.

이 조합 때문에 만료 시점과 실제 삭제·메모리 회수 사이에는 시차가 있다. 만료가 한꺼번에 몰리면 Active expiration 부하가 커지고, 접근이 드문 키는 Lazy로 안 지워져 메모리에 남는다. TTL에 Jitter를 주는 이유가 stale 분산만이 아니라 이 만료 부하 분산이기도 하다.


Q7. @Cacheable@Transactional을 같이 쓰면 왜 캐시가 오염되나?

주문 업데이트 후 트랜잭션이 롤백됐다. DB는 원래 상태인데, 캐시에는 롤백 전 값이 남아있다.

이것이 왜 발생하는지 AOP 프록시 실행 순서를 보면 알 수 있다.

@Transactional + @CachePut 동시 사용 시 실행 순서:

1. 트랜잭션 시작
2. 메서드 실행 (DB 업데이트)
3. @CachePut: 반환값을 캐시에 저장  ← 여기서 이미 캐시가 갱신됨
4. 트랜잭션 커밋 또는 롤백
   - 커밋: DB와 캐시 일치. 문제 없음.
   - 롤백: DB는 이전 상태로. 캐시는 갱신된 채로 남음. ← 문제

@CacheEvict의 경우는 afterInvocation 설정에 따라 다르다.

// afterInvocation = true (기본값): 메서드 실행 후 evict
// 트랜잭션 롤백 시: DB는 롤백, 캐시는 evict됨 → 다음 조회 시 DB에서 정확한 값 로드 (괜찮음)

// afterInvocation = false: 메서드 실행 전 evict
@CacheEvict(value = "orders", key = "#orderId", beforeInvocation = true)
@Transactional
public Order updateOrder(Long orderId, OrderUpdateRequest req) {
    // 캐시가 이미 지워진 상태에서 실행
    // 다른 스레드가 DB 읽고 구버전을 캐시에 올릴 수 있음
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.update(req);
    orderRepository.save(order);
    return order;
}

가장 안전한 패턴은 트랜잭션이 성공적으로 커밋된 후에만 캐시를 다루는 것이다. TransactionSynchronizationManagerafterCommit() 콜백을 활용한다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final CacheManager cacheManager;

    @Transactional
    public Order updateOrder(Long orderId, OrderUpdateRequest req) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.update(req);
        orderRepository.save(order);

        // 트랜잭션 커밋 성공 후에만 캐시 evict
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        // 이 코드는 트랜잭션이 커밋된 후에만 실행됨
                        // 롤백 시에는 호출되지 않음
                        Cache ordersCache = cacheManager.getCache("orders");
                        if (ordersCache != null) {
                            ordersCache.evict(orderId);
                        }
                    }
                }
        );

        return order;
    }
}

이것을 재사용 가능한 유틸리티로 분리하면 편리하다.

@Component
public class TransactionalCacheHelper {

    private final CacheManager cacheManager;

    public TransactionalCacheHelper(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    /**
     * 트랜잭션 커밋 성공 후 캐시 evict.
     * 트랜잭션 롤백 시에는 evict하지 않음.
     */
    public void evictAfterCommit(String cacheName, Object key) {
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(
                    new TransactionSynchronization() {
                        @Override
                        public void afterCommit() {
                            Cache cache = cacheManager.getCache(cacheName);
                            if (cache != null) {
                                cache.evict(key);
                            }
                        }
                    }
            );
        } else {
            // 트랜잭션 밖에서 호출된 경우 즉시 evict
            Cache cache = cacheManager.getCache(cacheName);
            if (cache != null) {
                cache.evict(key);
            }
        }
    }

    /**
     * 트랜잭션 커밋 성공 후 캐시 갱신.
     */
    public void putAfterCommit(String cacheName, Object key, Object value) {
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(
                    new TransactionSynchronization() {
                        @Override
                        public void afterCommit() {
                            Cache cache = cacheManager.getCache(cacheName);
                            if (cache != null) {
                                cache.put(key, value);
                            }
                        }
                    }
            );
        } else {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache != null) {
                cache.put(key, value);
            }
        }
    }
}

afterCommit()인가. 트랜잭션이 롤백되면 afterCommit()은 호출되지 않는다. afterCompletion()은 커밋/롤백 무관하게 호출되므로 구별해야 한다. afterCommit()을 쓰면 DB 커밋 성공 시에만 캐시가 변경되어, DB와 캐시가 항상 같은 방향으로 움직인다.


Q8. 여러 서버 인스턴스에서 @CacheEvict를 하면 다른 인스턴스 캐시는 어떻게 되나?

인스턴스 A에서 상품을 업데이트했다. 인스턴스 B에서 그 상품을 조회하면 구버전이 나온다.

Redis(L2)만 캐시로 쓰는 경우: 문제없다. 모든 인스턴스가 같은 Redis를 바라보므로 evict 하면 전체에서 evict된다.

Caffeine(L1) + Redis(L2) 멀티레이어 구조에서의 문제:

인스턴스 A: 상품 업데이트
  → L2(Redis) evict   ✓ 전체에서 삭제됨
  → L1(Caffeine) evict ✓ A의 로컬 캐시에서 삭제됨

인스턴스 B:
  → L2 miss (이미 evict됨)
  → B의 L1(Caffeine)에는 구버전 여전히 존재 ← 문제

인스턴스 B에서 상품 조회:
  → L1 Hit → 구버전 반환 ❌

Redis Pub/Sub으로 캐시 무효화 이벤트를 전체 인스턴스에 브로드캐스트해야 한다.

// 캐시 무효화 이벤트 발행자
@Component
@RequiredArgsConstructor
public class CacheInvalidationPublisher {

    private final RedisTemplate<String, CacheInvalidationEvent> redisTemplate;

    private static final String CHANNEL = "cache:invalidation";

    public void publish(String cacheName, Object key) {
        CacheInvalidationEvent event = new CacheInvalidationEvent(
                cacheName,
                String.valueOf(key),
                UUID.randomUUID().toString() // 이벤트 중복 처리 방지용 ID
        );
        redisTemplate.convertAndSend(CHANNEL, event);
    }
}

// 캐시 무효화 이벤트 구독자 (모든 인스턴스에서 실행됨)
@Component
@RequiredArgsConstructor
public class CacheInvalidationListener implements MessageListener {

    private final CacheManager cacheManager;
    private final ObjectMapper objectMapper;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            CacheInvalidationEvent event = objectMapper.readValue(
                    message.getBody(), CacheInvalidationEvent.class);

            // 이 인스턴스의 L1 캐시에서 해당 키 evict
            Cache cache = cacheManager.getCache(event.getCacheName());
            if (cache instanceof CaffeineCache caffeineCache) {
                caffeineCache.getNativeCache().invalidate(event.getKey());
            }
        } catch (Exception e) {
            // 무효화 실패 시 로그만 남기고 계속 진행 (non-critical)
        }
    }
}

// Redis Pub/Sub 설정
@Configuration
public class RedisSubscriberConfig {

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            RedisConnectionFactory connectionFactory,
            CacheInvalidationListener listener) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listener, new PatternTopic("cache:invalidation"));
        return container;
    }
}

// 서비스에서 사용
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final CacheInvalidationPublisher invalidationPublisher;
    private final TransactionalCacheHelper cacheHelper;

    @Transactional
    public Product updateProduct(Long productId, ProductUpdateRequest req) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.update(req);
        productRepository.save(product);

        // 트랜잭션 커밋 후 L2 evict + 전체 인스턴스 L1 무효화 브로드캐스트
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        // L2(Redis) evict
                        cacheHelper.evictAfterCommit("products", productId);
                        // 모든 인스턴스의 L1 무효화
                        invalidationPublisher.publish("products", productId);
                    }
                }
        );

        return product;
    }
}

로컬 캐시 TTL을 짧게 해서 완화하는 방법.

근본 해결이 아니라 영향을 줄이는 접근이다. L1 TTL을 1분으로 설정하면 최대 1분 동안만 구버전을 볼 수 있다. 구현 복잡도는 낮지만 1분 stale을 허용해야 한다.

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // L1: Caffeine (로컬, 빠름, TTL 짧게)
        CaffeineCache productL1Cache = new CaffeineCache("products",
                Caffeine.newBuilder()
                        .maximumSize(1_000)
                        .expireAfterWrite(1, TimeUnit.MINUTES) // 짧은 TTL로 stale 시간 제한
                        .build());

        // L2: Redis (분산, 모든 인스턴스 공유)
        RedisCacheConfiguration redisCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10));

        // 실제로는 CompositeCacheManager나 멀티레이어 캐시 매니저 구현 필요
        return new CompositeCacheManager(
                new SimpleCacheManager() {{ setCaches(List.of(productL1Cache)); }},
                RedisCacheManager.builder(connectionFactory)
                        .cacheDefaults(redisCacheConfig)
                        .build()
        );
    }
}

데이터의 일관성 요구 수준에 따라 선택한다. 1분 stale이 허용되면 TTL 단축, 즉시 일관성이 필요하면 Pub/Sub을 쓴다.


Q9. 배포 직후 캐시가 비면 DB에 부하가 폭발한다. 어떻게 막나?

배포할 때마다 처음 2분간 DB 응답이 1초를 넘어간다. 모니터링에 빨간불이 켜진다.

Cold Start 메커니즘이다.

배포 전: 캐시 히트율 99%. DB 트래픽 = 전체 트래픽의 1%.
배포 발생: 서버 재시작. 캐시 비워짐.
배포 직후: 캐시 히트율 0%. DB 트래픽 = 전체 트래픽의 100%.

결과:
  - 평소: DB가 받는 쿼리 = 100 QPS
  - 배포 직후: DB가 받는 쿼리 = 10,000 QPS (100배)
  - DB는 100 QPS를 위해 설계됨 → 10,000 QPS에서 즉시 병목

대규모 서비스일수록 치명적이다. 캐시가 막아주던 트래픽이 갑자기 DB로 쏟아지기 때문이다.

전략 1: ApplicationRunner로 Cache Warming

서버가 시작되면 가장 많이 조회되는 데이터를 선제적으로 캐시에 올린다. 트래픽을 받기 전에 캐시를 채운다.

@Component
@RequiredArgsConstructor
@Slf4j
public class CacheWarmupRunner implements ApplicationRunner {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;
    private final JitteredTtlProvider ttlProvider;

    // 워밍업할 상품 수
    private static final int TOP_N = 500;
    // 병렬 처리 스레드 수
    private static final int WARMUP_PARALLELISM = 10;

    @Override
    public void run(ApplicationArguments args) {
        log.info("캐시 워밍업 시작. 상위 {} 상품 적재 중...", TOP_N);
        long start = System.currentTimeMillis();

        try {
            // 1. 조회 빈도 상위 N개 상품 ID 조회 (analytics 테이블 또는 Redis sorted set 활용)
            List<Long> topProductIds = productRepository.findTopViewedProductIds(TOP_N);

            // 2. 병렬 처리로 빠르게 적재
            List<List<Long>> batches = partition(topProductIds, WARMUP_PARALLELISM);

            ExecutorService executor = Executors.newFixedThreadPool(WARMUP_PARALLELISM);
            List<CompletableFuture<Void>> futures = batches.stream()
                    .map(batch -> CompletableFuture.runAsync(
                            () -> warmupBatch(batch), executor))
                    .toList();

            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
            executor.shutdown();

            long elapsed = System.currentTimeMillis() - start;
            log.info("캐시 워밍업 완료. {}개 상품, {}ms 소요", topProductIds.size(), elapsed);

        } catch (Exception e) {
            // 워밍업 실패해도 서버 시작은 계속 (DB fallback 가능)
            log.warn("캐시 워밍업 실패. DB fallback으로 계속 진행.", e);
        }
    }

    private void warmupBatch(List<Long> productIds) {
        for (Long productId : productIds) {
            try {
                Product product = productRepository.findById(productId).orElse(null);
                if (product != null) {
                    String key = "product:" + productId;
                    redisTemplate.opsForValue().set(key, product, ttlProvider.productTtl());
                }
            } catch (Exception e) {
                log.debug("상품 {} 워밍업 실패. 건너뜀.", productId);
            }
        }
    }

    private <T> List<List<T>> partition(List<T> list, int size) {
        List<List<T>> partitions = new ArrayList<>();
        for (int i = 0; i < list.size(); i += size) {
            partitions.add(list.subList(i, Math.min(i + size, list.size())));
        }
        return partitions;
    }
}

전략 2: Blue/Green 배포 + Readiness Probe

Green 인스턴스가 뜨면 즉시 트래픽을 받지 않는다. Readiness Probe가 통과될 때까지 대기한다.

@Component
@RequiredArgsConstructor
public class CacheWarmupHealthIndicator implements HealthIndicator {

    private final AtomicBoolean warmupComplete = new AtomicBoolean(false);

    public void markWarmupComplete() {
        warmupComplete.set(true);
    }

    @Override
    public Health health() {
        if (warmupComplete.get()) {
            return Health.up().build();
        }
        return Health.down().withDetail("reason", "캐시 워밍업 진행 중").build();
    }
}

// ApplicationRunner에서 워밍업 완료 후 호출
@Component
@RequiredArgsConstructor
public class CacheWarmupRunner implements ApplicationRunner {

    private final CacheWarmupHealthIndicator healthIndicator;
    // ... 위와 동일

    @Override
    public void run(ApplicationArguments args) {
        // 워밍업 실행
        doWarmup();
        // 완료 후 Readiness Probe 통과시킴 → 이 시점에 로드밸런서 트래픽 인입 시작
        healthIndicator.markWarmupComplete();
    }
}

전략 3: 캐시 유지 배포

Redis 캐시를 그대로 두고 앱만 교체한다. 직렬화 호환성이 핵심 주의사항이다.

// 주의: 클래스 구조가 바뀌면 역직렬화 실패
// 필드 추가는 괜찮지만 필드 삭제, 타입 변경은 위험

@JsonIgnoreProperties(ignoreUnknown = true) // 새 필드 무시: 하위 호환성 확보
public class Product {
    private Long id;
    private String name;
    private BigDecimal price;
    // 새 필드 추가는 안전함
    // private String category; // 이전 캐시에는 없어도 null로 처리됨
}

전략 4: 워밍업 중 Rate Limiting 임시 적용

캐시가 채워지는 동안 DB로 가는 요청 수를 제한한다.

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;
    private final RateLimiter warmupRateLimiter = RateLimiter.create(100.0); // 초당 100건
    private final AtomicBoolean warmupComplete = new AtomicBoolean(false);

    @GetMapping("/products/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable Long id) {
        // 워밍업 완료 전: 레이트 리밋 적용
        if (!warmupComplete.get() && !warmupRateLimiter.tryAcquire()) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
        }
        return ResponseEntity.ok(productService.getProduct(id));
    }
}

Q10. 로컬 캐시(Caffeine) + Redis 멀티레이어는 언제 쓰나?

Redis 히트율이 99%인데도 특정 API 레이턴시가 5ms다. Redis가 범인인가?

그렇다. Redis가 빠르다고 느껴도 네트워크를 경유한다.

Redis 조회 레이턴시 분해:
  - 네트워크 RTT: 0.5ms ~ 3ms (같은 리전 내 VPC 통신)
  - Redis 처리 시간: 0.1ms ~ 0.5ms
  - 직렬화/역직렬화: 0.1ms ~ 2ms (객체 크기에 따라)
  합계: 1ms ~ 5ms 이상

JVM 내 Caffeine 조회:
  - 나노초 단위 (서브 마이크로초)
  - 직렬화 없음 (Java 객체 그대로)

초당 10만 건 요청이라면 Redis RTT만 합산해도 처리 시간의 상당 부분을 차지한다. Caffeine L1 캐시를 앞에 두면 대부분의 요청이 네트워크 I/O 없이 처리된다.

L1(Caffeine) → L2(Redis) → L3(DB) 계층 구조:

요청 → L1(Caffeine) Hit? → 나노초 반환
           ↓ Miss
        L2(Redis) Hit? → 1~5ms 반환, L1에도 적재
           ↓ Miss
        L3(DB) 조회 → 10~100ms, L2와 L1 모두 적재

완전한 Spring Boot 멀티레이어 구현:

@Configuration
@EnableCaching
public class MultiLayerCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // L1: Caffeine (JVM 내 로컬 캐시)
        CaffeineCache productL1 = new CaffeineCache("products-l1",
                Caffeine.newBuilder()
                        .maximumSize(1_000)        // 최대 1,000개 항목
                        .expireAfterWrite(2, TimeUnit.MINUTES) // L2보다 짧은 TTL
                        .recordStats()             // Micrometer 연동 가능
                        .build());

        // L2: Redis (분산 캐시)
        RedisCacheConfiguration redisCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(redisCacheConfig)
                .build();

        // Composite: L1 먼저 확인, miss 시 L2 확인
        SimpleCacheManager l1CacheManager = new SimpleCacheManager();
        l1CacheManager.setCaches(List.of(productL1));
        l1CacheManager.afterPropertiesSet();

        return new CompositeCacheManager(l1CacheManager, redisCacheManager);
    }
}

CompositeCacheManager는 get 시 L1 miss면 L2를 확인하지만, L2 hit 시 자동으로 L1에 백필하지는 않는다. 수동으로 처리해야 한다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;
    private final Cache caffeineCache; // L1 캐시 빈 직접 주입
    private final JitteredTtlProvider ttlProvider;

    public Product getProduct(Long productId) {
        // L1 조회
        Cache.ValueWrapper l1Value = caffeineCache.get(productId);
        if (l1Value != null) {
            return (Product) l1Value.get();
        }

        // L2 조회
        String redisKey = "products:" + productId;
        Product l2Value = redisTemplate.opsForValue().get(redisKey);
        if (l2Value != null) {
            // L2 hit → L1 백필
            caffeineCache.put(productId, l2Value);
            return l2Value;
        }

        // L3 조회 (DB)
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));

        // L2 + L1 모두 적재
        redisTemplate.opsForValue().set(redisKey, product, ttlProvider.productTtl());
        caffeineCache.put(productId, product);

        return product;
    }

    public void updateProduct(Long productId, ProductUpdateRequest req) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.update(req);
        productRepository.save(product);

        // 트랜잭션 커밋 후 L2 + L1 모두 evict
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        // L2 evict
                        redisTemplate.delete("products:" + productId);
                        // L1 evict (이 인스턴스만)
                        caffeineCache.evict(productId);
                        // 다른 인스턴스 L1 무효화는 Pub/Sub으로 (Q8 참고)
                    }
                }
        );
    }
}

Caffeine 히트율 모니터링:

@Configuration
public class CaffeineMetricsConfig {

    @Bean
    public CaffeineCache instrumentedCache(MeterRegistry meterRegistry) {
        com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache =
                Caffeine.newBuilder()
                        .maximumSize(1_000)
                        .expireAfterWrite(2, TimeUnit.MINUTES)
                        .recordStats()
                        .build();

        // Micrometer로 Caffeine 통계 수집
        CaffeineCacheMetrics.monitor(meterRegistry, nativeCache, "products-l1");

        return new CaffeineCache("products-l1", nativeCache);
    }
}
# Actuator에서 Caffeine 히트율 확인
curl http://localhost:8080/actuator/metrics/cache.gets?tag=name:products-l1&tag=result:hit
curl http://localhost:8080/actuator/metrics/cache.gets?tag=name:products-l1&tag=result:miss

멀티레이어가 맞지 않는 경우.

  • L1 TTL이 너무 길면 L2/L3 변경이 L1에 반영되지 않아 stale 데이터를 오래 본다.
  • 데이터 변경이 잦으면 L1 캐시 hit 전에 만료되어 L1 히트율이 낮다. 복잡도만 늘어난다.
  • 멀티 인스턴스 환경에서 L1 일관성을 맞추려면 Pub/Sub이 필요하다.

멀티레이어가 맞는 경우. 거의 변경되지 않는 데이터 (공지사항, 카테고리, 설정값), 극도로 높은 읽기 빈도, 변경 시 약간의 지연(L1 TTL 내)을 허용하는 데이터. 이 세 조건이 모두 맞을 때 멀티레이어의 효과가 극대화된다.


최적화 요약

문제해결책
Cache Stampede (TTL 만료 시 DB 폭발)Mutex Lock + TTL Jitter. 핵심 데이터는 Probabilistic Early Expiry 추가
Cold Start (배포 직후 DB 부하)ApplicationRunner Cache Warming + Readiness Probe 연동
트랜잭션 롤백 후 캐시 오염TransactionSynchronizationManager.afterCommit() 후 evict/put
멀티 인스턴스 L1 캐시 불일치Redis Pub/Sub 캐시 무효화 브로드캐스트
극한 핫 데이터 레이턴시Caffeine(L1) + Redis(L2) 멀티레이어. L1 TTL 짧게, Pub/Sub 무효화 연동

튜닝 체크리스트

# 1. 히트율 확인 (90% 미만이면 TTL 또는 전략 재검토)
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"

# 2. 만료 키 추이 (급증하면 Stampede 발생 중)
redis-cli INFO stats | grep expired_keys
watch -n 5 'redis-cli INFO stats | grep expired_keys'

# 3. 메모리 압박에 의한 강제 eviction (0이어야 함)
redis-cli INFO stats | grep evicted_keys

# 4. 메모리 단편화 비율 (1.5 이상이면 MEMORY PURGE 고려)
redis-cli INFO memory | grep mem_fragmentation_ratio

# 5. 느린 커맨드 확인 (10ms 이상 소요 커맨드)
redis-cli SLOWLOG GET 20

# 6. 큰 키 탐지 (네트워크 병목 원인)
redis-cli --bigkeys

# 7. 특정 패턴 키 수 확인
redis-cli --scan --pattern "product:*" | wc -l

# 8. Caffeine L1 히트율 (Actuator 활성화 필요)
curl -s http://localhost:8080/actuator/metrics/cache.gets | \
  python3 -c "
import sys, json
data = json.load(sys.stdin)
measurements = {m['statistic']: m['value'] for m in data['measurements']}
print(f'Count: {measurements.get(\"COUNT\", 0)}')
"

# 9. Redis 연결 수 확인 (커넥션 풀 적정성 확인)
redis-cli INFO clients | grep connected_clients

# 10. 키 만료 패턴 확인 (키스페이스 알림 활성화 필요)
redis-cli CONFIG SET notify-keyspace-events "Ex"
redis-cli SUBSCRIBE '__keyevent@0__:expired'

운영상 주의사항

@Cacheable self-invocation 불가.

같은 빈 안에서 @Cacheable 메서드를 직접 호출하면 캐시가 동작하지 않는다. Spring의 AOP는 프록시 기반이라 내부 호출은 프록시를 거치지 않기 때문이다.

// 잘못된 패턴 - 캐시 동작 안 함
@Service
public class ProductService {

    @Cacheable("products")
    public Product getProduct(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    public Product getProductAndLog(Long id) {
        // this.getProduct() 는 프록시를 거치지 않아 캐시 동작 안 함
        Product product = getProduct(id);
        log.info("조회: {}", product.getName());
        return product;
    }
}

// 해결: ApplicationContext에서 프록시 빈을 직접 가져오거나,
// 별도 빈으로 분리하거나, @Scope("prototype") + self-inject 사용
@Service
@RequiredArgsConstructor
public class ProductService {

    @Lazy
    @Autowired
    private ProductService self; // 프록시된 자기 자신 주입

    @Cacheable("products")
    public Product getProduct(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    public Product getProductAndLog(Long id) {
        Product product = self.getProduct(id); // 프록시를 거침 → 캐시 동작
        log.info("조회: {}", product.getName());
        return product;
    }
}

Redis 연결 장애 시 fallback 전략.

Redis가 죽으면 캐시 미스로 DB 직격이 발생한다. 서비스가 멈추지 않도록 fallback을 설계해야 한다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;

    public Product getProduct(Long productId) {
        try {
            String key = "product:" + productId;
            Product cached = redisTemplate.opsForValue().get(key);
            if (cached != null) {
                return cached;
            }

            Product product = productRepository.findById(productId)
                    .orElseThrow(() -> new ProductNotFoundException(productId));

            // Redis 장애 시 여기서 예외 발생 → 저장 실패해도 조회는 성공 반환
            try {
                redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            } catch (Exception cacheWriteException) {
                log.warn("캐시 저장 실패. DB 결과 반환. productId={}", productId);
            }

            return product;

        } catch (RedisConnectionFailureException | QueryTimeoutException e) {
            // Redis 완전 장애 시 DB 직접 조회 (Circuit Breaker 패턴 적용 권장)
            log.warn("Redis 연결 실패. DB fallback 사용. productId={}", productId);
            return productRepository.findById(productId)
                    .orElseThrow(() -> new ProductNotFoundException(productId));
        }
    }
}

Resilience4j CircuitBreaker를 Redis 클라이언트에 감싸면 장애 시 자동으로 DB fallback으로 전환되고, Redis가 복구되면 자동으로 캐시 경로로 돌아온다.

null 값 캐싱 (Negative Cache Stampede 방지).

존재하지 않는 상품 ID로 요청이 반복적으로 들어오면, 매번 캐시 miss → DB 조회가 발생한다. 공격이나 버그로 대량 발생 시 DB가 터진다.

// null을 의미하는 센티넬 값 캐싱
private static final String NULL_SENTINEL = "__NULL__";

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    String cached = redisTemplate.opsForValue().get(key);

    if (NULL_SENTINEL.equals(cached)) {
        return null; // 캐시에서 "없음"을 빠르게 반환
    }

    if (cached != null) {
        return deserialize(cached);
    }

    // DB 조회
    Optional<Product> product = productRepository.findById(productId);

    if (product.isEmpty()) {
        // 없는 키도 짧은 TTL로 캐시 (너무 길면 실제로 생겼을 때 문제)
        redisTemplate.opsForValue().set(key, NULL_SENTINEL, Duration.ofMinutes(1));
        return null;
    }

    redisTemplate.opsForValue().set(key, serialize(product.get()), Duration.ofMinutes(10));
    return product.get();
}

Spring Cache 추상화에서는 unless 조건을 사용한다.

@Cacheable(value = "products", key = "#productId", unless = "#result == null")
public Product getProduct(Long productId) {
    return productRepository.findById(productId).orElse(null);
}

unless = "#result == null"이면 null은 캐시에 저장하지 않아 Negative Cache가 없다. Negative Cache가 필요하면 별도 구현을 해야 한다.