본문 바로가기

Producer, Worker와 작업 상태

직렬화 가능한 작업 계약을 만들고 Producer·Worker·상태 조회 API를 연결해 보고서 작업의 진행과 결과를 관찰합니다.

요청을 보내기 전에 이 장의 HTTP 경로 기준을 확인하세요. 앞 장의 api·URI 버전 1을 유지한다면 본문의 /reports 대신 /api/v1/reports를 요청하고, 응답에 만드는 statusUrl도 /api/v1/reports/jobs/:id 계약을 사용합니다.

지난 절에서는 reports 큐와 Redis 연결을 준비했습니다.

이제 보고서 생성 요청을 큐에 넣는 Producer, 작업을 처리하는 Worker, 상태를 조회하는 API를 연결합니다.

완성된 흐름은 POST /reports가 작업을 접수하고 202 Accepted를 반환한 뒤, GET /reports/jobs/:id가 진행 상태와 결과를 보여주는 구조입니다.

BullMQ 작업 접수와 상태 수명주기

작업 상태 모델의 waiting→active 뒤 정상 반환·재시도·소진을 같은 관찰 단계에서 비교합니다. 실제 HTTP 접수 직후 상태 관측은 worker와 경쟁하며 202와 progress 100은 완료를 보장하지 않습니다.

Nest BullMQ · 작업 상태

202 접수와 completed 업무 결과를 구분합니다

Worker 정상 반환이 completed를 만듭니다. progress 100은 완료 신호가 아닙니다. 상태와 결과는 원자적으로 함께 읽히지 않으므로 전이 경계에서는 다음 poll로 확인합니다.

BullMQ 작업 상태와 재시도 전이 Redis 접수 뒤 작업은 waiting에서 active로 이동한다. process 함수가 정상 반환하면 completed와 result가 되고, Error를 던져 시도가 남으면 exponential backoff 동안 delayed에 있다가 waiting으로 돌아간다. 시도를 모두 소진하면 failed와 failedReason이 된다. ADD FETCH 정상 반환 시도 소진 실패 · 재시도 대기 종료 Redis 접수 jobId · 202 waiting 실행 대기 active progress 0…100 completed returnvalue delayed backoff failed failedReason
작업 상태의 성공·재시도·소진 분기 waiting→active→정상반환이면completed. Error이고시도가남으면delayed에서대기후waiting으로복귀한다. Error이고시도를소진하면failed로간다. 오른쪽바깥선은소진경로,왼쪽바깥점선은대기종료복귀다. progress100만으로completed전이는일어나지않는다. 이Worker의보고서는모의결과다. 반환 예외 남음 Redis 접수 jobId · 202 waiting 실행 대기 active progress 0…100 실행 결과 판단 정상 반환 / Error? completed 정상 반환 returnvalue Error 시도 남음? 소진→failed delayed 남은 시도 · backoff failed 시도 소진 failedReason
  1. 접수를 확인한다

    Queue.add()가 Redis 등록에 성공한 뒤에만 202, jobId, statusUrl을 반환합니다.

  2. 상태는 조회마다 달라질 수 있다

    enqueue 직후에도 Worker와 경쟁하고 상태와 결과는 원자적으로 함께 읽히지 않으므로, 전이 경계에서는 다음 poll로 확인합니다.

  3. Worker가 실행한다

    active에서 진행률을 기록하지만 100도 완료 신호는 아닙니다.

  4. 반환하거나 재시도한다

    정상 반환은 completed와 결과가 되고, 예외는 남은 시도에 따라 delayed 뒤 재실행되거나 최종 failed가 됩니다.

HTTP 접수

202는 완료가 아니다

접수 응답은 작업 ID와 상태 URL만 확정합니다. 실제 상태는 Worker 속도에 따라 이미 바뀌었을 수 있습니다.

성공 기준

정상 반환이 완료를 만든다

progress: 100 뒤에도 실패할 수 있습니다. process()의 반환과 completed를 함께 확인합니다.

상태 조회

소유권과 보존 기간을 확인한다

state, 진행률, 시도 횟수, 결과·오류를 반환하되 다른 tenant, 알 수 없거나 제거된 작업은 같은 404로 숨깁니다.

removeOnComplete와 removeOnFail로 기록이 제거되면 결과 조회와 custom ID의 중복 방지 기간도 끝납니다. Redis 내구성은 persistence 구성에 달려 있고, lock 상실 시 at-least-once로 중복 실행될 수 있으므로 부수 효과는 멱등해야 합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Redis 접수 뒤 작업은 waiting에서 active로 이동한다. process 함수가 정상 반환하면 completed와 result가 되고, Error를 던져 시도가 남으면 exponential backoff 동안 delayed에 있다가 waiting으로 돌아간다. 시도를 모두 소진하면 failed와 failedReason이 된다.
  • waiting→active에서 정상 반환하면 completed로 이어진다. Error이고 시도가 남으면 delayed에서 대기 후 waiting으로 복귀한다. Error이고 시도를 소진하면 failed로 간다. 오른쪽 바깥 선은 소진 경로, 왼쪽 바깥 점선은 대기 종료 후 복귀다. progress 100만으로 completed 전이는 일어나지 않는다. 이 Worker의 보고서는 모의 결과다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1BullMQ 작업 접수와 상태 수명주기
