예약 작업과 큐 운영
지연 작업과 반복 스케줄을 등록하고 Worker 동시성·큐 이벤트·메트릭·안전한 종료까지 운영 기준을 세웁니다.
요청을 보내기 전에 이 장의 HTTP 경로 기준을 확인하세요. 앞 장의 api prefix와 URI 버전 1을 유지한다면 본문의 /reports 대신 /api/v1/reports로 요청하고, 응답에 담는 statusUrl도 /api/v1/reports/jobs/:id 형식으로 만듭니다.
앞 글까지 즉시 실행하는 작업의 접수, 처리, 재시도, 중복 방지를 구현했습니다. 6장의 마지막 글인 이 글에서는 특정 시각 이후에 실행하는 작업, 반복 작업, Worker 처리량, 이벤트와 지표, 안전한 종료를 하나의 운영 흐름으로 묶습니다.
반복 작업은 BullMQ 5.16에서 도입된 Job Scheduler의 upsertJobScheduler()로 등록합니다. BullMQ 6은 Queue.add()의 repeat 옵션과 getRepeatableJobs() 같은 예전 반복 작업(repeatable job) API를 제거했으므로, 이제 반복 작업을 만드는 방법은 Job Scheduler 하나뿐입니다. 같은 scheduler ID로 다시 등록하면 새로 만들지 않고 기존 설정을 갱신합니다. 아래 API와 최초 작업 생성 규칙은 BullMQ v6.5.3의 문서와 소스를 기준으로 확인했습니다.
Nest 예약 · 최초 생성과 실행 가능 시각
한 번 또는 반복을 정하고 실제 시작 지연을 허용합니다
delay와 cron 시각은 Worker가 가져갈 수 있는 경계입니다. 큐 적체나 Worker 부재로 실제 시작은 늦어질 수 있습니다. scheduler ID·template·시간대·첫 iteration 규칙을 함께 정합니다.
즉시 작업
지연 작업
delay 지정Job Scheduler
ID는 배포마다 바꾸지 않는다
새 ID면 scheduler를 만들고, 같은 ID면 기존 반복 설정과 job template을 갱신합니다. 동일 규칙의 scheduler를 중복 생성하지 않습니다.
upsertJobScheduler(id, repeat, template)
pattern과 every의 최초 작업은 다르다
pattern기본값: 다음 cron 일치 시각immediately: true: 해당 pattern upsert의 첫 작업을 지금 생성- 5.19+
every: 새 ID의 첫 작업을 즉시 생성 - 기존
everyID 재-upsert: 별도 즉시 반복 없음
서울 기준 다음 오전 3시에 실행 가능해진다
0 0 3 * * * · Asia/Seoul
이 pattern은 최초 작업도 다음 일치 시각에 delayed로 만듭니다. 그 시각에 Worker가 없거나 큐가 밀리면 active 전환은 늦어집니다.
pattern + immediately는 재-upsert 때도 주의한다
부팅 때 같은 ID를 매번 upsert하면 배포 시점에도 즉시 작업이 다시 예약될 수 있습니다. cron 전에 실행할 의도가 있을 때만 사용합니다.
선택 순서: 한 번인가 반복인가 → 지금인가 미래인가 → 반복이면 ID, 최초 작업, 시간대, 지연 허용 범위를 함께 정합니다.
위 그림은 작업을 한 번 실행할지 반복할지에 따라 등록 경로가 갈리는 모습을 보여 줍니다. 한 번 실행할 작업은 Queue.add()로 넣고, 미래에 실행할 때만 delay를 붙입니다. 반복 작업은 안정된 ID와 시간대를 정해 Job Scheduler로 등록하며, pattern과 every는 첫 작업을 만드는 규칙이 다릅니다. 두 경로 모두 실행 가능해지는 시각에 모이지만, 실제로 active가 되는 시각은 Worker의 여유와 큐 적체에 따라 늦어질 수 있습니다.
지연 작업 예약하기
delay는 작업을 지금 큐에 넣되, 지정한 밀리초가 지난 뒤에 처리할 수 있게 만드는 옵션입니다. 정확한 실행 시각을 보장하는 타이머가 아니라, 그 시각 이후에 Worker가 가져갈 수 있다는 뜻입니다. 큐가 밀렸거나 Worker가 꺼져 있으면 더 늦게 시작합니다.
예약 요청 DTO를 추가합니다.
import { IsDateString, IsInt, IsString, Max, Min } from 'class-validator';
export class ScheduleReportDto {
@IsString()
reportId: string;
@IsInt()
@Min(1)
@Max(100_000)
rows: number;
@IsDateString()
runAt: string;
}ReportsService에 예약 메서드를 추가합니다. BadRequestException은 앞 글에서 Injectable, NotFoundException과 합친 기존 @nestjs/common import를 그대로 쓰고, reportOperationKey도 앞 글에서 추가한 import를 그대로 씁니다. 같은 이름을 다시 import하면 중복 선언 오류가 나므로 새로 더할 import는 ScheduleReportDto 하나입니다.
import { ScheduleReportDto } from './dto/schedule-report.dto';
async enqueueAt(dto: ScheduleReportDto, principal: ReportPrincipal) {
const runAt = new Date(dto.runAt).getTime();
const delay = runAt - Date.now();
if (delay <= 0) {
throw new BadRequestException('runAt must be in the future');
}
const data: GenerateReportData = {
reportId: dto.reportId,
tenantId: principal.tenantId,
requestedBy: principal.userId,
rows: dto.rows,
};
const operationKey = reportOperationKey(data);
const job = await this.reportsQueue.add(
GENERATE_REPORT_JOB,
data,
{
delay,
attempts: 4,
backoff: { type: 'exponential', delay: 1_000, jitter: 0.5 },
deduplication: { id: operationKey },
removeOnComplete: 100,
removeOnFail: 100,
},
);
return {
jobId: job.id,
accepted: true,
statusUrl: `/reports/jobs/${job.id}`,
runAt: new Date(runAt).toISOString(),
};
}enqueueAt()은 요청한 실행 시각에서 현재 시각을 빼 delay를 밀리초로 계산하고, 그 값이 0 이하이면 이미 지난 시각이므로 400으로 거부합니다. delay 옵션을 받은 작업은 waiting이 아니라 delayed 상태로 저장되었다가, 그 시간이 지나야 Worker가 가져갈 수 있습니다. controller에서는 별도 경로로 노출합니다. @Post('scheduled')이므로 경로는 POST /reports/scheduled이며, 즉시 실행용 POST /reports와 구분됩니다.
import { ScheduleReportDto } from './dto/schedule-report.dto';
@Post('scheduled')
@HttpCode(HttpStatus.ACCEPTED)
schedule(
@Body() dto: ScheduleReportDto,
@Req() request: AuthenticatedRequest,
) {
return this.reportsService.enqueueAt(dto, request.user);
}앞 글의 인증 guard가 확인한 request.user와 ReportPrincipal 형식을 그대로 씁니다. Req는 기존 controller import에 포함하고, GenerateReportData와 ReportPrincipal도 기존 service import를 유지합니다. 예약 입력에서는 사용자나 tenant를 받지 않습니다.
같은 업무는 같은 결과로 처리하므로 앞 글의 operation key를 그대로 씁니다. 실행 시각마다 별도 결과가 필요한 업무라면 그 실행 구간을 키에 추가하고, Worker의 저장 키도 함께 바꿉니다.
Node.js로 현재 시각보다 1분 뒤의 ISO 문자열을 만들어 요청합니다. 이 명령은 셸의 날짜 문법에 의존하지 않으므로 Windows, macOS, Linux에서 똑같이 실행할 수 있습니다.
node -e "const runAt=new Date(Date.now()+60000).toISOString(); fetch('http://localhost:3000/reports/scheduled',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer <access-token>'},body:JSON.stringify({reportId:'scheduled-demo',rows:3000,runAt})}).then(async r=>console.log(r.status,await r.text()))"실행 가능 시각 전에는 delayed이고, 그 뒤 Worker가 가져가면 active가 됩니다. API 서버와 Redis 서버의 시계가 크게 어긋나면 예상 시각도 어긋나므로, 운영 환경에서는 시간 동기화를 확인합니다.
Job Scheduler로 반복 작업 만들기
매일 오래된 보고서 메타데이터를 정리하는 작업을 추가합니다. 먼저 작업 이름과 데이터 타입을 확장합니다.
export const REPORT_QUEUE = 'reports';
export const GENERATE_REPORT_JOB = 'report.generate';
export const CLEANUP_REPORTS_JOB = 'reports.cleanup';export interface CleanupReportsData {
keepDays: number;
}
export interface CleanupReportsResult {
removed: number;
}애플리케이션이 부팅될 때 scheduler를 등록하는 provider를 만듭니다. scheduler ID는 배포할 때마다 바뀌지 않는 상수여야 합니다.
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Queue } from 'bullmq';
import {
CLEANUP_REPORTS_JOB,
REPORT_QUEUE,
} from './reports.constants';
@Injectable()
export class ReportSchedulerService implements OnApplicationBootstrap {
constructor(
@InjectQueue(REPORT_QUEUE)
private readonly reportsQueue: Queue,
) {}
async onApplicationBootstrap() {
await this.reportsQueue.upsertJobScheduler(
'daily-report-cleanup',
{
pattern: '0 0 3 * * *',
tz: 'Asia/Seoul',
},
{
name: CLEANUP_REPORTS_JOB,
data: {
keepDays: 30,
},
opts: {
attempts: 3,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: 30,
removeOnFail: 30,
},
},
);
}
}0 0 3 * * *는 초부터 시작하는 6필드 cron 패턴으로, tz: 'Asia/Seoul' 기준 매일 오전 3시를 뜻합니다. 호스트 운영체제의 로컬 시간대에 해석을 맡기지 않도록 시간대를 명시했습니다.
이 예제처럼 pattern만 지정하면 최초 작업도 다음 cron 일치 시각의 delayed 작업으로 만들어집니다. cron과 상관없이 한 번 먼저 실행해야 할 때만 immediately: true를 명시합니다. pattern에서 immediately: true는 그 upsert 호출의 첫 반복에 적용되므로, 부팅 때마다 같은 ID를 upsert하는 코드에 넣으면 배포할 때마다 즉시 실행 작업이 다시 예약될 수 있습니다. 의도 없이 쓰지 않습니다.
every는 규칙이 다릅니다. BullMQ 5.19부터 every로 새 scheduler를 등록하면 immediately 없이도 첫 작업이 바로 만들어지며, BullMQ 6.5.3 소스도 every와 immediately를 함께 쓰면 어차피 즉시 실행된다는 경고를 출력합니다. 이 규칙을 pattern의 기본 동작과 섞어 해석하지 않습니다.
같은 scheduler ID를 다시 upsert하면 기존 scheduler의 반복 설정과 작업 template을 갱신합니다. 배포할 때마다 새 scheduler가 생기지 않도록 안정된 ID를 씁니다.
cron 시각과 delay의 만료 시각은 작업이 실행 가능해지는 시점일 뿐입니다. Worker가 없거나 큐가 밀리면 실제 active 전환은 더 늦어질 수 있으므로, 현재 시각이 정확히 오전 3시인지 검사해 늦게 시작한 정상 작업을 버리지 않습니다.
BullMQ 문서에 따르면 Job Scheduler는 마지막 작업이 처리를 시작할 때 다음 작업을 만듭니다. 그래서 Worker 처리량이 부족하면 실제 생성 주기가 설정한 간격보다 느려질 수 있으며, 정확한 초 단위 타이머로 해석하면 안 됩니다.
등록된 반복 규칙은 scheduler ID로 조회하고 제거합니다.
getSchedulers() {
return this.reportsQueue.getJobSchedulers(0, 20, true);
}
removeDailyCleanup() {
return this.reportsQueue.removeJobScheduler('daily-report-cleanup');
}Job Scheduler가 만드는 작업은 반복 설정보다 자주 생성되지 않도록 내부적으로 특별한 ID를 쓰므로, 작업 template에 임의의 jobId를 지정할 수 없습니다. 업무 중복 방지가 필요하면 Worker의 멱등성 키를 사용합니다.
@nestjs/schedule의 @Cron()은 한 프로세스 안에서 콜백을 실행할 때 간단합니다. 하지만 모든 API 복제본에서 동시에 실행될 수 있고, 실행 상태가 Redis에 남지 않습니다. 재시도와 상태 보관이 필요한 업무 작업은 Job Scheduler로 큐에 넣는 편이 안전합니다.
Worker에서 작업 이름 분기
BullMQ의 WorkerHost는 큐마다 process() 하나를 구현합니다. 작업 이름이 여러 개라면 switch로 명시적으로 분기합니다. 아래 코드는 앞 글의 보고서 생성 로직에 이 글의 정리 작업, 동시성 설정, Worker 이벤트를 합친 최종 형태입니다.
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job, UnrecoverableError } from 'bullmq';
import { ReportArtifactsService } from './report-artifacts.service';
import { reportOperationKey } from './report-operation-key';
import {
CleanupReportsData,
CleanupReportsResult,
GenerateReportData,
GenerateReportResult,
} from './report-job.types';
import {
CLEANUP_REPORTS_JOB,
GENERATE_REPORT_JOB,
REPORT_QUEUE,
} from './reports.constants';
const wait = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
@Processor(REPORT_QUEUE, { concurrency: 3 })
export class ReportsProcessor extends WorkerHost {
private readonly logger = new Logger(ReportsProcessor.name);
constructor(private readonly artifacts: ReportArtifactsService) {
super();
}
async process(job: Job): Promise<unknown> {
switch (job.name) {
case GENERATE_REPORT_JOB:
return this.generateReport(
job as Job<GenerateReportData, GenerateReportResult>,
);
case CLEANUP_REPORTS_JOB:
return this.cleanupReports(
job as Job<CleanupReportsData, CleanupReportsResult>,
);
default:
throw new UnrecoverableError(`unsupported report job: ${job.name}`);
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.log(`report job active: ${job.id}`);
}
@OnWorkerEvent('failed')
onFailed(job: Job | undefined, error: Error) {
this.logger.error(`report job failed: ${job?.id ?? 'unknown'} ${error.message}`);
}
private async generateReport(
job: Job<GenerateReportData, GenerateReportResult>,
): Promise<GenerateReportResult> {
if (job.data.rows < 1) {
throw new UnrecoverableError('rows must be greater than zero');
}
const operationKey = reportOperationKey(job.data);
const existing = this.artifacts.find(operationKey);
if (existing) return { ...existing, reused: true };
if (job.attemptsMade < (job.data.failUntilAttempt ?? 0)) {
throw new Error(`temporary storage failure on attempt ${job.attemptsMade + 1}`);
}
await job.updateProgress({ step: 'load', percent: 20 });
await wait(400);
await job.updateProgress({ step: 'render', percent: 70 });
await wait(400);
await job.updateProgress({ step: 'upload', percent: 95 });
await wait(400);
await job.updateProgress({ step: 'upload', percent: 100 });
return this.artifacts.saveOnce(operationKey, {
operationKey,
reportId: job.data.reportId,
objectKey: `reports/${job.data.tenantId}/${operationKey}.csv`,
generatedRows: job.data.rows,
reused: false,
});
}
private async cleanupReports(
job: Job<CleanupReportsData, CleanupReportsResult>,
): Promise<CleanupReportsResult> {
const removed = await this.artifacts.removeOlderThan(job.data.keepDays);
return { removed };
}
}실습의 ReportArtifactsService에는 호출 형태를 확인할 수 있도록 다음 메서드를 추가합니다. 메모리 저장소에는 생성 시각을 저장하지 않았으므로 0을 반환하고, 운영 구현에서는 데이터베이스의 생성 시각을 기준으로 지웁니다.
async removeOlderThan(_keepDays: number) {
return 0;
}async를 붙였으므로 이 메서드는 Promise<number>를 돌려주고, Worker의 cleanupReports()는 그 값을 await로 받아 { removed: 0 }을 작업 결과로 남깁니다. 매개변수 이름 앞의 _는 일부러 쓰지 않는 값이라는 표시입니다. 기능 모듈에 ReportSchedulerService도 provider로 등록합니다. onApplicationBootstrap()은 Nest가 등록된 provider의 인스턴스를 만든 뒤에 호출하는 hook이므로, 등록이 빠지면 scheduler를 만드는 코드도 실행되지 않습니다.
import { ReportSchedulerService } from './report-scheduler.service';
providers: [
ReportsService,
ReportsProcessor,
ReportArtifactsService,
ReportSchedulerService,
],동시성과 처리량 조절
위 Worker처럼 I/O 대기가 많은 작업에 concurrency: 3을 설정하면 Worker 하나가 최대 세 작업을 함께 처리할 수 있습니다. 하지만 동시성을 무조건 크게 잡지는 않습니다.
- 데이터베이스 connection pool보다 많은 작업을 동시에 실행하면 대기만 늘어납니다.
- 외부 API의 rate limit을 넘으면 실패와 재시도가 함께 늘어납니다.
- CPU를 많이 쓰는 작업은 Node.js 이벤트 루프를 막을 수 있으므로 별도 Worker 프로세스나 sandboxed processor를 검토합니다.
- 인스턴스를 여러 개 띄우면 전체 동시성은
인스턴스 수 × 인스턴스별 concurrency가 됩니다.
큐 전체의 global concurrency를 더 작게 설정했다면 active 작업 수는 그 값으로 제한됩니다. rate limit은 동시에 active인 작업 수가 아니라, 일정 시간 동안 새로 시작하는 작업 수를 따로 제한합니다.
이벤트와 큐 상태 확인하기
@OnWorkerEvent는 위 코드처럼 현재 Worker의 active와 failed 흐름을 지켜볼 때 씁니다. 여러 Worker에서 발생하는 큐 전체의 이벤트는 QueueEventsListener로 받습니다.
import {
OnQueueEvent,
QueueEventsHost,
QueueEventsListener,
} from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { REPORT_QUEUE } from './reports.constants';
@QueueEventsListener(REPORT_QUEUE)
export class ReportQueueEventsListener extends QueueEventsHost {
private readonly logger = new Logger(ReportQueueEventsListener.name);
@OnQueueEvent('completed')
onCompleted({ jobId }: { jobId: string }) {
this.logger.log(`report job completed: ${jobId}`);
}
@OnQueueEvent('failed')
onFailed({ jobId, failedReason }: { jobId: string; failedReason: string }) {
this.logger.error(`report job failed: ${jobId} ${failedReason}`);
}
@OnQueueEvent('deduplicated')
onDeduplicated({
jobId,
deduplicatedJobId,
}: {
jobId: string;
deduplicatedJobId: string;
}) {
this.logger.warn(`duplicate ${deduplicatedJobId} kept ${jobId}`);
}
}QueueEventsListener는 특정 Worker가 아니라 Redis에 기록되는 큐 이벤트를 구독하므로, 다른 프로세스의 Worker가 처리한 작업의 completed와 failed도 받습니다. deduplicated 처리기의 로그 문구가 보여 주듯, jobId는 큐에 남은 원래 작업이고 deduplicatedJobId는 추가되지 않은 쪽입니다. 리스너도 기능 모듈의 providers에 등록해야 동작합니다.
import { ReportQueueEventsListener } from './report-queue-events.listener';
providers: [
ReportsService,
ReportsProcessor,
ReportArtifactsService,
ReportSchedulerService,
ReportQueueEventsListener,
],위 목록은 이 글에서 더한 provider까지 모두 담은 최종 형태입니다. 이제 큐 길이를 확인하는 운영 API를 추가합니다. getJobCounts()는 넘긴 상태 이름마다 지금 그 상태에 있는 작업 수를 담은 객체를 돌려주고, controller는 그 객체를 그대로 응답합니다. @Get('queue/health')이므로 경로는 GET /reports/queue/health입니다.
async getQueueHealth() {
return this.reportsQueue.getJobCounts(
'waiting',
'active',
'delayed',
'completed',
'failed',
);
}@Get('queue/health')
getQueueHealth() {
return this.reportsService.getQueueHealth();
}waiting이 계속 늘어난다면 Worker의 처리량이 유입량보다 낮은 것입니다. failed의 증가율, 작업 대기 시간, 처리 시간, stalled 이벤트를 함께 봐야 합니다.
stalled는 조회할 수 있는 작업 상태가 아니라, lock을 잃은 active 작업이 waiting으로 돌아가거나 허용 횟수를 넘겨 failed로 옮겨질 때 발생하는 이벤트입니다. completed 누계만으로는 지금 어디가 병목인지 알 수 없습니다.
안전하게 종료하기
Nest 애플리케이션이 종료 신호를 받으면 수명 주기 hook을 실행하도록 설정합니다. enableShutdownHooks()를 호출하면 SIGTERM 같은 신호를 받았을 때 Nest가 onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown hook을 차례로 실행합니다. 이 기능은 기본으로 꺼져 있으므로, 아래 줄이 없으면 신호를 받아도 연결을 정리하는 hook이 실행되지 않습니다.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen(process.env.PORT ?? 3000);
}Nest의 BullMQ 통합이 관리하는 Worker와 Queue는 애플리케이션이 종료될 때 연결을 정리합니다. BullMQ Worker를 직접 만들었다면 종료할 때 await worker.close()를 호출해야 합니다.
close()는 새 작업을 가져오지 않고 현재 작업이 끝나기를 기다립니다. BullMQ 문서는 TypeScript SDK의 close()에 자체 timeout이 없다고 설명하므로, 작업 코드에도 종료할 수 있는 상한을 둡니다. 강제로 종료되면 active 작업은 stalled 감지를 거쳐 다른 Worker가 다시 가져갈 수 있으며, 이때도 결과가 안전하려면 앞 글의 멱등성 규칙이 필요합니다. BullMQ 2.0부터는 stalled 작업 감지를 Worker가 스스로 처리하므로 예전의 QueueScheduler 같은 보조 클래스를 만들지 않습니다.
BullMQ 동시성, 상태 관찰과 안전한 종료
위의 용량/병목 관계는 전체 그림에 보존하고 아래 종료 신호·수신 중지·active drain·연결 종료만 실제 종료 흐름으로 읽는다.
Nest 큐 운영 · 용량과 종료
자원 상한과 상태를 관찰하고 종료 때 active 작업을 비웁니다
인스턴스 수와 local concurrency를 계산한 뒤 global 상한·DB·Redis·외부 API·CPU를 함께 확인합니다. shutdown hook을 구성하고 새 수신 중지→active drain→연결 종료를 수행합니다.
이론상 최대 active 작업 수
instances × local concurrency
예를 들어 인스턴스 4개가 각각 concurrency 3이면 최대 12개를 함께 처리합니다. global concurrency가 더 작다면 그 값이 active 상한이며, rate limit은 시작 속도를 별도로 제한합니다.
I/O와 CPU는 같은 숫자로 늘지 않는다
I/O 대기가 많은 작업은 로컬 concurrency의 이점을 얻습니다. CPU 집약 작업은 이벤트 루프와 lock 갱신을 막을 수 있어 sandboxed processor나 별도 프로세스를 검토합니다.
Redis
데이터베이스
외부 API
CPU
유입 대비 처리량
지속 상승하면 Worker 처리량이 부족합니다.
현재 처리 중
총 동시성 안에서 변하고 오래 고정되지 않아야 합니다.
예약과 backoff
미래 실행과 재시도 대기 규모를 확인합니다.
최종 실패
원인별 증가율과 복구 절차를 확인합니다.
상태가 아닌 재처리 신호
lock을 잃은 active 작업은 waiting으로 돌아가며, 허용 횟수를 넘기면 failed가 됩니다.
SIGTERM 이후 순서를 보장한다
-
새 작업 수신 중지
Nest lifecycle이 시작되고 등록된 Worker는 더 이상 새 job을 가져오지 않습니다.
-
active 작업 drain
현재 처리 중인 작업이 completed 또는 failed로 끝날 때까지 기다립니다. Worker close에는 자체 timeout이 없습니다.
-
Redis 연결 종료
Worker와 Queue가 사용하던 연결을 정리한 뒤 프로세스를 종료합니다.
drain 전에 종료되면 stalled 재처리가 일어날 수 있다
lock을 잃은 작업은 다른 Worker가 다시 가져갈 수 있으므로 외부 결과 쓰기는 멱등해야 합니다. stalled는 독립 job state가 아니라 active에서 waiting 또는 failed로 이동할 때의 이벤트입니다.
운영 순서: 총 동시성 계산 → 실제 병목 확인 → waiting·active·delayed·failed와 stalled 이벤트 관찰 → 종료 유예 시간 안에서 drain.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 윗행의 용량 상한→실제 병목→관측과 아랫행의 종료 신호 뒤 수신 중지→active drain→연결 종료는 서로 다른 운영 절차다. 강제 종료 후 stalled 재처리에도 업무 결과를 안전하게 만들어야 한다.
- 위 영역은 local/global 동시성 상한→실제 자원 병목→상태/이벤트 관찰이다. 아래 영역은 종료 신호 뒤 새 수신 중지→active drain→Redis 연결 종료이며 관찰과 종료를 하나의 무조건적인 실행 순서로 잇지 않는다. 강제 종료는 stalled 재실행을 유발할 수 있어 업무 결과의 멱등성이 필요하다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BullMQ 동시성, 상태 관찰과 안전한 종료 |
| 6 | 윗행의 용량 상한→실제 병목→관측과 아랫행의 종료 신호 뒤 수신 중지→active drain→연결 종료는 서로 다른 운영 절차다. 강제 종료 후 stalled 재처리에도 업무 결과를 안전하게 만들어야 한다. |
| 26 | 위 영역은 local/global 동시성 상한→실제 자원 병목→상태/이벤트 관찰이다. 아래 영역은 종료 신호 뒤 새 수신 중지→active drain→Redis 연결 종료이며 관찰과 종료를 하나의 무조건적인 실행 순서로 잇지 않는다. 강제 종료는 stalled 재실행을 유발할 수 있어 업무 결과의 멱등성이 필요하다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 종료 신호 아랫행의 shutdown 절차가 종료 신호를 받습니다. | 절차: shutdown · 윗행 용량·관측: 별도 절차 | worker가 종료 단계로 들어갑니다. |
| 새 수신 중지·drain 새 작업 수신을 멈추고 진행 중인 active 작업의 종료를 기다립니다. | 새 수신: 중지 · active: drain | 작업 수가 더 늘지 않는 정리 상태가 됩니다. |
| 연결 종료 drain 뒤 연결을 닫습니다. | active: 정리 후 · 연결: 종료 · 강제 종료: stalled 재처리 대비 별도 | worker의 자원 소유 범위가 끝납니다. |
BullMQ 동시성, 상태 관찰과 안전한 종료
위의 용량/병목 관계는 전체 그림에 보존하고 아래 종료 신호·수신 중지·active drain·연결 종료만 실제 종료 흐름으로 읽는다.
Nest 큐 운영 · 용량과 종료
자원 상한과 상태를 관찰하고 종료 때 active 작업을 비웁니다
인스턴스 수와 local concurrency를 계산한 뒤 global 상한·DB·Redis·외부 API·CPU를 함께 확인합니다. shutdown hook을 구성하고 새 수신 중지→active drain→연결 종료를 수행합니다.
이론상 최대 active 작업 수
instances × local concurrency
예를 들어 인스턴스 4개가 각각 concurrency 3이면 최대 12개를 함께 처리합니다. global concurrency가 더 작다면 그 값이 active 상한이며, rate limit은 시작 속도를 별도로 제한합니다.
I/O와 CPU는 같은 숫자로 늘지 않는다
I/O 대기가 많은 작업은 로컬 concurrency의 이점을 얻습니다. CPU 집약 작업은 이벤트 루프와 lock 갱신을 막을 수 있어 sandboxed processor나 별도 프로세스를 검토합니다.
Redis
데이터베이스
외부 API
CPU
유입 대비 처리량
지속 상승하면 Worker 처리량이 부족합니다.
현재 처리 중
총 동시성 안에서 변하고 오래 고정되지 않아야 합니다.
예약과 backoff
미래 실행과 재시도 대기 규모를 확인합니다.
최종 실패
원인별 증가율과 복구 절차를 확인합니다.
상태가 아닌 재처리 신호
lock을 잃은 active 작업은 waiting으로 돌아가며, 허용 횟수를 넘기면 failed가 됩니다.
SIGTERM 이후 순서를 보장한다
-
새 작업 수신 중지
Nest lifecycle이 시작되고 등록된 Worker는 더 이상 새 job을 가져오지 않습니다.
-
active 작업 drain
현재 처리 중인 작업이 completed 또는 failed로 끝날 때까지 기다립니다. Worker close에는 자체 timeout이 없습니다.
-
Redis 연결 종료
Worker와 Queue가 사용하던 연결을 정리한 뒤 프로세스를 종료합니다.
drain 전에 종료되면 stalled 재처리가 일어날 수 있다
lock을 잃은 작업은 다른 Worker가 다시 가져갈 수 있으므로 외부 결과 쓰기는 멱등해야 합니다. stalled는 독립 job state가 아니라 active에서 waiting 또는 failed로 이동할 때의 이벤트입니다.
운영 순서: 총 동시성 계산 → 실제 병목 확인 → waiting·active·delayed·failed와 stalled 이벤트 관찰 → 종료 유예 시간 안에서 drain.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 윗행의 용량 상한→실제 병목→관측과 아랫행의 종료 신호 뒤 수신 중지→active drain→연결 종료는 서로 다른 운영 절차다. 강제 종료 후 stalled 재처리에도 업무 결과를 안전하게 만들어야 한다.
- 위 영역은 local/global 동시성 상한→실제 자원 병목→상태/이벤트 관찰이다. 아래 영역은 종료 신호 뒤 새 수신 중지→active drain→Redis 연결 종료이며 관찰과 종료를 하나의 무조건적인 실행 순서로 잇지 않는다. 강제 종료는 stalled 재실행을 유발할 수 있어 업무 결과의 멱등성이 필요하다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BullMQ 동시성, 상태 관찰과 안전한 종료 |
| 6 | 윗행의 용량 상한→실제 병목→관측과 아랫행의 종료 신호 뒤 수신 중지→active drain→연결 종료는 서로 다른 운영 절차다. 강제 종료 후 stalled 재처리에도 업무 결과를 안전하게 만들어야 한다. |
| 26 | 위 영역은 local/global 동시성 상한→실제 자원 병목→상태/이벤트 관찰이다. 아래 영역은 종료 신호 뒤 새 수신 중지→active drain→Redis 연결 종료이며 관찰과 종료를 하나의 무조건적인 실행 순서로 잇지 않는다. 강제 종료는 stalled 재실행을 유발할 수 있어 업무 결과의 멱등성이 필요하다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 종료 신호 아랫행의 shutdown 절차가 종료 신호를 받습니다. | 절차: shutdown · 윗행 용량·관측: 별도 절차 | worker가 종료 단계로 들어갑니다. |
| 새 수신 중지·drain 새 작업 수신을 멈추고 진행 중인 active 작업의 종료를 기다립니다. | 새 수신: 중지 · active: drain | 작업 수가 더 늘지 않는 정리 상태가 됩니다. |
| 연결 종료 drain 뒤 연결을 닫습니다. | active: 정리 후 · 연결: 종료 · 강제 종료: stalled 재처리 대비 별도 | worker의 자원 소유 범위가 끝납니다. |
위 그림의 윗줄은 용량을 판단하는 순서입니다. 먼저 인스턴스 수 × local concurrency와 global 상한으로 최대 active 수를 계산하고, 데이터베이스·Redis·외부 API·CPU 가운데 실제 병목을 찾은 뒤 상태별 개수와 stalled 이벤트를 지켜봅니다. 아랫줄은 종료 순서로, 종료 신호를 받으면 새 작업 수신을 멈추고 진행 중인 작업이 끝나기를 기다린 다음 연결을 닫습니다. 두 흐름은 별도의 운영 절차이며, 끝까지 기다리지 못하고 강제 종료되면 stalled 재처리가 일어날 수 있다는 점도 그림에 표시되어 있습니다.
더 깊이: 종료 hook이 실행되는 순서와 Windows의 제약
수명 주기 문서에 따르면 종료 신호를 받은 Nest는 onModuleDestroy(), beforeApplicationShutdown(), onApplicationShutdown()을 이 순서로 호출하고, 뒤의 두 hook에는 받은 신호의 이름을 첫 인자로 넘깁니다. hook이 Promise를 돌려주면 그것이 끝날 때까지 다음 단계로 넘어가지 않습니다. 모듈 사이의 순서도 정해져 있습니다. 초기화 hook은 import 그래프에서 루트로부터 가장 깊이 가져온 모듈과 전역 모듈부터 실행되고 루트 모듈이 마지막인데, 종료 hook은 그 반대 순서로 실행됩니다. 그래서 기능 모듈의 종료 hook이 실행되는 동안에는, 더 깊은 곳에서 가져온 연결 모듈이 아직 닫히지 않은 상태입니다.
enableShutdownHooks()가 기본으로 꺼져 있는 이유는 신호 리스너가 자원을 쓰기 때문입니다. 병렬 테스트처럼 한 Node.js 프로세스에서 Nest 앱을 여러 개 띄우면 리스너가 너무 많다는 경고가 나올 수도 있습니다. 또 Windows에서는 SIGINT와 SIGBREAK가 동작하고 SIGHUP도 어느 정도 동작하지만, SIGTERM은 동작하지 않습니다. 작업 관리자의 프로세스 종료는 애플리케이션이 알아차리거나 막을 수 없는 무조건 종료이기 때문입니다. Windows 개발 PC에서 종료 절차를 확인할 때는 Ctrl+C의 SIGINT로 시험하고, SIGTERM 경로는 Linux 컨테이너에서 따로 확인합니다.
운영 점검표
| 확인 항목 | 정상 신호 | 위험 신호 |
|---|---|---|
| waiting | 부하가 줄면 다시 감소 | 계속 우상향 |
| active | 설정한 전체 동시성 안에서 변화 | 같은 작업이 오래 머묾 |
| delayed | 예약·백오프 작업 수와 비슷 | 과거 시각 작업이 계속 남음 |
| failed | 원인별 알림과 복구 절차 있음 | 같은 원인이 빠르게 반복 |
| stalled | 드물게 발생하고 복구됨 | CPU 정지나 강제 종료로 반복 |
| 종료 시간 | 배포 유예 시간 안에 완료 | close()가 끝없이 대기 |
정리
delay는 실행 가능 시각을 늦출 뿐 정확한 실행 시각을 보장하지 않으며, Worker가 없거나 큐가 밀리면 더 늦게 시작합니다.- BullMQ 6에서 반복 작업은 Job Scheduler의
upsertJobScheduler()로만 만들고, 배포마다 바뀌지 않는 scheduler ID와 시간대를 명시합니다. pattern의 첫 작업은 다음 cron 일치 시각에, 새every의 첫 작업은 즉시 만들어집니다.- 전체 동시성은
인스턴스 수 × local concurrency이고, global concurrency가 더 작으면 그 값이 상한이며 rate limit은 시작 속도를 따로 제한합니다. enableShutdownHooks()와 Worker의close()로 진행 중인 작업을 끝낸 뒤 종료하고, 강제 종료에 대비해 결과 쓰기를 멱등하게 만듭니다.
연습 문제
1. 최대 동시 작업 수와 늦은 시작
Worker 인스턴스 두 개의 local concurrency가 각각 3이고, global concurrency는 4입니다. 동시에 처리 중인 작업은 최대 몇 개일까요? 또 서울 시각 03:00에 실행 가능해지는 정리 작업을 Worker가 03:05에 가져왔다면, 이 예약은 실패한 것일까요?
해설 보기
local 합계는 2 × 3 = 6이지만 global 상한이 더 작으므로 최대 active 작업은 4개입니다. 이 값은 시작 속도를 제한하는 rate limit이나 실제 데이터베이스 처리량과는 별개입니다.
cron은 실행 가능해지는 시각을 정할 뿐이므로, Worker가 없었거나 큐가 밀렸다면 03:05에 시작할 수 있습니다. 업무가 5분 지연을 허용하는지는 별도의 마감 기준으로 판단하며, 현재 시각이 03:00이 아니라는 이유만으로 정리 작업을 버리지 않습니다.
2. 복제본 세 개가 같은 scheduler를 등록하면
API 서버를 복제본 세 개로 띄웠고, 각 복제본이 부팅할 때 onApplicationBootstrap()에서 같은 ID daily-report-cleanup으로 upsertJobScheduler()를 호출합니다. 정리 작업은 하루에 몇 번 만들어질까요? 같은 일을 @nestjs/schedule의 @Cron('0 0 3 * * *')으로 각 복제본에 걸었다면 어떻게 될까요?
해설 보기
upsert는 같은 ID의 scheduler가 있으면 새로 만들지 않고 갱신하므로, Redis에는 scheduler가 하나만 남고 정리 작업도 하루에 한 번 만들어집니다. 세 복제본의 Worker 가운데 하나가 그 작업을 가져가 실행합니다.
@Cron()은 각 프로세스 안에서 콜백을 직접 실행하므로, 복제본 세 개가 모두 03:00에 정리 로직을 실행합니다. 정리 작업이 멱등하지 않다면 같은 데이터를 세 번 처리하게 되고, 실행 상태도 Redis에 남지 않습니다.
3. BullMQ 5에서 6으로 올릴 때
BullMQ 5를 쓰던 서비스가 queue.add('reports.cleanup', data, { repeat: { pattern: '0 0 3 * * *' } })로 반복 작업을 등록해 두었습니다. 코드와 Redis를 그대로 둔 채 BullMQ 6으로 올리면 무엇이 문제가 될까요? 공식 마이그레이션 문서가 권하는 순서는 무엇일까요?
해설 보기
BullMQ 6은 Queue.add()의 repeat 옵션, getRepeatableJobs(), removeRepeatable() 같은 예전 반복 작업 API를 제거했습니다. 코드가 더 이상 그 API를 쓸 수 없을 뿐 아니라, Redis에 남은 예전 반복 작업 메타데이터를 만나면 BullMQ 6은 부분적으로 호환되는 척 동작하지 않고 오류를 냅니다.
마이그레이션 문서는 BullMQ 5의 최신 버전으로 먼저 올린 뒤, 5에서 getRepeatableJobs()로 기존 정의를 읽어 upsertJobScheduler()로 같은 규칙의 scheduler를 만들고, getJobSchedulers()로 확인한 다음 예전 정의를 지우라고 안내합니다. 모든 producer와 Worker가 Job Scheduler를 쓰게 된 뒤에야 BullMQ 6을 배포합니다.
이것으로 6장을 마칩니다. 6장에서는 오래 걸리는 HTTP 요청을 작업 큐로 떼어 내고, producer와 Worker가 주고받는 데이터, 상태 조회, 재시도와 멱등성, 지연·반복 실행, 동시성, 지표, 종료까지 이어 보았습니다. 작업 큐는 실행을 프로세스 밖의 영속 상태로 옮기는 첫 경험이기도 합니다. 다음 장의 마이크로서비스 개념과 NestJS 적용부터는 이 생각을 서비스 사이의 통신으로 넓혀, gRPC와 Kafka가 각각 무엇을 맡는지 비교합니다.