BullMQ 5 + Redis로 Node.js 백그라운드 작업 큐 운영하기
API 응답 안에서 이메일을 보내거나, 외부 결제 API를 호출하거나, 대용량 데이터 변환을 실행하면 어떤 일이 생기는지는 다들 한 번쯤 경험해봤을 겁니다. BullMQ는 Redis를 백엔드로 사용하는 Node.js 작업 큐 라이브러리로, 기존 Bull 라이브러리를 TypeScript로 전면 재작성한 후속작입니다. Bull이 유지보수 모드로 전환된 지금, Node.js 스택에서 백그라운드 작업 큐가 필요하다면 BullMQ가 기본 선택지입니다.
이 글에서는 우선순위 큐, 지연 실행, 재시도/백오프, Dead Letter Queue 네 가지 패턴을 중심으로 프로덕션에 바로 적용할 수 있는 코드와 함께 설명합니다. BullMQ 5.x 기준이며, TypeScript strict 모드 환경을 가정합니다.
핵심 개념
구성 요소
BullMQ를 처음 접하면 Queue, Worker, Job, FlowProducer가 동시에 등장해서 조금 혼란스러울 수 있습니다. 역할을 먼저 정리하고 시작합니다.
| 구성 요소 | 역할 |
|---|---|
| Queue | 작업을 등록하는 진입점. Redis에 작업을 적재합니다 |
| Worker | 큐에서 작업을 꺼내어 실제로 처리하는 소비자 |
| Job | 실행 단위. 데이터, 우선순위, 지연, 재시도 설정을 포함합니다 |
| FlowProducer | 작업 간 부모-자식 의존성으로 DAG 파이프라인을 구성합니다 |
| QueueEvents | completed, failed, delayed 등의 이벤트를 구독합니다 |
전체 데이터 흐름은 다음과 같습니다.
설치 및 기본 설정
pnpm add bullmq ioredis// lib/redis.ts
import { Redis } from 'ioredis';
export const redisConnection = new Redis({
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
maxRetriesPerRequest: null,
});
maxRetriesPerRequest: null을 빠뜨리면 BullMQ가 경고를 출력합니다. ioredis 기본값은 명령어 실패 시 재시도 횟수를 제한하는데, BullMQ는 연결이 끊겼을 때 무한 대기가 필요하기 때문에 반드시null로 설정해야 합니다.
// queues/email.queue.ts
import { Queue } from 'bullmq';
import { redisConnection } from '../lib/redis';
export const emailQueue = new Queue('email', {
connection: redisConnection,
defaultJobOptions: {
removeOnComplete: { count: 1_000, age: 24 * 3600 },
removeOnFail: { count: 5_000, age: 7 * 24 * 3600 },
},
});removeOnComplete와 removeOnFail은 반드시 설정해두시기 바랍니다. 설정하지 않으면 완료·실패 작업 데이터가 Redis에 계속 쌓입니다. 위의 count: 5_000과 age: 7일은 중간 규모 서비스 기준의 예시로, 트래픽이 높은 시스템에서는 Redis 메모리를 압박할 수 있으므로 실제 처리량에 맞게 조정하세요.
개발 환경에서 작업 흐름 시각화하기: Bull Board
로컬에서 BullMQ를 처음 설정할 때 가장 빠르게 확인하고 싶은 것이 "작업이 정말 큐에 쌓이고, Worker가 가져가고 있는가"입니다. Bull Board가 그 용도에 가장 적합합니다.
pnpm add @bull-board/api @bull-board/express// app.ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import basicAuth from 'express-basic-auth';
import { emailQueue } from './queues/email.queue';
import { paymentQueue } from './queues/payment.queue';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [
new BullMQAdapter(emailQueue),
new BullMQAdapter(paymentQueue),
],
serverAdapter,
});
// 프로덕션에서는 인증 미들웨어를 반드시 앞에 붙이세요.
app.use(
'/admin/queues',
basicAuth({
users: { admin: process.env.BULL_BOARD_PASSWORD ?? '' },
challenge: true,
}),
serverAdapter.getRouter(),
);Bearer 토큰이나 세션 기반 인증이 이미 있다면 basicAuth 대신 해당 미들웨어를 사용하면 됩니다. 핵심은 /admin/queues가 인증 없이 공개되어선 안 된다는 점입니다. 이 대시보드에서 누구나 실패한 작업을 재시도하거나 삭제할 수 있으니까요.
패턴 1: 우선순위 큐
BullMQ는 1부터 2,097,152까지의 정수 우선순위를 지원합니다. 숫자가 낮을수록 먼저 처리됩니다.
// VIP 사용자 트랜잭션 메일 — 즉시 처리
await emailQueue.add('transactional', { to: vipUser.email }, { priority: 1 });
// 일반 알림 메일
await emailQueue.add('notification', { to: user.email }, { priority: 5 });
// 마케팅 뉴스레터 — 여유 있을 때
await emailQueue.add('newsletter', { to: user.email }, { priority: 10 });"큐 세 개 만들면 되는 거 아닌가?" 하는 의문이 드는 게 자연스럽습니다. 실제로 Sidekiq의 queue weight 방식처럼 여러 큐를 두는 패턴도 유효한 대안입니다. 선택 기준은 이렇습니다.
- 단일 우선순위 큐가 유리한 경우: 전체 작업 수가 많지 않고, 모든 작업을 하나의 Worker 풀이 처리할 때. Worker 설정이 단순해지고 전역 순서 보장이 쉽습니다.
- 다중 큐가 나은 경우: 우선순위별로 Worker 풀 크기나 동시성을 독립적으로 제어해야 할 때. 예를 들어 VIP 큐에는 Worker 10개, 뉴스레터 큐에는 2개를 할당하고 싶은 경우입니다. 이 경우 각 큐에 별도 Worker를 연결하면 됩니다.
패턴 2: 지연 실행
delay 옵션에 밀리초 값을 넣으면 됩니다. Redis Sorted Set에 실행 예정 시각과 함께 저장되었다가, 그 시각이 지나면 대기 큐로 자동 이동합니다.
// 회원가입 5분 후 웰컴 이메일
await emailQueue.add(
'welcome',
{ to: newUser.email, name: newUser.name },
{ delay: 5 * 60 * 1_000 },
);
// 결제 1시간 후 영수증 발송
await emailQueue.add(
'receipt',
{ orderId, to: user.email },
{ delay: 60 * 60 * 1_000 },
);
// 매주 월요일 오전 9시 리포트 — 한국 시각 기준
await emailQueue.add(
'weekly-report',
{ type: 'summary' },
{
repeat: {
pattern: '0 9 * * MON',
tz: 'Asia/Seoul',
},
},
);repeat.pattern에서 tz 옵션을 지정하지 않으면 기본값은 UTC입니다. 한국 서비스라면 "매주 월요일 오전 9시"가 실제로는 오후 6시에 실행될 수 있으니, tz: 'Asia/Seoul' 설정을 빠뜨리지 않도록 주의하세요.
패턴 3: 재시도와 백오프
외부 API 호출이 실패했을 때 즉시 재시도하면 이미 과부하 상태인 서버에 요청을 퍼붓는 꼴이 됩니다. **지수 백오프(Exponential Backoff)**를 사용하는 이유입니다.
await paymentQueue.add('confirm-payment', { orderId }, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 1_000,
// 1초 → 2초 → 4초 → 8초 → 16초
},
});공식은 2^(attemptsMade - 1) × delay(ms)입니다.
재시도에도 전혀 의미 없는 오류 유형이 있습니다. 잘못된 입력값이나 존재하지 않는 리소스 참조처럼 재시도해도 결과가 달라지지 않는 오류는 즉시 실패 처리하는 것이 맞습니다. BullMQ 5에서는 UnrecoverableError를 던지면 됩니다.
import { UnrecoverableError } from 'bullmq';
async function processor(job: Job) {
try {
return await riskyApiCall(job.data);
} catch (error) {
if (error instanceof ValidationError) {
// 재시도해도 달라지지 않는 오류 — 즉시 최종 실패 처리
throw new UnrecoverableError(`검증 오류: ${error.message}`);
}
throw error; // 일시적 오류 — 재시도 트리거
}
}더 세밀하게 제어하고 싶다면 Worker의 backoffStrategy에서 직접 계산할 수 있습니다. 여러 작업이 동시에 재시도되는 썬더링 허드(Thundering Herd) 현상을 줄이기 위해 **Jitter(무작위 지연)**를 추가하는 패턴입니다.
import { Worker, Job } from 'bullmq';
const worker = new Worker('payment', processor, {
settings: {
backoffStrategy: (
attemptsMade: number,
_type: string,
_err: Error,
_job: Job,
): number => {
const base = Math.min(attemptsMade * 2_000, 30_000);
const jitter = Math.random() * 1_000;
return base + jitter;
},
},
connection: redisConnection,
});실전: 결제 확인 파이프라인
외부 PG API는 일시적 장애가 잦습니다. 지수 백오프로 일시적 장애를 버티고, 복구 불가 오류나 재시도가 모두 소진되면 Dead Letter Queue(DLQ)로 이동해 운영팀이 검토할 수 있게 합니다.
// queues/payment.queue.ts
import { Queue } from 'bullmq';
import { redisConnection } from '../lib/redis';
export interface PaymentJobData {
orderId: string;
amount: number;
userId: string;
}
export const paymentQueue = new Queue<PaymentJobData>('payment', {
connection: redisConnection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { count: 500 },
removeOnFail: { count: 2_000, age: 7 * 24 * 3600 },
},
});
export const dlqQueue = new Queue('payment-dlq', {
connection: redisConnection,
});// workers/payment.worker.ts
import { Worker, Job, UnrecoverableError } from 'bullmq';
import { PaymentJobData, paymentQueue, dlqQueue } from '../queues/payment.queue';
import { pgApiClient } from '../lib/pg-client';
import { notifyOncall } from '../lib/notifications';
import { redisConnection } from '../lib/redis';
async function processPayment(job: Job<PaymentJobData>) {
const { orderId, amount } = job.data;
await job.updateProgress(10);
try {
const result = await pgApiClient.confirmPayment({ orderId, amount });
await job.updateProgress(100);
return result;
} catch (error) {
if (error instanceof InvalidOrderError) {
// 재시도해도 달라지지 않는 오류 — 즉시 DLQ로
throw new UnrecoverableError(`주문 오류: ${(error as Error).message}`);
}
throw error;
}
}
const worker = new Worker<PaymentJobData>('payment', processPayment, {
connection: redisConnection,
concurrency: 5,
stalledInterval: 30_000, // 30초마다 stall 감지
maxStalledCount: 2, // 2번 stall 이후 failed 처리
});같은 orderId에 대해 결제 확인 작업이 중복으로 등록되는 상황은 결제 도메인에서 실제로 발생합니다. jobId를 orderId로 고정하면 이미 큐에 있는 작업을 덮어쓰지 않고 중복을 방지할 수 있습니다.
// 결제 확인 작업 등록 — orderId로 중복 방지
await paymentQueue.add(
'confirm-payment',
{ orderId, amount, userId },
{ jobId: orderId },
);DLQ 핸들러는 재시도 소진과 복구 불가 오류 둘 다 처리해야 합니다.
worker.on('failed', async (job, error) => {
if (!job) return;
const maxAttempts = job.opts.attempts ?? 1;
const isExhausted =
job.attemptsMade >= maxAttempts || error instanceof UnrecoverableError;
if (isExhausted) {
try {
await dlqQueue.add('failed-payment', {
originalJobId: job.id,
originalData: job.data,
error: error.message,
failedAt: new Date().toISOString(),
});
await notifyOncall(`결제 작업 최종 실패: orderId=${job.data.orderId}`);
} catch (dlqError) {
console.error('DLQ 등록 실패:', dlqError);
}
}
});
// 그레이스풀 셧다운 — 배포 중 작업 유실 방지
process.on('SIGTERM', async () => {
await worker.close();
await paymentQueue.close();
process.exit(0);
});실전: 외부 API 레이트 리밋 제어
Twilio, Stripe 같은 외부 API의 초당 호출 제한을 Worker 레벨에서 제어합니다. limiter 옵션의 핵심은 여러 Worker 인스턴스가 떠 있어도 Redis를 통해 글로벌 총량 제어가 된다는 점입니다.
const twilioWorker = new Worker('sms', async (job: Job) => {
const { to, body } = job.data;
await twilioClient.messages.create({
to,
body,
from: process.env.TWILIO_FROM,
});
}, {
connection: redisConnection,
concurrency: 10,
limiter: {
max: 100, // 최대 100건
duration: 1_000, // 1초 기준
},
});실전: FlowProducer로 데이터 파이프라인 구성
데이터 수집 → 변환 → 저장처럼 단계적 의존성이 있는 파이프라인은 FlowProducer가 적합합니다. 자식 작업이 모두 완료되어야 부모 작업이 실행되는 방식입니다.
FlowProducer에 넘기는 JSON 트리는 최상위가 부모인 역방향 구조로 정의하지만, 실제 실행은 하위(자식)에서 상위(부모)로 올라갑니다.
// FlowProducer로 파이프라인 등록
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection: redisConnection });
await flow.add({
name: 'store-result', // 부모 — 마지막에 실행
queueName: 'pipeline',
data: { step: 'store' },
children: [
{
name: 'transform-data',
queueName: 'pipeline',
data: { step: 'transform' },
children: [
{
name: 'fetch-data', // 최하위 자식 — 가장 먼저 실행
queueName: 'pipeline',
data: { step: 'fetch', source: 'api' },
},
],
},
],
});자식 작업의 반환값은 부모 작업에서 job.getChildrenValues()로 가져올 수 있습니다. 파이프라인 간 데이터를 별도 스토리지 없이 전달하는 방법입니다.
// workers/pipeline.worker.ts
import { Worker, Job } from 'bullmq';
import { redisConnection } from '../lib/redis';
const pipelineWorker = new Worker('pipeline', async (job: Job) => {
switch (job.name) {
case 'fetch-data': {
const raw = await fetchFromApi(job.data.source);
return raw; // 부모가 getChildrenValues()로 접근
}
case 'transform-data': {
const childValues = await job.getChildrenValues();
const rawData = Object.values(childValues)[0];
return transform(rawData);
}
case 'store-result': {
const childValues = await job.getChildrenValues();
const transformed = Object.values(childValues)[0];
await saveToDatabase(transformed);
break;
}
}
}, { connection: redisConnection });getChildrenValues()는 { 'pipeline:jobId': returnValue } 형태의 객체를 반환합니다. 자식이 하나라면 Object.values(childValues)[0]으로 꺼내는 게 가장 간단합니다.
자주 하는 실수
removeOnComplete/removeOnFail 누락
완료·실패 작업 데이터가 Redis에 영원히 쌓입니다. defaultJobOptions에 항상 지정해두세요.
영속적 오류에 재시도를 거는 경우
잘못된 입력값이나 존재하지 않는 ID처럼 재시도해도 달라지지 않는 오류에 attempts: 10을 걸면 무의미한 부하만 생깁니다. 오류를 분류해 일시적 오류만 재시도하고, 영속적 오류는 UnrecoverableError로 즉시 실패 처리하는 것이 좋습니다.
그레이스풀 셧다운 미구현
SIGTERM 핸들러 없이 배포하면 처리 중이던 작업이 stalled 상태로 남아 중복 실행될 수 있습니다. worker.close() 호출을 반드시 포함하세요.
비동기 이벤트 핸들러에 try-catch 생략
worker.on('failed', async () => { ... }) 내부에서 예외가 나면 unhandled rejection이 됩니다. DLQ 등록과 알림 전송은 반드시 감싸야 합니다.
repeat.pattern의 타임존 미지정
기본값은 UTC입니다. 한국 서비스라면 tz: 'Asia/Seoul'을 함께 지정하세요.
jobId 중복 방지 미적용
동일한 작업이 여러 번 등록될 수 있는 도메인(결제 확인, 알림 발송 등)에서는 jobId를 비즈니스 식별자로 고정해 중복을 방지하세요.
BullMQ를 선택할 때와 대안을 고려할 때
장점
| 항목 | 설명 |
|---|---|
| TypeScript 퍼스트 | Job 데이터 타입을 제네릭으로 명시해 컴파일 타임에 검증합니다 |
| 단순한 인프라 | Redis 하나로 큐 운영. 별도 메시지 브로커 불필요 |
| 풍부한 기능 | 우선순위, 지연, 반복, 레이트 리밋, 플로우 모두 지원 |
| Stall 보호 | Worker 크래시 시 작업 유실 없이 자동으로 대기 큐 복귀 |
| OpenTelemetry 지원 | 5.x 후반 버전부터 공식 지원. Datadog·Grafana Tempo와 end-to-end 추적이 가능합니다 |
주의사항
| 항목 | 설명 |
|---|---|
| Redis 가용성 의존 | Redis가 다운되면 큐 전체가 멈춥니다. 프로덕션에는 Sentinel 또는 Cluster를 권장합니다 |
| DLQ 미내장 | worker.on('failed', ...) 패턴으로 직접 구현해야 합니다 |
| 우선순위 오버헤드 | 우선순위 큐 사용 시 추가 Redis 연산이 발생합니다. 초고속 처리량이 필요하면 측정해보세요 |
| 크로스 언어 제한 | Python이나 Go에서 같은 큐를 소비해야 한다면 RabbitMQ나 Kafka가 더 적합합니다 |
기술 선택 기준
| BullMQ | RabbitMQ | Kafka | AWS SQS | |
|---|---|---|---|---|
| 주 사용 언어 | Node.js 중심 | 다언어 | 다언어 | 다언어 |
| 처리량 | 충분히 높음 | 높음 | 매우 높음 | 관리형 |
| 설정 복잡도 | 낮음 | 중간 | 높음 | 낮음 |
| 적합한 경우 | Node.js 잡 처리 | 크로스 언어 메시징 | 이벤트 스트리밍 | AWS 인프라 |
운영 체크리스트로 마무리
개발 환경에서 동작을 확인했다면, 프로덕션 배포 전 아래 항목을 점검해보시기 바랍니다.
- Redis에
maxRetriesPerRequest: null설정 여부 - 모든 Queue에
removeOnComplete/removeOnFail설정 여부 -
UnrecoverableError와 일시적 오류를 구분하는 로직 존재 여부 - 비동기
worker.on('failed', ...)핸들러의try-catch적용 여부 - DLQ 등록 후 운영팀 알림 연동 여부
-
SIGTERM핸들러에서worker.close()호출 여부 - 중복 등록이 가능한 작업에
jobId고정 여부 -
repeat.pattern사용 시tz옵션 지정 여부 - 장시간 실행 작업에
stalledInterval·lockDuration검토 여부 - Bull Board 경로에 인증 미들웨어 적용 여부
- 프로덕션 Redis에 Sentinel 또는 Cluster 구성 여부
참고 자료
- BullMQ 공식 문서
- BullMQ GitHub 저장소 & Releases
- BullMQ v5 마이그레이션 노트
- BullMQ 지연 실행 가이드
- BullMQ 우선순위 큐 가이드
- BullMQ 재시도 가이드
- BullMQ 레이트 리밋 가이드
- BullMQ Flow(의존성) 가이드
- BullMQ 프로덕션 가이드
- How to Implement Dead Letter Queues in BullMQ
- BullMQ 101: Jobs, Workers, Queues, Retries, Delays, and DLQ
- Kafka vs RabbitMQ vs SQS vs BullMQ — 2026 Guide
- BullMQ vs RabbitMQ 비교 (DragonflyDB)