27waiting→active에서 정상 반환하면 completed로 이어진다. Error이고 시도가 남으면 delayed에서 대기 후 waiting으로 복귀한다. Error이고 시도를 소진하면 failed로 간다. 오른쪽 바깥 선은 소진 경로, 왼쪽 바깥 점선은 대기 종료 후 복귀다. progress 100만으로 completed 전이는 일어나지 않는다. 이 Worker의 보고서는 모의 결과다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
접수와 실행 대기

Redis 등록이 성공하면 jobId와 202 접수가 확정되고 작업은 waiting에서 실행 후보가 됩니다.

접수: Redis 등록 성공·jobId·202 · 상태 모델: waiting→active · 조회 경계: enqueue 직후 이미 상태가 바뀔 수 있음202는 접수 증거이며 다음 조회에서 반드시 waiting을 관측한다거나 업무가 완료됐다는 뜻이 아닙니다.
active에서 실행

worker가 가져온 작업의 process를 active에서 실행하며 진행률을 기록합니다.

실행 상태: active · progress: 0…100, 100도 완료 신호 아님 · 결과: 정상 반환 또는 Error를 아직 판정 중process의 반환이나 실패가 다음 상태를 결정하며 progress만으로 결과를 확정하지 않습니다.
결과별 상태 판정

같은 실행 결과 판정에서 정상 반환과 Error의 남은 시도를 함께 비교합니다.

정상 반환: completed·returnvalue · Error·시도 남음: delayed·backoff · Error·시도 소진: failed·failedReason한 실행 결과에 맞는 상태 하나로 전이하며 completed 뒤에 실패 대안을 연속 실행하지 않습니다.
각 경로의 후속 관찰

정상은 완료 결과를, 재시도는 backoff 뒤 waiting 복귀를, 소진은 failedReason을 각각 확인합니다.

정상 후속: completed와 결과 확인 · 재시도 후속: delayed 대기 종료→waiting · 소진 후속: failed와 failedReason · 조회 경계: state·result는 원자적 조회 아님, 전이 경계에서 다음 poll조건별 후속 행동을 유지하고 lock 상실의 중복 실행·기록 제거·소유권 검사는 원표의 별도 계약으로 보존합니다.

BullMQ 작업 접수와 상태 수명주기

작업 상태 모델의 waiting→active 뒤 정상 반환·재시도·소진을 같은 관찰 단계에서 비교합니다. 실제 HTTP 접수 직후 상태 관측은 worker와 경쟁하며 202와 progress 100은 완료를 보장하지 않습니다.

Nest BullMQ · 작업 상태

202 접수와 completed 업무 결과를 구분합니다

Worker 정상 반환이 completed를 만듭니다. progress 100은 완료 신호가 아닙니다. 상태와 결과는 원자적으로 함께 읽히지 않으므로 전이 경계에서는 다음 poll로 확인합니다.

BullMQ 작업 상태와 재시도 전이 Redis 접수 뒤 작업은 waiting에서 active로 이동한다. process 함수가 정상 반환하면 completed와 result가 되고, Error를 던져 시도가 남으면 exponential backoff 동안 delayed에 있다가 waiting으로 돌아간다. 시도를 모두 소진하면 failed와 failedReason이 된다. ADD FETCH 정상 반환 시도 소진 실패 · 재시도 대기 종료 Redis 접수 jobId · 202 waiting 실행 대기 active progress 0…100 completed returnvalue delayed backoff failed failedReason
작업 상태의 성공·재시도·소진 분기 waiting→active→정상반환이면completed. Error이고시도가남으면delayed에서대기후waiting으로복귀한다. Error이고시도를소진하면failed로간다. 오른쪽바깥선은소진경로,왼쪽바깥점선은대기종료복귀다. progress100만으로completed전이는일어나지않는다. 이Worker의보고서는모의결과다. 반환 예외 남음 Redis 접수 jobId · 202 waiting 실행 대기 active progress 0…100 실행 결과 판단 정상 반환 / Error? completed 정상 반환 returnvalue Error 시도 남음? 소진→failed delayed 남은 시도 · backoff failed 시도 소진 failedReason
  1. 접수를 확인한다

    Queue.add()가 Redis 등록에 성공한 뒤에만 202, jobId, statusUrl을 반환합니다.

  2. 상태는 조회마다 달라질 수 있다

    enqueue 직후에도 Worker와 경쟁하고 상태와 결과는 원자적으로 함께 읽히지 않으므로, 전이 경계에서는 다음 poll로 확인합니다.

  3. Worker가 실행한다

    active에서 진행률을 기록하지만 100도 완료 신호는 아닙니다.

  4. 반환하거나 재시도한다

    정상 반환은 completed와 결과가 되고, 예외는 남은 시도에 따라 delayed 뒤 재실행되거나 최종 failed가 됩니다.

HTTP 접수

202는 완료가 아니다

접수 응답은 작업 ID와 상태 URL만 확정합니다. 실제 상태는 Worker 속도에 따라 이미 바뀌었을 수 있습니다.

성공 기준

정상 반환이 완료를 만든다

progress: 100 뒤에도 실패할 수 있습니다. process()의 반환과 completed를 함께 확인합니다.

상태 조회

소유권과 보존 기간을 확인한다

state, 진행률, 시도 횟수, 결과·오류를 반환하되 다른 tenant, 알 수 없거나 제거된 작업은 같은 404로 숨깁니다.

