시스템 디자인SYSTEM DESIGN
학습 시작하기 →
CACHE & CONCURRENCY

캐시를 붙였는데 DB가 더 바빠졌다

문제는 저장 공간보다 빈 순간이었습니다. 같은 키를 찾는 100개 요청을 한 번으로 묶어 봤습니다.

여러 요청 경로가 캐시 앞에서 하나의 노란 통로로 합쳐져 데이터베이스로 향하는 모형
내용의 흐름을 표현한 개념 이미지

캐시를 붙이는 이유는 보통 간단합니다. 같은 데이터를 매번 DB에서 읽지 않으려는 겁니다. 그런데 캐시가 비는 순간, 기다리던 요청들이 한꺼번에 DB로 내려가면 어떨까요? 평소에는 가볍던 서비스가 만료 직후에만 무거워질 수 있습니다. 저장된 동안의 성능보다, 비어 있는 동안의 행동이 문제입니다.

어디서 중복이 생기는지 보려고, 같은 키를 찾는 요청 100개를 Node.js에서 동시에 실행했습니다. 단순 캐시와 요청을 묶는 방식을 놓고 원본 조회가 몇 번 호출되는지 직접 비교했습니다. DB 대신 20ms 지연을 둔 조회 함수를 사용했습니다. 작지만 원인을 분리해서 보기에는 충분한 실험입니다.

1. ‘없으면 읽어서 저장’에 빠진 한 가지

처음에는 아래 정도면 괜찮아 보입니다. 있으면 반환하고, 없으면 원본에서 읽어 저장합니다. 요청 한 개로 확인하면 아무 문제도 없습니다.

async function get(key) {
  if (cache.has(key)) return cache.get(key);
  const value = await loadFromOrigin(key);
  cache.set(key, value);
  return value;
}

문제는 await에서 기다리는 동안입니다. 첫 번째 요청이 원본을 읽는 사이 두 번째 요청도 캐시가 비었다고 판단합니다. 세 번째도 같습니다. 첫 번째 값이 저장되기 전까지 들어온 요청들이 각자 같은 일을 시작합니다.

Node.js가 한 스레드에서 JavaScript를 실행한다는 사실만으로 이 중복이 사라지지는 않습니다. 비동기 조회를 시작한 뒤 다른 요청을 처리할 수 있기 때문입니다. 스레드 경쟁을 보지 않아도, ‘아직 결과가 없는 시간’을 공유하지 못하는 문제가 생깁니다.

  1. 캐시 비어 있음만료·퇴거·첫 실행
  2. 첫 요청 조회 시작아직 저장된 값 없음
  3. 나머지도 조회 시작같은 키를 각각 읽음
  4. 원본에 부하 집중끝난 뒤에야 캐시 채움

이런 현상을 캐시 스탬피드라고 부릅니다. 여러 키가 같은 시점에 만료되는 상황과, 인기 있는 키 하나로 요청이 몰리는 상황은 같이 나타날 수 있지만 대응책은 조금 다릅니다. 이 글에서는 우선 같은 키 하나의 중복 조회만 다룹니다.

2. 100개를 동시에 읽어 봤습니다

조건은 같게 뒀습니다. 빈 메모리 캐시, 키 하나, 동시 요청 100개, 원본 조회에 20ms 지연입니다. 만료 타이머 자체를 구현하지는 않았습니다. 비어 있는 캐시로 시작해 만료나 퇴거 직후의 미스 구간을 재현합니다. 시간을 재서 ‘몇 배 빨라졌다’고 말하기보다, 원본 함수를 실제로 몇 번 불렀는지 셌습니다.

2026-09-06 실행 · Node.js v24.13.1 · 단일 프로세스
측정 항목단순 캐시진행 중 요청 병합
빈 캐시에서 100개 동시 요청원본 호출 100회원본 호출 1회
값 저장 후 다시 100개 요청추가 호출 0회추가 호출 0회
반환된 값모두 동일모두 동일

차이는 캐시가 비어 있을 때만 드러났습니다. 한 번 채운 다음에는 두 방식 모두 추가 조회가 없었습니다. 캐시가 충분히 데워진 상태에서만 부하 테스트를 하면 이 문제가 잘 안 보이는 이유입니다.

이 수치는 DB QPS나 p99 응답 시간이 아닙니다. 연결 풀, 네트워크, DB 잠금, Redis 왕복도 넣지 않았습니다. 확인한 것은 정확히 동일 프로세스의 같은 키에 대해 진행 중 작업을 공유하면 중복 원본 호출을 줄일 수 있다는 범위입니다.

실행 예제 받기 · Node.js ↗외부 패키지·DB 연결 없이 실행됩니다.
node cache-stampede.mjs
# coldOriginCalls: naive 100, singleFlight 1
# assertions: PASS

3. 결과가 아니라, 진행 중인 작업도 저장합니다

방법은 두 번째 저장소를 두는 겁니다. cache에는 완성된 값을 넣고, inFlight에는 아직 끝나지 않은 Promise를 넣습니다. 같은 키가 들어오면 새 조회를 시작하지 않고 이미 시작한 작업을 기다립니다. 요청 병합, 또는 single-flight라고 부르는 방식입니다.

const cache = new Map();
const inFlight = new Map();

async function get(key) {
  if (cache.has(key)) return cache.get(key);
  if (inFlight.has(key)) return inFlight.get(key);

  const work = loadFromOrigin(key)
    .then(value => {
      cache.set(key, value);
      return value;
    })
    .finally(() => inFlight.delete(key));

  inFlight.set(key, work);
  return work;
}

