Node.js 비동기 작업을 안정적으로 처리하는 BullMQ 5 잡 큐 — 재시도 전략·Dead Letter Queue·Sandboxed Processor·Prometheus 메트릭 연동까지
회원 가입 API를 만들다 보면 어느 순간 이런 생각이 듭니다. "환영 이메일을 발송하는 동안 HTTP 응답이 블로킹되는 게 맞는 걸까?" 저도 처음엔 그냥 await sendEmail()을 응답 전에 넣었는데, SendGrid 타임아웃이 터지는 순간 모든 가입 요청이 500을 반환하더군요. 이미지 리사이징, 웹훅 처리, 외부 API 호출까지 생각하면 비동기 잡 큐는 선택이 아니라 필수입니다.
Node.js 생태계에서 Redis를 백엔드로 사용하는 잡 큐 라이브러리의 사실상 표준은 BullMQ입니다. 기존 Bull 라이브러리를 TypeScript-first로 완전히 재작성한 버전으로, 2025~2026년 현재 v5.x대에서 활발히 패치가 이어지고 있습니다. 이 글에서는 재시도 전략(Exponential Backoff + Jitter), Dead Letter Queue(DLQ) 직접 구현, CPU 집약적 작업을 위한 Sandboxed Processor, Prometheus 메트릭 연동까지 프로덕션에서 실제로 필요한 네 가지 주제를 코드와 함께 살펴봅니다.
이 글을 읽고 나면 "잡이 실패했는데 왜 알림이 없지?", "재시도가 얼마나 됐는지 어떻게 알지?" 같은 질문에 스스로 답할 수 있게 됩니다. Node.js v20 LTS와 BullMQ 5, Redis 7 기준으로 작성했습니다.
핵심 개념
BullMQ의 구성 요소
BullMQ를 처음 접하면 Queue, Worker, QueueEvents가 왜 각각 존재하는지 헷갈립니다. 솔직히 저도 처음엔 Worker 하나에서 다 해결하려다가 꼬인 적이 있었는데요. 각각의 역할은 명확하게 분리돼 있습니다.
| 구성 요소 | 역할 |
|---|---|
| Queue | Redis에 잡을 추가하는 생산자(Producer). add() 메서드로 데이터와 옵션 설정 |
| Worker | 큐에서 잡을 꺼내 실행하는 소비자(Consumer). concurrency로 동시 처리 수 제어 |
| QueueEvents | 큐 이벤트(완료, 실패 등)를 구독하는 이벤트 리스너 |
| FlowProducer | 부모-자식 의존성 잡 흐름(DAG) 생성 및 관리 |
| Sandboxed Processor | 잡을 독립된 자식 프로세스에서 실행해 메인 프로세스를 보호 |
전체 아키텍처를 시각적으로 보면 다음과 같습니다. DLQ로의 이동은 BullMQ 내장 기능이 아니라 QueueEvents를 통해 애플리케이션 레이어에서 직접 구현하는 부분입니다.
잡 상태 머신
BullMQ의 잡은 waiting → active → completed/failed 상태를 Lua 스크립트로 원자적으로 전환합니다. 이 덕분에 잡 유실이나 중복 실행 위험이 최소화됩니다. Worker가 비정상 종료되면 active 상태로 남는 "stalled job"이 발생할 수 있는데, stalledInterval 설정으로 자동 감지 및 재시작이 가능합니다. 단, 이 경우 잡이 두 번 실행될 수 있으므로 멱등성(idempotency) 설계가 반드시 필요합니다.
설치 및 기본 설정
npm install bullmq ioredis// src/queue/config.ts
import IORedis from 'ioredis';
const connection = new IORedis({
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
maxRetriesPerRequest: null, // BullMQ 필수 설정
});
export { connection };maxRetriesPerRequest: null은 BullMQ가 요구하는 필수 옵션입니다. BullMQ 내부에서는 BRPOPLPUSH 같은 blocking command를 사용하는데, ioredis의 기본 maxRetriesPerRequest 설정이 이와 충돌하기 때문입니다. 이 옵션이 없으면 BullMQ가 시작 시점에 명시적 에러를 던지며 Worker 초기화를 차단합니다.
재시도 전략: Backoff와 Jitter
BullMQ의 재시도는 attempts와 backoff 옵션으로 제어합니다.
Fixed Backoff는 고정된 간격으로 재시도합니다. 일시적인 오류에 적합하지만, 여러 Worker가 동시에 실패하면 같은 시점에 몰려서 재시도하는 "Thundering Herd" 문제가 생길 수 있습니다.
Exponential Backoff는 재시도 간격이 점점 늘어납니다. delay: 3000ms로 설정하고 attempts: 5를 지정하면 4번의 재시도가 3s → 6s → 12s → 24s 간격으로 실행됩니다. 외부 API 레이트 리밋 초과 상황에서 특히 유용합니다.
Jitter는 재시도 간격에 무작위 편차를 추가해 Thundering Herd를 방지합니다. jitter: 0.5 설정 시 delay의 50%~100% 범위에서 무작위 값을 사용합니다. 아래 시나리오 1 코드에서 실제 적용 방법을 확인하세요.
Custom Backoff는 HTTP 429의 Retry-After 헤더처럼 외부 신호에 따라 재시도 간격을 동적으로 결정해야 할 때 사용합니다. Worker의 settings.backoffStrategy 함수로 정의하고, 잡 추가 시 backoff: { type: 'custom' }로 참조합니다.
import { Worker, Job } from 'bullmq';
import { connection } from './config';
const worker = new Worker(
'email',
async (job) => { /* 처리 로직 */ },
{
connection,
concurrency: 10,
settings: {
// HTTP 429 응답의 Retry-After 헤더 값을 우선 사용하는 예시
backoffStrategy: (attemptsMade: number, type: string, err: any, job: Job) => {
if (err?.retryAfter) {
return err.retryAfter * 1000;
}
return Math.pow(2, attemptsMade) * 1000;
},
},
}
);
// 잡 추가 시 'custom' 타입으로 위 함수를 참조
await emailQueue.add('jobName', data, {
attempts: 5,
backoff: { type: 'custom' },
});실전 적용
시나리오 1: 이메일 발송 파이프라인
가장 전형적인 사례입니다. 회원 가입 API에서 이메일 발송을 분리해 응답 지연 없이 처리하는 패턴입니다.
// src/queue/email.queue.ts
import { Queue } from 'bullmq';
import { connection } from './config';
const emailQueue = new Queue('email', { connection });
await emailQueue.add(
'sendWelcomeEmail',
{ userId: 'user-123', email: 'user@example.com', name: '홍길동' },
{
attempts: 5,
backoff: {
type: 'exponential',
delay: 3000, // 1차 재시도: 3s, 2차: 6s, 3차: 12s, 4차: 24s
jitter: 0.5, // delay의 50%~100% 범위로 무작위 편차 적용해 Thundering Herd 방지
},
removeOnComplete: 100, // 완료된 잡 최대 100개만 보관
removeOnFail: 500, // 실패한 잡 최대 500개만 보관
}
);// src/queue/email.worker.ts
import { Worker, Job, MetricsTime } from 'bullmq';
import { connection } from './config';
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
interface EmailJobData {
userId: string;
email: string;
name: string;
}
export const emailWorker = new Worker<EmailJobData>(
'email',
async (job: Job<EmailJobData>) => {
const { email, name } = job.data;
await sgMail.send({
to: email,
from: 'noreply@example.com',
subject: '환영합니다!',
text: `안녕하세요, ${name}님. 가입을 환영합니다.`,
});
return { sentAt: new Date().toISOString() };
},
{
connection,
concurrency: 10, // I/O 바운드 잡은 vCPU × 2 정도
metrics: {
maxDataPoints: MetricsTime.ONE_WEEK, // exportPrometheusMetrics() 시계열 데이터 활성화
},
}
);
emailWorker.on('completed', (job) => {
console.log(`이메일 발송 완료: jobId=${job.id}`);
});
emailWorker.on('failed', (job, err) => {
console.error(`이메일 발송 실패: jobId=${job?.id}, error=${err.message}`);
});시나리오 2: Dead Letter Queue 구현
BullMQ는 DLQ를 내장 기능으로 제공하지 않습니다. QueueEvents의 failed 이벤트를 구독하고, 최대 재시도 횟수를 모두 소진한 잡만 DLQ로 이동시키는 패턴으로 직접 구현합니다.
재시도가 남아있는 경우에도 failed 이벤트가 발생하므로, job.attemptsMade >= job.opts.attempts 조건 확인이 핵심입니다.
// src/queue/dlq.ts
import { Queue, QueueEvents } from 'bullmq';
import { connection } from './config';
const SOURCE_QUEUE_NAME = 'email';
// 모듈 스코프에서 한 번만 생성해 재사용
// 매 이벤트마다 재생성하면 고부하 시 Redis 연결이 폭증합니다
const sourceQueue = new Queue(SOURCE_QUEUE_NAME, { connection });
const dlqQueue = new Queue(`${SOURCE_QUEUE_NAME}-dlq`, { connection });
const queueEvents = new QueueEvents(SOURCE_QUEUE_NAME, { connection });
queueEvents.on('failed', async ({ jobId, failedReason }) => {
const job = await sourceQueue.getJob(jobId);
if (!job) return;
// 재시도가 아직 남아있는 경우는 DLQ로 이동하지 않음
const maxAttempts = job.opts.attempts ?? 1;
if (job.attemptsMade < maxAttempts) return;
// 모든 재시도 소진 후 DLQ로 이동
await dlqQueue.add(
'dead-letter',
{
originalJobId: job.id,
originalQueue: SOURCE_QUEUE_NAME,
jobName: job.name,
data: job.data,
failedReason,
failedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade,
},
{
removeOnComplete: false, // DLQ 잡은 수동 검토 전까지 보관
removeOnFail: false,
}
);
console.warn(`DLQ로 이동: originalJobId=${job.id}, reason=${failedReason}`);
});DLQ에 쌓인 잡은 BullBoard UI에서 확인하거나, 별도의 관리 스크립트로 재처리할 수 있습니다.
// src/queue/dlq-reprocess.ts
import { Queue } from 'bullmq';
import { connection } from './config';
async function reprocessDLQ(limit = 10) {
const dlqQueue = new Queue('email-dlq', { connection });
const emailQueue = new Queue('email', { connection });
const waitingJobs = await dlqQueue.getJobs(['waiting'], 0, limit);
for (const dlqJob of waitingJobs) {
const { data, jobName } = dlqJob.data;
await emailQueue.add(jobName, data, {
attempts: 5,
backoff: { type: 'exponential', delay: 3000, jitter: 0.5 },
});
await dlqJob.remove();
console.log(`재투입 완료: originalJobId=${dlqJob.data.originalJobId}`);
}
await dlqQueue.close();
await emailQueue.close();
}
reprocessDLQ();시나리오 3: 이미지 처리 — Sandboxed Processor 활용
이미지 리사이징은 CPU 집약적인 작업이라, 메인 Worker 프로세스에서 직접 실행하면 이벤트 루프가 블로킹됩니다. BullMQ의 Sandboxed Processor를 활용하면 잡을 독립된 자식 프로세스에서 실행해 메인 프로세스를 보호할 수 있습니다.
Sandboxed Processor는 별도의 자식 프로세스로 실행되므로, Worker가 참조하는 파일은 컴파일된 JS여야 합니다. 이 때문에 프로세서 파일에서는 TypeScript임에도 ESM 방식이 아닌 CommonJS의 module.exports를 사용해야 합니다.
// src/queue/image.worker.ts
import { Worker } from 'bullmq';
import path from 'path';
import { connection } from './config';
export const imageWorker = new Worker(
'image-processing',
path.resolve(__dirname, './processors/image.processor.js'), // 컴파일된 JS 파일 참조
{
connection,
concurrency: 2, // CPU 바운드 잡은 1~2로 설정 권장
}
);// src/queue/processors/image.processor.ts
import { SandboxedJob } from 'bullmq';
import sharp from 'sharp';
// 자식 프로세스 실행 방식이므로 CommonJS module.exports가 요구됩니다
module.exports = async (job: SandboxedJob) => {
const { inputPath, outputPath, width, height } = job.data;
await sharp(inputPath)
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: 85 })
.toFile(outputPath);
await job.updateProgress(100);
return { outputPath };
};시나리오 4: Prometheus 메트릭 연동
queue.exportPrometheusMetrics()가 시계열 지표(완료 수, 실패 수 등)를 내보내려면, 먼저 Worker에서 메트릭 수집을 활성화해야 합니다. 이 설정 없이 /metrics 엔드포인트만 구성하면 시계열 지표가 빈 값으로 반환됩니다. Worker에 metrics: { maxDataPoints: MetricsTime.ONE_WEEK } 옵션을 추가하는 것이 선행 조건이며, 시나리오 1의 emailWorker 코드에서 이미 적용했습니다.
Worker 설정이 완료됐다면 Express에 /metrics 엔드포인트를 추가합니다.
// src/metrics/prometheus.ts
import express from 'express';
import { Queue } from 'bullmq';
import { Registry, Gauge, collectDefaultMetrics } from 'prom-client';
import { connection } from '../queue/config';
const app = express();
const register = new Registry();
collectDefaultMetrics({ register });
const emailQueue = new Queue('email', { connection });
const imageQueue = new Queue('image-processing', { connection });
const queues = [emailQueue, imageQueue];
const queueDepthGauge = new Gauge({
name: 'bullmq_queue_depth',
help: '큐에 대기 중인 잡 수',
labelNames: ['queue', 'status'],
registers: [register],
});
app.get('/metrics', async (_req, res) => {
for (const queue of queues) {
const counts = await queue.getJobCounts(
'waiting', 'active', 'completed', 'failed', 'delayed'
);
for (const [status, count] of Object.entries(counts)) {
queueDepthGauge.set({ queue: queue.name, status }, count);
}
}
const bullMetrics = await Promise.all(
queues.map((q) => q.exportPrometheusMetrics())
);
const customMetrics = await register.metrics();
res.set('Content-Type', 'text/plain');
res.send([customMetrics, ...bullMetrics].join('\n'));
});
app.listen(9100, () => {
console.log('Prometheus 메트릭 서버 시작: http://localhost:9100/metrics');
});이렇게 구성하면 Grafana에서 큐 깊이, 처리율, 실패율을 실시간으로 대시보드로 확인할 수 있습니다. 독립적인 익스포터를 원한다면 bullmq-prometheus나 bullmq-exporter 같은 오픈소스 도구를 그대로 활용하는 방법도 있습니다.
Graceful Shutdown
Worker가 SIGTERM을 받을 때 worker.close()를 호출하지 않고 프로세스를 종료하면, 처리 중이던 잡이 active 상태로 Redis에 남습니다. 다음 재시작 시 이 잡은 stalled job으로 처리되어 다시 실행되므로, 멱등성이 없는 잡이라면 이메일 중복 발송이나 결제 중복 처리로 이어질 수 있습니다.
// src/queue/graceful-shutdown.ts
import { emailWorker } from './email.worker';
import { imageWorker } from './image.worker';
const workers = [emailWorker, imageWorker];
async function shutdown() {
console.log('종료 신호 수신, 진행 중인 잡 완료 후 종료합니다...');
// close()는 현재 처리 중인 잡이 완료될 때까지 대기 후 Worker를 종료합니다
await Promise.all(workers.map((w) => w.close()));
console.log('Worker 종료 완료');
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);컨테이너 환경에서는 SIGTERM이 기본적으로 전달되므로 이 핸들러를 반드시 등록해야 합니다. Kubernetes를 사용한다면 terminationGracePeriodSeconds를 Worker가 처리 중인 잡의 최대 실행 시간보다 길게 설정하는 것도 함께 고려하세요.
장단점 분석
BullMQ vs 대안 도구 비교
처리량 수치는 하드웨어·페이로드 크기·설정에 따라 수 배씩 달라지므로 실제 워크로드 기반 벤치마크를 별도로 진행하세요. 아래 표는 상대적인 특성 비교입니다.
| 도구 | 백엔드 | 처리량 | 특징 |
|---|---|---|---|
| BullMQ | Redis | 높음 | 풍부한 기능, TypeScript 네이티브, Flow/DAG 지원 |
| Bee-Queue | Redis | 높음 | 단순·경량, 기능 제한적 |
| pg-boss | PostgreSQL | 중간 | Redis 불필요, ACID 보장, 고처리량에서 병목 가능 |
| Agenda | MongoDB | 중간 | MongoDB 백엔드, 기능 제한적 |
| RabbitMQ | 독립 브로커 | 높음 | pub/sub 강점, 잡 처리 기능은 약함 |
| AWS SQS | 완전 관리형 | 중간 | 자동 스케일, 레이턴시 높음 |
| Temporal | 독립 서비스 | 높음 | 복잡한 워크플로우 오케스트레이션에 강점, 운영 복잡도 높음 |
BullMQ 장단점
| 구분 | 내용 |
|---|---|
| ✅ 풍부한 기능 세트 | 우선순위, 지연 잡, 레이트 리밋, 반복 잡, Flow, Sandboxed Processor 내장 |
| ✅ TypeScript 네이티브 | 완전한 타입 정의, IDE 자동완성 |
| ✅ 안정적인 상태 머신 | Lua 스크립트 기반 원자적 상태 전환, 잡 유실·중복 실행 방지 |
| ✅ 수평 확장 | Worker를 독립 컨테이너로 분산 배포해 처리량 선형 확장 |
| ✅ 생태계 성숙 | BullBoard, NestJS 공식 통합, Prometheus 익스포터 등 |
| ❌ Redis 의존성 | Redis 없이 사용 불가. PostgreSQL만 쓰는 팀은 pg-boss 검토 가치 있음 |
| ❌ DLQ 미내장 | 직접 구현 필요 (QueueEvents + 별도 큐 패턴) |
| ❌ Redis SPOF 위험 | Redis 장애 시 전체 큐 마비. Sentinel 또는 Cluster 구성이 필요 |
| ❌ 메모리 기반 저장소 | 대용량 페이로드는 S3에 저장 후 큐에는 참조만 담는 패턴 권장 |
실무에서 흔한 실수
1. maxRetriesPerRequest: null 누락
가장 흔한 설정 실수입니다. BullMQ가 시작 시점에 명시적 에러로 차단하므로, 처음 연결 시 반드시 확인하세요.
2. DLQ 이동 조건 확인 누락
QueueEvents.failed는 재시도 중인 잡에서도 발생합니다. job.attemptsMade < maxAttempts 조건을 확인하지 않으면 매 실패마다 DLQ에 쌓입니다.
3. DLQ 핸들러 내 Queue 인스턴스 반복 생성
failed 이벤트 콜백 안에서 new Queue()를 매번 호출하면 고부하 시 Redis 연결이 폭증합니다. 모듈 스코프에서 한 번 생성해 재사용하세요.
4. CPU 바운드 잡의 높은 concurrency 설정
I/O 바운드(이메일, HTTP 호출)는 vCPU × 2 수준, CPU 바운드(이미지 처리, 암호화)는 1~2가 권장됩니다. CPU 바운드 잡에 높은 concurrency를 설정하면 이벤트 루프가 블로킹됩니다.
5. Redis maxmemory-policy 설정 무시
noeviction으로 설정하지 않으면 Redis가 메모리 압박 시 잡 데이터를 임의로 삭제할 수 있습니다. 프로덕션에서 반드시 확인해야 할 항목입니다.
6. Stalled Job에 대한 멱등성 미설계
Worker가 비정상 종료되면 active 상태의 잡이 다시 처리됩니다. 잡 처리 로직에 멱등성이 없다면 이메일 중복 발송이나 결제 중복 처리 같은 문제로 이어집니다.
7. Prometheus 메트릭 선행 조건 누락
Worker에 metrics: { maxDataPoints: MetricsTime.ONE_WEEK } 없이 exportPrometheusMetrics()만 호출하면 시계열 지표가 비어 있습니다. Worker 설정이 먼저입니다.
마치며
BullMQ 5는 Redis를 인프라에 이미 가지고 있는 팀이라면 Node.js 비동기 잡 처리에서 가장 검증된 조합입니다. 단순한 Fire-and-Forget 큐를 넘어, 재시도 전략(Exponential Backoff + Jitter)으로 외부 의존성 장애를 흡수하고, DLQ로 실패한 잡을 유실 없이 보관하며, Prometheus + Grafana로 큐 상태를 실시간으로 파악하는 것이 프로덕션 수준의 잡 큐입니다.
지금 시작할 수 있는 3단계:
-
로컬 Redis 띄우고 기본 Queue + Worker 연결해 보기 — Docker로
redis:7-alpine이미지를 실행하고 이 글의 설치 코드를 그대로 붙여 넣어 보시면 됩니다.maxRetriesPerRequest: null옵션은 꼭 챙겨주세요. -
실패 시나리오 시뮬레이션 — Worker 처리 함수에 의도적으로 에러를 던지고,
attempts: 3,backoff: { type: 'exponential', delay: 1000, jitter: 0.5 }옵션을 줘서 재시도 로그를 확인해 보시면 동작 방식이 바로 와닿습니다. -
DLQ 이벤트 핸들러 붙이기 — 재시도 소진 후 DLQ 큐로 이동되는 것을 확인하고,
@bull-board/express로 BullBoard UI를 붙여보시면 됩니다. 큐의 상태를 실시간으로 시각적으로 파악할 수 있어 운영 대응이 훨씬 명확해집니다.
Redis 없이 시작하고 싶다면 pg-boss도 좋은 선택입니다. 하지만 초당 수백 건 이상을 목표로 하거나, Flow/DAG나 Sandboxed Processor 같은 고급 기능이 필요하다면 BullMQ + Redis가 여전히 가장 신뢰할 수 있는 선택입니다.
참고 자료
- BullMQ 공식 홈페이지
- BullMQ 공식 문서 — 전체 가이드
- BullMQ 공식 문서 — 재시도 실패 잡
- BullMQ 공식 문서 — Prometheus 메트릭
- BullMQ 공식 문서 — Going to Production
- BullMQ 공식 문서 — Sandboxed Processors
- BullMQ 공식 문서 — Concurrency
- BullMQ v5 마이그레이션 노트
- BullMQ GitHub Releases
- How to Build a Job Queue in Node.js with BullMQ and Redis
- How to Implement Dead Letter Queues in BullMQ
- How to Implement Job Retries with Exponential Backoff in BullMQ
- How to Export BullMQ Metrics to Prometheus
- BullMQ 5 Background Jobs in Node.js 2026 Guide
- BullMQ Production Architecture: Patterns That Hold at 500 Jobs/Second
- BullMQ Architecture for High Traffic: Queue Isolation and Redis Clustering
- Node.js Job Queues in Production: BullMQ, Bull, and Worker Threads
- Background Job Processing in Node.js: BullMQ, Queues, and Worker Patterns 2026
- BullMQ vs Other Queue Systems
- BullMQ vs Bee-Queue vs pg-boss 2026
- BullMQ Demystified: Queue Names, Job Names, and DLQs
- bullmq-prometheus — GitHub
- bullmq-exporter — GitHub
- How I Scaled a Node.js Worker System to Handle 2M Background Jobs per Day
- Level Up Your NestJS App with BullMQ Queues, DLQs & Bull Board