removeOnComplete와 removeOnFail로 기록이 제거되면 결과 조회와 custom ID의 중복 방지 기간도 끝납니다. Redis 내구성은 persistence 구성에 달려 있고, lock 상실 시 at-least-once로 중복 실행될 수 있으므로 부수 효과는 멱등해야 합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Redis 접수 뒤 작업은 waiting에서 active로 이동한다. process 함수가 정상 반환하면 completed와 result가 되고, Error를 던져 시도가 남으면 exponential backoff 동안 delayed에 있다가 waiting으로 돌아간다. 시도를 모두 소진하면 failed와 failedReason이 된다.
  • waiting→active에서 정상 반환하면 completed로 이어진다. Error이고 시도가 남으면 delayed에서 대기 후 waiting으로 복귀한다. Error이고 시도를 소진하면 failed로 간다. 오른쪽 바깥 선은 소진 경로, 왼쪽 바깥 점선은 대기 종료 후 복귀다. progress 100만으로 completed 전이는 일어나지 않는다. 이 Worker의 보고서는 모의 결과다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1BullMQ 작업 접수와 상태 수명주기
27waiting→active에서 정상 반환하면 completed로 이어진다. Error이고 시도가 남으면 delayed에서 대기 후 waiting으로 복귀한다. Error이고 시도를 소진하면 failed로 간다. 오른쪽 바깥 선은 소진 경로, 왼쪽 바깥 점선은 대기 종료 후 복귀다. progress 100만으로 completed 전이는 일어나지 않는다. 이 Worker의 보고서는 모의 결과다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
접수와 실행 대기

Redis 등록이 성공하면 jobId와 202 접수가 확정되고 작업은 waiting에서 실행 후보가 됩니다.

접수: Redis 등록 성공·jobId·202 · 상태 모델: waiting→active · 조회 경계: enqueue 직후 이미 상태가 바뀔 수 있음202는 접수 증거이며 다음 조회에서 반드시 waiting을 관측한다거나 업무가 완료됐다는 뜻이 아닙니다.
active에서 실행

worker가 가져온 작업의 process를 active에서 실행하며 진행률을 기록합니다.

실행 상태: active · progress: 0…100, 100도 완료 신호 아님 · 결과: 정상 반환 또는 Error를 아직 판정 중process의 반환이나 실패가 다음 상태를 결정하며 progress만으로 결과를 확정하지 않습니다.
결과별 상태 판정

같은 실행 결과 판정에서 정상 반환과 Error의 남은 시도를 함께 비교합니다.

정상 반환: completed·returnvalue · Error·시도 남음: delayed·backoff · Error·시도 소진: failed·failedReason한 실행 결과에 맞는 상태 하나로 전이하며 completed 뒤에 실패 대안을 연속 실행하지 않습니다.
각 경로의 후속 관찰

정상은 완료 결과를, 재시도는 backoff 뒤 waiting 복귀를, 소진은 failedReason을 각각 확인합니다.

정상 후속: completed와 결과 확인 · 재시도 후속: delayed 대기 종료→waiting · 소진 후속: failed와 failedReason · 조회 경계: state·result는 원자적 조회 아님, 전이 경계에서 다음 poll조건별 후속 행동을 유지하고 lock 상실의 중복 실행·기록 제거·소유권 검사는 원표의 별도 계약으로 보존합니다.

직렬화 가능한 작업 계약 만들기

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

따라서 함수, 클래스 인스턴스, 스트림, HTTP 요청 객체를 payload에 넣지 않습니다.

ID와 숫자, 문자열처럼 다시 읽을 수 있는 값만 전달하고 Worker가 필요한 데이터를 저장소에서 다시 조회하게 합니다.

src/reports/report-job.types.ts
export interface GenerateReportData {
  reportId: string;
  tenantId: string;
  requestedBy: string;
  rows: number;
}

export interface GenerateReportResult {
  reportId: string;
  objectKey: string;
  generatedRows: number;
}

export interface ReportProgress {
  step: 'load' | 'render' | 'upload';
  percent: number;
}

export interface ReportPrincipal {
  tenantId: string;
  userId: string;
}

요청 DTO는 HTTP 입력을 검증하고, 작업 데이터 타입은 Producer와 Worker 사이의 내부 계약을 표현합니다.

src/reports/dto/create-report.dto.ts
import { IsInt, IsString, Max, Min } from 'class-validator';

export class CreateReportDto {
  @IsString()
  reportId: string;

  @IsInt()
  @Min(1)
  @Max(100_000)
  rows: number;
}

이 예제는 앞 장에서 전역 ValidationPipe를 설정했다고 가정합니다.

설정하지 않았다면 main.ts에 먼저 추가합니다. 아래 코드는 검증 등록 위치를 보여주므로, 이미 설정한 전역 접두사와 다른 전역 등록·포트 설정은 유지합니다.

src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(process.env.PORT ?? 3000);
}

void bootstrap();

외부 입력의 requestedBy를 그대로 믿으면 다른 사용자의 작업을 만들 수 있습니다.

이 절의 ReportPrincipal은 4장의 JWT 예제와 별도의 확장 계약입니다. 4장의 Guard가 반환하는 숫자 userId·username·roles만으로는 tenantId를 얻을 수 없습니다. 아래 Controller를 연결하려면 사용자가 속한 tenant를 서버에서 확인하고, 그 tenantId와 문자열로 표현한 userId를 request.user에 넣는 인증 처리가 먼저 있어야 합니다.

여기서는 그 처리가 구현되어 있다고 가정하며, tenant를 확인하는 구현은 제공하지 않습니다. Controller는 서버가 확인한 두 문자열만 작업 데이터로 복사하고, 토큰·비밀번호·원본 요청 객체는 넘기지 않습니다.

