재시도, 중복 방지와 실패 복구
일시적 실패와 영구 실패를 구분하고 지수 백오프·범위가 명확한 중복 제거·멱등 저장·감사 가능한 수동 복구를 연결합니다.
요청을 보내기 전에 이 장의 HTTP 경로 기준을 확인하세요. 앞 장의 api prefix와 URI 버전 1을 유지한다면 본문의 /reports 대신 /api/v1/reports로 요청하고, 응답에 담는 statusUrl도 /api/v1/reports/jobs/:id 형식으로 만듭니다.
작업 큐를 운영하면 외부 저장소의 순간 장애, 네트워크 단절, 잘못된 입력처럼 성격이 다른 실패를 만나게 됩니다. 모든 실패를 같은 방식으로 재시도하면 복구될 가능성이 없는 작업이 자원을 계속 차지하고, 중복 실행 때문에 보고서가 여러 번 저장될 수도 있습니다.
이 글에서는 앞 글의 보고서 작업에 자동 재시도, 지수 백오프(exponential backoff), 범위가 분명한 중복 제거, 멱등한 결과 저장, 수동 복구를 차례로 추가합니다. 아래에서 쓰는 BullMQ 옵션과 Job.retry()의 서명은 BullMQ v6.5.3 공식 소스를 기준으로 확인했습니다.
재시도할 실패와 바로 끝낼 실패
먼저 실패를 두 종류로 나눕니다.
- 일시적 실패: 외부 저장소의
503응답이나 잠깐의 연결 끊김처럼, 시간이 지나면 성공할 가능성이 있는 실패입니다. - 영구적 실패: 존재하지 않는 보고서 종류나 허용 범위를 벗어난 입력처럼, 같은 데이터로 다시 실행해도 실패하는 경우입니다.
BullMQ Worker가 일반 Error를 던지면 남은 attempts가 있을 때 작업을 다시 실행합니다. 반면 UnrecoverableError를 던지면 남은 실행 기회와 상관없이 작업을 바로 최종 failed 상태로 보냅니다.
실패를 실험하기 위해 앞 글의 작업 데이터에 선택 속성을 하나 추가합니다. tenantId와 requestedBy는 계속 서버가 확인한 인증 주체에서 가져옵니다.
export interface GenerateReportData {
reportId: string;
tenantId: string;
requestedBy: string;
rows: number;
failUntilAttempt?: number;
}
export interface GenerateReportResult {
operationKey: string;
reportId: string;
objectKey: string;
generatedRows: number;
reused: boolean;
}
export interface ReportProgress {
step: 'load' | 'render' | 'upload';
percent: number;
}
export interface ReportPrincipal {
tenantId: string;
userId: string;
}DTO에는 장애를 일부러 일으키는 실험용 필드만 추가합니다. 운영 API에서는 이 필드를 외부에 노출하지 않고 테스트 환경에서만 켭니다.
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateReportDto {
@IsString()
reportId: string;
@IsInt()
@Min(1)
@Max(100_000)
rows: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(5)
failUntilAttempt?: number;
}failUntilAttempt는 @IsOptional()이므로 보내지 않으면 검사를 건너뛰고, 보낼 때는 0부터 5까지의 정수만 허용합니다. 뒤의 Worker는 이 값만큼의 실행을 일부러 실패시킵니다. 예를 들어 2를 보내면 처음 두 번의 실행이 실패하고 세 번째 실행에서 보고서를 만들며, 뒤에서 정할 attempts: 4보다 크거나 같은 값을 보내면 모든 실행이 실패합니다.
같은 업무를 나타내는 키 만들기
reportId 하나만 중복 제거 키로 쓰면, tenant가 다르거나 결과를 바꾸는 입력이 다른 요청까지 하나로 합쳐질 수 있습니다. 이 예제는 작업 종류와 버전, 인증된 tenant와 사용자, 보고서 ID, 행 수를 모두 직렬화한 뒤 해시합니다. 실제 업무에서는 결과를 바꾸는 모든 입력을 포함하되, 비밀 값 자체는 payload나 키에 넣지 않습니다.
import { createHash } from 'node:crypto';
import type { GenerateReportData } from './report-job.types';
export function reportOperationKey(
data: Pick<
GenerateReportData,
'tenantId' | 'requestedBy' | 'reportId' | 'rows'
>,
) {
return createHash('sha256')
.update(
JSON.stringify([
'report.generate.v1',
data.tenantId,
data.requestedBy,
data.reportId,
data.rows,
]),
)
.digest('hex');
}해시는 같은 업무의 범위를 정하는 수단일 뿐, 권한 검사를 대신하지 않습니다.
Producer의 재시도 옵션
앞 글의 ReportsService.enqueue()에서 인증 주체로 작업 데이터를 만들고, 옵션을 다음과 같이 확장합니다. 앞 글과 비교하면 작업 데이터를 변수 data로 먼저 만들어 failUntilAttempt까지 담고, 같은 데이터로 operationKey를 계산합니다. 옵션에서는 attempts를 4로 늘리고 jitter와 deduplication을 더했으며, 각 값의 뜻은 코드 아래에서 설명합니다. 발췌에 없는 import로는 import { reportOperationKey } from './report-operation-key';를 service 파일 위쪽에 추가합니다.
const data: GenerateReportData = {
reportId: dto.reportId,
tenantId: principal.tenantId,
requestedBy: principal.userId,
rows: dto.rows,
failUntilAttempt: dto.failUntilAttempt,
};
const operationKey = reportOperationKey(data);
const job = await this.reportsQueue.add(
GENERATE_REPORT_JOB,
data,
{
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,
};attempts: 4는 최초 실행을 포함해 최대 네 번 실행할 수 있다는 뜻입니다.
delay: 1_000인 지수 백오프의 최대 대기 시간은 첫 실패 뒤 1초, 두 번째 실패 뒤 2초, 세 번째 실패 뒤 4초입니다. BullMQ 소스는 2^(attemptsMade - 1) × delay로 최대치를 계산하고, jitter: 0.5가 있으면 그 최대치의 50%에서 100% 사이에서 실제 대기를 무작위로 고릅니다. 이렇게 하면 여러 작업이 같은 순간에 한꺼번에 다시 몰리는 현상을 줄일 수 있습니다.
재시도 간격은 외부 시스템이 복구되는 데 걸리는 시간과 업무 마감 시간을 기준으로 정합니다. 너무 짧으면 장애 중인 시스템을 다시 압박하고, 너무 길면 사용자가 복구를 기다리는 시간이 늘어납니다.
BullMQ 실패 분류와 지수 백오프 재시도
일시적 Error만 최대 4회 예산의 backoff/재실행을 거치고 영구 실패/예산 소진은 failed로 끝난다.
Nest · BullMQ Retry Policy
재시도는 실패 원인과 남은 실행 기회로 결정한다
attempts: 4는 최초 실행을 포함한 네 번의 기회입니다. 일반 Error는 남은 기회와 backoff를 따르고, UnrecoverableError는 즉시 최종 failed로 갑니다.
같은 입력이 나중에는 성공할 수 있음
네트워크 단절이나 외부 저장소의 503처럼 원인이 시간에 따라 바뀝니다. 일반 Error를 던져 자동 재시도 정책에 맡깁니다.
같은 입력으로는 결과가 바뀌지 않음
지원하지 않는 job name이나 유효하지 않은 업무 입력은 UnrecoverableError로 추가 자동 재시도 없이 종료합니다.
-
실행 기회 1 · 최초 실행
정상 반환하면
completed입니다. 일반Error면jitter: 0.5가 적용된 약 0.5–1초 대기 뒤 다음 기회로 갑니다. -
실행 기회 2 · 첫 자동 재시도
다시 실패하면 지수 백오프 최대치가 2초로 늘어 실제 대기는 약 1–2초 범위가 됩니다.
-
실행 기회 3 · 두 번째 자동 재시도
다시 실패하면 최대치 4초의 약 2–4초 범위에서 대기합니다. 실제 상태는
delayed,waiting,active사이에서 빠르게 바뀔 수 있습니다. -
실행 기회 4 · 마지막 실행
정상 반환하면
completed, 일반Error가 계속되면 실행 예산을 소진해 최종failed가 됩니다.
process() 정상 반환
progress: 100만으로는 완료가 아닙니다. 멱등 저장까지 성공한 뒤 Worker가 값을 반환해야 completed와 결과가 기록됩니다.
영구 실패 또는 실행 예산 소진
실패 evidence는 보존 정책 안에서만 Redis에 남습니다. 수동 retry 전에 오류와 counter를 외부 감사 저장소에 먼저 기록합니다.
BullMQ v5.77.6 기준입니다. jitter: 0.5는 각 지수 백오프 최대치의 50%에서 100% 사이를 무작위로 선택하며, 재시도는 장애를 고치는 기능이 아니라 복구될 시간을 주는 정책입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 정상 반환만 완료이며, 영구 실패는 즉시 최종 실패가 되고, 일시적 Error는 남은 최대 4회 예산 안에서 backoff 후 재실행한다. 소진은 최종 failed로 합류한다.
- 정상 반환은 completed, UnrecoverableError에 의한 영구 실패는 오른쪽 바깥 선으로 즉시 failed로 이어진다. 일시적 Error는 남은 예산을 판단한다. 예산이 남으면 backoff 후 왼쪽 점선을 통해 재실행하고 소진이면 failed로 끝난다. 네 번은 최초 실행을 포함하며 세 backoff의 최대치는 1/2/4초이고 정확한 시작 시각은 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BullMQ 실패 분류와 지수 백오프 재시도 |
| 16 | 정상 반환만 완료이며, 영구 실패는 즉시 최종 실패가 되고, 일시적 Error는 남은 최대 4회 예산 안에서 backoff 후 재실행한다. 소진은 최종 failed로 합류한다. |
| 29 | 최초 포함 4회 |
| 33 | 최대 1 · 2 · 4초 |
| 36 | 정상 반환은 completed, UnrecoverableError에 의한 영구 실패는 오른쪽 바깥 선으로 즉시 failed로 이어진다. 일시적 Error는 남은 예산을 판단한다. 예산이 남으면 backoff 후 왼쪽 점선을 통해 재실행하고 소진이면 failed로 끝난다. 네 번은 최초 실행을 포함하며 세 backoff의 최대치는 1/2/4초이고 정확한 시작 시각은 보장하지 않는다. |
| 39 | 최초 포함 최대 4회 |
| 45 | 영구 실패는 즉시 종료 |
| 49 | 최대 1→2→4초 |
| 51 | retry 전 감사 저장 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 작업 실행 최대 4회의 시도 예산 안에서 process를 실행합니다. | 시도 상한: 4 · 완료 조건: 정상 반환 | 하나의 실행 시도가 진행됩니다. |
| 실패 종류 판정 영구 실패는 즉시 최종 실패이고 일시적 Error는 남은 예산을 확인합니다. | 영구 실패: 즉시 failed · 일시 실패: 예산 확인 | 실패 종류에 따라 재실행 여부가 달라집니다. |
| backoff 또는 소진 예산이 남으면 backoff 뒤 재실행하고 소진이면 failed로 끝납니다. | 예산 남음: backoff→재실행 · 소진: failed | 일시 실패만 제한된 반복으로 돌아갑니다. |
BullMQ 실패 분류와 지수 백오프 재시도
일시적 Error만 최대 4회 예산의 backoff/재실행을 거치고 영구 실패/예산 소진은 failed로 끝난다.
Nest · BullMQ Retry Policy
재시도는 실패 원인과 남은 실행 기회로 결정한다
attempts: 4는 최초 실행을 포함한 네 번의 기회입니다. 일반 Error는 남은 기회와 backoff를 따르고, UnrecoverableError는 즉시 최종 failed로 갑니다.
같은 입력이 나중에는 성공할 수 있음
네트워크 단절이나 외부 저장소의 503처럼 원인이 시간에 따라 바뀝니다. 일반 Error를 던져 자동 재시도 정책에 맡깁니다.
같은 입력으로는 결과가 바뀌지 않음
지원하지 않는 job name이나 유효하지 않은 업무 입력은 UnrecoverableError로 추가 자동 재시도 없이 종료합니다.
-
실행 기회 1 · 최초 실행
정상 반환하면
completed입니다. 일반Error면jitter: 0.5가 적용된 약 0.5–1초 대기 뒤 다음 기회로 갑니다. -
실행 기회 2 · 첫 자동 재시도
다시 실패하면 지수 백오프 최대치가 2초로 늘어 실제 대기는 약 1–2초 범위가 됩니다.
-
실행 기회 3 · 두 번째 자동 재시도
다시 실패하면 최대치 4초의 약 2–4초 범위에서 대기합니다. 실제 상태는
delayed,waiting,active사이에서 빠르게 바뀔 수 있습니다. -
실행 기회 4 · 마지막 실행
정상 반환하면
completed, 일반Error가 계속되면 실행 예산을 소진해 최종failed가 됩니다.
process() 정상 반환
progress: 100만으로는 완료가 아닙니다. 멱등 저장까지 성공한 뒤 Worker가 값을 반환해야 completed와 결과가 기록됩니다.
영구 실패 또는 실행 예산 소진
실패 evidence는 보존 정책 안에서만 Redis에 남습니다. 수동 retry 전에 오류와 counter를 외부 감사 저장소에 먼저 기록합니다.
BullMQ v5.77.6 기준입니다. jitter: 0.5는 각 지수 백오프 최대치의 50%에서 100% 사이를 무작위로 선택하며, 재시도는 장애를 고치는 기능이 아니라 복구될 시간을 주는 정책입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 정상 반환만 완료이며, 영구 실패는 즉시 최종 실패가 되고, 일시적 Error는 남은 최대 4회 예산 안에서 backoff 후 재실행한다. 소진은 최종 failed로 합류한다.
- 정상 반환은 completed, UnrecoverableError에 의한 영구 실패는 오른쪽 바깥 선으로 즉시 failed로 이어진다. 일시적 Error는 남은 예산을 판단한다. 예산이 남으면 backoff 후 왼쪽 점선을 통해 재실행하고 소진이면 failed로 끝난다. 네 번은 최초 실행을 포함하며 세 backoff의 최대치는 1/2/4초이고 정확한 시작 시각은 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BullMQ 실패 분류와 지수 백오프 재시도 |
| 16 | 정상 반환만 완료이며, 영구 실패는 즉시 최종 실패가 되고, 일시적 Error는 남은 최대 4회 예산 안에서 backoff 후 재실행한다. 소진은 최종 failed로 합류한다. |
| 29 | 최초 포함 4회 |
| 33 | 최대 1 · 2 · 4초 |
| 36 | 정상 반환은 completed, UnrecoverableError에 의한 영구 실패는 오른쪽 바깥 선으로 즉시 failed로 이어진다. 일시적 Error는 남은 예산을 판단한다. 예산이 남으면 backoff 후 왼쪽 점선을 통해 재실행하고 소진이면 failed로 끝난다. 네 번은 최초 실행을 포함하며 세 backoff의 최대치는 1/2/4초이고 정확한 시작 시각은 보장하지 않는다. |
| 39 | 최초 포함 최대 4회 |
| 45 | 영구 실패는 즉시 종료 |
| 49 | 최대 1→2→4초 |
| 51 | retry 전 감사 저장 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 작업 실행 최대 4회의 시도 예산 안에서 process를 실행합니다. | 시도 상한: 4 · 완료 조건: 정상 반환 | 하나의 실행 시도가 진행됩니다. |
| 실패 종류 판정 영구 실패는 즉시 최종 실패이고 일시적 Error는 남은 예산을 확인합니다. | 영구 실패: 즉시 failed · 일시 실패: 예산 확인 | 실패 종류에 따라 재실행 여부가 달라집니다. |
| backoff 또는 소진 예산이 남으면 backoff 뒤 재실행하고 소진이면 failed로 끝납니다. | 예산 남음: backoff→재실행 · 소진: failed | 일시 실패만 제한된 반복으로 돌아갑니다. |
위 그림은 Worker의 실행 결과가 세 갈래로 나뉘는 모습을 보여 줍니다. 정상 반환만 completed가 되고, UnrecoverableError로 분류한 영구 실패는 곧바로 최종 failed로 갑니다. 일시적인 Error는 최초 실행을 포함한 네 번의 실행 기회가 남았는지 확인해, 남았다면 최대 1·2·4초의 backoff 뒤 다시 실행하고 모두 썼다면 failed로 합류합니다. 그림 아래쪽 단계 설명은 실행 기회마다 실제 대기 시간이 jitter 때문에 어느 범위에 들어가는지 정리한 것입니다.
중복 제거와 멱등성은 다른 문제입니다
위 설정은 TTL이 없는 BullMQ의 Simple 모드 중복 제거(deduplication)입니다. 같은 deduplication.id를 가진 작업이 아직 completed나 failed가 아니라면, 뒤에 들어온 추가 요청은 새 작업으로 저장되지 않고 deduplicated 이벤트만 발생합니다. 이 기능이 하는 일은 큐 안에서 아직 끝나지 않은 같은 작업이 다시 등록되는 것을 막는 데까지입니다.
- 원래 작업이
completed나failed가 되면 Simple 모드의 키도 사라지므로, 같은 업무 요청을 다시 추가할 수 있습니다. - Worker가 lock을 갱신하지 못해
stalled가 되면 작업은waiting으로 돌아가 다시 실행될 수 있습니다. - 파일을 저장한 직후 Worker가 종료되면, BullMQ가 완료를 기록하지 못한 채 같은 부수 효과를 다시 수행할 수 있습니다.
removeOnComplete와removeOnFail로 작업이 지워지면 큐 기록은 상태 조회용으로도, 영구 중복 방지 원장으로도 쓸 수 없습니다.
따라서 큐의 중복 제거는 정확히 한 번(exactly-once) 실행이나 결과의 한 번 저장을 보장하지 않습니다. 최종 부수 효과 자체를 업무 키로 멱등하게 만들어야 합니다.
실습에서는 동작을 눈으로 확인하기 위해 메모리 저장소를 사용합니다.
import { Injectable } from '@nestjs/common';
import { GenerateReportResult } from './report-job.types';
@Injectable()
export class ReportArtifactsService {
private readonly artifacts = new Map<string, GenerateReportResult>();
find(operationKey: string) {
return this.artifacts.get(operationKey);
}
saveOnce(operationKey: string, result: GenerateReportResult) {
const existing = this.artifacts.get(operationKey);
if (existing) return existing;
this.artifacts.set(operationKey, result);
return result;
}
}메모리 Map은 프로세스를 다시 시작하거나 Worker를 여러 대 띄우면 공유되지 않습니다. 한 프로세스 안에서 개념을 보여 주는 예제일 뿐이며, 운영 환경에서는 데이터베이스의 고유 제약과 원자적 upsert, 객체 저장소의 조건부 생성, 외부 API의 idempotency key로 바꿉니다.
Worker는 같은 operation key로 기존 결과를 먼저 조회하고, 정상 반환할 결과도 같은 키로 저장합니다.
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, UnrecoverableError } from 'bullmq';
import { ReportArtifactsService } from './report-artifacts.service';
import {
GenerateReportData,
GenerateReportResult,
} from './report-job.types';
import { reportOperationKey } from './report-operation-key';
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 {
constructor(private readonly artifacts: ReportArtifactsService) {
super();
}
async process(
job: Job<
GenerateReportData,
GenerateReportResult,
typeof GENERATE_REPORT_JOB
>,
): Promise<GenerateReportResult> {
if (job.name !== GENERATE_REPORT_JOB) {
throw new UnrecoverableError(
'unsupported report job: ' + job.name,
);
}
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,
});
}
}percent: 100은 Worker가 기록한 진행 값일 뿐 완료가 아닙니다. 그 뒤의 조건부 저장이 성공하고 process()가 정상 반환해야 BullMQ가 completed와 returnvalue를 기록합니다.
기능 모듈의 provider에도 저장소를 추가합니다. module 파일 위쪽에는 import { ReportArtifactsService } from './report-artifacts.service';를 더합니다.
providers: [
ReportsService,
ReportsProcessor,
ReportArtifactsService,
],ReportsProcessor가 생성자로 ReportArtifactsService를 주입받으므로, 이 등록이 빠지면 Nest가 Worker를 만들 때 의존성을 찾지 못해 애플리케이션이 시작하지 않습니다.
운영 저장소의 원자성
실제 저장소에서 find()와 save()를 서로 떨어진 두 쿼리로 구현하면, 두 Worker가 동시에 조회를 통과해 둘 다 저장할 수 있습니다. 다음 방법 중 하나로 원자성을 확보합니다.
- operation key에 유일 인덱스를 두고, insert가 충돌하면 기존 결과를 조회합니다.
- 데이터베이스의
INSERT ... ON CONFLICT나 트랜잭션을 사용합니다. - 객체 저장소에서 같은 key에 대한 조건부 생성 기능을 사용합니다.
- 외부 API가 idempotency key를 지원하면 operation key를 함께 보냅니다.
BullMQ 중복 억제와 감사 가능한 실패 복구
단계 대상은 아랫행의 수동 복구입니다. failed·권한·소유 범위와 원자적 경쟁 방지를 확인한 뒤 외부 evidence를 먼저 보관하고 retry로 지워지는 필드·두 시도 counter reset·202 뒤 상태 조회를 추적합니다. 윗행 미완료 접수 억제·결과 멱등성은 독립 책임으로 전체 관계도와 표·출력에 보존하며 예제의 분리 조회가 운영 lock을 구현했다고 주장하지 않습니다.
Nest 복구 · 중복 경계
접수 억제, 멱등 저장, 수동 복구의 조건을 구분합니다
Simple deduplication은 미완료 job의 뒤 추가를 억제합니다. 본문의 Map은 한 프로세스 실습용입니다. 운영 부수 효과에는 원자적 저장을 구현하고 retry 전에 감사 evidence와 복구 경쟁을 확인합니다.
-
HTTP 경계 · 인증된 operation key
서버가 확인한
tenantId와 사용자, 작업 종류·버전, 결과를 바꾸는 입력을 묶습니다. 접수 응답은Queue.add()가 반환한jobId와statusUrl을 사용합니다. -
Queue 경계 · Simple deduplication
같은 키의 원 작업이
completed나failed가 되기 전까지만 뒤 추가를 무시합니다. 이 키는 exactly-once 실행이나 영구 업무 원장이 아닙니다. -
Sink 경계 · 원자적 멱등 저장
unique constraint, conditional create, atomic upsert 또는 외부 idempotency key로 같은 operation 결과를 한 번만 만듭니다. lock 상실로 작업이 다시 실행돼도 기존 결과를 반환합니다.
지워지기 전에 외부 감사 저장소에 append
jobId, 안전한 payload 식별자, failedReason, stacktrace, 두 attempt counter, 실행자·이유·시각을 먼저 남깁니다.
권한·tenant·경쟁·원인 수정 확인
같은 operation의 새 작업이나 결과가 없는지 원자적으로 확인한 뒤 retry합니다. attemptsMade reset은 자동 재시도 예산을 다시 주고, attemptsStarted reset은 시작 횟수를 0으로 되돌립니다.
Job.retry()는 failedReason, finishedOn, processedOn, returnvalue를 지우므로 snapshot이 먼저입니다. 개수·기간 기반 자동 제거는 새 작업이 완료 또는 실패할 때 lazy하게 실행되며, 제거된 job은 상태 조회와 수동 retry가 불가능합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 윗행은 HTTP 업무 키→queue 미완료 접수 억제→sink 원자적 결과 저장의 서로 다른 책임이다. 아랫행은 실패 복구 조건→외부 감사→retry의 별도 절차이며 두 행 사이에 무조건적인 제어 흐름은 없다.
- 위의 세 영역은 업무 키를 쓰지만 서로 다른 보장을 제공한다. HTTP 입력은 업무 범위를 정하고 queue dedup은 미완료 작업이 있는 동안 후속 추가만 억제한다. Worker의 원자적 결과 저장은 재실행의 부수 효과를 보호한다. 아래 복구 영역은 실패한 작업에 별도로 적용하며 sink 성공 뒤 자동 retry로 이어지는 선은 없다. 조건 충족→외부 감사→retry 순으로 수행하고 예제 Map은 프로세스 밖의 원자성을 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BullMQ 중복 억제와 감사 가능한 실패 복구 |
| 6 | 윗행은 HTTP 업무 키→queue 미완료 접수 억제→sink 원자적 결과 저장의 서로 다른 책임이다. 아랫행은 실패 복구 조건→외부 감사→retry의 별도 절차이며 두 행 사이에 무조건적인 제어 흐름은 없다. |
| 28 | 위의 세 영역은 업무 키를 쓰지만 서로 다른 보장을 제공한다. HTTP 입력은 업무 범위를 정하고 queue dedup은 미완료 작업이 있는 동안 후속 추가만 억제한다. Worker의 원자적 결과 저장은 재실행의 부수 효과를 보호한다. 아래 복구 영역은 실패한 작업에 별도로 적용하며 sink 성공 뒤 자동 retry로 이어지는 선은 없다. 조건 충족→외부 감사→retry 순으로 수행하고 예제 Map은 프로세스 밖의 원자성을 보장하지 않는다. |
| 58 | 같은 키의 원래 작업이 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 복구 자격과 경쟁 확인 failed job의 retry 권한·tenant·requestedBy를 확인하고 결과·새 미완료 작업과 복구 경쟁을 점검합니다. | 상태: failed · 범위: tenant·requestedBy · 운영 원자성: lock·상태 전이 필요 | 허용된 복구만 증거 보관으로 진행합니다. |
| retry 전에 evidence 식별자·failedReason·stacktrace·attemptsMade·attemptsStarted·실행자·이유를 외부 감사 저장소에 기록합니다. | evidence: 외부 append · 시점: retry 전 · 비밀: 기록 제외 | 지워질 실패 증거와 운영 결정을 먼저 보관합니다. |
| 원인 수정 뒤 retry Job.retry가 실패 완료 필드를 지우고 두 counter를 reset하여 전체 예산을 다시 부여합니다. | attemptsMade: 0 · attemptsStarted: 0 · 실행 예산: 4회 | 실패 기록의 작업 상태와 재실행 예산이 바뀝니다. |
| 202와 상태 재조회 202는 같은 jobId·statusUrl만 반환하며 worker가 이미 가져갈 수 있어 waiting을 약속하지 않습니다. | 응답: 202 Accepted · 자료: jobId·statusUrl · 현재 상태: 다시 조회 | 접수 응답과 실제 실행 상태를 구분합니다. |
BullMQ 중복 억제와 감사 가능한 실패 복구
단계 대상은 아랫행의 수동 복구입니다. failed·권한·소유 범위와 원자적 경쟁 방지를 확인한 뒤 외부 evidence를 먼저 보관하고 retry로 지워지는 필드·두 시도 counter reset·202 뒤 상태 조회를 추적합니다. 윗행 미완료 접수 억제·결과 멱등성은 독립 책임으로 전체 관계도와 표·출력에 보존하며 예제의 분리 조회가 운영 lock을 구현했다고 주장하지 않습니다.
Nest 복구 · 중복 경계
접수 억제, 멱등 저장, 수동 복구의 조건을 구분합니다
Simple deduplication은 미완료 job의 뒤 추가를 억제합니다. 본문의 Map은 한 프로세스 실습용입니다. 운영 부수 효과에는 원자적 저장을 구현하고 retry 전에 감사 evidence와 복구 경쟁을 확인합니다.
-
HTTP 경계 · 인증된 operation key
서버가 확인한
tenantId와 사용자, 작업 종류·버전, 결과를 바꾸는 입력을 묶습니다. 접수 응답은Queue.add()가 반환한jobId와statusUrl을 사용합니다. -
Queue 경계 · Simple deduplication
같은 키의 원 작업이
completed나failed가 되기 전까지만 뒤 추가를 무시합니다. 이 키는 exactly-once 실행이나 영구 업무 원장이 아닙니다. -
Sink 경계 · 원자적 멱등 저장
unique constraint, conditional create, atomic upsert 또는 외부 idempotency key로 같은 operation 결과를 한 번만 만듭니다. lock 상실로 작업이 다시 실행돼도 기존 결과를 반환합니다.
지워지기 전에 외부 감사 저장소에 append
jobId, 안전한 payload 식별자, failedReason, stacktrace, 두 attempt counter, 실행자·이유·시각을 먼저 남깁니다.
권한·tenant·경쟁·원인 수정 확인
같은 operation의 새 작업이나 결과가 없는지 원자적으로 확인한 뒤 retry합니다. attemptsMade reset은 자동 재시도 예산을 다시 주고, attemptsStarted reset은 시작 횟수를 0으로 되돌립니다.
Job.retry()는 failedReason, finishedOn, processedOn, returnvalue를 지우므로 snapshot이 먼저입니다. 개수·기간 기반 자동 제거는 새 작업이 완료 또는 실패할 때 lazy하게 실행되며, 제거된 job은 상태 조회와 수동 retry가 불가능합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 윗행은 HTTP 업무 키→queue 미완료 접수 억제→sink 원자적 결과 저장의 서로 다른 책임이다. 아랫행은 실패 복구 조건→외부 감사→retry의 별도 절차이며 두 행 사이에 무조건적인 제어 흐름은 없다.
- 위의 세 영역은 업무 키를 쓰지만 서로 다른 보장을 제공한다. HTTP 입력은 업무 범위를 정하고 queue dedup은 미완료 작업이 있는 동안 후속 추가만 억제한다. Worker의 원자적 결과 저장은 재실행의 부수 효과를 보호한다. 아래 복구 영역은 실패한 작업에 별도로 적용하며 sink 성공 뒤 자동 retry로 이어지는 선은 없다. 조건 충족→외부 감사→retry 순으로 수행하고 예제 Map은 프로세스 밖의 원자성을 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BullMQ 중복 억제와 감사 가능한 실패 복구 |
| 6 | 윗행은 HTTP 업무 키→queue 미완료 접수 억제→sink 원자적 결과 저장의 서로 다른 책임이다. 아랫행은 실패 복구 조건→외부 감사→retry의 별도 절차이며 두 행 사이에 무조건적인 제어 흐름은 없다. |
| 28 | 위의 세 영역은 업무 키를 쓰지만 서로 다른 보장을 제공한다. HTTP 입력은 업무 범위를 정하고 queue dedup은 미완료 작업이 있는 동안 후속 추가만 억제한다. Worker의 원자적 결과 저장은 재실행의 부수 효과를 보호한다. 아래 복구 영역은 실패한 작업에 별도로 적용하며 sink 성공 뒤 자동 retry로 이어지는 선은 없다. 조건 충족→외부 감사→retry 순으로 수행하고 예제 Map은 프로세스 밖의 원자성을 보장하지 않는다. |
| 58 | 같은 키의 원래 작업이 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 복구 자격과 경쟁 확인 failed job의 retry 권한·tenant·requestedBy를 확인하고 결과·새 미완료 작업과 복구 경쟁을 점검합니다. | 상태: failed · 범위: tenant·requestedBy · 운영 원자성: lock·상태 전이 필요 | 허용된 복구만 증거 보관으로 진행합니다. |
| retry 전에 evidence 식별자·failedReason·stacktrace·attemptsMade·attemptsStarted·실행자·이유를 외부 감사 저장소에 기록합니다. | evidence: 외부 append · 시점: retry 전 · 비밀: 기록 제외 | 지워질 실패 증거와 운영 결정을 먼저 보관합니다. |
| 원인 수정 뒤 retry Job.retry가 실패 완료 필드를 지우고 두 counter를 reset하여 전체 예산을 다시 부여합니다. | attemptsMade: 0 · attemptsStarted: 0 · 실행 예산: 4회 | 실패 기록의 작업 상태와 재실행 예산이 바뀝니다. |
| 202와 상태 재조회 202는 같은 jobId·statusUrl만 반환하며 worker가 이미 가져갈 수 있어 waiting을 약속하지 않습니다. | 응답: 202 Accepted · 자료: jobId·statusUrl · 현재 상태: 다시 조회 | 접수 응답과 실제 실행 상태를 구분합니다. |
위 그림의 윗줄은 업무 키가 쓰이는 세 곳이 서로 다른 것을 보장한다는 점을 보여 줍니다. HTTP 쪽은 tenant·사용자·입력으로 업무의 범위를 정하고, 큐의 Simple 중복 제거는 끝나지 않은 작업의 추가 등록만 막으며, 결과 저장소의 원자적 저장이 재실행되어도 같은 결과를 남기게 합니다. 아랫줄은 실패한 작업에 따로 적용하는 수동 복구 절차로, 권한·경쟁·결과를 확인한 뒤 외부 감사 기록을 먼저 남기고 마지막에 Job.retry()를 호출하는 순서입니다.
자동 재시도 확인하기
처음 두 번은 실패하고 세 번째에 성공하는 작업을 제출합니다. requestedBy와 tenantId는 access token으로 확인한 주체에서 채워집니다.
curl -i -X POST http://localhost:3000/reports \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access-token>" \
-d '{"reportId":"retry-demo","rows":1000,"failUntilAttempt":2}'응답의 jobId나 statusUrl로 상태를 반복해서 조회합니다. 자동 증가하는 ID를 추측해 /jobs/2처럼 고정해 두지 않습니다.
JOB_ID='POST 응답의 jobId'
curl -H "Authorization: Bearer <access-token>" \
"http://localhost:3000/reports/jobs/$JOB_ID"조회 시점에 따라 delayed, waiting, active 가운데 일부를 보지 못하고 지나칠 수 있으며, 최종 성공은 state === 'completed'와 result로 확인합니다. 다음 동작도 함께 확인해 봅니다.
- 같은 인증 주체가 같은
reportId와rows를 첫 작업이 끝나기 전에 다시 제출하면, Simple 중복 제거가 뒤의 추가 요청을 막습니다. rows처럼 결과를 바꾸는 입력이 달라지면 operation key도 달라지므로 별도 작업이 됩니다.- 같은 결과가 이미 저장된 뒤 새 작업을 실행하면, 멱등 저장소가 기존 결과를 돌려주고
reused가true가 됩니다. failUntilAttempt를5로 보내면 네 번의 실행 기회를 모두 쓴 뒤failed가 됩니다.
실패 작업을 수동으로 복구하기
자동 재시도를 모두 쓴 작업은 원인을 고친 뒤 권한이 있는 운영자가 다시 실행할 수 있어야 합니다. 수동 복구는 다음 순서를 지킵니다.
- 인증과 재시도 권한, 작업의 소유 범위를 확인합니다. 이 예제는
tenantId와requestedBy가 모두 일치해야 하며, 권한이 없으면 존재 여부를 숨기고404로 처리합니다. - 같은 operation key의 결과나 새로 진행 중인 작업이 없는지 확인하고, 운영 저장소의 lock이나 원자적 상태 전이로 복구 경쟁을 막습니다.
- payload의 비밀이 아닌 식별자,
failedReason,stacktrace,attemptsMade,attemptsStarted, 실행자와 이유를 외부 감사 저장소에 먼저 기록합니다. - 장애 원인이 해결된 뒤에만
Job.retry()를 호출하고, 바뀐 상태를 상태 API로 다시 확인합니다.
BullMQ v6.5.3의 Job.retry()는 작업을 다시 대기열로 옮기면서 failedReason, finishedOn, processedOn, returnvalue를 지웁니다. 그래서 실패 당시의 기록은 호출하기 전에 남겨 두어야 합니다.
재시도 이유를 검증할 DTO를 추가합니다.
import { IsString, MinLength } from 'class-validator';
export class RetryReportJobDto {
@IsString()
@MinLength(10)
reason: string;
}아래 recoveryAudit.append()는 데이터베이스나 변경할 수 없는 감사 저장소에 기록을 덧붙이는 애플리케이션 서비스이고, artifacts는 앞에서 만든 결과 저장소라고 가정합니다. 기존 Nest import에는 BadRequestException, ConflictException, NotFoundException을 합칩니다. 두 객체는 ReportsService가 생성자로 주입받아야 하므로, 기존 reportsQueue 매개변수 뒤에 private readonly artifacts: ReportArtifactsService와 private readonly recoveryAudit: ReportRecoveryAuditService를 추가하고, service 파일 위쪽에 import { ReportArtifactsService } from './report-artifacts.service';를 더합니다. ReportArtifactsService는 앞에서 providers에 등록했습니다. 반면 ReportRecoveryAuditService는 append() 메서드를 가진 감사 서비스라고 가정한 이름이며 이 글에서 구현하지 않으므로, 직접 만들어 import하고 같은 모듈의 providers에 등록해야 컴파일과 주입이 됩니다.
예제의 조회 조건은 판단 순서를 보여 주기 위한 것입니다. 운영 환경에서는 producer와 수동 재시도가 함께 참여하는 operation key 단위의 lock이나 원자적 복구 상태 전이 안에서, 기존 결과 확인, 진행 중인 작업 확인, 감사 기록, 재시도를 차례로 수행해야 합니다.
async retry(
jobId: string,
reason: 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 !== 'failed') {
throw new BadRequestException(
'only failed jobs can be retried: ' + state,
);
}
const operationKey = reportOperationKey(job.data);
const liveJobId =
await this.reportsQueue.getDeduplicationJobId(operationKey);
if (liveJobId && liveJobId !== job.id) {
throw new ConflictException(
'another report job is already in progress',
);
}
if (this.artifacts.find(operationKey)) {
throw new ConflictException('report result already exists');
}
await this.recoveryAudit.append({
operationKey,
jobId: job.id,
tenantId: job.data.tenantId,
requestedBy: job.data.requestedBy,
reportId: job.data.reportId,
rows: job.data.rows,
failedReason: job.failedReason,
stacktrace: job.stacktrace ?? [],
attemptsMade: job.attemptsMade,
attemptsStarted: job.attemptsStarted,
retriedBy: principal.userId,
reason,
recordedAt: new Date().toISOString(),
});
await job.retry('failed', {
resetAttemptsMade: true,
resetAttemptsStarted: true,
});
return {
jobId: job.id,
accepted: true,
statusUrl: '/reports/jobs/' + job.id,
};
}resetAttemptsMade: true는 자동 재시도 판단에 쓰는 attemptsMade를 0으로 되돌려, 작업에 attempts: 4의 실행 기회를 처음부터 다시 줍니다. resetAttemptsStarted: true는 작업이 active로 옮겨진 횟수인 attemptsStarted를 따로 0으로 되돌립니다. 두 카운터를 되돌리는 일은 단순한 정리가 아니라, 감사 기록과 실행 기회를 바꾸는 운영 결정입니다.
모두 쓴 attemptsMade를 되돌리지 않으면, 다시 실행했을 때 오류가 나는 순간 추가 자동 재시도 없이 최종 실패합니다. 반대로 원인을 고치지 않은 채 되돌리면 같은 장애를 최대 네 번 더 겪게 됩니다.
controller에서는 인증 guard에 더해 재시도 전용 권한 guard를 적용합니다. 아래의 ReportRetryGuard는 운영자의 재시도 권한을 확인하는 guard라고 가정하며, 이 글에서 구현하지 않습니다. UseGuards와 RetryReportJobDto는 기존 controller import에 합칩니다.
@UseGuards(ReportRetryGuard)
@Post('jobs/:jobId/retry')
@HttpCode(HttpStatus.ACCEPTED)
retry(
@Param('jobId') jobId: string,
@Body() dto: RetryReportJobDto,
@Req() request: AuthenticatedRequest,
) {
return this.reportsService.retry(
jobId,
dto.reason,
request.user,
);
}Job.retry()가 반환된 직후 Worker가 작업을 가져갈 수 있으므로 응답은 waiting을 약속하지 않습니다. 202 Accepted와 같은 jobId, statusUrl만 반환하고 실제 상태는 다시 조회합니다.
실패 기록을 너무 빨리 지우지 않기
removeOnFail: true로 모든 실패 작업을 끝나자마자 지우면 getJob()과 수동 재시도를 할 수 없고, 원인과 payload도 조사할 수 없습니다.
개수나 기간으로 지정한 자동 제거는 BullMQ 문서의 설명대로 지연 방식(lazy)으로 동작합니다. 다른 새 작업이 completed나 failed가 될 때에야 제거가 일어나므로, removeOnComplete: 100과 removeOnFail: 100은 정확한 보존 시간이 아니라 Redis 사용량을 제한하는 정책입니다.
지워진 작업은 상태·결과·오류를 더 이상 조회하거나 재시도할 수 없습니다. 오래 남겨야 할 감사 기록과 복구용 기록은 보존 기간을 정한 별도 저장소로 내보냅니다.
| 정책 | 목적 | 함께 정해야 할 것 |
|---|---|---|
attempts | 일시 장애 복구 | 영구 실패 분류와 최대 실행 비용 |
backoff | 재시도 부하 분산 | 외부 시스템 복구 시간과 jitter |
deduplication.id | 끝나지 않은 같은 작업의 추가 등록 억제 | tenant·사용자·작업 버전·결과 입력의 범위 |
| operation key | 부수 효과 중복 방지 | 원자적 고유 제약이나 조건부 쓰기 |
| 완료·실패 보관 | 상태 조회와 단기 조사 | 지연 제거, Redis 한도, 외부 감사 보존 |
| 수동 재시도 | 원인 수정 뒤 복구 | 권한·tenant·경쟁 방지·감사 기록·실행 기회 |
복구할 수 있는 시스템은 단순히 많이 재시도하는 시스템이 아닙니다. 실패의 성격을 구분하고, 다시 실행해도 결과가 망가지지 않게 만들며, 자동 복구가 끝난 뒤의 권한·감사·보존 절차까지 갖춘 시스템입니다.
정리
- 일시적 실패는 일반
Error로 던져attempts와backoff에 맡기고, 영구적 실패는UnrecoverableError로 던져 바로 끝냅니다. exponentialbackoff의 최대 대기는2^(attemptsMade - 1) × delay이고,jitter는 그 최대치 아래쪽 일부 범위에서 실제 대기를 무작위로 고릅니다.- Simple 모드의 중복 제거는 끝나지 않은 같은 작업의 추가 등록만 막을 뿐, 정확히 한 번 실행을 보장하지 않습니다.
- 부수 효과의 중복은 업무 키를 쓰는 원자적 저장으로 막고, 메모리
Map은 한 프로세스 안의 실습용으로만 씁니다. - 수동 재시도 전에는 권한과 경쟁을 확인하고 감사 기록을 먼저 남깁니다.
Job.retry()가 실패 정보를 지우기 때문입니다.
연습 문제
1. 몇 번째 실행이 성공할까
기존 결과가 없는 같은 프로세스에서 attempts: 4, failUntilAttempt: 3으로 실행합니다. 일반 오류만 발생하고 stalled 재실행은 없다고 가정합니다. 몇 번째 실행이 성공하며, 그 전에 대기하는 구간은 몇 번이고 각각 어느 범위일까요? 완료 뒤 같은 업무를 다시 등록할 수 있다면 새 파일을 반드시 만들어야 할까요?
해설 보기
실행이 시작될 때 attemptsMade는 그때까지 실패한 횟수입니다. 값이 0, 1, 2일 때는 3보다 작아 실패하고, 값이 3인 네 번째 실행에서 장애 주입 조건을 통과해 성공합니다.
대기는 세 번입니다. 최대치는 각각 1초, 2초, 4초이고 jitter: 0.5가 적용되므로 실제 대기는 약 0.5–1초, 1–2초, 2–4초 범위에 들어갑니다.
완료되면 Simple 중복 제거 키도 사라지므로 새 작업을 접수할 수는 있습니다. 하지만 같은 operation key의 결과가 이 프로세스의 Map에 남아 있다면, Worker는 새 파일을 만들지 않고 기존 결과를 reused: true로 반환합니다. 다른 프로세스나 재시작 뒤에는 이 Map이 이어지지 않으므로 같은 보장을 얻으려면 운영 저장소가 필요합니다.
2. 원인을 고치지 않고 재시도하면
failUntilAttempt: 5로 제출한 작업이 네 번 모두 실패해 failed가 되었습니다. 운영자가 원인을 확인하지 않고 이 글의 retry()를 호출했습니다. failUntilAttempt 값은 그대로입니다. 작업은 최종적으로 어떻게 되고, 처음 제출한 뒤부터 Worker는 모두 몇 번 실행될까요?
해설 보기
resetAttemptsMade: true로 attemptsMade가 0으로 돌아가므로 작업은 다시 네 번의 실행 기회를 얻습니다. 하지만 failUntilAttempt가 5라서 attemptsMade가 0부터 3까지인 네 번의 실행 모두 장애 주입 조건에 걸려 실패하고, 작업은 다시 failed가 됩니다.
처음 네 번과 재시도 뒤 네 번을 합쳐 Worker는 모두 여덟 번 실행되며, 그 사이 외부 시스템에도 같은 부하를 두 배로 준 셈입니다. 카운터를 되돌리는 재시도는 원인이 해결되었다는 확인과 함께 수행해야 합니다.
3. 지원하지 않는 작업 이름
누군가 같은 큐에 이름이 report.export인 작업을 attempts: 4로 넣었습니다. 이 글의 Worker는 이 작업을 어떻게 처리하고, 상태 조회에서 state와 attemptsMade는 어떤 값이 될까요? Worker가 UnrecoverableError 대신 일반 Error를 던졌다면 무엇이 달라질까요?
해설 보기
Worker는 job.name이 GENERATE_REPORT_JOB과 다르므로 UnrecoverableError를 던집니다. 남은 실행 기회와 상관없이 작업은 첫 실행 뒤 바로 failed가 되고, 실패할 때도 카운터가 1 늘어나므로 attemptsMade는 1, failedReason은 unsupported report job: report.export입니다.
일반 Error를 던졌다면 같은 이름으로는 절대 성공할 수 없는데도 backoff를 거치며 네 번 모두 실행된 뒤에야 failed가 됩니다. 같은 입력으로 결과가 바뀌지 않는 실패는 영구 실패로 분류해야 자원을 낭비하지 않습니다.
다음 글 예약 작업과 큐 운영에서는 지연 작업과 Job Scheduler를 추가하고, Worker 동시성, 이벤트, 지표, 종료 절차를 운영 관점에서 연결합니다.