- Published on
MySQL+Redis 캐시 스탬피드 방지 5가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
캐시를 붙이면 평균 응답시간은 내려가지만, 만료 시점에 트래픽이 한꺼번에 DB로 쏟아지는 순간(캐시 스탬피드, cache stampede)에는 오히려 장애가 더 빨리 확산됩니다. 특히 MySQL은 커넥션 풀과 InnoDB 락, 버퍼 풀 압박이 겹치면 회복이 느리고, Redis는 “캐시 미스” 자체를 막아주지 않기 때문에 애플리케이션 레이어에서의 제어가 필수입니다.
이 글은 MySQL+Redis 조합에서 스탬피드를 재현 가능한 형태로 정의하고, 5가지 방지 패턴(락, 확률적 조기 재검증, stale-while-revalidate, TTL 분산, 음수 캐싱)을 실전 코드로 설명합니다.
대규모 트래픽에서의 병목/메모리 이슈를 다루는 글로는 Transformers 로컬 LLM OOM·속도 최적화 가이드도 참고할 만합니다. 캐시도 결국 “리소스(메모리/CPU/IO) 보호” 문제라는 점에서 공통점이 많습니다.
캐시 스탬피드가 왜 터지나
전형적인 흐름은 아래와 같습니다.
- 인기 키
user:123이 Redis에 있고 TTL이 60초 - TTL 만료 직후 1,000 rps가 동시에 요청
- 모두 캐시 미스 → 모두 MySQL 조회
- MySQL 커넥션 풀 고갈, 쿼리 지연 증가
- 지연이 늘면서 타임아웃/재시도까지 발생 → 더 큰 폭주
핵심은 동일 키에 대한 동시 미스가 “병렬 DB 조회”로 증폭된다는 점입니다. 이를 막는 방법은 크게 두 부류입니다.
- 동시성 제어: 한 번만 DB를 치고 나머지는 기다리거나 stale을 반환
- 미스 자체를 줄이기: TTL/만료 정책을 분산하거나, 없는 데이터도 캐시
아래 5가지는 서로 대체재가 아니라, 중요 키부터 조합할수록 효과가 큽니다.
1) 분산 락으로 단일 플라이트(Singleflight) 구현
캐시 미스 시 “DB를 치는 주체를 1명으로 제한”하는 가장 정석적인 방법입니다.
- 미스 발생
- Redis
SET lock:key value NX PX 3000로 락 획득 시도 - 락을 잡은 1개 요청만 MySQL 조회 후 캐시 갱신
- 다른 요청은 짧게 대기 후 캐시 재조회(스핀) 또는 실패 시 degrade
주의점
- 락 TTL(
PX)은 DB 조회 최악 시간보다 약간 길게 - 락 해제는 “내가 잡은 락인지” 검증 후 삭제(토큰 비교)
- 대기 요청은 무한 대기 금지(최대 대기 시간, 지수 백오프)
Node.js 예시(ioredis)
import Redis from 'ioredis';
import crypto from 'crypto';
const redis = new Redis(process.env.REDIS_URL);
async function getUserFromMySQL(userId) {
// 실제로는 mysql2/promise 등 사용
return { id: userId, name: 'alice', updatedAt: Date.now() };
}
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function getUserCached(userId) {
const key = `user:${userId}`;
const lockKey = `lock:${key}`;
const token = crypto.randomUUID();
// 1) 캐시 먼저 확인
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// 2) 락 획득 시도
const lockOk = await redis.set(lockKey, token, 'NX', 'PX', 3000);
if (lockOk) {
try {
// 더블 체크(락 잡는 동안 다른 누군가 채웠을 수 있음)
const cached2 = await redis.get(key);
if (cached2) return JSON.parse(cached2);
const user = await getUserFromMySQL(userId);
await redis.set(key, JSON.stringify(user), 'EX', 60);
return user;
} finally {
// 토큰 검증 후 해제(Lua)
const lua = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`;
await redis.eval(lua, 1, lockKey, token);
}
}
// 3) 락 못 잡았으면 짧게 대기 후 캐시 재조회
for (let i = 0; i < 10; i++) {
await sleep(30 + i * 10);
const v = await redis.get(key);
if (v) return JSON.parse(v);
}
// 4) 최후: DB 직접 조회(혹은 503)
return await getUserFromMySQL(userId);
}
이 패턴은 “정확성”은 좋지만, 락 경합이 심한 키에서는 대기 요청이 늘어 p99 지연이 상승할 수 있습니다. 그래서 아래 3번 stale 전략과 같이 쓰는 경우가 많습니다.
2) TTL 지터(Jitter)로 만료 동시성을 분산
스탬피드의 촉발점은 “많은 키가 동시에 만료”하거나 “핫키가 정확히 같은 시각에 만료”되는 상황입니다. TTL에 랜덤 지터를 주면 만료 시점이 퍼져서 동시 미스가 줄어듭니다.
- 예: 기본 TTL 60초
- 실제 TTL은 60초
+ random(0..20)초
적용 포인트
- 모든 키에 무조건 주기보다, 핫키/세션/피드/랭킹처럼 동시 접근이 큰 키에 우선 적용
- 배치로 대량 set 하는 경우는 지터 효과가 특히 큼
예시
function ttlWithJitter(baseSec, jitterSec) {
return baseSec + Math.floor(Math.random() * jitterSec);
}
await redis.set(key, value, 'EX', ttlWithJitter(60, 20));
지터는 구현이 매우 쉽고 부작용이 적지만, 핫키 단일 키 폭주를 완전히 막지는 못합니다. 그 경우는 1번(락) 또는 3번(stale)이 필요합니다.
3) Stale-While-Revalidate로 “만료 후에도 stale 제공”
사용자에게 최신값이 1~2초 늦어도 되는 데이터라면, “만료되자마자 미스”가 아니라 만료 후에도 일정 기간 stale을 제공하면서 백그라운드로 갱신하는 방식이 매우 강력합니다.
구성은 보통 2단 TTL을 둡니다.
softTTL: 이 시간이 지나면 “갱신 필요” 상태(하지만 stale은 제공 가능)hardTTL: 이 시간이 지나면 stale 제공도 중단(완전 미스)
Redis 자체 TTL 하나로는 soft/hard를 동시에 표현하기 어렵기 때문에, 값에 메타데이터를 같이 넣거나(권장) 별도 키로 상태를 둡니다.
값에 만료 메타를 포함하는 예시
async function cacheSetWithSoftHard(key, data, softSec, hardSec) {
const payload = {
data,
softExpireAt: Date.now() + softSec * 1000,
hardExpireAt: Date.now() + hardSec * 1000,
};
// Redis TTL은 hard 기준으로 설정
await redis.set(key, JSON.stringify(payload), 'EX', hardSec);
}
async function cacheGetSoftHard(key) {
const raw = await redis.get(key);
if (!raw) return { hit: false };
const payload = JSON.parse(raw);
const now = Date.now();
if (now > payload.hardExpireAt) return { hit: false };
const stale = now > payload.softExpireAt;
return { hit: true, stale, data: payload.data };
}
요청 처리 흐름
- 캐시 hit & fresh: 즉시 반환
- 캐시 hit & stale: stale 즉시 반환 + 비동기 갱신 트리거
- 캐시 miss: 1번 락 패턴으로 단일 갱신
비동기 갱신 트리거는 같은 프로세스에서 fire-and-forget로 해도 되고, 안정성을 위해 큐(예: SQS, Kafka)로 넘겨도 됩니다.
이 패턴은 p99를 크게 안정화하지만, “stale 허용”이 가능한 도메인인지 합의가 필요합니다. 인증/결제/재고 같은 강한 정합성 도메인에는 부적합할 수 있습니다.
4) 음수 캐싱(Negative Caching)으로 존재하지 않는 키 폭주 차단
스탬피드는 “존재하는 인기 키”뿐 아니라, 존재하지 않는 키에서도 발생합니다.
- 봇/스크래퍼가 랜덤 ID를 때림
- 클라이언트 버그로 잘못된 키를 반복 요청
- 삭제된 리소스에 대한 재시도 폭주
이때 매번 MySQL에서 SELECT ... WHERE id = ? 가 실행되면, 캐시가 있어도 DB는 보호되지 않습니다. 해결은 “없음” 결과도 짧게 캐싱하는 것입니다.
설계 팁
- 음수 캐시 TTL은 짧게(예: 10~60초)
- 실제 데이터와 구분되는 센티넬 값을 사용
- 404/NULL을 캐싱할 때는 권한/테넌트 조건이 섞이지 않도록 키 설계를 신중히
예시
const NIL = '__nil__';
async function getUserCachedWithNegative(userId) {
const key = `user:${userId}`;
const v = await redis.get(key);
if (v === NIL) return null;
if (v) return JSON.parse(v);
const user = await getUserFromMySQL(userId); // 없으면 null 반환한다고 가정
if (!user) {
await redis.set(key, NIL, 'EX', 30);
return null;
}
await redis.set(key, JSON.stringify(user), 'EX', 60);
return user;
}
음수 캐싱은 DB 보호 효과가 크지만, “잠깐 생겼다가 바로 조회되는 신규 데이터”가 있는 도메인에서는 음수 TTL을 너무 길게 잡으면 UX가 나빠질 수 있습니다.
5) “쓰기 시 캐시 갱신” 또는 버전 키로 읽기 폭주 완화
많은 팀이 캐시를 “읽기 경로에서만” 관리합니다. 하지만 스탬피드의 근본 원인 중 하나는 만료 시점에 읽기 요청이 갱신 책임을 떠안는 구조입니다.
가능하다면 갱신을 읽기에서 떼어내서,
- 쓰기 시점에 캐시를 갱신(write-through / write-behind)
- 혹은 버전 키를 사용해 “새 버전으로 스위칭”
같은 방식으로 읽기 폭주를 줄일 수 있습니다.
(A) Write-through 간단 예시
MySQL 업데이트 성공 후 Redis도 업데이트합니다.
-- MySQL
UPDATE users SET name = ? WHERE id = ?;
async function updateUserName(userId, name) {
// 1) MySQL 트랜잭션/업데이트 성공
// 2) 캐시 갱신(또는 삭제)
const key = `user:${userId}`;
const newValue = { id: userId, name, updatedAt: Date.now() };
// 강정합이 필요하면 DEL 후 다음 읽기에서 재생성도 선택지
await redis.set(key, JSON.stringify(newValue), 'EX', 60);
}
이 방식은 읽기 미스가 줄어 스탬피드 가능성이 낮아지지만, 쓰기 경로가 Redis 장애에 영향을 받지 않게(타임아웃 짧게, 실패 시 무시 또는 비동기 재시도) 설계해야 합니다.
(B) 버전 키 패턴
user:123:ver에 버전(정수 또는 타임스탬프)- 실제 캐시는
user:123:v{ver}로 저장 - 업데이트 시
ver만 증가시키면 읽기는 자동으로 새 키를 보게 됨
이 패턴은 “구 키를 즉시 무효화”하면서도, 구 키는 TTL로 자연 소멸하게 만들 수 있어 대규모 키 삭제 부담을 줄입니다.
실무 조합 가이드(추천)
서비스 특성에 따라 우선순위를 이렇게 잡으면 시행착오가 줄어듭니다.
- TTL 지터(2번): 가장 싸고 안전한 기본기
- 음수 캐싱(4번): 봇/오류 트래픽 방어에 효과 큼
- 락 기반 단일 플라이트(1번): 핫키 보호의 핵심
- stale-while-revalidate(3번): p99 안정화, 트래픽 피크에 강함
- 쓰기 시 캐시 갱신/버전 키(5번): 구조적으로 읽기 폭주를 줄임
추가로, 캐시 스탬피드는 종종 “시스템 전반의 병목”과 같이 나타납니다. 예를 들어 커넥션 누수나 컨텍스트 누수로 DB 풀이 고갈되면, 작은 미스도 장애로 번집니다. 비슷한 맥락의 안정화 기법은 contextlib로 async 컨텍스트 누수·예외 잡기에서도 아이디어를 얻을 수 있습니다.
관측(Observability) 없이는 재발한다
방지책을 넣었다면 아래 지표를 반드시 같이 봐야 합니다.
- Redis hit ratio(키 유형별)
- 캐시 미스 시 MySQL QPS, p95/p99 latency
- 락 획득 성공률, 락 대기 시간, 락 TTL 만료로 인한 중복 갱신 횟수
- stale 응답 비율(soft 만료 후 제공된 비율)
- 음수 캐시 적중률(봇/오류 트래픽 탐지 힌트)
특히 락 기반 설계는 “락 대기”가 새로운 병목이 될 수 있으므로, 대기 루프 횟수와 타임아웃을 수치로 관리해야 합니다.
마무리
MySQL+Redis에서 캐시 스탬피드를 막는 핵심은 “캐시가 비는 순간을 어떻게 다룰지”를 정책으로 정하는 것입니다.
- 동시 미스를 락으로 직렬화하고
- 만료 시점을 지터로 분산하며
- 가능한 경우 stale을 제공해 사용자 경험과 DB 보호를 동시에 잡고
- 없는 데이터도 캐싱해 불필요한 DB 왕복을 줄이고
- 더 나아가 쓰기 시 캐시 갱신/버전 키로 읽기 경로의 부담을 구조적으로 낮추면
트래픽 피크에서도 MySQL이 무너지지 않는 캐시 계층을 만들 수 있습니다.