BullMQ 작업 payload와 신뢰 경계

원 코드의 모의 Worker 한 경로를 계약 수신→진행률 기록→반환으로 추적합니다. 실제 tenant 원본 조회·CSV 생성·업로드는 추가 구현이고 상태 조회는 독립 요청이며 원 표에 함께 보존합니다.

Nest job data · 신뢰와 직렬화

서버가 확인한 식별자와 작은 실행 입력을 전달합니다

본문의 Worker는 대기와 결과를 모의 실행합니다. 실제 보고서 처리를 붙일 때는 tenant 범위에서 원본을 다시 조회합니다. 인증 주체·payload·결과 조회의 경계를 유지합니다.

신뢰 입력과 직렬화 경계를 구분한다 서로다른외부DTO와인증주체가job.data를만든다. Worker는tenant범위로실제원본을찾고조회API는현재주체와저장된owner를비교한다. 선은데이터와검사대상을전달하며실제CSV생성은모의예제이후구현할책임이다. 실행 데이터 소유권 비교 외부 입력 reportId · rows HTTP DTO 검증 인증된 주체 tenantId · userId 서버에서 확인 job.data 작은 직렬화 값 비밀·연결 제외 Worker tenant 범위 원본 부수 효과 구현 상태 조회 현재 주체=owner? 권한 밖/없음 404
HTTP·큐·Worker/조회에서 신뢰를 유지한다 외부DTO와서버인증주체가독립적으로job.data의작은값을만든다. HTTP경계,Redis직렬화경계,Worker와조회책임영역을표시한다. Worker는job값을현재tenant원본과연결해야하며상태API는현재주체와저장owner를다시비교한다. job.data에서상태API로가는점선은저장된소유권참조이며Worker완료를기다리는제어흐름이아니다. 작업이waiting또는active인때도현재주체가별도로상태를조회할수있다. Worker에서인증주체를새로만든다는선은아니며,본문Worker는원본/파일을실제처리하지않는모의다. HTTP 신뢰 입력 Redis 직렬화 실행 · 조회 책임 HTTP DTO reportId · rows 서버 인증 주체 tenantId · userId 직렬화한 job.data 보고서 ID · 작은 값 token · 요청 객체 제외 Worker tenant 범위 원본 조회 실제 업무 효과 구현 상태 조회 API 현재 주체와 owner 비교 조회 결과 같은 소유자: 상태·결과 권한 밖/없음: 동일404
1 · HTTP 경계

DTO와 인증 주체를 분리한다

DTO는 reportId와 rows를 검증합니다. tenantId와 userId는 요청 본문이 아니라 인증 Guard가 확인한 주체에서 가져옵니다.

2 · job.data

직렬화 가능한 최소 계약

reportId, tenantId, requestedBy, rows처럼 JSON으로 저장할 작은 값만 전달합니다.

Redis의 작업 데이터는 평문으로 남을 수 있으므로 비밀과 원본 데이터는 넣지 않습니다.

3 · Worker

서버 저장소에서 다시 조회한다

Worker는 tenantId + reportId로 권한 범위 안의 보고서 원본을 읽고 처리합니다. 오래된 요청 객체나 연결을 복원하려 하지 않습니다.

payload 금지

실행 환경과 비밀을 직렬화하지 않는다

Request·Response, 함수·class instance, stream·열린 file, database handle, access token·password, 민감한 원본 전체는 payload에서 제외합니다.

조회 권한

작업 ID만으로 결과를 공개하지 않는다

상태 API는 job data의 tenant·owner를 현재 인증 주체와 비교합니다. 알 수 없거나 제거됐거나 권한 밖인 작업은 같은 404로 응답합니다.

payload는 실행에 필요한 원본 자체가 아니라 원본을 안전하게 다시 찾는 계약입니다. 큰 결과는 object storage나 database에 두고 job result에는 위치와 작은 요약만 남깁니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 외부 DTO와 인증 주체라는 서로 다른 입력이 job.data를 만든다. 추가 구현에서는 Worker가 tenant 범위로 실제 원본을 찾아야 하며 조회 API는 현재 주체와 저장된 owner를 비교한다. 선은 데이터와 검사 대상을 전달하며 실제 CSV 생성은 모의 예제 이후 구현할 책임이다.
  • 외부 DTO와 서버 인증 주체가 독립적으로 job.data의 작은 값을 만든다. HTTP 경계, Redis 직렬화 경계, Worker와 조회 책임 영역을 표시한다. Worker는 job 값을 현재 tenant 원본과 연결해야 하며 상태 API는 현재 주체와 저장된 owner를 다시 비교한다. job.data에서 상태 API로 가는 점선은 저장된 소유권 참조이며 Worker 완료를 기다리는 제어 흐름이 아니다. 작업이 waiting 또는 active인 때도 현재 주체가 별도로 상태를 조회할 수 있다. Worker에서 인증 주체를 새로 만든다는 선은 아니며, 본문 Worker는 원본/파일을 실제 처리하지 않는 모의 구현이다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1BullMQ 작업 payload와 신뢰 경계
