BullMQ v5로 LLM API 쿼터 초과를 방어하는 작업 큐 — 동시성 제한·우선순위·지수 백오프를 실제 코드로
LLM API를 직접 호출하는 서버에서 try/catch에 setTimeout 재시도 로직만 달아놓으면, 트래픽이 조금만 올라가도 429 Too Many Requests가 쏟아집니다. Worker를 수평 확장하면 오히려 쿼터 초과가 더 심해지고, 동기 재시도 루프는 Lambda 요금을 태워먹으며, 우선순위 처리 코드는 배치 작업이 실시간 요청을 밀어내는 기아(starvation) 현상을 막지 못합니다.
BullMQ v5는 그 문제들을 각각 잡아낼 수 있는 도구를 갖추고 있습니다. **글로벌 동시성(Global Concurrency)**으로 수평 확장 시에도 전체 동시 요청 수를 Redis 레벨에서 원자적으로 제한하고, 우선순위 큐로 프리미엄 사용자 요청이 배치 작업에 밀리지 않게 하며, **지수 백오프 + Worker.RateLimitError()**로 429 응답에 우아하게 대응합니다.
큐 생성부터 429 동적 백오프, stalled Job 방어, 멀티 큐 분리 아키텍처까지 하나의 흐름으로 살펴봅니다. TypeScript 기준이지만 JavaScript에서도 그대로 적용됩니다.
핵심 개념
왜 LLM API 앞에 큐가 필요한가
LLM 제공자는 두 축으로 제한을 겁니다. **RPM(분당 요청 수)**과 **TPM(분당 토큰 수)**입니다. 단순히 동시 요청 수를 줄여도, 긴 프롬프트를 연속으로 보내면 TPM 한도에 먼저 걸립니다.
Worker를 여러 인스턴스로 띄우면 각 Worker의 로컬 concurrency를 아무리 낮춰도 전체 합산은 그대로 쿼터를 초과합니다. v5 이전에는 이를 BullMQ 레벨에서 막을 방법이 없었습니다.
v5에서 추가된 queue.setGlobalConcurrency(N)는 Redis 레벨에서 전체 Worker 클러스터의 동시 처리 수를 원자적으로 제한합니다. Worker가 몇 개든, 어느 서버에 있든 상관없이 전체 합산이 N을 넘지 않습니다.
동시성 제한: 로컬 vs. 글로벌
두 가지 동시성 제어가 있고, 역할이 다릅니다.
| 설정 | 위치 | 역할 |
|---|---|---|
Worker({ concurrency }) |
Worker 인스턴스별 | 해당 인스턴스가 동시에 처리할 최대 Job 수 |
queue.setGlobalConcurrency(N) |
Redis 큐 레벨 | 클러스터 전체의 동시 처리 수 상한 |
두 제한은 동시에 적용됩니다. 실제 처리 수는 둘 중 더 낮은 값에 의해 결정됩니다. Worker 3개가 각각 concurrency: 5라도 setGlobalConcurrency(5)를 걸면 전체에서 5개만 동시에 처리됩니다. LLM API처럼 전역 쿼터가 있는 외부 서비스에는 글로벌 동시성이 필수입니다.
import { Queue, Worker, Job } from 'bullmq';
import { Redis } from 'ioredis';
// ioredis 기본값은 명령 실패 시 자동 재시도를 수행하는데,
// BullMQ의 블로킹 명령과 충돌해 예기치 않은 타임아웃이 발생합니다.
// BullMQ와 함께 쓸 때는 반드시 null로 설정해야 합니다.
const connection = new Redis({ maxRetriesPerRequest: null });
const llmQueue = new Queue('llm-requests', {
connection,
defaultJobOptions: {
attempts: 6,
backoff: {
type: 'exponential',
delay: 2000, // 2s → 4s → 8s → 16s → 32s (5회 재시도)
},
removeOnComplete: { count: 500 },
removeOnFail: { count: 200 },
},
});
// 클러스터 전체에서 동시에 최대 5개 Job만 처리
await llmQueue.setGlobalConcurrency(5);우선순위: 같은 큐 안에서 SLA를 나누는 방법
BullMQ의 우선순위는 1~2,097,152 사이 정수로, 숫자가 낮을수록 높은 우선순위입니다. "1번이 1등"이라고 외우면 됩니다. 우선순위 없이 추가된 Job은 BullMQ 내부 구현상 대체로 우선 처리되지만, 공식적으로 보장된 동작은 아닙니다.
우선순위는 엄격한 순서 보장이 아니라 가중치에 가깝습니다. 배치 작업이 절대 실시간 요청보다 앞서면 안 된다면, 뒤에서 다룰 멀티 큐 분리가 더 안전합니다.
지수 백오프 재시도: 429를 우아하게 넘기는 법
지수 백오프는 2^(attemptsMade-1) × delay 공식으로 재시도 간격을 늘립니다. delay: 2000이면 2s → 4s → 8s → 16s → 32s 순서가 됩니다.
여러 Worker가 동시에 같은 간격에 재시도하면 **동시 재시도 폭주(thundering herd)**가 발생합니다. 일제히 재시도가 몰리면서 다시 429가 터지는 악순환입니다. settings.backoffStrategy로 커스텀 전략을 등록하면 각 Job마다 다른 지연을 줄 수 있습니다.
// processor 함수는 Worker 생성 전에 선언되며, worker 변수는 아래에서 할당됩니다.
let worker: Worker;
const processor = async (job: Job<{ prompt: string; model: string }>) => {
try {
return await callLLMApi(job.data);
} catch (err: any) {
if (err.status === 429) {
const retryAfterRaw = err.headers?.['retry-after'] ?? '30';
const retryAfter = parseInt(retryAfterRaw, 10);
// Retry-After 헤더는 HTTP-date 형식으로도 올 수 있으며,
// 그 경우 parseInt가 NaN을 반환합니다.
const waitMs = Number.isNaN(retryAfter) ? 30_000 : retryAfter * 1000;
await worker.rateLimit(waitMs);
throw Worker.RateLimitError(); // processor 내부에서만 동작
}
throw err;
}
};
worker = new Worker('llm-requests', processor, {
connection,
concurrency: 3,
settings: {
backoffStrategy: (attemptsMade: number) => {
const base = Math.pow(2, attemptsMade) * 1000;
const jitter = Math.random() * 1000; // 0~1초 랜덤 지터
return base + jitter;
},
},
});
await llmQueue.add('generate', data, {
attempts: 6,
backoff: { type: 'custom' },
});429 응답이 왔을 때 worker.rateLimit(ms)로 워커 자체를 일시 정지하고, Worker.RateLimitError()를 throw해 현재 Job을 대기열 앞으로 돌려보내는 방식입니다.
중요한 점: Worker.RateLimitError()는 반드시 processor 함수 내부에서 throw해야 합니다. worker.on('failed', ...) 이벤트 리스너 안에서 throw해도 그 시점엔 이미 Job이 실패 처리된 이후라서 아무 효과가 없습니다.
실전 적용
큐 기본 세팅과 OpenAI 연동
실제 OpenAI API를 연동하는 Worker 예시입니다. limiter 옵션으로 분당 요청 수를 1차로 막고, setGlobalConcurrency로 동시 처리 수를 제한합니다.
import { Queue, Worker, QueueEvents, Job } from 'bullmq';
import OpenAI from 'openai';
import { Redis } from 'ioredis';
const connection = new Redis({ maxRetriesPerRequest: null });
const openai = new OpenAI();
const llmQueue = new Queue('llm-requests', {
connection,
defaultJobOptions: {
attempts: 6,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { count: 500 },
removeOnFail: { count: 200 },
},
});
await llmQueue.setGlobalConcurrency(5);
let worker: Worker;
const processor = async (job: Job<{ prompt: string; model: string }>) => {
const { prompt, model } = job.data;
try {
const response = await openai.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
});
return response.choices[0].message.content;
} catch (err: any) {
if (err.status === 429) {
const retryAfterRaw = err.headers?.['retry-after'] ?? '30';
const retryAfter = parseInt(retryAfterRaw, 10);
// Retry-After가 HTTP-date 형식일 경우 parseInt는 NaN을 반환합니다.
const waitMs = Number.isNaN(retryAfter) ? 30_000 : retryAfter * 1000;
await worker.rateLimit(waitMs);
throw Worker.RateLimitError();
}
throw err;
}
};
worker = new Worker('llm-requests', processor, {
connection,
concurrency: 3,
limiter: {
max: 60, // 분당 최대 60 요청
duration: 60_000,
},
lockDuration: 90_000,
});429 응답 흐름 — 실제로 어떻게 흘러가는가
Worker.RateLimitError()는 재시도 횟수(attempts)를 소모하지 않습니다. 쿼터 한도를 넘어서 발생한 일이지 Job 자체의 실패가 아니기 때문입니다.
멀티 Worker 환경에서 주의할 점: worker.rateLimit(ms)는 해당 Worker 인스턴스만 일시 중단합니다. 여러 Worker가 동시에 429를 받으면 각각 독립적으로 중단되고, 재개 타이밍이 겹치면 또다시 429 폭주로 이어질 수 있습니다. jitter가 포함된 커스텀 backoffStrategy가 이 구간에서도 재시도 타이밍을 분산시켜 줍니다.
우선순위 기반 사용자 등급 처리
// 프리미엄 사용자: 즉각 처리
await llmQueue.add('generate', { userId, prompt }, {
priority: 1,
attempts: 6,
backoff: { type: 'exponential', delay: 1000 },
});
// 무료 사용자 실시간 요청
await llmQueue.add('generate', { userId, prompt }, {
priority: 10,
attempts: 6,
backoff: { type: 'exponential', delay: 2000 },
});
// 야간 배치 작업
await llmQueue.add('batch-generate', { userId, prompt }, {
priority: 100,
attempts: 3,
backoff: { type: 'exponential', delay: 3000 },
});stalled Job 방어 — LLM 타임아웃 대응
LLM 추론은 응답이 길게 걸립니다. lockDuration 안에 응답이 오지 않으면 BullMQ는 해당 Job을 stalled로 판단하고 다른 Worker가 가져가게 합니다. 같은 Job이 두 번 처리되는 중복 문제가 생기는 지점입니다.
const worker = new Worker('llm-requests', processor, {
connection,
concurrency: 3,
lockDuration: 90_000, // 모델 최대 응답 시간 + 여유 시간
maxStalledCount: 2, // 2회 stalled 발생 시 강제 실패로 전환
stalledInterval: 30_000, // 30초마다 stalled 체크
});
worker.on('stalled', (jobId: string) => {
console.warn(`Job ${jobId} stalled — 재시도 대기열로 복귀`);
});lockDuration은 모델별 최대 응답 시간 기준으로 잡아야 합니다. 너무 짧으면 stalled 오탐이 생기고, 너무 길면 실제 Worker 크래시를 뒤늦게 감지합니다.
멀티 큐 분리 아키텍처와 모니터링
느린 배치 Job이 실시간 요청을 차단하는 현상을 없애고 싶다면 큐 자체를 분리하는 방법이 가장 확실합니다. 최대 재시도를 소진한 Job은 failed 이벤트를 수신해 수동으로 별도 분석 큐에 추가하는 패턴(Dead Letter Queue)으로 처리합니다.
const priorityQueue = new Queue('llm-priority-queue', { connection });
const dlqQueue = new Queue('llm-dlq', { connection });
const priorityWorker = new Worker('llm-priority-queue', processor, {
connection,
concurrency: 3,
limiter: { max: 40, duration: 60_000 },
lockDuration: 90_000,
maxStalledCount: 2,
});
const batchWorker = new Worker('llm-batch-queue', processor, {
connection,
concurrency: 1,
limiter: { max: 10, duration: 60_000 },
lockDuration: 120_000,
maxStalledCount: 2,
});
// 각 큐마다 동일한 패턴으로 DLQ 핸들러를 연결합니다
const priorityQueueEvents = new QueueEvents('llm-priority-queue', { connection });
priorityQueueEvents.on('failed', async ({ jobId, failedReason }) => {
const job = await Job.fromId(priorityQueue, jobId);
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await dlqQueue.add('failed-job', {
originalData: job.data,
reason: failedReason,
source: 'llm-priority-queue',
});
}
alertSlack(`Job ${jobId} failed: ${failedReason}`);
});
priorityQueueEvents.on('stalled', ({ jobId }) => {
alertSlack(`Job ${jobId} stalled in llm-priority-queue — 확인 필요`);
});장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 글로벌 동시성 제어 | setGlobalConcurrency로 수평 확장 시에도 API 쿼터 초과 방지 |
| 정밀한 재시도 제어 | 지수 백오프 + 커스텀 jitter 전략 조합 가능 |
| 우선순위 큐 내장 | 단일 큐에서 SLA가 다른 워크로드를 혼합 처리 |
| Redis 기반 내구성 | Worker 크래시 후 stalled Job 자동 복구 |
| 동적 레이트 리밋 | worker.rateLimit(ms) + Worker.RateLimitError()로 429 즉각 대응 |
| DLQ 패턴 구현 가능 | failed 이벤트를 수신해 별도 분석 큐로 수동 라우팅 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| Redis 단일 의존성 | Redis 장애 시 큐 전체 불가. Sentinel/Cluster 구성 권장 |
| TPM 제한 처리 한계 | 요청 수 기반 limiter만으로는 토큰 수 제한에 대응 불가. 사전 토큰 추정 로직 별도 구현 필요 |
| 우선순위는 보장이 아닌 가중치 | 엄격한 순서가 필요하면 큐 분리가 더 안전 |
| lockDuration 설정 오류 | 너무 짧으면 stalled 오탐, 너무 길면 실제 문제 감지 지연. 모델별 최대 응답 시간 기준으로 설정 |
실무에서 흔한 실수 두 가지
1. worker.on('failed') 안에서 Worker.RateLimitError() throw
failed 이벤트는 Job이 이미 실패 처리된 이후에 발생하기 때문에, 그 안에서 throw해도 큐에 아무 영향이 없습니다.
// 잘못된 예 — 아무런 효과 없음
worker.on('failed', async (job, err: any) => {
if (err.status === 429) {
throw Worker.RateLimitError(); // 여기선 동작하지 않음
}
});
// 올바른 예 — processor 내부에서 처리
const processor = async (job: Job<{ prompt: string; model: string }>) => {
try {
return await callLLMApi(job.data);
} catch (err: any) {
if (err.status === 429) {
await worker.rateLimit(30_000);
throw Worker.RateLimitError();
}
throw err;
}
};2. jitter 없이 지수 백오프만 쓰는 경우
type: 'exponential'만 설정하면 동시에 실패한 모든 Job이 똑같은 시점에 재시도합니다. Worker 10개가 동시에 429를 받고 8초 후에 일제히 재시도하면 다시 429가 터집니다. settings.backoffStrategy로 jitter를 추가하면 재시도 타이밍이 분산됩니다.
마치며
세 가지가 핵심입니다.
setGlobalConcurrency(N): Worker를 몇 개로 늘려도 전체 동시 요청 수가 N을 넘지 않습니다. LLM API 쿼터 방어의 핵심입니다.- processor 내부의
Worker.RateLimitError(): 429가 왔을 때 재시도 횟수를 소모하지 않고 Job을 대기열 앞으로 돌려보내는 올바른 방법입니다. - 커스텀
backoffStrategy+ jitter: 여러 Worker의 재시도 타이밍을 분산시켜 동시 재시도 폭주를 막습니다.
- 기존 동기 LLM 호출 코드를 BullMQ Queue + Worker로 감쌉니다.
setGlobalConcurrency(5)와limiter설정으로 기본 쿼터 방어부터 확인할 수 있습니다. - processor 내부에 429 처리 분기를 추가합니다.
retry-after헤더 값을 읽어worker.rateLimit()에 넘깁니다. @bull-board/express로 대시보드를 띄웁니다. Job 상태를 시각화하면 어떤 Job이 쌓이고, 어디서 실패하는지 바로 보입니다.
TPM 제한 대응은 요청 수 기반 limiter만으로는 해결이 안 됩니다. 프롬프트 길이를 기반으로 토큰 수를 사전 추정하고, 이를 커스텀 limiter의 가중치로 사용하는 방식이 현실적인 접근이며, 이 로직은 processor 진입 전에 잡아야 효과가 있습니다.
참고 자료
- BullMQ 공식 문서 — 동시성(Concurrency)
- BullMQ 공식 문서 — 글로벌 동시성(Global Concurrency)
- BullMQ 공식 문서 — 우선순위 Job(Prioritized)
- BullMQ 공식 문서 — 레이트 리밋(Rate Limiting)
- BullMQ 공식 문서 — 실패 Job 재시도(Retrying Failing Jobs)
- BullMQ 공식 문서 — Stalled Jobs
- BullMQ v5 마이그레이션 노트
- BullMQ 병렬성과 동시성 — 공식 가이드
- BullMQ 커스텀 백오프 전략
- Advanced BullMQ Features: Rate Limiting, Job Retries, and Concurrency — GoPenAI Blog
- LLM API Rate Limiting Best Practices — ClawPulse Blog
- Rate-Limit Recipes in Node.js using BullMQ — Taskforce Blog
- BullMQ Production Architecture: Patterns That Hold at 500 Jobs/Second — Markaicode
- Reliable Job Queue with BullMQ and Redis: AI Task Processing — Markaicode
- How to Implement Dead Letter Queues in BullMQ — OneUptime Blog
- BullMQ 5 Background Jobs in Node.js 2026 Guide — 1xAPI
- GitHub: taskforcesh/bullmq — Releases