BullMQ 실전 튜토리얼 — Node.js 백그라운드 작업 큐 구현, 재시도·스케줄링·모니터링까지
BullMQ로 Node.js 백그라운드 작업 큐를 구현하는 단계별 실전 튜토리얼. 레디스 연결 설정부터 이메일 발송 큐 구현, 재시도 전략, cron 반복 스케줄링, Bull Board 모니터링 UI까지 프로덕션에서 바로 사용할 수 있는 타입스크립트 코드와 Docker Compose 환경 설정을 단계별로 안내한다. 백엔드 개발자가 작업 큐를 처음 도입할 때 필요한 핵심 개념도 함께 설명한다.
회원가입 후 이메일 인증 메일을 보낼 때 API 응답이 3초씩 걸리거나, SMTP 서버가 순간 다운되면 회원가입 자체가 실패했던 경험이 있다면 작업 큐가 필요한 신호다.
BullMQ(불엠큐)는 레디스(Redis)를 기반으로 한 Node.js 작업 큐 라이브러리다. 이메일 발송·파일 처리·외부 API 호출처럼 시간이 걸리는 작업을 백그라운드 워커(Worker)에 맡기고, API 핸들러는 즉시 응답을 돌려줄 수 있다. 이 글에서는 BullMQ를 실제 프로젝트에 연결하는 방법을 단계별로 정리한다.
레디스 연결부터 첫 번째 큐 구현까지
재시도·지연 실행·cron 스케줄링 설정법
Bull Board로 작업 현황 모니터링
프로덕션 배포 시 놓치기 쉬운 설정
큐·워커·잡 세 요소로 이루어지는 BullMQ의 기본 흐름 — 프로듀서가 큐에 잡을 넣으면 워커가 꺼내 처리한다
작업 큐가 없을 때 생기는 문제
이메일 인증 API를 예로 들자. 가장 단순한 구현은 POST /register 핸들러 안에서 SMTP 서버에 직접 연결해 메일을 보내는 것이다.
이 방식의 문제는 세 가지다. 첫째, SMTP 연결·인증·발송에 보통 1~3초가 걸린다. 사용자는 그 시간 동안 로딩 화면을 보게 된다. 둘째, SMTP 서버가 일시 다운되거나 타임아웃이 나면 회원가입 자체가 실패 처리된다. 셋째, 이메일이 실패하면 재시도 로직을 직접 구현해야 하는데, API 핸들러 안에 넣기가 어색하다.
큐를 쓰면 HTTP 핸들러는 "이메일 발송 작업을 큐에 넣었다"는 응답만 즉시 돌려주고, 실제 발송은 워커 프로세스가 백그라운드에서 처리한다. 핸들러 응답 시간이 10ms 수준으로 내려가고, 발송 실패 시 자동 재시도까지 된다.
작업 큐가 필요한 대표적인 상황:
이메일·SMS·푸시 알림 발송
이미지·동영상 변환 처리
외부 API 대량 호출 (rate limit 관리 포함)
리포트·PDF 생성
정기 배치 작업 (일별 집계, 만료 처리 등)
BullMQ 핵심 개념 — Queue, Worker, Job
BullMQ의 세 가지 핵심 개념을 이해하면 나머지는 자연스럽게 따라온다.
큐(Queue)는 작업을 쌓아두는 저장소다. 레디스에 작업 데이터를 JSON으로 직렬화해 저장한다. "이메일 발송 큐", "이미지 처리 큐"처럼 작업 종류별로 별도 큐를 만드는 것이 일반적이다.
잡(Job)은 큐에 넣은 개별 작업 단위다. 작업에 필요한 데이터(페이로드)와 옵션(재시도 횟수, 지연 시간 등)을 담고 있다. 잡은 waiting → active → completed 또는 failed 상태를 순서대로 거친다.
워커(Worker)는 큐에서 잡을 꺼내 실제로 처리하는 프로세스다. API 서버와 같은 프로세스에 있어도 되고, 별도 프로세스로 분리해도 된다. 프로덕션에서는 별도 프로세스로 운영하는 것이 일반적이다.
워커 여러 개를 동시에 띄워도 BullMQ가 레디스 원자 연산으로 중복 처리를 방지하기 때문에 스케일 아웃이 간단하다.
레디스 설치와 BullMQ 연결
BullMQ는 레디스 5.0 이상이 필요하다. 로컬 개발 환경에서는 Docker Compose로 레디스를 간단하게 띄울 수 있다.
# npm
npm install bullmq
# pnpm
pnpm add bullmq
# BullMQ는 타입 정의를 내장하고 있어 @types 별도 설치 불필요
# ioredis는 BullMQ 내부 의존성이지만, 레디스를 직접 다룰 때 설치
npm install ioredis
레디스 연결 설정을 별도 파일로 분리해 큐와 워커에서 공유하는 것이 좋다.
연결 옵션은 host와 port만 담은 단순 객체면 충분하다. TLS나 비밀번호가 필요한 프로덕션 환경은 ioredis 옵션을 그대로 넣을 수 있다.
API 서버(프로듀서)와 워커 프로세스가 레디스를 공유 저장소로 삼아 잡을 주고받는 구조
첫 번째 큐 구현 — 이메일 발송 예시
이메일 발송 큐를 예시로 큐와 워커를 구현해보자. 먼저 큐 인스턴스를 만들고, API 핸들러에서 잡을 추가하는 코드다.
src/queues/email.queue.ts — 큐 정의
import { Queue } from 'bullmq'
export interface EmailJobData {
to: string
subject: string
body: string
template?: string
}
const connection = {
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
}
export const emailQueue = new Queue<EmailJobData>('email', {
connection,
defaultJobOptions: {
attempts: 3, // 최대 재시도 횟수
backoff: {
type: 'exponential',
delay: 2000, // 첫 재시도 2초 후, 이후 2배씩 증가
},
removeOnComplete: 100, // 완료된 잡은 최근 100개만 레디스에 보관
removeOnFail: 500, // 실패 잡은 최근 500개 보관
},
})
// API 핸들러에서 호출
export async function enqueueEmail(data: EmailJobData) {
const job = await emailQueue.add('send-email', data)
console.log(`이메일 잡 추가 완료 — job id: ${job.id}`)
return job
}
이번엔 큐에서 잡을 꺼내 실제로 처리하는 워커를 만든다. 워커는 보통 별도 진입점 파일(예: src/workers/index.ts)에서 초기화한다.
src/workers/email.worker.ts — 워커 정의
import { Worker, Job } from 'bullmq'
import { EmailJobData } from '../queues/email.queue'
import { sendEmail } from '../lib/mailer' // 실제 메일 발송 함수
const connection = {
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
}
const emailWorker = new Worker<EmailJobData>(
'email',
async (job: Job<EmailJobData>) => {
const { to, subject, body, template } = job.data
// 진행 상태 업데이트 — Bull Board에서 확인 가능
await job.updateProgress(10)
await sendEmail({ to, subject, body, template })
await job.updateProgress(100)
console.log(`이메일 발송 완료 — to: ${to}`)
},
{
connection,
concurrency: 5, // 워커 하나가 동시에 처리할 잡 수
}
)
emailWorker.on('completed', (job) => {
console.log(`잡 완료 — id: ${job.id}`)
})
emailWorker.on('failed', (job, err) => {
console.error(`잡 실패 — id: ${job?.id}, 오류: ${err.message}`)
})
export default emailWorker
concurrency 설정 기준 I/O 바운드 작업(이메일, HTTP 요청, DB 쿼리)은 5~20이 일반적이다. 외부 API rate limit이 있으면 해당 제한보다 낮게 설정한다. CPU 바운드 작업(이미지 변환 등)은 CPU 코어 수와 같거나 낮게 잡는다.
재시도와 백오프 전략
네트워크 오류나 외부 서비스 일시 장애처럼 일시적인 실패는 잠시 기다렸다가 재시도하면 해결되는 경우가 많다. BullMQ는 두 가지 백오프 전략을 기본 제공한다.
fixed: 매번 동일한 간격으로 재시도 (예: 5초, 5초, 5초)
exponential: 재시도할수록 간격이 2배씩 증가 (예: 2초, 4초, 8초)
외부 API나 이메일 SMTP 서버를 대상으로 할 때는 exponential 백오프가 일반적이다. 서버가 과부하 상태일 때 연속 요청을 방지해 복구에 도움이 된다.
커스텀 백오프를 써서 HTTP 상태 코드별로 재시도 간격을 다르게 할 수도 있다. 429(Too Many Requests)는 더 길게, 그 외 일시 오류는 짧게 잡는 식이다.
재시도 전략 설정 예시
// 큐 기본값 설정 — 모든 잡에 적용
const emailQueue = new Queue('email', {
connection,
defaultJobOptions: {
attempts: 5, // 최초 실행 포함 총 5회
backoff: {
type: 'exponential',
delay: 1000, // 1초 → 2초 → 4초 → 8초 순서로 증가
},
},
})
// 개별 잡에 별도 옵션 지정 — 큐 기본값을 덮어씀
await emailQueue.add('send-email', data, {
attempts: 3,
backoff: { type: 'fixed', delay: 5000 },
})
// 커스텀 백오프 — 워커 설정에서 지정
const worker = new Worker('email', processor, {
connection,
settings: {
backoffStrategy: (attemptsMade, type, err) => {
// 429 Too Many Requests는 30초 후 재시도
if (err?.message?.includes('429')) return 30000
// 그 외는 지수 증가
return Math.pow(2, attemptsMade) * 1000
},
},
})
지연 실행과 cron 반복 스케줄링
BullMQ는 즉시 실행 외에도 지연 실행과 반복 실행을 지원한다. 이를 이용하면 cron 잡을 외부 스케줄러 없이 큐 안에서 관리할 수 있다.
지연 실행(delayed job)은 잡을 큐에 넣되 지정한 시간 이후에만 처리하게 한다. 회원가입 7일 후 리마인드 이메일, 결제 24시간 후 청구서 자동 발송 같은 시나리오에 유용하다.
반복 실행(repeatable job)은 cron 표현식이나 밀리초 인터벌로 주기적으로 잡을 자동 생성한다. 매일 새벽 집계 배치, 10분마다 상태 체크 같은 작업에 사용한다.
지연 실행과 cron 스케줄링
import { Queue } from 'bullmq'
const queue = new Queue('notifications', { connection })
// 지연 실행 — 1시간 후 처리 (밀리초 단위)
await queue.add(
'reminder-email',
{ userId: '12345', type: 'signup-followup' },
{ delay: 60 * 60 * 1000 }
)
// cron 반복 — 매일 새벽 2시 KST
await queue.add(
'daily-report',
{ reportType: 'daily-summary' },
{
repeat: {
pattern: '0 2 * * *',
tz: 'Asia/Seoul',
},
}
)
// 3시간마다 반복 (인터벌 방식)
await queue.add(
'cache-warmup',
{ cacheKey: 'popular-posts' },
{
repeat: { every: 3 * 60 * 60 * 1000 },
}
)
// 등록된 반복 잡 목록 확인
const repeatableJobs = await queue.getRepeatableJobs()
// 특정 반복 잡 제거
await queue.removeRepeatableByKey(repeatableJobs[0].key)
Bull Board로 작업 현황 모니터링
Bull Board는 BullMQ 큐 상태를 웹 UI로 확인하는 오픈소스 대시보드다. 현재 처리 중인 잡, 완료된 잡, 실패한 잡을 한눈에 볼 수 있고, 실패한 잡을 수동으로 재시도하거나 삭제하는 것도 UI에서 바로 된다.
Express, Fastify, Hono, Koa 등 주요 Node.js 프레임워크 어댑터를 모두 제공하므로 기존 서버에 라우트 하나만 추가하면 된다.
Bull Board Express 연동
import express from 'express'
import { createBullBoard } from '@bull-board/api'
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
import { ExpressAdapter } from '@bull-board/express'
import { emailQueue } from './queues/email.queue'
import { notificationQueue } from './queues/notification.queue'
// 패키지 설치
// npm install @bull-board/api @bull-board/express
const app = express()
const serverAdapter = new ExpressAdapter()
serverAdapter.setBasePath('/admin/queues')
createBullBoard({
queues: [
new BullMQAdapter(emailQueue),
new BullMQAdapter(notificationQueue),
],
serverAdapter,
})
// 대시보드 라우트 마운트
app.use('/admin/queues', serverAdapter.getRouter())
app.listen(3000, () => {
console.log('대시보드: http://localhost:3000/admin/queues')
})
Bull Board 접근 제어 필수 프로덕션에서 /admin/queues 경로는 반드시 인증 미들웨어로 보호해야 한다. Bearer 토큰이나 Basic Auth를 Express 미들웨어로 앞에 붙이는 것이 가장 간단하다. 인증 없이 열어두면 누구나 잡을 삭제하거나 재실행할 수 있다.
Bull Board 대시보드: 대기·처리 중·완료·실패 잡을 실시간으로 확인하고 수동 재시도 가능
프로덕션 배포 시 주의사항
로컬에서 잘 동작하는 코드를 프로덕션에 올릴 때 놓치기 쉬운 설정들을 정리했다.
워커 프로세스 분리: API 서버와 워커를 같은 프로세스에서 실행하면 워커가 CPU를 점유할 때 API 응답이 느려진다. 프로덕션에서는 Dockerfile이나 PM2 ecosystem 파일로 별도 프로세스를 분리한다.
graceful shutdown: 배포 중 처리 중인 잡이 강제 종료되지 않도록 SIGTERM 핸들러에서 워커를 안전하게 종료해야 한다. worker.close()를 호출하면 현재 처리 중인 잡이 완료될 때까지 기다린 뒤 종료한다.
레디스 퍼시스턴스: 레디스 재시작 시 큐 데이터가 사라지지 않도록 AOF(appendonly) 또는 RDB 스냅샷을 활성화한다. Docker Compose 예시에서 --appendonly yes를 이미 설정했다. 관리형 레디스(Upstash, ElastiCache 등)는 기본으로 퍼시스턴스가 켜져 있다.
메모리 관리: removeOnComplete와 removeOnFail 옵션으로 완료·실패 잡이 레디스에 무한정 쌓이지 않게 한다. 기본값이 없으므로 반드시 명시한다.
Graceful shutdown 처리
import { Worker } from 'bullmq'
const worker = new Worker('email', processor, { connection })
// SIGTERM 처리 — 쿠버네티스, PM2, Docker 배포 시 중요
process.on('SIGTERM', async () => {
console.log('SIGTERM 수신 — 워커 종료 중...')
// 현재 처리 중인 잡이 완료될 때까지 대기 후 정상 종료
await worker.close()
console.log('워커 종료 완료')
process.exit(0)
})
process.on('SIGINT', async () => {
await worker.close()
process.exit(0)
})
Bull은 BullMQ의 이전 버전이다. BullMQ는 Bull을 타입스크립트로 완전히 재작성한 버전으로, 워커 분리 아키텍처·향상된 큐 이벤트·그룹 작업·스로틀링 기능이 추가됐다. 신규 프로젝트라면 BullMQ를 쓰는 것이 맞고, 기존 Bull 프로젝트는 BullMQ 마이그레이션 가이드를 참고해 전환할 수 있다. Bull은 현재 유지보수 모드로 신규 기능 개발이 없다.
레디스 없이 BullMQ를 쓸 수 있나?
BullMQ는 레디스에 강하게 의존하기 때문에 레디스 없이는 동작하지 않는다. 서버리스 환경에서는 Upstash Redis(서버리스 레디스 서비스)를 연결하면 버셀(Vercel) 함수나 AWS Lambda에서도 BullMQ를 쓸 수 있다. 단, 서버리스 환경은 워커를 항상 실행하기 어려우므로 별도 워커 서버나 컨테이너를 두는 방식이 현실적이다.
concurrency는 몇으로 설정하는 게 좋나?
I/O 바운드 작업(이메일, HTTP 요청, 데이터베이스 쿼리)은 5~20이 일반적이다. 외부 API rate limit이 있으면 해당 제한보다 낮게 설정해야 한다. CPU 바운드 작업(이미지 처리, 파일 변환)은 서버 CPU 코어 수와 같거나 낮게 잡는다. 정답이 없으니 성능 테스트로 조정하는 것이 맞다.
잡 실패 시 알림을 받는 방법은?
워커의 failed 이벤트 리스너에서 슬랙이나 이메일 알림을 보내면 된다. 또는 QueueEvents 클래스를 이용해 큐 레벨에서 이벤트를 구독할 수도 있다. 프로덕션에서는 Sentry나 Datadog 같은 오류 추적 도구와 연동해 워커 에러를 자동 수집하는 방식도 흔히 사용한다.
여러 서버에서 같은 큐의 워커를 동시에 실행해도 되나?
가능하고, 권장되는 방식이다. BullMQ는 레디스의 원자적 연산을 사용해 여러 워커 인스턴스가 같은 잡을 중복 처리하지 않도록 보장한다. 처리량이 부족하면 워커 컨테이너 수만 늘리면 선형으로 확장된다. 각 워커 인스턴스는 독립적으로 동작하므로 단일 장애점도 없다.