6외부 DTO와 인증 주체라는 서로 다른 입력이 job.data를 만든다. 추가 구현에서는 Worker가 tenant 범위로 실제 원본을 찾아야 하며 조회 API는 현재 주체와 저장된 owner를 비교한다. 선은 데이터와 검사 대상을 전달하며 실제 CSV 생성은 모의 예제 이후 구현할 책임이다.
25외부 DTO와 서버 인증 주체가 독립적으로 job.data의 작은 값을 만든다. HTTP 경계, Redis 직렬화 경계, Worker와 조회 책임 영역을 표시한다. Worker는 job 값을 현재 tenant 원본과 연결해야 하며 상태 API는 현재 주체와 저장된 owner를 다시 비교한다. job.data에서 상태 API로 가는 점선은 저장된 소유권 참조이며 Worker 완료를 기다리는 제어 흐름이 아니다. 작업이 waiting 또는 active인 때도 현재 주체가 별도로 상태를 조회할 수 있다. Worker에서 인증 주체를 새로 만든다는 선은 아니며, 본문 Worker는 원본/파일을 실제 처리하지 않는 모의 구현이다.
40권한 밖/없음: 동일 404
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
계약을 받은 모의 Worker

검증된 DTO와 인증 주체로 만든 job.data를 Worker가 받고 지원하는 작업 이름을 검사합니다.

계약: reportId·rows는 DTO, tenantId·requestedBy는 인증 주체 · 선택 흐름: 지원하는 작업 이름의 모의 Worker · 첫 관찰: load 20 기록·700 ms 모의 대기작업 계약은 HTTP 응답과 분리됩니다. 현재 Worker는 tenant 원본을 조회하지 않으며 인증 주체의 tenant 검증은 이 장의 전제입니다.
모의 render 관찰

현재 Worker는 render 70을 기록하고 700 ms 기다립니다. CSV 렌더링 코드는 없습니다.

progress: render 70 · 실제 처리: 700 ms 모의 대기 · 추가 구현 책임: tenant·reportId 범위 원본 조회와 CSV 생성관찰 값이 load에서 render로 바뀌지만 실제 원본 조회·CSV 생성을 완료한 것은 아닙니다.
모의 upload와 반환

upload 95와 모의 대기 뒤 100을 기록하고 reportId·objectKey·generatedRows를 반환합니다.

progress: upload 95→700 ms 모의 대기→100 · 반환: reports/{reportId}.csv 문자열·generatedRows=rows · 완료 경계: 모의 함수 정상 반환, 파일 존재 보장 없음 · 상태 조회: 별도 요청·현재 주체와 소유 범위 검사정상 반환이 작업 결과가 됩니다. 실제 업로드·멱등 저장은 추가 구현이며 별도 상태 조회가 이 Worker 실행의 다음 명령은 아닙니다.

BullMQ 작업 payload와 신뢰 경계

원 코드의 모의 Worker 한 경로를 계약 수신→진행률 기록→반환으로 추적합니다. 실제 tenant 원본 조회·CSV 생성·업로드는 추가 구현이고 상태 조회는 독립 요청이며 원 표에 함께 보존합니다.

Nest job data · 신뢰와 직렬화

서버가 확인한 식별자와 작은 실행 입력을 전달합니다

본문의 Worker는 대기와 결과를 모의 실행합니다. 실제 보고서 처리를 붙일 때는 tenant 범위에서 원본을 다시 조회합니다. 인증 주체·payload·결과 조회의 경계를 유지합니다.

신뢰 입력과 직렬화 경계를 구분한다 서로다른외부DTO와인증주체가job.data를만든다. Worker는tenant범위로실제원본을찾고조회API는현재주체와저장된owner를비교한다. 선은데이터와검사대상을전달하며실제CSV생성은모의예제이후구현할책임이다. 실행 데이터 소유권 비교 외부 입력 reportId · rows HTTP DTO 검증 인증된 주체 tenantId · userId 서버에서 확인 job.data 작은 직렬화 값 비밀·연결 제외 Worker tenant 범위 원본 부수 효과 구현 상태 조회 현재 주체=owner? 권한 밖/없음 404
HTTP·큐·Worker/조회에서 신뢰를 유지한다 외부DTO와서버인증주체가독립적으로job.data의작은값을만든다. HTTP경계,Redis직렬화경계,Worker와조회책임영역을표시한다. Worker는job값을현재tenant원본과연결해야하며상태API는현재주체와저장owner를다시비교한다. job.data에서상태API로가는점선은저장된소유권참조이며Worker완료를기다리는제어흐름이아니다. 작업이waiting또는active인때도현재주체가별도로상태를조회할수있다. Worker에서인증주체를새로만든다는선은아니며,본문Worker는원본/파일을실제처리하지않는모의다. HTTP 신뢰 입력 Redis 직렬화 실행 · 조회 책임 HTTP DTO reportId · rows 서버 인증 주체 tenantId · userId 직렬화한 job.data 보고서 ID · 작은 값 token · 요청 객체 제외 Worker tenant 범위 원본 조회 실제 업무 효과 구현 상태 조회 API 현재 주체와 owner 비교 조회 결과 같은 소유자: 상태·결과 권한 밖/없음: 동일404
1 · HTTP 경계

DTO와 인증 주체를 분리한다

DTO는 reportId와 rows를 검증합니다. tenantId와 userId는 요청 본문이 아니라 인증 Guard가 확인한 주체에서 가져옵니다.

2 · job.data

직렬화 가능한 최소 계약

reportId, tenantId, requestedBy, rows처럼 JSON으로 저장할 작은 값만 전달합니다.

Redis의 작업 데이터는 평문으로 남을 수 있으므로 비밀과 원본 데이터는 넣지 않습니다.

3 · Worker

