캐시를 켰는데 정각마다 DB가 터졌다 — 캐시 스탬피드

인기 상품 목록을 Redis에 캐싱하고 TTL을 10분으로 잡았습니다. 조회가 잦고 잘 안 바뀌는 데이터라 캐시로 DB 부하를 크게 덜 수 있었죠. 실제로 평소 DB는 아주 한가했습니다. 그런데 배포 며칠 뒤부터 매시 정각마다 DB CPU가 규칙적으로 튀는 이상한 패턴이 잡혔습니다. 그래프가 정확히 톱니 모양이었습니다.

규칙적이라는 게 결정적 단서였다

불규칙하게 튀면 트래픽 탓이라고 볼 텐데, 매시 정각에 칼같이 튀는 건 사람 트래픽의 모양이 아닙니다. 뭔가 정해진 시각에 동시에 일어나는 일이 있다는 뜻이죠. 캐시 키들의 TTL을 redis-cli로 몇 개 찍어 봤더니 만료까지 남은 시간이 서로 거의 똑같았습니다. 그제야 원인이 보였습니다.

같은 순간에 캐시가 한꺼번에 만료된다

배포 직후 캐시를 미리 채우는 워밍업 로직이 여러 키를 한 번에 set 했습니다. TTL도 다 같은 10분으로 시작했으니, 만료 시각이 전부 같은 순간에 몰렸습니다. 그 순간이 오면 수백 개의 요청이 동시에 캐시 미스를 맞고, 그게 전부 DB로 쏟아졌습니다. 이걸 캐시 스탬피드, 혹은 thundering herd라고 부릅니다. 캐시가 분명히 있는데도 정각마다 캐시가 통째로 없는 서버처럼 행동한 겁니다. 게다가 미스가 난 요청들이 각자 DB를 조회해 각자 캐시를 다시 채우니 같은 일을 수백 번 중복으로 했습니다.

1차 처방 — TTL에 지터를 넣기

가장 먼저 만료 시각을 흩뜨렸습니다. 고정 10분 대신 10분에 0~90초 사이 랜덤을 더했습니다.

Duration ttl = Duration.ofMinutes(10)
        .plusSeconds(ThreadLocalRandom.current().nextInt(0, 90));
redis.opsForValue().set(key, value, ttl);

이 작은 랜덤 하나로 만료가 90초 폭으로 퍼졌습니다. 정각에 몰리던 미스가 시간축으로 흩어지니 톱니 그래프가 눈에 띄게 완만해졌습니다. 대부분의 스탬피드는 사실 이 지터 한 줄로 충분히 잡힙니다.

2차 처방 — 재생성은 한 명만

그래도 정말 인기 있는 단일 키는 만료되는 그 찰나에 요청이 몰릴 수 있습니다. 이 키만큼은 캐시를 다시 만드는 구간에 분산 락을 걸었습니다. 락을 잡은 요청 하나만 DB를 조회해 캐시를 채우고, 나머지는 아주 잠깐 기다렸다가 갱신된 캐시를 읽게 했습니다.

String v = redis.opsForValue().get(key);
if (v != null) return v;

if (lock.tryLock(200, 2000, MILLISECONDS)) {
    try {
        v = redis.opsForValue().get(key);       // 더블 체크
        if (v == null) v = loadFromDbAndCache(key);
    } finally { lock.unlock(); }
} else {
    Thread.sleep(50);
    v = redis.opsForValue().get(key);           // 다른 요청이 채운 값
}
return v;

이렇게 하니 하나의 인기 키에 대해 DB 조회는 만료당 딱 한 번만 나갔습니다.

같이 막은 함정 — 캐시 관통

스탬피드를 손보면서 관련된 문제 하나를 같이 정리했습니다. 존재하지 않는 상품 ID로 조회가 들어오면 캐시에 값이 없으니 매번 DB까지 내려갑니다. 이걸 캐시 관통(penetration)이라고 하는데, 없는 결과도 짧은 TTL로 빈 값을 캐싱해서 반복 조회가 DB를 때리지 못하게 막았습니다.

남은 이야기

캐시는 있으면 무조건 빠르다는 감각이 있었는데, 만료 타이밍이 겹치는 순간엔 오히려 DB에 채찍질을 하는 도구가 될 수 있었습니다. 이제 TTL을 정할 때 유효기간뿐 아니라 만료가 시간축에 어떻게 분포하는지까지 같이 생각합니다. 지터 한 줄, 인기 키엔 락 하나, 그리고 없는 값도 캐싱. 이 세 가지가 캐시를 진짜 방패로 만들어 줬습니다.