정의
폼 제출처럼 "받아서 다른 시스템에 기록하는" 일을 할 때, 요청을 받는 쪽(API)과 실제로 기록하는 쪽 사이에 큐(queue, 할 일을 줄 세워 두는 곳) 를 끼우는 패턴이에요. API는 요청을 검증해서 큐에 넣고 곧바로 성공을 돌려주고, 워커(worker) 라는 별도 프로그램이 큐에서 하나씩 꺼내 실제 일을 해요.
이름의 "부하 평탄화"는 갑자기 몰린 요청의 뾰족한 모양을 큐가 눌러서, 상대 시스템에는 자기가 감당할 수 있는 속도로만 도착하게 한다는 뜻이에요. 큐가 "한 번에 하나씩 처리"를 자동으로 보장해 준다는 뜻은 아니에요. 이 차이는 아래에서 다시 다뤄요.
왜 필요한가
API가 외부 시스템을 직접 호출하고 끝날 때까지 기다리면, 이런 문제가 생겨요.
- 외부 시스템이 쓰는 동안 전체에 잠금(lock) 을 거는 경우(스프레드시트에 행을 추가하는 스크립트 서버가 대표적이에요), 동시에 열 건이 오면 한 건만 들어가고 나머지는 "지금 바쁨"으로 실패해요.
- 외부 시스템이 호출 횟수를 제한하면(rate limit) 몰린 순간에만 에러가 나서, 평소에는 안 보이다가 이벤트 직후에만 터지는 버그가 돼요.
- 사용자는 느린 호출이 끝날 때까지 기다리고, 실패하면 입력한 내용을 잃어요.
큐를 두면 사용자 응답은 "큐에 넣는 시간"만큼 빨라지고, 처리 실패는 워커가 재시도로 흡수할 수 있어요.
동작 원리
브라우저 → API: 검증 → 고유 번호 부여 → 큐에 publish → 즉시 { ok: true }
↓
큐(내구성 있게 보관) → 워커: 외부 호출 1회
├─ 성공 → ack (큐에서 삭제)
├─ 일시 실패 → 워커 안에서 짧은 지터 재시도
└─ 그래도 실패 → 예외를 던짐 → 큐가 간격을 두고 재배달
- 접수(publish): API는 입력 검증, 중복 방지용 고유 번호 생성, 큐 publish만 하고 응답해요. 이 순간부터 "접수 완료"를 약속한 상태예요.
- 배달: 큐는 메시지를 워커에 전달해요. 워커가 성공을 알리는 ack(확인 응답) 를 보내기 전에는 메시지를 지우지 않고 다시 배달해요.
- 재시도 두 층: 워커 안의 짧은 재시도가 잠금 경합 같은 순간적 충돌을 풀고, 큐의 긴 간격 재배달은 상대 장애처럼 오래 가는 실패를 맡아요(retry-backoff-with-jitter 참고).
- 소진 처리: 최대 배달 횟수를 넘기면 식별자와 함께 오류 추적 도구에 보고한 뒤 ack해서 큐가 막히지 않게 해요.
주의: 큐는 동시성을 줄여 주지 않을 수 있어요. 많은 매니지드 큐는 FIFO나 "동시에 하나만 배달" 옵션이 없어서, 열 건이 같은 순간에 워커로 배달되고 재배달도 같은 시각에 몰려요. 순서를 정리하는 일은 워커 안의 무작위 재시도가 맡아야 해요.
실무 적용
// API: 접수만 하고 응답
export async function POST(req: Request) {
const values = validate(await req.json());
const submissionId = crypto.randomUUID();
try {
await queue.send("contact", { submissionId, values }, { idempotencyKey: submissionId });
} catch {
await callBackendDirectly(submissionId, values); // 큐 publish 실패 시에만 직접 호출 폴백
}
return Response.json({ ok: true });
}
// 워커: 성공했을 때만 정상 반환(= ack)
export async function handle(message: { submissionId: string; values: Values }) {
const result = await callBackendWithJitterRetry(message); // 짧은 지터 재시도 포함
if (!result.ok) throw new Error(result.code); // 던지면 큐가 재배달
}
- 폴백 경로와 워커가 같은 호출·실패 분류 코드를 쓰게 해야 두 경로가 따로 놀지 않아요.
- 큐 경로는 로컬 개발에서 인증 토큰이 없어 호출이 실패하는 경우가 많아요. 로컬 테스트가 폴백 경로로만 통과하고 있지는 않은지 확인하고, 큐 경로는 운영 로그로 한 번 이상 검증해요.
트레이드오프
| 얻는 것 | 잃는 것 |
|---|---|
| 빠른 응답, 몰림 흡수, 실패 재시도 | 즉시 일관성: 응답 시점에 기록은 아직 없어요 |
| 상대 시스템 보호 | 큐·워커라는 구성 요소와 운영 부담 |
| 장애 중에도 접수 계속 | 유실·중복을 직접 설계해야 해요(idempotent-consumer-ack-timing) |
사용하면 안 되는 경우
- 사용자가 결과를 바로 알아야 하는 요청(결제 승인 결과 같은 것)은 접수 응답만으로는 부족해요.
- 상대 시스템이 충분히 빠르고 동시성 제한이 없다면 큐는 복잡도만 더해요.
- 큐가 순서·동시성을 보장해 주지 않는데 "큐가 있으니 경합이 사라진다"고 가정하면 안 돼요.
흔한 실수
- 예상 못 한 예외를 ack해서 조용히 버리기. 이미 "접수 완료"라고 답한 뒤라서 아무도 모르게 유실돼요. 성공했을 때만 ack해요.
- 큐의 재배달만 믿기. 같은 시각에 몰린 재배달은 다시 같은 곳에서 충돌해요. 워커 안에서 무작위 간격으로 먼저 재시도해요.
- publish 실패를 처리하지 않기. 큐 장애가 곧 접수 장애가 돼요. 직접 호출 폴백을 두되 횟수를 제한해요.
- 큐 경로를 한 번도 실행해 보지 않기. 로컬에서는 폴백만 돌 수 있어요.
관련 개념
- retry-backoff-with-jitter — 워커 안과 큐 수준의 재시도를 나누는 방법
- idempotent-consumer-ack-timing — 재배달로 중복이 생기는 이유와 ack 규칙
- optimistic-outbox-rebase — 보낼 일을 쌓아 두고 나중에 전달하는 클라이언트 측 구조
- serverless-stateless-execution — 워커가 요청마다 새로 뜨는 무상태 프로세스라 재시도 상태를 메모리에 못 두는 이유