서버 저장소에서 다시 조회한다

Worker는 tenantId + reportId로 권한 범위 안의 보고서 원본을 읽고 처리합니다. 오래된 요청 객체나 연결을 복원하려 하지 않습니다.

payload 금지

실행 환경과 비밀을 직렬화하지 않는다

Request·Response, 함수·class instance, stream·열린 file, database handle, access token·password, 민감한 원본 전체는 payload에서 제외합니다.

조회 권한

작업 ID만으로 결과를 공개하지 않는다

상태 API는 job data의 tenant·owner를 현재 인증 주체와 비교합니다. 알 수 없거나 제거됐거나 권한 밖인 작업은 같은 404로 응답합니다.

payload는 실행에 필요한 원본 자체가 아니라 원본을 안전하게 다시 찾는 계약입니다. 큰 결과는 object storage나 database에 두고 job result에는 위치와 작은 요약만 남깁니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 외부 DTO와 인증 주체라는 서로 다른 입력이 job.data를 만든다. 추가 구현에서는 Worker가 tenant 범위로 실제 원본을 찾아야 하며 조회 API는 현재 주체와 저장된 owner를 비교한다. 선은 데이터와 검사 대상을 전달하며 실제 CSV 생성은 모의 예제 이후 구현할 책임이다.
  • 외부 DTO와 서버 인증 주체가 독립적으로 job.data의 작은 값을 만든다. HTTP 경계, Redis 직렬화 경계, Worker와 조회 책임 영역을 표시한다. Worker는 job 값을 현재 tenant 원본과 연결해야 하며 상태 API는 현재 주체와 저장된 owner를 다시 비교한다. job.data에서 상태 API로 가는 점선은 저장된 소유권 참조이며 Worker 완료를 기다리는 제어 흐름이 아니다. 작업이 waiting 또는 active인 때도 현재 주체가 별도로 상태를 조회할 수 있다. Worker에서 인증 주체를 새로 만든다는 선은 아니며, 본문 Worker는 원본/파일을 실제 처리하지 않는 모의 구현이다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1BullMQ 작업 payload와 신뢰 경계
6외부 DTO와 인증 주체라는 서로 다른 입력이 job.data를 만든다. 추가 구현에서는 Worker가 tenant 범위로 실제 원본을 찾아야 하며 조회 API는 현재 주체와 저장된 owner를 비교한다. 선은 데이터와 검사 대상을 전달하며 실제 CSV 생성은 모의 예제 이후 구현할 책임이다.
25외부 DTO와 서버 인증 주체가 독립적으로 job.data의 작은 값을 만든다. HTTP 경계, Redis 직렬화 경계, Worker와 조회 책임 영역을 표시한다. Worker는 job 값을 현재 tenant 원본과 연결해야 하며 상태 API는 현재 주체와 저장된 owner를 다시 비교한다. job.data에서 상태 API로 가는 점선은 저장된 소유권 참조이며 Worker 완료를 기다리는 제어 흐름이 아니다. 작업이 waiting 또는 active인 때도 현재 주체가 별도로 상태를 조회할 수 있다. Worker에서 인증 주체를 새로 만든다는 선은 아니며, 본문 Worker는 원본/파일을 실제 처리하지 않는 모의 구현이다.
40권한 밖/없음: 동일 404
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
계약을 받은 모의 Worker

검증된 DTO와 인증 주체로 만든 job.data를 Worker가 받고 지원하는 작업 이름을 검사합니다.

계약: reportId·rows는 DTO, tenantId·requestedBy는 인증 주체 · 선택 흐름: 지원하는 작업 이름의 모의 Worker · 첫 관찰: load 20 기록·700 ms 모의 대기작업 계약은 HTTP 응답과 분리됩니다. 현재 Worker는 tenant 원본을 조회하지 않으며 인증 주체의 tenant 검증은 이 장의 전제입니다.
모의 render 관찰

현재 Worker는 render 70을 기록하고 700 ms 기다립니다. CSV 렌더링 코드는 없습니다.

progress: render 70 · 실제 처리: 700 ms 모의 대기 · 추가 구현 책임: tenant·reportId 범위 원본 조회와 CSV 생성관찰 값이 load에서 render로 바뀌지만 실제 원본 조회·CSV 생성을 완료한 것은 아닙니다.
모의 upload와 반환

upload 95와 모의 대기 뒤 100을 기록하고 reportId·objectKey·generatedRows를 반환합니다.

progress: upload 95→700 ms 모의 대기→100 · 반환: reports/{reportId}.csv 문자열·generatedRows=rows · 완료 경계: 모의 함수 정상 반환, 파일 존재 보장 없음 · 상태 조회: 별도 요청·현재 주체와 소유 범위 검사정상 반환이 작업 결과가 됩니다. 실제 업로드·멱등 저장은 추가 구현이며 별도 상태 조회가 이 Worker 실행의 다음 명령은 아닙니다.

Producer에서 작업 추가하기

Producer는 큐를 주입받아 작업 이름, 데이터, 보관 정책을 전달합니다.

작업이 완료되거나 실패한 기록을 무한히 쌓지 않도록 최근 100개씩만 남깁니다.

src/reports/reports.service.ts
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Queue } from 'bullmq';
import { CreateReportDto } from './dto/create-report.dto';
import {
  GenerateReportData,
  GenerateReportResult,
  ReportPrincipal,
} from './report-job.types';
import { GENERATE_REPORT_JOB, REPORT_QUEUE } from './reports.constants';