코드가 길지는 않습니다. 그래도 finally는 빼면 안 됩니다. 원본 호출이 실패했는데 실패한 Promise가 계속 남아 있으면, 다음 요청도 이미 실패한 결과만 받게 됩니다. 성공과 실패 모두에서 진행 중 표시를 정리해야 다시 시도할 수 있습니다.

첨부한 예제에서는 첫 조회를 일부러 실패시켰습니다. 기다리던 100개 요청이 모두 거절되고, 진행 중 항목은 0개가 되며, 다음 조회는 성공하는지 검사합니다. 서로 다른 키 두 개를 각각 100번 조회했을 때는 원본 호출이 총 2회인지도 확인합니다. 서로 다른 데이터까지 한 작업으로 묶으면 안 되니까요.

4. 서버가 열 대라면 이야기가 달라집니다

inFlight는 프로세스 메모리입니다. 서버가 여러 대면 각 서버가 자기 작업 하나씩을 시작할 수 있습니다. 단일 프로세스에서 원본 호출이 1회였다고 해서 전체 서비스에서도 1회가 된다는 뜻은 아닙니다. 서버리스 인스턴스가 늘어나는 환경도 이 경계를 봐야 합니다.

그럼 바로 분산 락을 넣으면 될까요? 저는 먼저 원본이 그 정도 동시 조회를 감당할 수 있는지부터 보겠습니다. 인스턴스당 한 번으로 충분히 줄어든 상황에서 전역 락까지 넣으면, 락 서비스의 지연과 장애까지 떠안게 됩니다. 원본을 꼭 한 번만 호출해야 할 때라면 만료 시간, 소유자 확인, 실패한 소유자의 복구까지 포함해 별도로 설계해야 합니다.

상황먼저 검토할 방법남는 부담
같은 키로 동시 미스프로세스 내 요청 병합프로세스 사이에서는 중복 가능
많은 키의 만료 시각이 겹침TTL에 작은 무작위 편차 추가인기 키 하나의 동시 미스는 남음
잠깐 이전 값을 보여줘도 됨기존 값 제공 + 백그라운드 갱신얼마나 오래된 값까지 허용할지 필요
원본의 허용 동시성이 작음동시 요청 제한·대기열 상한늦추거나 거절할 요청의 기준 필요

뉴스 목록과 결제 잔액은 같은 기준으로 다루기 어렵습니다. 뉴스는 짧은 시간 이전 내용을 보여주는 편이 빈 화면보다 나을 수 있지만, 결제 판단에 오래된 값을 쓰는 것은 별도의 정확성 문제가 됩니다. 캐시 정책은 저장소 성능만이 아니라 데이터의 용도에서 출발해야 합니다.

AWS도 캐시가 비거나 사용할 수 없을 때 원본 부하가 급증할 수 있다는 점과 요청 병합을 다룹니다. 더 큰 규모의 운영 관점은 Amazon Builders’ Library의 캐싱 전략을 함께 읽어볼 만합니다.

5. 실제 적용 전에 더 확인할 것들

첫 번째는 키입니다. 사용자별 권한이 다른 데이터를 같은 키로 묶으면 남의 응답을 공유할 수 있습니다. 테넌트, 사용자 범위, 언어, 조회 조건처럼 결과를 바꾸는 요소가 키에 반영되어야 합니다. 민감한 값을 키나 로그에 그대로 남기는 방식은 피해야 합니다.

두 번째는 기다리는 요청입니다. 원본이 멈췄는데 Promise 하나를 모두 기다리면 호출 수는 줄어도 사용자 요청은 쌓입니다. 원본 호출의 시간 제한과 대기자 수 상한이 필요합니다. 한 사용자의 취소가 나머지 사용자의 공유 작업까지 취소하지 않도록, 요청 수명과 공유 작업 수명도 구분해야 합니다.

세 번째는 쓰기입니다. 조회를 시작한 뒤 새 값이 저장되었는데, 느린 이전 조회가 나중에 끝나 예전 값으로 캐시를 채울 수 있습니다. 이런 환경에서는 버전 비교나 세대 번호로 ‘이 결과를 아직 저장해도 되는지’를 확인해야 합니다. 요청 병합은 쓰기 일관성까지 자동으로 해결해 주지 않습니다.

  • 캐시 적중률뿐 아니라 원본 호출 수와 현재 대기자 수를 함께 봅니다.
  • 캐시가 비어 있는 배포 직후와 인기 키 만료 직후를 별도로 검사합니다.
  • 원본 실패 뒤 다음 요청이 복구되는지 확인합니다.
  • 캐시를 우회한 부하가 원본의 허용 범위를 넘으면 중단할 기준을 정합니다.

결론은 캐시를 더 크게 만드는 게 아닙니다

이번 실험에서는 저장 공간을 늘리지 않았습니다. 같은 원본 조회를 각자 시작하던 것을 함께 기다리도록 바꿨을 뿐입니다. 그 결과 빈 캐시에서 원본 호출이 100회에서 1회로 줄었습니다. 반대로 이미 채워진 캐시에서는 차이가 없었습니다.

캐시 적중률이 좋다고 안심하기 전에, 비는 순간의 요청 흐름을 한 번 그려보면 좋겠습니다. “없으면 DB를 읽는다” 다음에 “이미 누군가 읽고 있다면?”을 붙이는 것. 시작은 그 정도면 됩니다. 다만 서버 수, 실패, 오래된 결과의 재저장까지 확인한 뒤 실제 서비스에 맞게 넓혀야 합니다.