이 글의 목차

Thundering Herd가 캐시 만료 순간에 발생한다면 같은 키의 재조회 요청을 합치는 singleflight를 검토하세요. 캐시가 없는 동안 요청마다 DB를 읽으면 인기 키 한 개가 수백 개의 중복 조회를 만들 수 있습니다. 한 조회의 결과를 같은 프로세스에서 기다리는 요청이 함께 사용하면 중복 작업을 줄일 수 있습니다.

일반 개념은 Thundering Herd 문제, Redis 캐시 운영은 캐시 스탬피드 가이드를 참고하세요. 이 글은 완료된 결과의 캐시와 진행 중인 작업 공유를 구별하고 구현에서 생기는 경계 조건을 다룹니다.

캐시와 singleflight는 다른 역할

캐시는 완료된 값을 일정 기간 재사용합니다. singleflight는 진행 중인 같은 작업을 하나로 합칩니다. Go의 golang.org/x/sync/singleflight 공식 패키지도 같은 키의 중복 실행을 억제하고 결과를 공유하는 인터페이스를 제공합니다. Go singleflight 문서

방식재사용 대상주의할 점
TTL 캐시이미 완료된 값오래된 값, 동시 만료
singleflight현재 진행 중인 조회키 범위, 오류 공유, 작업 종료
TTL jitter서로 다른 키의 만료 시각같은 인기 키의 동시 조회는 남음
분산 락여러 프로세스의 작업 소유임대 만료, 해제 권한, 실패 처리

서로 다른 키가 동시에 만료되는 문제와 하나의 키에 요청이 몰리는 문제는 겹칠 수 있습니다. 한 가지 도구로 두 문제를 모두 해결한다고 기대하지 마세요.

Node.js로 진행 중인 Promise 공유

다음은 의존성 없이 동작하는 JavaScript 예시입니다. 완료·실패 후 Map에서 제거하므로 결과 캐시 역할은 하지 않습니다.

const inFlight = new Map();
function singleflight(key, load) {
const existing = inFlight.get(key);
if (existing) return existing;
// load의 동기 throw도 Promise rejection으로 변환
const task = Promise.resolve().then(load);
inFlight.set(key, task);
const cleanup = () => {
if (inFlight.get(key) === task) inFlight.delete(key);
};
// 별도 finally Promise의 unhandled rejection을 만들지 않음
task.then(cleanup, cleanup);
return task;
}
let originCalls = 0;
const load = async () => {
originCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return { price: 1000 };
};
const results = await Promise.all(
Array.from({ length: 100 }, () => singleflight("demo:product:42", load))
);
console.log({ originCalls, responses: results.length });
// 기대 결과: { originCalls: 1, responses: 100 }

ES module인 .mjs 파일로 저장해 실행할 수 있습니다. 100개 호출이 같은 진행 중인 Promise를 공유합니다. 작업이 끝난 뒤 다음 요청은 다시 원본을 호출하므로, 실제 앱에서는 값 캐시와 결합해야 합니다. 이 예시는 100배의 운영 성능 향상을 증명하는 벤치마크가 아닙니다.

캐시와 결합하는 흐름

보통 캐시 조회가 성공하면 바로 값을 반환합니다. 미스이면 singleflight 안에서 캐시를 다시 확인하고 원본을 읽은 뒤 캐시에 기록합니다. 첫 캐시 조회와 작업 시작 사이에 다른 경로가 값을 채웠을 수 있기 때문입니다.

요청 → 캐시 조회
적중 → 반환
미스 → 같은 키의 진행 중 작업 공유
→ 캐시 재확인
→ 없으면 원본 조회
→ 캐시 기록
→ 기다리는 요청에 결과 공유

캐시 기록 실패를 어떻게 처리할지도 정해야 합니다. 원본 값은 있는데 캐시 장애 때문에 전체 요청을 실패시킬지, 값을 반환하되 캐시 실패를 기록할지는 서비스 요구에 따라 다릅니다. 어느 쪽이든 캐시가 계속 실패하면 다음 작업에서 원본 부하가 반복될 수 있어 지표가 필요합니다.

키에는 사용자·테넌트·옵션을 포함

같은 상품 ID여도 테넌트·언어·권한·할인 정책에 따라 결과가 다르면 하나의 작업으로 합쳐서는 안 됩니다. 첫 요청자의 결과를 다른 사용자에게 공유할 위험이 있습니다.

