Redis 단일 노드 장애로 서비스 전체가 9.5시간 멈춘 사례를 복원한다. 캐시 스탬피드 메커니즘, 뮤텍스 락·stale-while-revalidate·서킷 브레이커 3가지 긴급 수정, Redis 역할 분리 아키텍처 재설계까지 시간순으로 정리.
금요일 오후 5시 47분, 슬랙에 알림이 울렸다. "API 응답 시간 15초 초과." 30초 뒤 두 번째 알림. "Redis 연결 풀 고갈." 1분 뒤 세 번째. "서비스 전체 5xx 에러율 94%." 월간 활성 사용자 12만 명을 처리하던 B2B SaaS 플랫폼이 Redis 단일 노드 장애로 완전히 멈춘 순간이었다.
이 글은 한 중견 SaaS 팀이 겪은 Redis 캐시 장애의 전체 타임라인을 복원한다. 장애 발생부터 72시간 동안 팀이 내린 판단, 시도한 조치, 실패한 접근, 그리고 최종적으로 아키텍처를 어떻게 바꿨는지를 시간순으로 정리했다. 캐시 스탬피드(cache stampede)라는 단어를 처음 듣는 사람이든, 이미 겪어본 사람이든 — 이 사례에서 얻을 수 있는 교훈은 같다. 캐시는 성능 최적화 도구가 아니라, 장애 시 시스템 전체를 멈출 수 있는 의존성이라는 것이다.
※ 이 글은 2026년 4월 기준, 해당 팀의 포스트모템 문서와 컨퍼런스 발표 자료를 참조하여 재구성했습니다. 기업명과 일부 수치는 익명 처리했습니다.
장애 전 시스템 구조 — Redis가 담당하던 것들
이 팀의 서비스는 기업 고객에게 프로젝트 관리 + 실시간 협업 기능을 제공하는 B2B SaaS였다. 백엔드는 Node.js(Express) + PostgreSQL 조합이었고, Redis는 다음 네 가지 역할을 동시에 수행하고 있었다.
역할
용도
TTL
세션 저장소
JWT 리프레시 토큰, 로그인 상태
24h
API 응답 캐시
대시보드 집계, 프로젝트 목록
5m
Rate Limiter
API 호출 제한 (슬라이딩 윈도우)
1m
Pub/Sub
실시간 알림, WebSocket 브로드캐스트
-
문제는 이 네 가지 역할이 단일 Redis 인스턴스(AWS ElastiCache r6g.large, 1노드)에 몰려 있었다는 점이다. Redis Cluster나 Sentinel 구성 없이, 단일 노드에 읽기/쓰기 전부를 맡겼다. 팀의 시니어 엔지니어는 포스트모템에서 이렇게 썼다: "Redis가 빠르니까 괜찮다고 생각했다. 장애 가능성을 아예 고려하지 않은 게 아니라, 장애가 나도 빨리 복구하면 된다고 판단했다. 그 판단이 틀렸다."
장애 전 아키텍처 — Redis 단일 노드가 4가지 역할을 동시에 수행 (출처: 팀 포스트모템 자료 재구성)
장애 타임라인 — 금요일 17:47부터 토요일 03:20까지
장애의 직접 원인은 ElastiCache 노드의 메모리 부족(OOM)이었다. 그날 오후, 한 엔터프라이즈 고객이 대시보드 데이터 일괄 내보내기를 실행했고, 이 과정에서 평소보다 3배 많은 캐시 키가 생성됐다. Redis 메모리 사용률이 95%를 넘기면서 eviction이 시작됐고, 핵심 캐시 키가 제거됐다.
여기서 캐시 스탬피드가 발생했다. 캐시 키가 사라진 순간, 해당 키에 의존하던 수백 개의 요청이 동시에 PostgreSQL로 직행했다. 대시보드 집계 쿼리는 원래 PostgreSQL에서 실행하면 2~3초가 걸리는 무거운 쿼리였는데, 이 쿼리가 동시에 수백 개 실행되면서 PostgreSQL CPU가 100%에 도달했다. 타임라인을 정리하면 다음과 같다.
시각
사건
영향
17:47
Redis 메모리 95% → eviction 시작
캐시 미스율 급증
17:48
PostgreSQL 동시 쿼리 폭주
DB CPU 100%, 커넥션 풀 고갈
17:49
API 서버 응답 타임아웃 연쇄
5xx 에러율 94%
17:52
세션 저장소 접근 불가
전체 사용자 로그아웃
18:15
온콜 엔지니어 합류, Redis 재시작 시도
재시작 후 캐시 콜드 스타트 → 2차 스탬피드
19:30
PostgreSQL 커넥션 수동 제한(max 50)
부분 복구, 처리량 30%
21:00
캐시 워밍 스크립트 수동 실행
주요 캐시 키 복원
03:20
전체 서비스 정상 복구
약 9.5시간 장애
가장 뼈아팠던 실수는 18:15의 Redis 재시작이었다. 온콜 엔지니어는 Redis를 재시작하면 메모리가 비워지고 정상화될 거라 판단했다. 하지만 재시작은 곧 모든 캐시가 비워진다는 뜻이었고, 그 순간 모든 API 요청이 PostgreSQL을 직접 때렸다. 1차 스탬피드보다 더 심한 2차 스탬피드가 발생한 것이다. 포스트모템에서 이 판단을 "가장 큰 실수"로 기록했다.
캐시 스탬피드는 왜 이렇게 위험한가
캐시 스탬피드(thundering herd problem이라고도 불린다)의 메커니즘은 단순하다. 캐시 키가 만료되거나 제거된 순간, 같은 키를 요청하는 N개의 클라이언트가 동시에 원본 데이터 소스(DB)에 접근한다. N이 10이면 문제가 안 되지만, N이 500이면 DB가 죽는다.
이 팀의 경우 대시보드 집계 API가 가장 치명적이었다. 이 API는 한 번 호출에 PostgreSQL에서 3개 테이블을 JOIN하고 GROUP BY로 집계하는 쿼리를 실행했다. 캐시가 있을 때는 5ms에 응답했지만, 캐시 미스 시에는 2,300ms가 걸렸다. 이 쿼리가 동시에 수백 개 실행되면서 DB 커넥션 풀(기본 20개)이 즉시 고갈됐다.
장애를 일으킨 원래 캐시 패턴 (위험한 코드)
// 전형적인 cache-aside 패턴 — 스탬피드에 취약
async function getDashboardStats(projectId) {
const cacheKey = `dashboard:${projectId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 캐시 미스 → DB 직접 조회
// 동시에 500명이 이 코드를 실행하면 500개의 DB 쿼리 발생
const stats = await db.query(`
SELECT project_id, COUNT(*) as task_count,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done_count
FROM tasks
JOIN projects ON projects.id = tasks.project_id
WHERE project_id = $1
GROUP BY project_id
`, [projectId]);
await redis.setex(cacheKey, 300, JSON.stringify(stats));
return stats;
}
캐시 스탬피드 메커니즘 — 캐시 미스 시 N개 요청이 동시에 DB를 직접 조회 (출처: 팀 포스트모템 발표 슬라이드)
72시간 안에 적용한 3가지 긴급 수정
주말 동안 팀은 재발 방지를 위한 긴급 패치 3가지를 배포했다. 각각의 판단 근거와 구현 방식을 정리한다.
수정 1: 뮤텍스 락으로 스탬피드 방지
가장 먼저 적용한 것은 캐시 미스 시 뮤텍스 락(mutex lock)을 거는 패턴이다. 캐시 키가 없을 때, 첫 번째 요청만 DB 쿼리를 실행하고 나머지 요청은 대기하게 만든다. Redis의 SET NX EX 명령어를 사용해 분산 락을 구현했다.
뮤텍스 락 기반 캐시 조회 패턴
async function getDashboardStatsWithLock(projectId) {
const cacheKey = `dashboard:${projectId}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 분산 락 획득 시도 (5초 TTL)
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (acquired) {
try {
const stats = await db.query(DASHBOARD_QUERY, [projectId]);
await redis.setex(cacheKey, 300, JSON.stringify(stats));
return stats;
} finally {
await redis.del(lockKey);
}
}
// 락 획득 실패 → 짧은 대기 후 캐시 재확인
await new Promise(r => setTimeout(r, 200));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried);
// 최종 폴백: stale 데이터라도 반환
return getFallbackStats(projectId);
}
수정 2: stale-while-revalidate 패턴
두 번째 수정은 캐시 만료 전에 미리 갱신하는 방식이다. 캐시 TTL을 5분으로 설정하되, 4분이 지난 시점부터는 "stale" 상태로 표시한다. stale 상태의 캐시를 요청받으면, 기존 값을 즉시 반환하면서 백그라운드에서 갱신을 시작한다. 이렇게 하면 캐시가 완전히 비워지는 순간이 사라진다.
stale-while-revalidate 구현
async function getWithSWR(key, fetchFn, ttl = 300) {
const raw = await redis.get(key);
if (raw) {
const { data, refreshAt } = JSON.parse(raw);
if (Date.now() > refreshAt) {
// stale 상태 → 백그라운드 갱신
refreshInBackground(key, fetchFn, ttl);
}
return data; // 기존 데이터 즉시 반환
}
// 캐시 없음 → 뮤텍스 락 + DB 조회
return fetchWithLock(key, fetchFn, ttl);
}
async function refreshInBackground(key, fetchFn, ttl) {
const lockKey = `refresh:${key}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!acquired) return; // 이미 갱신 중
try {
const data = await fetchFn();
const refreshAt = Date.now() + (ttl * 0.8 * 1000);
await redis.setex(key, ttl, JSON.stringify({ data, refreshAt }));
} finally {
await redis.del(lockKey);
}
}
수정 3: Redis 서킷 브레이커 추가
세 번째 수정이 가장 근본적이었다. Redis가 죽었을 때 시스템 전체가 멈추는 구조 자체를 바꿨다. Redis 연결에 서킷 브레이커를 추가해서, Redis 장애 시 캐시를 건너뛰고 DB에 직접 접근하되, DB 쿼리에도 동시성 제한을 걸었다.
서킷 브레이커 + DB 동시성 제한
const Bottleneck = require('bottleneck');
// DB 쿼리 동시 실행 제한 (최대 10개)
const dbLimiter = new Bottleneck({
maxConcurrent: 10,
minTime: 50
});
// 서킷 브레이커 상태
let redisCircuitOpen = false;
let redisFailCount = 0;
async function resilientCacheGet(key) {
if (redisCircuitOpen) return null; // 서킷 열림 → 캐시 스킵
try {
const result = await Promise.race([
redis.get(key),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 500)
)
]);
redisFailCount = 0;
return result;
} catch (err) {
redisFailCount++;
if (redisFailCount >= 3) {
redisCircuitOpen = true;
setTimeout(() => {
redisCircuitOpen = false;
redisFailCount = 0;
}, 30000); // 30초 후 반개방
}
return null;
}
}
이 세 가지 수정을 조합한 결과, Redis가 죽어도 서비스가 느려질 뿐 완전히 멈추지는 않게 됐다. 서킷 브레이커가 열리면 캐시를 건너뛰고, DB 쿼리는 Bottleneck으로 동시 실행 수를 제한하며, stale 데이터가 있으면 우선 반환한다. 응답 시간은 5ms → 800ms로 느려지지만, 5xx 에러는 발생하지 않는다.
장애 후 아키텍처를 어떻게 바꿨나
긴급 패치 이후 2주에 걸쳐 팀은 아키텍처 자체를 재설계했다. 핵심 변경은 3가지였다.
첫째, Redis 역할 분리. 하나의 Redis 인스턴스가 맡던 4가지 역할을 용도별로 분리했다. 세션은 별도의 Redis 인스턴스(Sentinel 구성), API 캐시는 Redis Cluster(3노드), Rate Limiter는 캐시 클러스터에 포함, Pub/Sub은 별도 인스턴스로 분리했다. 비용은 월 $180에서 $520으로 올랐지만, 단일 장애점이 사라졌다.
둘째, PostgreSQL 쿼리 최적화. 캐시 없이도 버틸 수 있는 구조를 목표로, 대시보드 집계 쿼리를 materialized view로 전환했다. 원래 2,300ms 걸리던 쿼리가 materialized view에서는 15ms로 줄었다. 5분마다 REFRESH MATERIALIZED VIEW CONCURRENTLY를 실행해 데이터를 갱신한다.
셋째, 캐시 의존도 등급 분류. 모든 캐시 키를 critical(장애 시 서비스 중단), important(장애 시 성능 저하), nice-to-have(장애 시 영향 미미) 3등급으로 분류하고, critical 키에만 뮤텍스 락 + SWR + 서킷 브레이커 전체를 적용했다.
아키텍처 변경 전후의 장애 시나리오 비교는 다음과 같다.
시나리오
변경 전
변경 후
Redis 캐시 노드 장애
서비스 전체 중단
응답 지연(800ms), 서비스 유지
캐시 키 대량 eviction
DB 과부하 → 연쇄 장애
뮤텍스 락 + SWR로 DB 부하 제한
세션 Redis 장애
전체 사용자 로그아웃
Sentinel 자동 페일오버(5초)
복구 시간
9.5시간
자동 복구 또는 수동 5분 이내
재설계된 아키텍처 — Redis 역할 분리 + 서킷 브레이커 + DB 동시성 제한 (출처: 팀 포스트모템 자료 재구성)
이 장애에서 배운 5가지 체크리스트
팀이 포스트모템에서 도출한 재발 방지 체크리스트다. Redis를 캐시로 사용하는 모든 서비스에 해당한다.
1. Redis가 죽으면 서비스가 죽는가? 캐시가 없어도 서비스가 (느리더라도) 동작해야 한다. Redis 프로세스를 강제 종료하는 카오스 테스트를 분기 1회 실행한다.
2. 캐시 미스 시 DB 동시 쿼리 수에 상한이 있는가? Bottleneck이든 semaphore든, 캐시 없이 DB에 직접 접근할 때 동시 실행 수를 반드시 제한한다. 커넥션 풀 크기만으로는 부족하다.
3. 하나의 Redis에 몇 가지 역할을 맡기고 있는가? 세션, 캐시, Pub/Sub, Rate Limiter를 한 인스턴스에 넣으면 장애 범위가 전체로 확대된다. 최소한 세션과 캐시는 분리한다.
4. 장애 복구 시 캐시 워밍 절차가 문서화되어 있는가? Redis 재시작 = 캐시 전체 초기화 = 콜드 스타트 스탬피드. 재시작 전에 어떤 키를 먼저 워밍할지, 워밍 스크립트가 있는지 확인한다.
5. maxmemory-policy를 확인했는가? 기본값 noeviction은 메모리가 차면 쓰기를 거부한다. allkeys-lru는 오래된 키를 제거한다. 어떤 정책이든 장애 시 어떤 키가 사라질지 예측 가능해야 한다.
이 팀은 현재 격주로 "Redis kill drill"을 실행한다. 스테이징 환경에서 Redis를 강제 종료하고, 서비스가 degraded 모드로 전환되는지 확인하는 절차다. 팀 리드는 이렇게 말했다: "장애는 항상 금요일 오후에 온다. 준비는 수요일에 해놔야 한다."