본문으로 건너뛰기

안동민 개발노트

본문 시작

오래 걸리는 요청과 작업 큐 분리

보고서 생성처럼 오래 걸리는 HTTP 작업을 식별하고 BullMQ와 Redis로 API 접수와 백그라운드 실행을 분리합니다.

REST API에서 파일 변환, 대량 보고서 생성, 이메일 발송처럼 오래 걸리는 일을 요청 안에서 끝내려 하면 문제가 생깁니다.

처리 시간이 길어질수록 HTTP 연결이 끊길 가능성이 커지고, 같은 요청이 재전송되면 비싼 작업이 중복 실행될 수 있습니다.

이 장에서는 하나의 보고서 생성 API를 네 절에 걸쳐 확장합니다.

이번 절에서는 HTTP 요청과 실제 작업을 분리할 기준을 세우고, NestJS 애플리케이션에 BullMQ와 Redis를 연결합니다.

이때 HTTP 성공과 업무 성공을 분리해야 합니다. API는 Queue.add()가 성공해 Redis에 작업이 등록된 뒤 202 Accepted와 작업 ID를 반환하고, 실제 보고서 생성의 성공·실패는 그 뒤의 작업 상태로 표현합니다.

클라이언트 요청을 Nest Controller와 Producer가 검증해 BullMQ queue에 등록하고, 등록 성공 뒤 202와 작업 ID를 반환하며, Worker가 실행 결과나 오류를 Redis 작업 상태에 기록하는 비동기 HTTP 작업 흐름

Nest · Async Job Boundary

큐 접수와 업무 완료는 서로 다른 성공이다

Queue.add()가 성공한 뒤에만 202 AcceptedjobId를 확정하고, 완료·실패는 상태 조회나 callback으로 따로 전달합니다.

HTTP 요청에서 BullMQ Worker까지 이어지는 작업 데이터 흐름 Client가 보고서 생성을 요청하면 Controller와 Producer가 검증한 직렬화 데이터를 Queue.add로 Redis의 BullMQ queue에 저장한다. WorkerHost는 waiting 작업을 active로 가져가 실행하고, 반환값이나 Error를 completed 또는 retry와 failed 상태로 다시 기록한다. POST QUEUE.ADD NEXT JOB RETURN · ERROR Client POST /reports Controller + Producer validate · await add() BullMQ Queue job · state · attempts Redis WorkerHost process(job) progress · side effect
  1. 요청을 검증한다

    Controller는 HTTP 입력을 검증하고 Producer는 직렬화 가능한 job data와 options를 준비합니다.

  2. Redis 등록을 확인한다

    Producer가 Queue.add()를 await하고 성공한 경우에만 202, jobId, statusUrl을 반환합니다.

  3. Worker가 실행한다

    WorkerHost.process(job)가 작업을 가져가며 반환값은 returnvalue, throw한 Error는 retry 또는 failedReason이 됩니다.

  4. 완료 상태를 따로 관찰한다

    클라이언트는 status URL을 polling하거나 별도 callback 알림을 받고, completedfailed를 구분합니다.

HTTP 접수

202 Accepted

Queue.add()가 완료돼 job을 식별할 수 있다는 뜻입니다. Redis 등록을 확인하지 못했다면 job ID를 발급하지 않고 요청을 실패시킵니다.

작업 관찰

polling 또는 callback

상태·진행률·결과·오류는 별도 계약으로 전달합니다. callback도 인증과 전달 재시도, 중복 수신 처리가 필요하며 202 자체는 완료를 약속하지 않습니다.

상태 전이는 보통 waiting 또는 delayed에서 active로, 성공하면 completed로 갑니다. 실패하면 attemptsbackoff에 따라 다시 대기하거나 failed가 되므로 Worker의 부수 효과는 멱등하게 설계합니다.


동기 처리에서 분리해야 하는 신호

모든 작업을 큐로 보낼 필요는 없습니다.

짧은 조회나 즉시 확정해야 하는 결제 승인처럼 요청 안에서 결과가 필요한 일은 동기 처리가 자연스럽습니다.

반대로 다음 신호가 겹치면 백그라운드 작업을 검토합니다.

  • 처리 시간이 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를 구분하게 합니다.

Queue.add()가 Redis 장애로 완료되지 않았다면 작업 ID와 202를 발급하지 않습니다. Producer는 무한히 기다리지 않도록 연결·요청 timeout을 정하고, 접수를 확인하지 못한 요청은 503 Service Unavailable 같은 실패로 끝내는 편이 안전합니다.

callback을 선택한다면 enqueue 시 callback 대상과 상관관계 ID를 함께 보관하고, 알림 인증·서명, 전달 재시도, 중복 전달 ID를 별도 계약으로 정합니다. callback은 완료 상태를 알려 주는 전달 채널이고, 내부 작업 상태나 상태 조회 API가 기준 기록으로 남아야 합니다.