const flightKey = JSON.stringify([
"product:v1", tenantId, productId, locale, priceTier
]);

이 키도 예시일 뿐입니다. 권한에 따라 응답이 달라진다면 별도의 적절한 권한 범위가 필요합니다. 인증 토큰 전체를 로그에 남기는 방식은 피하고, 공유해도 되는 데이터인지부터 판단하세요. 입력이 무제한이면 Map 키 개수도 무제한으로 늘 수 있으므로 실제 구현에는 동시 작업 상한과 요청 제한이 필요합니다.

오류·타임아웃·취소를 따로 설계

같은 작업을 공유하면 실패도 함께 전달됩니다. 실패한 Promise를 Map에 영구 보관하면 이후 요청이 같은 오류만 받으므로 정리가 필요합니다. 반대로 실패 직후 모든 요청이 즉시 재시도하면 다시 원본을 압박할 수 있습니다. 제한된 재시도와 backoff, jitter를 함께 고려하세요. AWS SDK 재시도 동작

원본 작업이 끝나지 않으면 Map 항목도 남습니다. 실제 HTTP·DB 클라이언트의 타임아웃을 설정해 작업 자체가 종료되도록 해야 합니다. Promise.race로 기다리기만 끝냈다고 원본 작업이 취소되는 것은 아닙니다.

한 사용자의 요청이 취소됐다고 공유 작업 전체를 중단하면 다른 대기자도 실패합니다. 첫 요청자의 AbortSignal을 그대로 공유 조회에 넘기는 설계는 특히 주의하세요. 대기자별 취소와 공통 작업 수명을 분리하거나, 더 복잡한 참조 관리가 필요합니다. 위 최소 예제는 그런 정책까지 구현한 운영 라이브러리가 아닙니다.

여러 인스턴스에서는 효과 범위가 달라집니다

프로세스마다 Map이 하나씩 있다면 각 프로세스에서 원본 조회가 한 번씩 일어날 수 있습니다. Node.js worker나 여러 파드에 자동으로 공유되는 상태가 아닙니다. 예를 들어 동시에 20개 프로세스에 요청이 도달하면 같은 키의 조회가 최대 20개 진행되는 상황을 생각해야 합니다.

이것도 요청당 조회보다 개선될 수 있지만 ‘전 시스템에서 정확히 한 번’ 보장은 아닙니다. 필요하면 분산 락, 중앙 갱신 작업, stale-while-revalidate 등 다른 설계를 비교하세요. 분산 락을 쓰면 락 만료보다 원본 조회가 길어질 때와 락 해제 권한까지 고려해야 합니다. 결제나 상태 변경을 singleflight만으로 정확히 한 번 실행한다고 주장해서는 안 됩니다.

검증할 네 가지 상황

  1. 같은 키의 동시 호출에서 원본 호출 수가 줄어드는지.
  2. 다른 테넌트·다른 키는 서로 결과를 공유하지 않는지.
  3. 원본 실패 후 다음 호출이 새 작업을 시작할 수 있는지.
  4. 재시작·캐시 전체 미스·원본 지연 때 큐와 메모리가 제한되는지.

평균 응답 속도뿐 아니라 원본 호출 수, 대기자 수, 실패율, p99 지연을 비교하세요. Redis Hot Key 측정과 연결하면 요청 합치기가 어느 병목을 줄였는지 확인하기 쉽습니다.

자주 묻는 질문

singleflight를 쓰면 TTL 캐시가 필요 없나요?

진행 중인 작업만 공유한다면 완료 후 다음 요청은 다시 원본을 호출합니다. 완료 값의 재사용이 필요하면 별도 캐시와 결합해야 합니다.

TTL에 랜덤 값을 넣으면 같은 키의 몰림도 해결되나요?

여러 키의 만료 시각을 분산하는 데 도움이 되지만 하나의 인기 키가 만료된 뒤 동시 조회가 발생하는 상황은 남을 수 있습니다.

원본 작업이 실패하면 한 요청만 다시 시도하나요?

위 예제는 실패를 함께 반환하고 항목을 정리합니다. 후속 재시도 정책은 별도로 설계해야 하며 무제한 즉시 재시도를 붙이면 원본 부하가 다시 커질 수 있습니다.

참고 자료

2026년 10월 8일 문서 확인. JavaScript 예제는 단일 프로세스에서 중복 조회를 합치는 동작을 설명합니다.

← 전체 글로 돌아가기