캐시 무효화 버그가 우리 시스템을 거의 무너뜨릴 뻔했다 - 그리고 그 후 우리가 바꾼 것
Source: Dev.to
🎬 설정
사고가 발생하기 전날 밤, 우리는 Aurora MySQL 엔진 버전을 업그레이드했습니다.
모든 것이 정상적으로 보였고—알람도 없고, 빨간 플래그도 없었습니다.
다음 날 아침 오전 8시경, 일일 작업이 실행되었습니다—다음 역할을 담당하는 작업입니다:
- 오래된 “마스터 데이터” 캐시 삭제
- DB에서 최신 마스터 데이터를 다시 가져오기
- 캐시에 다시 저장
이 마스터 데이터셋은 애플리케이션이 정상적으로 동작하기 위해 사용되므로, 캐시가 따뜻하지 않으면 DB에 부하가 걸립니다.
💥 폭발
엔진 업그레이드 직후, Lambda 내 특정 쿼리가 갑자기 30 초 이상 걸리기 시작했습니다.
우리 Lambda는 30초 타임아웃을 가지고 있었기 때문에 cacheInvalidate → cacheRebuild 흐름이 실패했습니다:
- 캐시가 비어 있게 남음.
- 모든 사용자 요청이 캐시 미스가 됨.
- 모든 요청이 직접 DB를 호출함.
- Aurora CPU가 **99 %**까지 급등.
- 애플리케이션 응답이 전반적으로 지연.
전형적인 캐시 스탬프(stampede) 상황이었습니다.
우리는 결국 페일오버를 트리거했으며, 다행히 같은 쿼리가 새 라이터에서 ~28.7 초에 실행돼 Lambda 타임아웃 바로 아래에서 동작해 몇 분간 시스템을 안정화할 수 있었습니다.
그날 밤에 실제 원인을 발견했는데, 해당 쿼리에 새로운 인덱스가 필요했고 업그레이드로 인해 실행 계획이 바뀐 것이었습니다. 우리는 핫픽스로 인덱스를 생성했고 DB는 안정화되었습니다. 근본적인 문제는 우리의 캐시 무효화 접근 방식이었습니다.
🧹 기존 캐시 무효화 방식: 먼저 삭제하고 나중에 희망하기
우리의 초기 흐름은 다음과 같았습니다:
- 기존 캐시 키 삭제
- DB에서 최신 데이터 가져오기
- 캐시에 다시 저장
2단계가 실패하면 모든 것이 무너집니다. 이번 경우 Lambda가 최신 데이터를 가져오지 못해 캐시가 비어 있게 남았습니다.
🔧 변경 내용 (및 권장 사항)
1. 최신 데이터를 확보하기 전에는 절대 캐시를 삭제하지 않기
흐름을 뒤집었습니다:
- Fetch → Validate → Update cache
- 이미 최신 데이터가 준비된 경우에만 삭제
이렇게 하면 “빈 캐시” 창이 사라집니다.
2. 무차별 삭제 대신 “stale rollover” 사용
리프레시 작업이 실패하면 이제 다음과 같이 처리합니다:
- 키 이름 변경
"Master-Data"→"Master-Data-Stale" - 기존 값을 그대로 유지
- 팀이 조사할 수 있도록 내부 알림 추가
이렇게 하면 DB가 느리거나 다운돼도 시스템이 무언가를 제공할 수 있습니다. 이상적이지는 않지만 붕괴를 방지합니다.
3. API 레이어가 최신 데이터를 사용할 수 없을 때는 오래된 데이터를 반환하도록 변경
API 로직은 다음과 같이 바뀌었습니다:
"Master-Data"읽기 시도- 없으면 (허용되는 경우) 재구축 시도
- 재구축 실패 → 오래된 데이터 반환
이렇게 하면 연쇄적인 실패를 피할 수 있습니다.
4. 캐시 스탬프를 방지하기 위해 Redis 분산 락 추가
이 락이 없으면 여러 API 노드나 Lambda가 동시에 재구축을 시도해 DB를 다시 두드릴 수 있습니다. Redis 락을 사용하면:
- 오직 하나의 요청만 락을 획득하고 재구축을 수행합니다.
- 나머지는 DB를 호출하지 않고 오래된 데이터를 반환하거나 승자가 캐시를 채울 때까지 대기합니다.
Node.js – 분산 락 획득 (Redis)
// redis.js
const { createClient } = require("redis");
const redis = createClient({
url: process.env.REDIS_URL
});
redis.connect();
module.exports = redis;
락 획득 및 해제
// lock.js
const redis = require("./redis");
const { randomUUID } = require("crypto");
const LOCK_KEY = "lock:master-data-refresh";
const LOCK_TTL = 10000; // 10 seconds
async function acquireLock() {
const lockId = randomUUID();
const result = await redis.set(LOCK_KEY, lockId, {
NX: true,
PX: LOCK_TTL
});
if (result === "OK") {
return lockId; // lock acquired
}
return null; // lock not acquired
}
async function releaseLock(lockId) {
const current = await redis.get(LOCK_KEY);
if (current === lockId) {
await redis.del(LOCK_KEY);
}
}
module.exports = { acquireLock, releaseLock };
사용 예시
const { acquireLock, releaseLock } = require("./lock");
async function refreshMasterData() {
const lockId = await acquireLock();
if (!lockId) {
console.log("Another request is refreshing. Returning stale data.");
return getStaleData();
}
try {
const newData = await fetchFromDB();
await saveToCache(newData);
return newData;
} finally {
await releaseLock(lockId);
}
}
5. 리프레시 시간에 대한 가시성 추가
이제 다음을 기록합니다:
- 쿼리 실행 시간
- 캐시 리프레시 소요 시간
- 락 획득 메트릭
- 리프레시가 임계값을 초과할 경우 알림
목표는 타임아웃이 발생하기 전에 지연을 포착하는 것입니다.
📝 핵심 정리
- 엔진 업그레이드는 실행 계획을 크게 바꿀 수 있습니다. 주요 DB 변경 후에는 반드시 핵심 쿼리를 벤치마크하세요.
- 캐시 무효화 전략은 리프레시 실패를 전제로 설계해야 합니다.
- 오래된 데이터를 제공하는 것이 오류를 반환하는 것보다 낫습니다.
- 분산 락은 캐시 스탬프를 방지하는 데 필수적입니다.
🚀 마무리 생각
이번 사고는 스트레스를 많이 주었지만, 얻은 교훈은 값졌습니다. 캐시 문제는 일반 트래픽에서는 거의 나타나지 않으며, 시스템이 가장 바쁠 때 드러납니다. 애플리케이션 어딘가에 “삭제 후 새로 고침” 패턴이 있다면, 시스템이 여러분을 검토하기 전에 반드시 검토해 보세요.