큐가 보장하지 않는 것

큐를 도입했다고 업무 처리가 자동으로 정확해지는 것은 아닙니다.

  • 정확히 한 번 실행은 기본 보장이 아닙니다. Worker가 외부 저장소에 기록한 직후 lock을 잃거나 종료되면 같은 작업이 다시 실행될 수 있습니다.
  • 작업 순서는 큐 옵션, 우선순위, Worker 수에 따라 달라질 수 있습니다.
  • Redis가 실행 중이어도 AOF·RDB, 저장 장치, 복제, 백업 정책이 없으면 장애 후 작업을 잃을 수 있습니다.
  • CPU를 오래 점유하는 코드를 같은 Node.js 프로세스에서 실행하면 lock 갱신이 막혀 stalled 이벤트가 발생하고 작업이 waiting 또는 failed로 이동할 수 있습니다.
  • 완료·실패 작업을 자동 제거하면 jobId 중복 방지와 결과 조회 가능 기간도 함께 끝납니다.

따라서 큐는 실행 기회를 저장하는 장치이고, 실제 부수 효과의 중복 방지는 업무 코드에서 따로 설계해야 합니다.

이 문제는 3절에서 다룹니다.


실습 환경 준비

이 교재는 Nest의 공식 BullMQ 통합인 @nestjs/bullmq를 사용합니다.

BullMQ는 Redis에 작업 데이터와 상태를 저장합니다.

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:7-alpine \
  redis-server --appendonly yes --maxmemory-policy noeviction

Windows PowerShell에서는 줄바꿈 대신 한 줄로 실행해도 됩니다.

docker run --name nest-queue-redis -p 6379:6379 -v nest-queue-data:/data -d redis:7-alpine redis-server --appendonly yes --maxmemory-policy noeviction

Redis가 준비됐는지 확인합니다.

docker exec nest-queue-redis redis-cli ping

정상 응답은 PONG입니다.

BullMQ queue key가 임의로 제거되면 상태가 깨질 수 있으므로 Redis의 maxmemory-policynoeviction으로 둡니다. 운영 환경에서는 허용 가능한 손실 구간에 맞춰 AOF·RDB, 복제, 외부 백업과 복구 훈련을 별도로 설계합니다.

Connection refused가 나오면 Nest 코드를 보기 전에 컨테이너 상태와 포트 매핑부터 확인합니다.

docker ps --filter name=nest-queue-redis
docker logs nest-queue-redis

패키지 설치

npm install @nestjs/bullmq bullmq

@nestjs/bullmq는 Nest 모듈과 데코레이터를 제공하고, bullmqQueue, Job 같은 핵심 타입과 실행 엔진을 제공합니다.

구형 @nestjs/bullbull 패키지를 섞지 않습니다.


Nest 모듈에 큐 연결하기

실습에서는 reports라는 큐 하나를 사용합니다.

큐 이름은 등록, 주입, Worker 연결에 모두 쓰이므로 문자열을 한곳에 고정합니다.

src/reports/reports.constants.ts
export const REPORT_QUEUE = 'reports';
export const GENERATE_REPORT_JOB = 'report.generate';

기능 모듈에서 사용할 큐를 등록합니다.

src/reports/reports.module.ts
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 연결을 한 번 설정합니다.

포트 환경 변수는 문자열이므로 숫자로 변환합니다.

src/app.module.ts
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()가 모든 구성에서 필수인 것은 아닙니다.

이 예제에서는 두 등록을 함께 사용합니다. 특히 registerQueue()name@InjectQueue(REPORT_QUEUE)의 주입 token이자 @Processor(REPORT_QUEUE)가 Worker를 연결할 queue 이름이므로 세 값을 정확히 일치시켜야 합니다.

BullMQ consumer는 @Processor(REPORT_QUEUE)를 붙인 class가 WorkerHost를 상속하고 하나의 process(job) method를 구현합니다. 여러 named job을 처리할 때는 그 method 안에서 job.name으로 분기하며, 구형 Bull용 @Process() named handler는 @nestjs/bullmq에서 사용하지 않습니다.

Nest의 BullMQ 연결과 queue name token, job 상태와 결과 오류, retry와 idempotency 및 concurrency, Redis durability와 monitoring recovery 조건을 네 영역으로 정리한 운영 계약

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 · delayedactivecompleted 또는 failed
process()의 반환값은 returnvalue, throw한 Error는 retry 또는 failedReason이 됩니다. progressattemptsMade도 상태 응답에 포함할 수 있습니다.
3 · Retry, idempotency, concurrency
attemptsbackoff는 일시적 실패를 다시 실행할 뿐, 외부 부수 효과의 중복을 막지 않습니다.
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-policynoeviction으로 두고, 복제·외부 backup·복구 시험을 정합니다.
waiting·delayed depth와 가장 오래된 job, active 시간, 처리량, retry, failed·stalled, Redis 오류를 함께 관찰합니다.
Producer는 접수를 확인할 수 없을 때 빠르게 실패하고, Worker는 재연결 뒤 재개하며 배포 시 graceful shutdown합니다.