@Injectable()
export class ReportsService {
  constructor(
    @InjectQueue(REPORT_QUEUE)
    private readonly reportsQueue: Queue<
      GenerateReportData,
      GenerateReportResult,
      typeof GENERATE_REPORT_JOB
    >,
  ) {}

  async enqueue(dto: CreateReportDto, principal: ReportPrincipal) {
    const job = await this.reportsQueue.add(
      GENERATE_REPORT_JOB,
      {
        reportId: dto.reportId,
        tenantId: principal.tenantId,
        requestedBy: principal.userId,
        rows: dto.rows,
      },
      {
        attempts: 3,
        backoff: { type: 'exponential', delay: 1_000 },
        removeOnComplete: 100,
        removeOnFail: 100,
      },
    );

    return {
      jobId: job.id,
      accepted: true,
      statusUrl: `/reports/jobs/${job.id}`,
    };
  }

  async getStatus(jobId: string, principal: ReportPrincipal) {
    const job = await this.reportsQueue.getJob(jobId);

    if (
      !job ||
      job.data.tenantId !== principal.tenantId ||
      job.data.requestedBy !== principal.userId
    ) {
      throw new NotFoundException('report job not found');
    }

    const state = await job.getState();
    if (state === 'unknown') {
      throw new NotFoundException('report job not found');
    }

    return {
      jobId: job.id,
      name: job.name,
      state,
      progress: job.progress,
      attemptsMade: job.attemptsMade,
      result: job.returnvalue ?? null,
      failedReason: job.failedReason || null,
    };
  }
}

Queue.add()가 성공했다는 것은 보고서가 완성됐다는 뜻이 아니라 Redis가 작업을 접수했다는 뜻입니다.

그러므로 Controller는 201 Created가 아니라 202 Accepted를 반환합니다.

Worker가 매우 빠르면 Queue.add()가 반환된 뒤 HTTP 응답이 도착하기 전에도 작업을 가져갈 수 있습니다. 따라서 접수 응답은 waiting을 약속하지 않고, 상태는 별도 조회 시점의 값으로 읽습니다.

getJob()과 getState()는 서로 다른 Redis 조회입니다. 그러므로 위 응답의 state, progress, result는 하나의 원자적 snapshot이 아니며, 전이 경계에서 completed인데 result가 아직 null처럼 보이면 다음 poll에서 다시 읽습니다.

src/reports/reports.controller.ts
import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Post,
  Req,
} from '@nestjs/common';
import { CreateReportDto } from './dto/create-report.dto';
import { ReportPrincipal } from './report-job.types';
import { ReportsService } from './reports.service';

interface AuthenticatedRequest {
  user: ReportPrincipal;
}

@Controller('reports')
export class ReportsController {
  constructor(private readonly reportsService: ReportsService) {}

  @Post()
  @HttpCode(HttpStatus.ACCEPTED)
  create(@Body() dto: CreateReportDto, @Req() request: AuthenticatedRequest) {
    return this.reportsService.enqueue(dto, request.user);
  }

  @Get('jobs/:jobId')
  getStatus(
    @Param('jobId') jobId: string,
    @Req() request: AuthenticatedRequest,
  ) {
    return this.reportsService.getStatus(jobId, request.user);
  }
}

Worker에서 작업 처리하기

BullMQ용 Nest Worker는 WorkerHost를 상속하고 큐마다 하나의 process() 메서드를 구현합니다.

src/reports/reports.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import {
  GenerateReportData,
  GenerateReportResult,
} from './report-job.types';
import { GENERATE_REPORT_JOB, REPORT_QUEUE } from './reports.constants';

const wait = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

@Processor(REPORT_QUEUE)
export class ReportsProcessor extends WorkerHost {
  async process(
    job: Job<
      GenerateReportData,
      GenerateReportResult,
      typeof GENERATE_REPORT_JOB
    >,
  ): Promise<GenerateReportResult> {
    if (job.name !== GENERATE_REPORT_JOB) {
      throw new Error(`unsupported report job: ${job.name}`);
    }

    await job.updateProgress({ step: 'load', percent: 20 });
    await wait(700);

    await job.updateProgress({ step: 'render', percent: 70 });
    await wait(700);

    await job.updateProgress({ step: 'upload', percent: 95 });
    await wait(700);
    await job.updateProgress({ step: 'upload', percent: 100 });

    return {
      reportId: job.data.reportId,
      objectKey: `reports/${job.data.reportId}.csv`,
      generatedRows: job.data.rows,
    };
  }
}

이 Worker의 wait()는 실행 단계를 관찰하기 위한 모의 대기입니다. CSV를 읽고 렌더링하거나 업로드하는 코드는 없으며, objectKey도 반환한 문자열입니다. 따라서 이 예제의 completed는 모의 함수가 끝났다는 뜻입니다. 실제 보고서에서는 권한 범위 안의 원본 조회와 업로드가 성공한 뒤에만 결과를 반환해야 파일 존재를 완료 조건으로 삼을 수 있습니다.

process()가 정상적으로 값을 반환해야 작업이 completed 상태가 되고 반환값은 job.returnvalue에 저장됩니다.

Error를 던졌을 때 남은 시도가 있으면 작업은 설정한 backoff 동안 delayed에 머문 뒤 다시 waiting과 active를 거칩니다. attempts를 모두 소진하면 최종 failed가 되고 failedReason을 조회할 수 있습니다.

