오래 걸리는 요청과 작업 큐 분리
보고서 생성처럼 오래 걸리는 HTTP 작업을 식별하고 BullMQ와 Redis로 API 접수와 백그라운드 실행을 분리합니다.
REST API에서 파일 변환, 대량 보고서 생성, 이메일 발송처럼 오래 걸리는 일을 요청 안에서 끝내려 하면 문제가 생깁니다. 처리 시간이 길어질수록 HTTP 연결이 끊길 가능성이 커지고, 클라이언트가 같은 요청을 다시 보내면 비싼 작업이 두 번 실행될 수 있습니다.
이 장에서는 보고서 생성 API 하나를 네 글에 걸쳐 확장합니다. 이 글에서는 HTTP 요청과 실제 작업을 나눌 기준을 세우고, NestJS 애플리케이션에 BullMQ와 Redis를 연결합니다.
이때 HTTP 요청의 성공과 업무의 성공을 구분해야 합니다. API는 Queue.add()가 성공해 Redis에 작업이 등록된 뒤에 202 Accepted와 작업 ID를 반환하고, 실제 보고서 생성의 성공·실패는 그 뒤의 작업 상태로 따로 표현합니다.
202 접수와 BullMQ 작업 완료를 분리하는 흐름
Queue.add 접수(202)·waiting·active·process·completed/retry/failed의 비동기 작업 상태를 구별한다.
Nest 작업 큐 · HTTP와 업무 완료
큐 접수를 확인한 뒤 응답하고, 업무 완료는 따로 조회합니다
Queue.add() 성공 뒤에만 202·jobId·statusUrl을 반환합니다. Worker가 실행한 실제 결과와 실패는 별도 상태 조회로 전달하며, callback을 쓸 때는 추가 전달 계약을 구현합니다.
-
요청을 검증한다
Controller는 HTTP 입력을 검증하고 Producer는 직렬화 가능한 job data와 options를 준비합니다.
-
Redis 등록을 확인한다
Producer가
Queue.add()를 await하고 성공한 경우에만202,jobId,statusUrl을 반환합니다. -
Worker가 실행한다
WorkerHost.process(job)가 작업을 가져가며 반환값은returnvalue, throw한Error는 retry 또는failedReason이 됩니다. -
완료 상태를 따로 관찰한다
클라이언트는 status URL을 polling하거나 별도 callback 알림을 받고,
completed와failed를 구분합니다.
202 Accepted
Queue.add()가 완료돼 job을 식별할 수 있다는 뜻입니다. Redis 등록을 확인하지 못했다면 job ID를 발급하지 않고 요청을 실패시킵니다.
polling 또는 callback
상태·진행률·결과·오류는 별도 계약으로 전달합니다. callback도 인증과 전달 재시도, 중복 수신 처리가 필요하며 202 자체는 완료를 약속하지 않습니다.
상태 전이는 보통 waiting 또는 delayed에서 active로, 성공하면 completed로 갑니다. 실패하면 attempts와 backoff에 따라 다시 대기하거나 failed가 되므로 Worker의 부수 효과는 멱등하게 설계합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Client가 보고서 생성을 요청하면 Controller와 Producer가 검증한 직렬화 데이터를 Queue.add로 Redis의 BullMQ queue에 저장한다. WorkerHost는 waiting 작업을 active로 가져가 실행하고, 반환값이나 Error를 completed 또는 retry와 failed 상태로 다시 기록한다.
- Client 요청→Controller/Producer→Redis 등록→Worker 실행→Redis 결과 기록을 잇는다. Redis 등록을 확인한 별도 점선은 202·jobId·statusUrl 접수 응답이며 Worker 완료를 기다리는 선이 아니다. 마지막 Client 조회는 인증된 jobId로 Redis 상태를 읽는 poll이다. callback은 이 그림에서 구현하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 202 접수와 BullMQ 작업 완료를 분리하는 흐름 |
| 23 | Client 요청→Controller/Producer→Redis 등록→Worker 실행→Redis 결과 기록을 잇는다. Redis 등록을 확인한 별도 점선은 202·jobId·statusUrl 접수 응답이며 Worker 완료를 기다리는 선이 아니다. 마지막 Client 조회는 인증된 jobId로 Redis 상태를 읽는 poll이다. callback은 이 그림에서 구현하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Queue 접수 검증한 직렬화 자료를 Queue.add로 Redis에 저장합니다. | 응답: 202 접수 · job: waiting | HTTP 완료와 비동기 업무를 분리합니다. |
| worker active WorkerHost가 waiting을 가져가 active로 실행합니다. | job: active · 업무: process | 대기 작업이 실제 실행에 들어갑니다. |
| 완료·retry·failed 정상 반환은 completed이고 Error는 retry 또는 failed입니다. | 정상: completed · 실패: retry·failed | 접수 뒤에야 업무 완료 상태가 정해집니다. |
202 접수와 BullMQ 작업 완료를 분리하는 흐름
Queue.add 접수(202)·waiting·active·process·completed/retry/failed의 비동기 작업 상태를 구별한다.
Nest 작업 큐 · HTTP와 업무 완료
큐 접수를 확인한 뒤 응답하고, 업무 완료는 따로 조회합니다
Queue.add() 성공 뒤에만 202·jobId·statusUrl을 반환합니다. Worker가 실행한 실제 결과와 실패는 별도 상태 조회로 전달하며, callback을 쓸 때는 추가 전달 계약을 구현합니다.
-
요청을 검증한다
Controller는 HTTP 입력을 검증하고 Producer는 직렬화 가능한 job data와 options를 준비합니다.
-
Redis 등록을 확인한다
Producer가
Queue.add()를 await하고 성공한 경우에만202,jobId,statusUrl을 반환합니다. -
Worker가 실행한다
WorkerHost.process(job)가 작업을 가져가며 반환값은returnvalue, throw한Error는 retry 또는failedReason이 됩니다. -
완료 상태를 따로 관찰한다
클라이언트는 status URL을 polling하거나 별도 callback 알림을 받고,
completed와failed를 구분합니다.
202 Accepted
Queue.add()가 완료돼 job을 식별할 수 있다는 뜻입니다. Redis 등록을 확인하지 못했다면 job ID를 발급하지 않고 요청을 실패시킵니다.
polling 또는 callback
상태·진행률·결과·오류는 별도 계약으로 전달합니다. callback도 인증과 전달 재시도, 중복 수신 처리가 필요하며 202 자체는 완료를 약속하지 않습니다.
상태 전이는 보통 waiting 또는 delayed에서 active로, 성공하면 completed로 갑니다. 실패하면 attempts와 backoff에 따라 다시 대기하거나 failed가 되므로 Worker의 부수 효과는 멱등하게 설계합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Client가 보고서 생성을 요청하면 Controller와 Producer가 검증한 직렬화 데이터를 Queue.add로 Redis의 BullMQ queue에 저장한다. WorkerHost는 waiting 작업을 active로 가져가 실행하고, 반환값이나 Error를 completed 또는 retry와 failed 상태로 다시 기록한다.
- Client 요청→Controller/Producer→Redis 등록→Worker 실행→Redis 결과 기록을 잇는다. Redis 등록을 확인한 별도 점선은 202·jobId·statusUrl 접수 응답이며 Worker 완료를 기다리는 선이 아니다. 마지막 Client 조회는 인증된 jobId로 Redis 상태를 읽는 poll이다. callback은 이 그림에서 구현하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 202 접수와 BullMQ 작업 완료를 분리하는 흐름 |
| 23 | Client 요청→Controller/Producer→Redis 등록→Worker 실행→Redis 결과 기록을 잇는다. Redis 등록을 확인한 별도 점선은 202·jobId·statusUrl 접수 응답이며 Worker 완료를 기다리는 선이 아니다. 마지막 Client 조회는 인증된 jobId로 Redis 상태를 읽는 poll이다. callback은 이 그림에서 구현하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Queue 접수 검증한 직렬화 자료를 Queue.add로 Redis에 저장합니다. | 응답: 202 접수 · job: waiting | HTTP 완료와 비동기 업무를 분리합니다. |
| worker active WorkerHost가 waiting을 가져가 active로 실행합니다. | job: active · 업무: process | 대기 작업이 실제 실행에 들어갑니다. |
| 완료·retry·failed 정상 반환은 completed이고 Error는 retry 또는 failed입니다. | 정상: completed · 실패: retry·failed | 접수 뒤에야 업무 완료 상태가 정해집니다. |
위 그림은 클라이언트의 POST /reports 요청이 controller와 producer를 거쳐 Redis의 BullMQ 큐에 저장되고, WorkerHost가 그 작업을 꺼내 실행한 뒤 결과를 다시 Redis에 기록하는 흐름입니다. Redis 등록을 확인한 직후의 202 응답은 Worker의 완료를 기다리지 않는다는 점이 중요합니다. 클라이언트는 받은 jobId로 상태를 따로 조회해 completed와 failed를 구분하며, Worker가 던진 Error는 남은 시도 횟수에 따라 재시도되거나 최종 실패로 기록됩니다.
동기 처리에서 분리해야 하는 신호
모든 작업을 큐로 보낼 필요는 없습니다. 짧은 조회나 즉시 확정해야 하는 결제 승인처럼 요청 안에서 결과가 필요한 일은 동기로 처리하는 편이 자연스럽습니다. 반대로 다음 신호가 여러 개 겹치면 백그라운드 작업을 검토합니다.
- 처리 시간이 HTTP 타임아웃에 가까워집니다.
- 요청이 순간적으로 몰리면 서버 CPU나 외부 API가 버티지 못합니다.
- 실패했을 때 일정한 간격으로 다시 시도해야 합니다.
- 요청이 끝난 뒤에도 작업 상태와 결과를 조회해야 합니다.
- API 서버와 작업 처리기의 개수를 따로 늘리고 싶습니다.
작업 큐를 쓰면 API는 작업을 접수만 하고, Worker가 나중에 작업을 실행합니다. 클라이언트에는 최종 결과 대신 작업 ID를 먼저 돌려주고, 상태 조회 API로 진행 상황을 확인하게 합니다.
| 구분 | HTTP 요청 안에서 처리 | 작업 큐로 분리 |
|---|---|---|
| 응답 | 결과가 나올 때까지 기다림 | 큐 등록 뒤 작업 ID와 상태 URL 반환 |
| 실패 | 요청 실패로 바로 드러남 | 재시도와 최종 실패 상태를 나누어 기록 |
| 확장 | API 인스턴스와 함께 확장 | Worker만 따로 확장 가능 |
| 상태 | 연결이 끝나면 잃기 쉬움 | Redis 보존 옵션 범위 안에서 조회 가능 |
202 Accepted 뒤에 클라이언트가 확인할 것
202 Accepted는 처리가 끝났다는 뜻이 아니라 처리 대상으로 접수되었다는 뜻입니다. 그래서 응답에는 적어도 안정적인 jobId, accepted 같은 접수 상태, statusUrl을 넣고, 클라이언트가 GET statusUrl을 주기적으로 조회(polling)해 실제 상태인 waiting, delayed, active, completed, failed를 구분하게 합니다.
Redis 장애로 Queue.add()가 완료되지 않았다면 작업 ID와 202를 발급하지 않습니다. producer가 무한히 기다리지 않도록 연결과 요청에 timeout을 정하고, 접수를 확인하지 못한 요청은 503 Service Unavailable 같은 실패로 끝내는 편이 안전합니다.
callback 방식을 쓴다면 작업을 넣을 때 callback 대상과 상관관계 ID를 함께 보관하고, 알림의 인증·서명, 전달 재시도, 중복 전달 ID를 따로 정해야 합니다. callback은 완료를 알려 주는 전달 수단일 뿐이며, 기준이 되는 기록은 내부 작업 상태나 상태 조회 API에 남아 있어야 합니다.
큐가 보장하지 않는 것
큐를 도입했다고 업무 처리가 저절로 정확해지지는 않습니다.
- 정확히 한 번 실행은 기본으로 보장되지 않습니다. Worker가 외부 저장소에 기록한 직후 lock을 잃거나 종료되면 같은 작업이 다시 실행될 수 있습니다.
- 작업 순서는 큐 옵션, 우선순위, Worker 수에 따라 달라질 수 있습니다.
- Redis가 실행 중이어도 AOF·RDB, 저장 장치, 복제, 백업 정책이 없으면 장애 뒤 작업을 잃을 수 있습니다.
- CPU를 오래 점유하는 코드를 같은 Node.js 프로세스에서 실행하면 lock 갱신이 막혀 stalled 이벤트가 생기고, 작업이
waiting이나failed로 옮겨질 수 있습니다. - 완료·실패 작업을 자동으로 지우면
jobId중복 방지와 결과 조회가 가능한 기간도 함께 끝납니다.
큐는 실행할 기회를 저장하는 장치이고, 실제 부수 효과의 중복 방지는 업무 코드에서 따로 설계해야 합니다. 이 문제는 이 장의 세 번째 글에서 다룹니다.
실습 환경 준비
이 장의 /reports 요청과 statusUrl은 global prefix와 URI 버전 관리가 없는 독립 실습 설정을 기준으로 적었습니다. 앞 장의 api prefix와 기본 URI 버전 1을 유지하는 프로젝트라면 실제 경로는 /api/v1/reports와 /api/v1/reports/jobs/:id입니다. 기존 전역 설정을 유지한다면 curl과 예약 요청의 URL뿐 아니라 service가 만드는 statusUrl도 같은 경로로 맞춰야 합니다. 큐 이름 reports는 이 HTTP 경로와 상관없으므로 바꾸지 않습니다.
이 교재는 Nest의 공식 BullMQ 통합 패키지인 @nestjs/bullmq를 사용합니다. BullMQ는 기본적으로 Redis에 작업 데이터와 상태를 저장하며, BullMQ 문서는 Redis 6.2 이상을 요구합니다.
Redis 실행
Redis를 로컬에 직접 설치하는 대신 Docker 컨테이너를 사용합니다. 볼륨과 AOF를 켜면 컨테이너를 다시 만들 때 생기는 데이터 손실을 줄일 수 있습니다. 다만 Docker volume은 백업이 아니며, 기본 appendfsync everysec 정책에서도 장애 직전 약 1초 동안의 쓰기는 잃을 수 있습니다.
docker volume create nest-queue-data
docker run --name nest-queue-redis \
-p 6379:6379 \
-v nest-queue-data:/data \
-d redis:8-alpine \
redis-server --appendonly yes --maxmemory-policy noeviction-v nest-queue-data:/data는 앞에서 만든 볼륨을 Redis가 데이터를 쓰는 /data에 연결하고, 이미지 이름 뒤의 redis-server ...는 컨테이너가 시작할 때 실행할 명령입니다. --appendonly yes가 AOF를 켜고, --maxmemory-policy noeviction은 메모리가 차도 키를 지우지 않게 하며, 그 이유는 아래에서 설명합니다. 줄 끝의 \는 bash에서 한 명령을 여러 줄로 잇는 표시입니다. Windows PowerShell에서는 이 표시 없이 한 줄로 실행해도 됩니다.
docker run --name nest-queue-redis -p 6379:6379 -v nest-queue-data:/data -d redis:8-alpine redis-server --appendonly yes --maxmemory-policy noevictionRedis가 준비되었는지 확인합니다. docker exec는 실행 중인 컨테이너 안에서 명령을 실행하므로, 호스트에 redis-cli를 따로 설치하지 않아도 이미지에 들어 있는 클라이언트로 서버에 PING을 보낼 수 있습니다.
docker exec nest-queue-redis redis-cli ping정상이라면 PONG이 출력됩니다.
BullMQ 문서는 큐가 올바르게 동작하는 유일한 설정으로 maxmemory-policy noeviction을 안내합니다. 메모리가 부족할 때 Redis가 큐의 키를 임의로 지우면 작업 상태가 깨지기 때문입니다. 운영 환경에서는 허용할 수 있는 손실 구간에 맞춰 AOF·RDB, 복제, 외부 백업과 복구 연습을 따로 설계합니다.
Connection refused가 나오면 Nest 코드를 보기 전에 컨테이너 상태와 포트 매핑부터 확인합니다.
docker ps --filter name=nest-queue-redis
docker logs nest-queue-redis패키지 설치
이 장은 @nestjs/bullmq 12, BullMQ 6, ioredis 5 조합을 사용합니다. 아래 명령은 세 패키지의 주 버전을 @로 고정하므로, 나중에 새 주 버전이 나와도 이 장의 코드와 API가 다른 버전이 설치되지 않습니다.
npm install @nestjs/bullmq@12 bullmq@6 ioredis@5@nestjs/bullmq는 Nest 모듈과 데코레이터를 제공하고, bullmq는 Queue, Job 같은 핵심 타입과 실행 엔진을 제공합니다. BullMQ 6에서는 Redis 클라이언트인 ioredis가 선택적 peer dependency이므로, 연결 옵션만 넘겨 BullMQ가 직접 Redis 연결을 만들게 하려면 ioredis를 따로 설치해야 합니다. BullMQ 6.5.3에서 ioredis를 설치하지 않으면 npm install ioredis를 안내하는 오류가 납니다. ioredis는 6 버전도 나와 있지만, BullMQ 6.5.3 저장소가 개발 의존성으로 ioredis 5.11을 쓰므로 이 장은 5 계열에 맞춥니다.
구형 @nestjs/bull과 bull 패키지는 섞어 쓰지 않습니다. 통합 패키지의 peer dependency는 Nest 12와 BullMQ 6을 허용합니다. 설치한 뒤에는 npm ls @nestjs/core @nestjs/bullmq bullmq ioredis로 실제 버전을 확인하고 lockfile을 함께 보관합니다.
BullMQ 6은 5에서 쓰던 반복 작업(repeatable job) API를 제거하고 Job Scheduler API만 남겼습니다. 이 장의 네 번째 글에서 예약 작업을 다룰 때 이 변화를 다시 확인합니다.
Nest 모듈에 큐 연결하기
실습에서는 reports라는 큐 하나를 사용합니다. 큐 이름은 등록, 주입, Worker 연결에 모두 쓰이므로 문자열을 한곳에 상수로 둡니다.
export const REPORT_QUEUE = 'reports';
export const GENERATE_REPORT_JOB = 'report.generate';REPORT_QUEUE는 Redis에서 큐를 구분하는 이름이고, GENERATE_REPORT_JOB은 그 큐 안에서 작업의 종류를 나타내는 이름입니다. 큐 하나에 여러 종류의 작업을 넣을 수 있으므로 두 이름은 맡는 일이 다릅니다. 이제 기능 모듈에서 사용할 큐를 등록합니다.
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { REPORT_QUEUE } from './reports.constants';
@Module({
imports: [
BullModule.registerQueue({
name: REPORT_QUEUE,
}),
],
})
export class ReportsModule {}루트 모듈에서는 Redis 연결을 한 번 설정합니다. 아래 코드는 큐에 필요한 등록만 보여 줍니다. 앞 장의 프로젝트를 이어서 쓴다면 기존 ConfigModule, 인증·DB 모듈, 전역 guard는 그대로 두고 BullModule.forRoot(...)와 ReportsModule만 기존 imports에 추가합니다. 포트 환경 변수는 문자열이므로 숫자로 바꿉니다.
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ReportsModule } from './reports/reports.module';
@Module({
imports: [
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
},
}),
ReportsModule,
],
})
export class AppModule {}forRoot()는 이 예제의 모든 큐가 쓸 기본 BullMQ 설정을 등록하고, registerQueue()는 이름이 reports인 큐를 현재 기능 모듈에 등록합니다. 큐마다 connection을 직접 줄 수도 있으므로 모든 구성에서 forRoot()가 필수인 것은 아닙니다. connection에 넘긴 옵션은 BullMQ가 내부에서 만드는 ioredis 클라이언트의 생성자로 그대로 전달됩니다.
이 예제는 두 등록을 함께 사용합니다. registerQueue()의 name은 @InjectQueue(REPORT_QUEUE)의 주입 token이면서, @Processor(REPORT_QUEUE)가 Worker를 연결할 큐 이름이기도 하므로 세 값이 정확히 같아야 합니다.
BullMQ consumer는 @Processor(REPORT_QUEUE)를 붙인 클래스가 WorkerHost를 상속하고 process(job) 메서드 하나를 구현하는 형태입니다. 이름이 다른 여러 작업을 처리할 때는 그 메서드 안에서 job.name으로 분기하며, 구형 Bull에서 쓰던 @Process() handler는 @nestjs/bullmq에서 쓰지 않습니다.
Nest · BullMQ Operating Contract
등록 token부터 장애 복구까지 같은 queue 계약으로 묶는다
Nest는 이름으로 Producer와 Worker를 연결하고, BullMQ는 Redis job 상태를 조정합니다. 재실행 안전성과 데이터 내구성은 애플리케이션과 운영 설정이 보완해야 합니다.
- 1 · Nest queue binding
-
BullModule.forRoot()공통 기본connection을 등록합니다. -
BullModule.registerQueue()name: REPORT_QUEUE로 queue를 등록합니다. @InjectQueue(REPORT_QUEUE)와@Processor(REPORT_QUEUE)가 같은 이름을 써야 합니다. BullMQ consumer는WorkerHost.process()를 구현하며 구형@Process()를 쓰지 않습니다.- 2 · Job identity와 상태
-
Queue.add(name, data, options)직렬화 가능한 data를 Redis에 등록하고job.id를 받습니다. waiting·delayed→active→completed또는failedprocess()의 반환값은returnvalue, throw한Error는 retry 또는failedReason이 됩니다.progress와attemptsMade도 상태 응답에 포함할 수 있습니다.- 3 · Retry, idempotency, concurrency
attempts와backoff는 일시적 실패를 다시 실행할 뿐, 외부 부수 효과의 중복을 막지 않습니다.- business key와 원자적 기록으로 Worker를 멱등하게 만들고, custom
jobId나 deduplication은 enqueue 중복 완화 수단으로만 봅니다. - 비동기 I/O는 local concurrency와 여러 Worker가 맞고, CPU 집약 작업은 sandboxed processor나 별도 process로 event loop와 lock 갱신을 보호합니다.
- 4 · Redis durability와 recovery
- AOF와 volume은 재시작 손실을 줄이지만 무손실·백업을 보장하지 않습니다.
maxmemory-policy는noeviction으로 두고, 복제·외부 backup·복구 시험을 정합니다. waiting·delayeddepth와 가장 오래된 job,active시간, 처리량, retry,failed·stalled, Redis 오류를 함께 관찰합니다.- Producer는 접수를 확인할 수 없을 때 빠르게 실패하고, Worker는 재연결 뒤 재개하며 배포 시 graceful shutdown합니다.
완료·실패 job을 제거하면 결과 조회와 custom jobId 중복 방지 기간도 끝납니다. 장기 결과와 업무 원장은 별도 database 또는 object storage에 보존합니다.
위 그림의 위쪽은 reports라는 큐 이름 하나가 producer의 @InjectQueue()와 Worker의 @Processor()를 함께 연결하는 구조를 보여 줍니다. producer는 Redis에 작업을 등록하고 Worker는 실행 결과를 기록하지만, Worker가 만든 업무 결과는 Redis의 작업 기록과 별개로 업무 키를 가진 저장소에 원자적으로 남겨야 합니다. 아래쪽 네 칸은 등록 방법, 작업 상태와 결과, 재시도·멱등성·동시성, Redis 내구성과 복구 조건을 차례로 요약합니다.
연결 확인
애플리케이션을 실행합니다. 시작할 때 BullModule.forRoot()의 연결 정보로 Redis에 연결하므로, 앞에서 띄운 컨테이너가 실행 중이어야 합니다. REDIS_HOST와 REDIS_PORT를 정하지 않았다면 연결 대상은 127.0.0.1의 6379번 포트입니다.
npm run start:devstart:dev는 감시 모드로 계속 실행되므로 이 터미널은 그대로 둡니다. 다른 터미널에서 Redis에 연결된 클라이언트 목록을 확인합니다. CLIENT LIST는 지금 서버에 붙어 있는 연결을 한 줄에 하나씩 보여 주며, 이 명령을 보낸 redis-cli 자신의 연결도 목록에 함께 나옵니다.
docker exec nest-queue-redis redis-cli CLIENT LISTNest를 실행한 뒤 BullMQ 연결이 목록에 보이면 기반 구성이 끝난 것입니다. 아직 producer와 Worker를 만들지 않았으므로 작업은 생성되지 않습니다.
더 깊이: 상태 전이를 Redis 안에서 한 번에 처리하는 방법
BullMQ는 작업의 상태를 대기 목록, 실행 중인 작업의 집합, 지연 작업의 집합처럼 서로 다른 자료 구조로 나누어 관리합니다. 그래서 작업을 waiting에서 active로 옮기는 일은 한 곳에서 빼고 다른 곳에 넣는 여러 명령이 됩니다. BullMQ 구조 문서에 따르면 Redis 백엔드는 이런 연산을 모두 Lua 스크립트로 구현하므로, 상태 전이 하나가 Redis 안에서 원자적으로 실행됩니다. 그래서 Worker 프로세스가 중간에 멈춰도 작업이 두 상태에 반쯤 걸친 채로 남지 않습니다.
이 구조는 Redis Cluster에서 제약이 됩니다. Redis Cluster는 키 공간을 16384개의 해시 슬롯으로 나누고, 여러 키를 함께 다루는 연산은 관련된 키가 모두 같은 슬롯에 있을 때만 실행합니다. 키 이름에 중괄호로 감싼 부분, 즉 해시 태그(hash tag)가 있으면 그 부분만으로 슬롯을 정하므로, BullMQ 문서는 큐의 prefix를 {myprefix}처럼 감싸거나 큐 이름 자체를 중괄호로 감싸라고 안내합니다. 단일 Redis로 개발하다가 운영에서 클러스터로 옮기는 경우에는 이 설정을 미리 정해 두어야 키 이름을 바꾸는 이전 작업을 피할 수 있습니다.
네 구성 요소의 역할 나누기
다음 글부터 코드를 추가할 때는 네 구성 요소의 역할을 섞지 않습니다.
- controller는 입력을 검증하고, producer가 작업을 큐에 넣을 때까지 기다린 뒤
202 Accepted,jobId,statusUrl을 반환합니다. - producer service는 직렬화할 수 있는 작업 데이터와
attempts,backoff, 보존 옵션을Queue.add()에 넘기고, 큐 등록 실패를 HTTP 계층에 그대로 돌려줍니다. - Redis는 작업 데이터와
waiting,delayed,active,completed,failed상태를 보관하지만, 영구적인 업무 원장 역할까지 대신하지는 않습니다. - Worker는
WorkerHost.process(job)에서 실제 부수 효과를 수행합니다. 반환값은returnvalue가 되고,Error를 던지면 남은 시도에 따라 재시도되거나failedReason과 함께 최종 실패합니다.
상태 조회 응답은 jobId와 getState() 결과에 더해 progress, attemptsMade, 완료했을 때의 returnvalue, 실패했을 때의 failedReason을 구분해 보여 줍니다. 결과가 크거나 오래 보존해야 한다면 Redis 작업 기록이 아니라 별도 object storage나 데이터베이스에 저장하고, 작업에는 그 위치만 남깁니다.
실패 복구와 처리량의 기본 원칙
attempts와backoff는 일시적인 실패를 다시 시도하는 정책입니다. 같은 작업이 다시 실행되어도 최종 업무 상태가 같도록, 업무 키·고유 제약·outbox 같은 멱등성 장치를 부수 효과 쪽에 둡니다.- 직접 지정한
jobId나 BullMQ deduplication은 일정 기간 중복 등록을 줄여 주지만, 기존 작업이 지워진 뒤의 재등록이나 이미 실행된 외부 부수 효과까지 되돌리지는 않습니다. - 데이터베이스나 HTTP처럼 비동기 I/O가 많은 작업은 Worker concurrency와 여러 Worker 프로세스로 처리량을 늘립니다. CPU를 많이 쓰는 작업은 높은 concurrency 대신 sandboxed processor나 별도 프로세스를 써서 event loop와 lock 갱신을 보호합니다.
waiting·delayed개수와 가장 오래 기다린 작업,active시간, 처리량,failed·stalled수, 재시도 횟수, Redis 연결 오류를 함께 지켜봅니다. 큐 이벤트만 영구 이력으로 여기지 말고 상태별 개수와 업무 지표를 같이 수집합니다.- BullMQ 문서는 Redis 연결이 끊겼을 때
Queue쪽은 빠르게 실패하고Worker쪽은 재연결될 때까지 기다리도록 구성하라고 안내합니다. 배포할 때는 Worker를 graceful shutdown해 불필요한 stalled 복구를 줄입니다.
자주 막히는 지점
| 증상 | 먼저 볼 곳 | 원인 |
|---|---|---|
ECONNREFUSED 127.0.0.1:6379 | Redis 컨테이너와 포트 | Redis가 꺼졌거나 주소가 다름 |
could not load the optional 'ioredis' package | 설치한 패키지 목록 | BullMQ 6의 선택 peer인 ioredis를 설치하지 않음 |
| 큐 주입 실패 | registerQueue()의 이름 | 상수와 등록 이름이 다름 |
| Redis 재시작 뒤 작업이 사라짐 | volume, AOF/RDB, 백업 복구 | 메모리 전용이거나 영속화·저장 장치 설정이 잘못됨 |
| 메모리 압박 뒤 상태가 깨짐 | maxmemory-policy | noeviction이 아닌 정책이 큐 키를 지움 |
| 같은 부수 효과가 두 번 발생 | stalled·재시도 이력과 업무 키 | 재시도를 고려한 멱등성 장치가 없음 |
| 앱은 뜨지만 아무 일도 없음 | producer와 Worker 등록 | 이 글의 단계에서는 정상 |
작업 큐를 도입하는 이유는 요청을 빨리 끝내는 데만 있지 않습니다. 작업의 대기, 실행, 실패, 복구를 HTTP 연결과 떼어 내어, 언제든 조회할 수 있는 상태로 만드는 데 있습니다.
정리
- 오래 걸리거나, 몰리면 버티기 어렵거나, 재시도와 상태 조회가 필요한 작업은 HTTP 요청에서 떼어 내 작업 큐로 보냅니다.
- API는
Queue.add()가 성공한 뒤에만202 Accepted,jobId,statusUrl을 반환하고, 업무 결과는 작업 상태로 따로 전달합니다. - 큐는 정확히 한 번 실행을 보장하지 않으므로, 부수 효과의 중복 방지는 업무 키와 멱등성 장치로 따로 설계합니다.
- 이 장은
@nestjs/bullmq12, BullMQ 6, ioredis 5를 쓰며, BullMQ 6에서는 ioredis를 직접 설치해야 합니다. registerQueue()의 이름,@InjectQueue(),@Processor()는 같은 상수를 써야 하고, Redis는maxmemory-policy noeviction으로 실행합니다.
연습 문제
1. PONG은 되는데 부팅이 실패한다
Redis의 PONG 응답은 정상인데 Nest가 큐 주입 오류로 부팅하지 못합니다. 등록 이름은 reports이고 주입 이름은 report입니다. Redis 컨테이너를 다시 만드는 것이 첫 해결책일까요? 이 글의 모듈만 등록한 상태에서 Worker 로그가 보이지 않는 것도 같은 오류일까요?
해설 보기
PONG은 Redis 연결이 된다는 뜻이고, 큐 token이 일치하는지는 Nest 의존성 주입 구성의 문제입니다. 컨테이너를 다시 만들어도 이름 불일치는 그대로 남으므로, 먼저 registerQueue()와 @InjectQueue()가 같은 상수를 쓰도록 고칩니다.
반면 이 글에서는 Processor provider를 아직 등록하지 않았으므로 Worker 로그와 실제 작업이 없는 것은 정상입니다. 부팅 실패와 아직 만들지 않은 실행 단계를 구분해서 봐야 합니다.
2. 202를 언제 보낼까
controller에서 this.queue.add(...)를 await 없이 호출하고 곧바로 202 Accepted와 임시로 만든 ID를 반환하도록 바꾸었습니다. 그 순간 Redis가 내려가 있었다면 클라이언트는 어떤 상황에 놓일까요?
해설 보기
클라이언트는 접수되었다는 응답과 ID를 받았지만, Redis에는 작업이 등록되지 않았습니다. 그 ID로 상태를 조회해도 작업을 찾을 수 없고, 보고서는 영원히 만들어지지 않습니다. 클라이언트 입장에서는 재시도할 이유도 알 수 없습니다.
Queue.add()를 await해 등록 성공을 확인한 뒤에만 BullMQ가 돌려준 작업 ID로 202를 보내고, 등록에 실패하면 503 Service Unavailable 같은 오류로 끝내야 클라이언트가 다시 시도할 수 있습니다.
3. 캐시용 Redis를 같이 쓰면
다른 팀이 캐시용으로 운영하는 Redis를 이 큐에도 쓰려고 합니다. 그 Redis는 maxmemory-policy allkeys-lru로 설정되어 있습니다. 평소에는 잘 동작하다가 트래픽이 몰린 뒤 일부 작업 상태가 사라지거나 조회가 깨졌습니다. 원인과 대책은 무엇일까요?
해설 보기
allkeys-lru는 메모리가 한도에 이르면 최근에 덜 쓴 키부터 지웁니다. 캐시 키뿐 아니라 BullMQ가 작업과 상태를 저장한 키도 삭제 대상이 되므로, 메모리 압박이 생긴 뒤 큐 상태가 깨집니다.
BullMQ 문서는 noeviction만이 큐의 올바른 동작을 보장하는 설정이라고 안내합니다. 큐 전용 Redis를 noeviction으로 따로 두고, 메모리 사용량을 지켜보면서 완료·실패 작업의 보존 기간을 조절합니다.
다음 글 Producer, Worker와 작업 상태에서는 같은 reports 큐에 producer와 Worker를 연결하고, 작업 ID로 상태와 진행률을 조회합니다.