완료·실패 job을 제거하면 결과 조회와 custom jobId 중복 방지 기간도 끝납니다. 장기 결과와 업무 원장은 별도 database 또는 object storage에 보존합니다.

연결 확인

애플리케이션과 Redis를 함께 실행합니다.

npm run start:dev

다른 터미널에서 Redis 연결 수를 확인합니다.

docker exec nest-queue-redis redis-cli CLIENT LIST

Nest를 실행한 뒤 BullMQ 연결이 보이면 기반 구성이 끝난 것입니다.

아직 Producer와 Worker를 만들지 않았으므로 작업은 생성되지 않습니다.


책임 경계를 먼저 고정하기

다음 절부터 코드를 추가할 때는 네 책임을 섞지 않습니다.

  1. Controller는 입력을 검증하고 Producer의 enqueue 성공을 기다린 뒤 202 Accepted, jobId, statusUrl을 반환합니다.
  2. Producer Service는 직렬화 가능한 작업 데이터와 attempts, backoff, 보존 옵션을 Queue.add()에 전달하고 enqueue 실패를 HTTP 계층에 돌려줍니다.
  3. Redis는 job data와 waiting, delayed, active, completed, failed 상태를 보관하지만 영구적인 업무 원장 역할까지 대신하지는 않습니다.
  4. WorkerWorkerHost.process(job)에서 실제 부수 효과를 수행합니다. 반환값은 returnvalue가 되고, Error를 throw하면 남은 시도에 따라 재시도되거나 failedReason과 함께 최종 실패합니다.

상태 조회 응답은 jobIdgetState() 결과에 더해 progress, attemptsMade, 완료 시 returnvalue, 실패 시 failedReason을 구분해 노출합니다. 결과가 크거나 오래 보존돼야 한다면 Redis job record가 아니라 별도 object storage나 database에 저장하고 job에는 위치만 남깁니다.

실패·복구와 처리량의 최소 계약

  • attemptsbackoff는 일시적 실패를 다시 시도하는 정책입니다. 같은 작업이 재실행돼도 최종 업무 상태가 같도록 business key, unique constraint, outbox 같은 멱등성 장치를 부수 효과 쪽에 둡니다.
  • custom jobId나 BullMQ deduplication은 일정 기간 중복 enqueue를 줄일 수 있지만, 기존 job이 제거된 뒤의 재등록이나 이미 실행된 외부 부수 효과까지 되돌리지는 않습니다.
  • database·HTTP처럼 비동기 I/O가 많은 작업은 Worker concurrency와 여러 Worker process로 처리량을 늘립니다. CPU 집약 작업은 높은 local concurrency 대신 sandboxed processor나 별도 process를 사용해 event loop와 lock 갱신을 보호합니다.
  • waiting·delayed 수와 가장 오래 기다린 job, active 시간, 처리량, failed·stalled, 재시도 횟수, Redis 연결 오류를 함께 관찰합니다. queue event만 영구 이력으로 간주하지 말고 상태 count와 업무 지표를 같이 수집합니다.
  • Producer는 Redis 단절 시 빠르게 실패하고, Worker는 재연결 뒤 남은 작업을 이어받도록 구성합니다. 배포 때는 Worker를 graceful shutdown해 불필요한 stalled 복구를 줄입니다.

자주 막히는 지점

증상먼저 볼 곳원인
ECONNREFUSED 127.0.0.1:6379Redis 컨테이너와 포트Redis가 꺼졌거나 주소가 다름
큐 주입 실패registerQueue()의 이름상수와 등록 이름이 다름
Redis 재시작 뒤 작업이 사라짐volume, AOF/RDB, backup 복구메모리 전용이거나 persistence·storage가 잘못됨
메모리 압박 뒤 상태가 깨짐maxmemory-policynoeviction이 아닌 정책이 queue key를 제거함
같은 부수 효과가 두 번 발생stalled·retry 이력과 business keyretry를 고려한 멱등성 장치가 없음
앱은 뜨지만 아무 일도 없음Producer와 Worker 등록현재 절에서는 정상 상태

작업 큐를 도입하는 핵심 이유는 요청을 빨리 끝내기 위해서만이 아닙니다.

작업의 대기, 실행, 실패, 복구를 HTTP 연결과 분리하여 관찰 가능한 상태로 만드는 데 있습니다.

다음 절에서는 같은 reports 큐에 Producer와 Worker를 연결하고, 작업 ID로 상태와 진행률을 조회합니다.