캐시 무효화 버그가 우리 시스템을 거의 무너뜨릴 뻔했다 - 그리고 그 후 우리가 바꾼 것

발행: (2025년 12월 5일 오전 10:57 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

🎬 설정

사고가 발생하기 전날 밤, 우리는 Aurora MySQL 엔진 버전을 업그레이드했습니다.
모든 것이 정상적으로 보였고—알람도 없고, 빨간 플래그도 없었습니다.

다음 날 아침 오전 8시경, 일일 작업이 실행되었습니다—다음 역할을 담당하는 작업입니다:

  • 오래된 “마스터 데이터” 캐시 삭제
  • DB에서 최신 마스터 데이터를 다시 가져오기
  • 캐시에 다시 저장

이 마스터 데이터셋은 애플리케이션이 정상적으로 동작하기 위해 사용되므로, 캐시가 따뜻하지 않으면 DB에 부하가 걸립니다.


💥 폭발

엔진 업그레이드 직후, Lambda 내 특정 쿼리가 갑자기 30 초 이상 걸리기 시작했습니다.
우리 Lambda는 30초 타임아웃을 가지고 있었기 때문에 cacheInvalidate → cacheRebuild 흐름이 실패했습니다:

  • 캐시가 비어 있게 남음.
  • 모든 사용자 요청이 캐시 미스가 됨.
  • 모든 요청이 직접 DB를 호출함.
  • Aurora CPU가 **99 %**까지 급등.
  • 애플리케이션 응답이 전반적으로 지연.

전형적인 캐시 스탬프(stampede) 상황이었습니다.

우리는 결국 페일오버를 트리거했으며, 다행히 같은 쿼리가 새 라이터에서 ~28.7 초에 실행돼 Lambda 타임아웃 바로 아래에서 동작해 몇 분간 시스템을 안정화할 수 있었습니다.

그날 밤에 실제 원인을 발견했는데, 해당 쿼리에 새로운 인덱스가 필요했고 업그레이드로 인해 실행 계획이 바뀐 것이었습니다. 우리는 핫픽스로 인덱스를 생성했고 DB는 안정화되었습니다. 근본적인 문제는 우리의 캐시 무효화 접근 방식이었습니다.

🧹 기존 캐시 무효화 방식: 먼저 삭제하고 나중에 희망하기

우리의 초기 흐름은 다음과 같았습니다:

  1. 기존 캐시 키 삭제
  2. DB에서 최신 데이터 가져오기
  3. 캐시에 다시 저장

2단계가 실패하면 모든 것이 무너집니다. 이번 경우 Lambda가 최신 데이터를 가져오지 못해 캐시가 비어 있게 남았습니다.

🔧 변경 내용 (및 권장 사항)

1. 최신 데이터를 확보하기 전에는 절대 캐시를 삭제하지 않기

흐름을 뒤집었습니다:

  • Fetch → Validate → Update cache
  • 이미 최신 데이터가 준비된 경우에만 삭제

이렇게 하면 “빈 캐시” 창이 사라집니다.

2. 무차별 삭제 대신 “stale rollover” 사용

리프레시 작업이 실패하면 이제 다음과 같이 처리합니다:

  1. 키 이름 변경
    "Master-Data""Master-Data-Stale"
  2. 기존 값을 그대로 유지
  3. 팀이 조사할 수 있도록 내부 알림 추가

이렇게 하면 DB가 느리거나 다운돼도 시스템이 무언가를 제공할 수 있습니다. 이상적이지는 않지만 붕괴를 방지합니다.

3. API 레이어가 최신 데이터를 사용할 수 없을 때는 오래된 데이터를 반환하도록 변경

API 로직은 다음과 같이 바뀌었습니다:

  1. "Master-Data" 읽기 시도
  2. 없으면 (허용되는 경우) 재구축 시도
  3. 재구축 실패 → 오래된 데이터 반환

이렇게 하면 연쇄적인 실패를 피할 수 있습니다.

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 변경 후에는 반드시 핵심 쿼리를 벤치마크하세요.
  • 캐시 무효화 전략은 리프레시 실패를 전제로 설계해야 합니다.
  • 오래된 데이터를 제공하는 것이 오류를 반환하는 것보다 낫습니다.
  • 분산 락은 캐시 스탬프를 방지하는 데 필수적입니다.

🚀 마무리 생각

이번 사고는 스트레스를 많이 주었지만, 얻은 교훈은 값졌습니다. 캐시 문제는 일반 트래픽에서는 거의 나타나지 않으며, 시스템이 가장 바쁠 때 드러납니다. 애플리케이션 어딘가에 “삭제 후 새로 고침” 패턴이 있다면, 시스템이 여러분을 검토하기 전에 반드시 검토해 보세요.

Back to Blog

관련 글

더 보기 »

AI 기반 개발 플랫폼

🤔 밤새도록 나를 괴롭힌 문제 상상해 보세요: GitHub에서 멋진 오픈‑소스 프로젝트를 발견합니다. 이 프로젝트는 10,000개가 넘는 issues와 수백 명의 contributors를 가지고 있습니다, …