처리 중에는 updateProgress()로 숫자나 직렬화 가능한 객체를 기록할 수 있습니다.

percent: 100은 Worker가 기록한 관찰 값일 뿐 완료 신호가 아닙니다. 그다음 코드가 실패할 수도 있으므로 클라이언트는 state === 'completed'와 result를 기준으로 성공을 판단합니다.

모듈에 등록

Controller, Producer Service, Worker를 같은 기능 모듈에 등록합니다.

src/reports/reports.module.ts
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsProcessor } from './reports.processor';
import { ReportsService } from './reports.service';
import { REPORT_QUEUE } from './reports.constants';

@Module({
  imports: [BullModule.registerQueue({ name: REPORT_QUEUE })],
  controllers: [ReportsController],
  providers: [ReportsService, ReportsProcessor],
})
export class ReportsModule {}

ReportsProcessor를 providers에서 빼면 작업은 waiting에 계속 남습니다.

큐 등록만으로 Worker가 자동 생성되는 것은 아닙니다.


실행하며 상태 관찰하기

Redis와 Nest 애플리케이션을 실행한 상태에서 작업을 제출합니다.

curl -i -X POST http://localhost:3000/reports \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token>" \
  -d '{"reportId":"sales-2026-07","rows":5000}'

응답 예시는 다음과 같습니다.

{
  "jobId": "1",
  "accepted": true,
  "statusUrl": "/reports/jobs/1"
}

응답에 상태를 넣지 않은 이유는 Worker가 enqueue 직후 작업을 가져가 HTTP 응답 전에 이미 active나 completed 상태로 바꿀 수 있기 때문입니다.

응답의 jobId로 여러 번 조회합니다.

curl -H "Authorization: Bearer <access-token>" \
  http://localhost:3000/reports/jobs/1

조회 시점에 따라 waiting, delayed, active, completed, failed 중 하나를 볼 수 있고, progress도 함께 변합니다. 이 목록은 가능한 상태이지 한 클라이언트가 모든 중간 상태를 반드시 관찰한다는 뜻은 아닙니다.

{
  "jobId": "1",
  "name": "report.generate",
  "state": "completed",
  "progress": { "step": "upload", "percent": 100 },
  "attemptsMade": 1,
  "result": {
    "reportId": "sales-2026-07",
    "objectKey": "reports/sales-2026-07.csv",
    "generatedRows": 5000
  },
  "failedReason": null
}

실패를 구분하는 기준

관찰 결과의미확인할 코드
요청 자체가 400DTO 검증 실패Controller 진입 전 ValidationPipe
작업이 계속 waitingWorker가 없음Processor provider와 큐 이름
작업이 delayed 뒤 다시 activeWorker 예외 뒤 재시도 대기attemptsMade, attempts, backoff
작업이 failedWorker가 재시도를 모두 소진함failedReason, Worker 로그
작업 조회가 404ID가 없거나, 기록이 제거됐거나, 현재 주체가 소유하지 않음권한 검사, removeOnComplete, removeOnFail
completed인데 파일이 없음반환값과 실제 저장이 어긋남부수 효과 완료 기준
같은 파일이 두 번 생성됨retry·stalled 복구로 중복 실행됨업무 키와 멱등 저장

스스로 확인하기

Worker가 진행률 100을 기록한 직후 일반 Error를 던졌고 실행 기회가 남아 있습니다. 클라이언트가 보고서를 성공으로 표시해도 될까요? 다른 tenant의 사용자가 같은 jobId를 조회해 받은 404는 작업 실패를 뜻할까요?

판단 과정과 답

진행률 100은 기록 값이고 정상 반환을 대신하지 않습니다. 남은 기회와 backoff에 따라 재시도되므로 최종 completed와 결과를 다시 조회해야 합니다. 다른 tenant의 404는 이 API가 소유 범위를 숨기는 응답이며, 그 사용자에게 작업의 성공·실패 여부를 알려 주지 않습니다. 상태와 조회 권한을 각각 판단합니다.

보존과 중복 실행의 경계

removeOnComplete: 100과 removeOnFail: 100은 최근 기록 수를 제한합니다. 제거된 작업은 상태·결과·오류를 더는 조회할 수 없고, 같은 custom jobId도 다시 추가할 수 있으므로 이 옵션을 영구 업무 원장이나 영구 중복 방지 장치로 사용하지 않습니다.

BullMQ는 보통 한 번 처리를 목표로 하지만 lock 상실이나 Worker 종료 같은 최악의 경우에는 at-least-once가 되어 같은 작업이 다시 실행될 수 있습니다. Redis 장애 뒤 남는 상태 또한 AOF·RDB, 복제와 백업 구성의 내구성 범위를 넘지 않습니다.

따라서 Worker는 tenantId와 reportId로 권한 범위 안의 원본을 다시 조회하고, 외부 저장 같은 부수 효과는 업무 키로 멱등하게 만들어야 합니다. 상태 API는 존재하지 않는 작업, 보존 정책으로 제거된 작업, 다른 tenant의 작업에 같은 404를 반환해 작업 ID 존재 여부를 노출하지 않습니다.


Producer는 실행 방법을 알지 않고, Worker는 HTTP 응답을 알지 않습니다.

두 계층은 직렬화 가능한 작업 계약과 큐 이름으로만 연결됩니다.

다음 절에서는 재시도 대상과 즉시 실패 대상을 나누고, jitter·중복 제거·멱등 저장·수동 복구까지 운영 기준으로 확장합니다.