캐시를 붙이는 이유는 보통 간단합니다. 같은 데이터를 매번 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를 실행한다는 사실만으로 이 중복이 사라지지는 않습니다. 비동기 조회를 시작한 뒤 다른 요청을 처리할 수 있기 때문입니다. 스레드 경쟁을 보지 않아도, ‘아직 결과가 없는 시간’을 공유하지 못하는 문제가 생깁니다.
- 캐시 비어 있음만료·퇴거·첫 실행
- 첫 요청 조회 시작아직 저장된 값 없음
- 나머지도 조회 시작같은 키를 각각 읽음
- 원본에 부하 집중끝난 뒤에야 캐시 채움
이런 현상을 캐시 스탬피드라고 부릅니다. 여러 키가 같은 시점에 만료되는 상황과, 인기 있는 키 하나로 요청이 몰리는 상황은 같이 나타날 수 있지만 대응책은 조금 다릅니다. 이 글에서는 우선 같은 키 하나의 중복 조회만 다룹니다.
2. 100개를 동시에 읽어 봤습니다
조건은 같게 뒀습니다. 빈 메모리 캐시, 키 하나, 동시 요청 100개, 원본 조회에 20ms 지연입니다. 만료 타이머 자체를 구현하지는 않았습니다. 비어 있는 캐시로 시작해 만료나 퇴거 직후의 미스 구간을 재현합니다. 시간을 재서 ‘몇 배 빨라졌다’고 말하기보다, 원본 함수를 실제로 몇 번 불렀는지 셌습니다.
| 측정 항목 | 단순 캐시 | 진행 중 요청 병합 |
|---|---|---|
| 빈 캐시에서 100개 동시 요청 | 원본 호출 100회 | 원본 호출 1회 |
| 값 저장 후 다시 100개 요청 | 추가 호출 0회 | 추가 호출 0회 |
| 반환된 값 | 모두 동일 | 모두 동일 |
차이는 캐시가 비어 있을 때만 드러났습니다. 한 번 채운 다음에는 두 방식 모두 추가 조회가 없었습니다. 캐시가 충분히 데워진 상태에서만 부하 테스트를 하면 이 문제가 잘 안 보이는 이유입니다.
이 수치는 DB QPS나 p99 응답 시간이 아닙니다. 연결 풀, 네트워크, DB 잠금, Redis 왕복도 넣지 않았습니다. 확인한 것은 정확히 동일 프로세스의 같은 키에 대해 진행 중 작업을 공유하면 중복 원본 호출을 줄일 수 있다는 범위입니다.
node cache-stampede.mjs
# coldOriginCalls: naive 100, singleFlight 1
# assertions: PASS3. 결과가 아니라, 진행 중인 작업도 저장합니다
방법은 두 번째 저장소를 두는 겁니다. 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를 읽는다” 다음에 “이미 누군가 읽고 있다면?”을 붙이는 것. 시작은 그 정도면 됩니다. 다만 서버 수, 실패, 오래된 결과의 재저장까지 확인한 뒤 실제 서비스에 맞게 넓혀야 합니다.


