BullMQ로 구축하는 프로덕션 작업 큐 — 우선순위, 재시도 백오프, Dead Letter Queue를 TypeScript로 구현하기
API 핸들러가 이메일 발송이나 결제 요청을 직접 처리하면, 외부 서비스 응답 지연이 곧 HTTP 타임아웃으로 이어집니다. 비동기 작업 큐는 이 결합을 끊습니다. API는 잡을 큐에 넣는 즉시 응답하고, 실제 처리는 백그라운드 워커가 맡습니다.
그런데 단순히 "큐에 넣기"만으로는 충분하지 않은 시나리오가 금방 나타납니다. 결제와 마케팅 뉴스레터가 같은 우선순위로 경쟁하면 안 되고, 외부 API가 503을 반환할 때 1초마다 재시도하면 이미 부하가 걸린 서버를 더 압박합니다. 모든 재시도가 소진된 잡을 그냥 버리면 나중에 원인을 분석할 방법이 없습니다.
이 글에서는 이 세 가지 — 우선순위 큐, 지수 백오프 재시도, Dead Letter Queue — 를 BullMQ와 TypeScript로 구현하는 방법을 다룹니다.
BullMQ를 선택할 때 고려할 것들
BullMQ는 Redis를 스토어로 사용하는 Node.js 작업 큐 라이브러리로, 기존 Bull의 완전한 재작성 버전입니다. TypeScript를 처음부터 고려해 설계됐고, Lua 스크립트 기반의 원자적 연산으로 잡 유실을 최소화합니다.
| 항목 | 평가 | 비고 |
|---|---|---|
| TypeScript 지원 | ★★★★★ | 제네릭으로 잡 데이터 타입 완전 보장 |
| Redis 기반 성능 | ★★★★★ | 단일 인스턴스에서도 높은 처리량 |
| 기능 풍부성 | ★★★★★ | 우선순위, 지연, 반복, 속도 제한, DAG 플로우 |
| 신뢰성 | ★★★★☆ | Lua 스크립트 원자적 연산, at-least-once 보장 |
| 운영 복잡도 | ★★★☆☆ | Redis 별도 프로비저닝·모니터링 필요 |
| 생태계 | ★★★★★ | NestJS 공식 지원, OpenTelemetry, Bull Board |
| DLQ 내장 여부 | ★★☆☆☆ | 직접 구현 필요 — 유연성은 오히려 강점 |
PostgreSQL 기반 대안(pgboss, Graphile Worker 등)은 기존 DB 인프라를 그대로 활용할 수 있다는 장점이 있습니다. Redis 추가 인프라가 부담된다면 비교 검토할 만합니다. 이미 Redis를 쓰고 있거나 처리량이 중요하다면 BullMQ가 강점을 발휘합니다.
핵심 개념
BullMQ 아키텍처
BullMQ는 세 가지 핵심 구성 요소로 이루어집니다.
- Queue: 잡을 추가하고 설정을 관리하는 인터페이스
- Worker: 실제로 잡을 소비하고 처리하는 프로세서
- QueueEvents: 잡 상태 변화(완료, 실패, 진행 등) 이벤트를 구독
FlowProducer(부모-자식 의존성 DAG 파이프라인)도 있지만 이 글에서는 다루지 않습니다.
DLQ는 BullMQ 내장 기능이 아닙니다. 점선으로 표시한 것처럼, failed 이벤트를 구독해 별도 큐로 이송하는 패턴을 직접 구현합니다.
설치:
pnpm add bullmqRedis 연결 설정을 분리해두면 Queue와 Worker 모두에서 재사용할 수 있습니다.
// config/redis.ts
import { ConnectionOptions } from 'bullmq'
export const connection: ConnectionOptions = {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
}우선순위 큐
BullMQ의 우선순위는 1~2,097,152 범위의 숫자로 지정하며, 낮은 숫자일수록 높은 우선순위입니다.
// queues/notification.queue.ts
import { Queue } from 'bullmq'
import { connection } from '../config/redis'
export interface NotificationJobData {
userId: string
type: 'urgent' | 'normal' | 'batch'
message: string
}
export const notificationQueue = new Queue<NotificationJobData>('notifications', {
connection,
defaultJobOptions: {
removeOnComplete: { count: 1000, age: 3600 },
removeOnFail: { count: 5000 },
},
})
// 우선순위 1: 즉시 처리해야 하는 보안 경고
await notificationQueue.add(
'urgent-alert',
{ userId: 'u-123', type: 'urgent', message: '계정 보안 경고' },
{ priority: 1 }
)
// 우선순위 10: 일반 사용자 알림
await notificationQueue.add(
'normal-notification',
{ userId: 'u-456', type: 'normal', message: '새 댓글이 달렸습니다' },
{ priority: 10 }
)
// 우선순위 100: 배치 마케팅 메일 (가장 낮음)
await notificationQueue.add(
'batch-email',
{ userId: 'u-789', type: 'batch', message: '이번 주 뉴스레터' },
{ priority: 100 }
)잡이 큐에 들어간 후에도 changePriority()로 동적으로 변경할 수 있습니다. 사용자가 특정 작업을 "긴급"으로 표시하는 UI가 있다면 이 API가 유용합니다.
const job = await notificationQueue.getJob(jobId)
if (job) {
await job.changePriority({ priority: 1 })
}재시도 백오프
외부 API가 일시적으로 503을 반환할 때 즉시 재시도하면 이미 부하가 걸린 서버에 요청을 더 쌓습니다. 지수 백오프는 재시도마다 대기 시간을 2배씩 늘려서 외부 서비스가 회복할 여유를 줍니다.
await paymentQueue.add(
'process-payment',
{
orderId: 'order-001',
amount: 15000,
currency: 'KRW',
idempotencyKey: crypto.randomUUID(),
},
{
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000, // 1s → 2s → 4s → 8s → 16s
},
}
)Rate Limit 헤더처럼 에러 내용에 따라 다른 대기 시간이 필요하다면 커스텀 백오프 함수를 사용합니다.
const worker = new Worker('api-calls', processor, {
connection,
settings: {
backoffStrategy: (attemptsMade, type, err, job) => {
// TypeScript strict 모드에서 err는 unknown — 타입 가드 필요
if (err instanceof Error && err.message.includes('Rate limit')) {
return 60_000 * attemptsMade // Rate limit이면 분 단위로 증가
}
return 2 ** attemptsMade * 1000
},
},
})Dead Letter Queue
재시도를 모두 소진한 잡을 어떻게 처리할지가 DLQ의 핵심입니다. 단순한 실패 로그와 달리, DLQ는 원인 분석과 수동 재처리를 위한 공간입니다.
구현 패턴은 단순합니다. Worker의 failed 이벤트에서 job.attemptsMade >= maxAttempts 조건을 확인하고, 그때만 별도 DLQ 큐에 잡 메타데이터를 추가합니다.
// 핵심 패턴 — 전체 구현은 아래 "실전 구현" 섹션 참고
const dlqQueue = new Queue('payments-dlq', { connection })
paymentWorker.on('failed', async (job, err) => {
if (!job) return // BullMQ는 failed 이벤트에서 job을 undefined로 넘길 수 있음
const maxAttempts = job.opts.attempts ?? 1
if (job.attemptsMade >= maxAttempts) {
await dlqQueue.add('failed-payment', {
originalJobId: job.id ?? 'unknown',
originalData: job.data,
errorMessage: err instanceof Error ? err.message : String(err),
failedAt: new Date().toISOString(),
})
}
})실전 구현 — 결제 처리 큐
세 가지 개념을 엮은 전체 예시입니다. 파일 구조부터 DLQ 재처리 유틸리티까지 프로덕션에서 쓰는 구조를 보여드립니다.
큐 정의
// queues/payment.queue.ts
import { Queue } from 'bullmq'
import { connection } from '../config/redis'
export interface PaymentJobData {
orderId: string
amount: number
currency: string
idempotencyKey: string
}
export const paymentQueue = new Queue<PaymentJobData>('payments', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 500, age: 3600 },
removeOnFail: { count: 2000 },
},
})DLQ 큐 정의 (별도 파일로 분리)
DLQ를 별개 파일로 분리하면 DLQ 전용 보존 정책·모니터링·워커를 독립적으로 설정할 수 있습니다.
// queues/payment-dlq.queue.ts
import { Queue } from 'bullmq'
import { connection } from '../config/redis'
import type { PaymentJobData } from './payment.queue'
export interface DLQJobData {
originalJobId: string
originalJobName: string
originalData: PaymentJobData
errorMessage: string
attemptsMade: number
failedAt: string
}
export const paymentDLQQueue = new Queue<DLQJobData>('payments-dlq', { connection })워커와 DLQ 이송
// workers/payment.worker.ts
import { Worker } from 'bullmq'
import { connection } from '../config/redis'
import type { PaymentJobData } from '../queues/payment.queue'
import { paymentDLQQueue } from '../queues/payment-dlq.queue'
const paymentWorker = new Worker<PaymentJobData>(
'payments',
async (job) => {
await job.updateProgress(10)
// callPaymentGateway는 실제 결제 게이트웨이 클라이언트 — 구현체 생략
const result = await callPaymentGateway({
orderId: job.data.orderId,
amount: job.data.amount,
idempotencyKey: job.data.idempotencyKey,
})
await job.updateProgress(100)
return result
},
{
connection,
concurrency: 5,
limiter: {
max: 10, // 초당 최대 10건 — 결제 게이트웨이 Rate Limit 대응
duration: 1000,
},
}
)
paymentWorker.on('failed', async (job, err) => {
if (!job) return
const maxAttempts = job.opts.attempts ?? 1
if (job.attemptsMade >= maxAttempts) {
console.error(`[DLQ] 잡 ${job.id} 최종 실패 → DLQ 이송`)
await paymentDLQQueue.add(
'failed-payment',
{
originalJobId: job.id ?? 'unknown',
originalJobName: job.name,
originalData: job.data,
errorMessage: err instanceof Error ? err.message : String(err),
attemptsMade: job.attemptsMade,
failedAt: new Date().toISOString(),
},
{
removeOnComplete: { age: 7 * 24 * 3600 },
// DLQ에 워커를 추가해 자동 재처리할 경우, 그 처리가 실패해도 기록이 자동 삭제되지 않도록 함
removeOnFail: false,
}
)
}
})
paymentWorker.on('completed', (job) => {
console.log(`[${job.id}] 결제 완료: ${job.data.orderId}`)
})DLQ 재처리 유틸리티
원인을 해결한 후 DLQ 잡을 수동으로 재처리하는 스크립트입니다.
// scripts/reprocess-dlq.ts
import { Queue } from 'bullmq'
import { connection } from '../config/redis'
import type { DLQJobData } from '../queues/payment-dlq.queue'
const dlqQueue = new Queue<DLQJobData>('payments-dlq', { connection })
const paymentQueue = new Queue('payments', { connection })
async function listDLQJobs() {
// DLQ 워커가 없으므로 잡은 'waiting' 상태로 쌓임
const jobs = await dlqQueue.getJobs(['waiting', 'delayed'])
return jobs.map((job) => ({
id: job.id,
orderId: job.data.originalData.orderId,
error: job.data.errorMessage,
attempts: job.data.attemptsMade,
failedAt: job.data.failedAt,
}))
}
async function reprocessDLQJob(dlqJobId: string) {
const dlqJob = await dlqQueue.getJob(dlqJobId)
if (!dlqJob) throw new Error(`DLQ 잡 ${dlqJobId}를 찾을 수 없습니다`)
const newJob = await paymentQueue.add(
dlqJob.data.originalJobName,
dlqJob.data.originalData,
{
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
priority: 5, // 재처리는 일반 잡보다 높은 우선순위
}
)
await dlqJob.remove()
console.log(`재처리 완료: DLQ ${dlqJobId} → 새 잡 ${newJob.id}`)
return newJob.id
}자주 하는 실수들
Redis maxmemory-policy 설정 미스
조용하고 치명적인 실수입니다. Redis를 캐시와 겸용으로 쓸 때 allkeys-lru 같은 eviction 정책이 활성화되면, 처리 중이던 잡 데이터가 메모리 부족 시 임의로 삭제될 수 있습니다.
BullMQ용 Redis 인스턴스는 분리하고 반드시 이 설정을 적용합니다.
# redis.conf
maxmemory-policy noevictionremoveOnComplete / removeOnFail 미설정
완료된 잡은 자동으로 제거되지 않습니다. count와 age를 함께 지정하면 트래픽 패턴에 상관없이 메모리를 예측 가능하게 유지합니다.
// count만 지정: 트래픽이 없는 기간에 몇 달 된 잡이 남아있을 수 있음
// age만 지정: 트래픽 폭증 시 메모리가 급격히 증가할 수 있음
// 둘 다 지정:
removeOnComplete: { count: 1000, age: 3600 }
removeOnFail: { count: 5000 }프로세서 함수의 멱등성 미확보
BullMQ는 at-least-once 시멘틱을 따릅니다. 워커가 잡 처리 도중 크래시하면 해당 잡이 재처리됩니다. 결제·이메일 발송처럼 부작용이 있는 작업은 DB 레벨 중복 체크가 필수입니다.
// Prisma 기준 예시
async function paymentProcessor(job: Job<PaymentJobData>) {
const existing = await prisma.payment.findUnique({
where: { idempotencyKey: job.data.idempotencyKey },
})
if (existing) {
return existing // 이미 처리된 결제: 이전 결과 반환
}
return await prisma.payment.create({ data: { ...job.data } })
}동시성 설정 간과
Worker의 기본 concurrency는 1입니다. 처리량이 기대보다 낮다면 이 설정부터 확인하세요.
CPU 집약적 작업이라면 코어 수 기준으로, I/O 집약적 작업이라면 20~50 사이에서 부하 테스트로 튜닝하는 것이 일반적입니다.
NestJS 통합
@nestjs/bullmq 패키지를 사용하면 데코레이터 기반으로 동일한 패턴을 모듈화할 수 있습니다.
// notifications/notifications.module.ts
import { BullModule } from '@nestjs/bullmq'
import { Module } from '@nestjs/common'
import { NotificationProcessor } from './notification.processor'
import { NotificationService } from './notification.service'
@Module({
imports: [
BullModule.registerQueue({
name: 'notifications',
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 1000, age: 3600 },
removeOnFail: { count: 5000 },
},
}),
],
providers: [NotificationService, NotificationProcessor],
exports: [NotificationService],
})
export class NotificationsModule {}// notifications/notification.processor.ts
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'
import { Job } from 'bullmq'
import type { NotificationJobData } from './notification.types'
@Processor('notifications', { concurrency: 10 })
export class NotificationProcessor extends WorkerHost {
async process(job: Job<NotificationJobData>): Promise<void> {
switch (job.name) {
case 'send-email':
// sendEmail, sendPush는 실제 알림 서비스 클라이언트 — 구현체 생략
await this.sendEmail(job.data)
break
case 'send-push':
await this.sendPush(job.data)
break
default:
throw new Error(`알 수 없는 잡 타입: ${job.name}`)
}
}
@OnWorkerEvent('failed')
onFailed(job: Job<NotificationJobData> | undefined, err: Error) {
if (!job) return // failed 이벤트에서 job이 undefined일 수 있음
console.error(`알림 잡 실패 [${job.id}]: ${err.message}`)
}
}모니터링: Bull Board
@bull-board는 BullMQ 생태계에서 가장 널리 쓰이는 모니터링 UI입니다. 큐별 잡 상태, 실패 이유, 재시도 이력을 실시간으로 확인할 수 있고, UI에서 직접 재시도나 삭제도 가능합니다.
// app.ts (Express 기준)
import { createBullBoard } from '@bull-board/api'
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
import { ExpressAdapter } from '@bull-board/express'
import { notificationQueue } from './queues/notification.queue'
import { paymentQueue } from './queues/payment.queue'
import { paymentDLQQueue } from './queues/payment-dlq.queue'
const serverAdapter = new ExpressAdapter()
serverAdapter.setBasePath('/admin/queues')
createBullBoard({
queues: [
new BullMQAdapter(notificationQueue),
new BullMQAdapter(paymentQueue),
new BullMQAdapter(paymentDLQQueue),
],
serverAdapter,
})
app.use('/admin/queues', serverAdapter.getRouter())정리
핵심 패턴을 요약하면:
- 우선순위 큐: 숫자가 작을수록 먼저 처리.
changePriority()로 실행 중에도 변경 가능 - 재시도 백오프:
exponential전략이 외부 서비스 장애에 가장 효과적. 커스텀 함수로 Rate Limit 같은 특수 케이스 대응 가능 (단,err instanceof Error타입 가드 필수) - DLQ:
failed이벤트 +attemptsMade >= attempts조건으로 직접 구현. 별도 큐로 분리해야 보존 정책·모니터링을 독립적으로 설정할 수 있음
운영에서 가장 먼저 챙길 세 가지:
- Redis
noeviction설정: eviction 정책이 활성화된 인스턴스를 공유하면 잡 데이터가 조용히 사라집니다 removeOnComplete에count와age둘 다 지정: 어느 하나만 쓰면 트래픽 패턴에 따라 메모리가 예측 불가능하게 됩니다- 멱등성 키: at-least-once 보장이므로, 부작용이 있는 작업은 큐 레이어 외에 DB 레벨 중복 체크가 반드시 필요합니다
시작점으로는 가장 간단한 이메일 발송 큐 하나부터 attempts: 3 + 지수 백오프로 구성하고, Bull Board로 잡 흐름을 눈으로 확인하면서 점진적으로 우선순위와 DLQ를 추가하는 순서가 좋습니다.
참고 자료
- BullMQ 공식 문서 — 우선순위, 재시도, 동시성, 속도 제한 전체 가이드
- Prioritized Jobs | BullMQ Docs — 우선순위 큐 공식 문서
- Retrying Failing Jobs | BullMQ Docs — 재시도 전략 공식 문서
- BullMQ GitHub 릴리스 노트 — v5 계열 변경사항
- NestJS + BullMQ: Top 4 Use Cases & Quick Tutorial — NestJS 통합 실전 예제
- BullMQ Architecture for High Traffic — 고트래픽 아키텍처 설계