백그라운드 작업 큐 설계은(는) 무엇을 하나요?
백그라운드 작업 시스템은 처음부터 설계해야 하는 세 가지 실패 모드, 즉 작업 손실, 중복 실행, 무한 재시도가 있습니다. 잘못된 프리미티브를 선택하면 다음 해 내내 이 세 가지 문제를 해결하는 데 시간을 보내게 될 것입니다. BullMQ, Sidekiq, SQS, Temporal 문서에서 공통적으로 반복되는 업계 합의는 '최소 한 번(at-least-once)이 유일하게 정직한 전달 의미 체계' 라는 점입니다. 모든 핸들러를 멱등하게 설계하고 정확히 한 번(exactly-once)을 쫓는 것을 중단하세요. (Caduh — 큐 101)
문제 상황으로 바로 이동:
- 재시도 시 작업이 두 번 실행됨 → 멱등성
- 작업자(worker)가 작업 중간에 충돌, 작업 사라짐 → 신뢰할 수 있는 가져오기 / 가시성 타임아웃
- DLQ가 가득 차고 있음, 알림 발생 중 → 오류 분류 + DLQ
- 백로그 중 Redis OOM 발생 → 메모리 관리
- "이것 대신 Temporal을 써야 하나?" → 큐 vs 워크플로우
- 재시도 폭풍이 다운스트림에 영향 → 백오프 + 지터
- 동시성이 속도 제한을 초과함 → 동시성 + 제한기
사용 시기
- Node, Ruby, Python 또는 Go로 새 백그라운드 작업 시스템을 설계할 때.
setTimeout/cron/ 자체 제작 큐에서 실제 브로커로 마이그레이션할 때.- 작업자 충돌로 작업이 손실될 때.
- 프로덕션 재시도로 인해 중복 부작용이 발생할 때.
- BullMQ (Node + Redis), Sidekiq (Ruby + Redis), RQ / Celery / Dramatiq (Python), Asynq (Go + Redis), SQS (관리형), 또는 Temporal (지속적 실행) 중에서 선택할 때.
핵심 기능
작업 유형에 맞는 프리미티브 선택
| 상황 | 선택 | 이유 |
|---|---|---|
| Node에서 짧은 부작용 작업(이메일 전송, 이미지 리사이즈) | BullMQ | Redis 네이티브, 성숙한 DLQ, 흐름 패턴, 일반적으로 노드당 ~~5k jobs/sec |
| Ruby에서 동일한 작업 | Sidekiq (안정성을 위해 Pro) | 동일한 형태; Pro super_fetch는 LMOVE를 통해 신뢰할 수 있는 가져오기 추가 (Sidekiq 위키) |
| 외부 API 팬아웃, 수 시간~일 단위 지속 시간의 다단계 워크플로우 | Temporal | 지속적 실행; 이벤트 기록에서 재생 (Temporal 문서) |
| AWS 네이티브, 간단한 팬아웃, Redis를 실행하고 싶지 않음 | SQS + Lambda | 관리형 최소 한 번; 가시성 타임아웃 + DLQ 일급 지원 (AWS SQS DLQ) |
| 교차 리전, 멀티 테넌트 격리 | Temporal 또는 SQS | 단일 Redis는 멀티 리전을 깔끔하게 처리하지 못함 |
| DB + 이메일 + 결제 전반에 걸친 정확히 한 번 필요 | 없음 — 최소 한 번 + 멱등 핸들러로 설계 | 브로커 내부의 "정확히 한 번"은 시스템 간에 확장되지 않음 (Caduh) |
숨겨진 기준: 복구 스토리가 얼마나 복잡한가? "영수증 보내기"에는 재시도가 있는 간단한 BullMQ 큐로 충분합니다. 웹훅을 기다리며 24시간 동안 일시 중지되는 12단계 구매 워크플로우는 12개의 연결된 큐 작업이 아니라 Temporal 워크플로우입니다.
멱등성
필수 속성입니다. 모든 인기 있는 큐 문서가 이것으로 시작합니다. BullMQ 멱등 작업 페이지에서: "작업이 첫 번째 시도에서 성공적으로 완료되든, 처음에 실패했다가 재시도될 때 성공하든 시스템의 최종 상태에는 차이가 없어야 합니다." (BullMQ 멱등 작업)
실용적인 패턴:
// 1. 큐가 아닌 비즈니스 이벤트에서 파생된 멱등성 키.
// 상류 이벤트 ID가 있으면 사용하십시오 (Stripe event.id, Shopify hook id).
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ는 큐 내에서 jobId로 중복 제거.
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. 핸들러 내부에서도 부작용 발생 지점에서 중복 제거.
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// 이미 보냈으면 아무것도 하지 않음.
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
jobId는 중복 항목이 여전히 큐에 있는 동안 추가를 중복 제거합니다. DB 고유 제약 조건은 작업자 충돌, 재시도 및 재생 전반에 걸쳐 중복을 제거합니다. 둘 다 필요합니다; jobId만으로는 충분하지 않습니다. BullMQ는 완료된 작업을 제거하고 중복 제거 창이 사라지기 때문입니다.
신뢰할 수 있는 가져오기 및 가시성 타임아웃
작업자가 작업을 가져오고 충돌하면 어떻게 됩니까?
| 시스템 | 기본 동작 | 복구 |
|---|---|---|
| BullMQ | 작업에 lockDuration (기본 30초)이 있습니다. 중단 작업 검사기가 만료 후 다시 큐에 넣습니다 (BullMQ 프로덕션) | 작업자는 lockDuration 내에 잠금을 연장하거나 완료해야 합니다. 그렇지 않으면 두 번 실행됩니다 |
| Sidekiq OSS | Pop은 비원자적입니다. 작업자가 pop과 process 사이에 죽으면 작업이 손실됩니다 | Sidekiq Pro super_fetch로 업그레이드 (Redis 6.2+ LMOVE를 개인 작업 큐로) |
| Sidekiq Pro super_fetch | 프로세스별 작업 목록으로 LMOVE. 하트비트는 60초에 만료됩니다. 고아 검사가 정리하고 다시 큐에 넣습니다 (Sidekiq 위키) | 내장됨 |
| SQS | 가시성 타임아웃 (기본 30초, 최대 12시간). 삭제되지 않으면 타임아웃 후 메시지가 다시 나타납니다 | 타임아웃을 핸들러 p99 지연 시간보다 길게 설정. 긴 작업의 경우 ChangeMessageVisibility로 연장 |
| Temporal | 활동에는 하트비트 + 재시도 정책이 있습니다. 작업자 사망 후 워크플로우 기록이 유지됩니다 | 기록에서 재생; 이에 대해 생각할 필요가 없습니다 |
함정: lockDuration / 가시성 타임아웃이 핸들러 p99보다 짧으면 → 작업이 두 번 실행됩니다. 허용 가능한 복구 시간보다 길면 → 충돌한 작업이 너무 오래 기다립니다. 먼저 p99를 측정하고, 타임아웃을 약 3배로 설정하고, 진정으로 긴 작업의 경우 핸들러가 하트비트를 보내거나 잠금을 연장하십시오.
오류 분류 및 DLQ
모든 오류가 영원히 재시도되어야 하는 것은 아닙니다. SQS 문서에서: "최대 수신 횟수...와 임계값을 초과하면 DLQ로 메시지를 보내는 재전송 정책을 설정하십시오." (AWS SQS DLQ)
// 던지기 전에 분류하십시오. 영구 오류는 재시도하면 안 됩니다.
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → 첫 시도에 DLQ
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// 즉시 DLQ로 이동 — 재시도는 도움이 되지 않음.
job.discard(); // BullMQ: 재시도 방지
throw e;
}
throw e; // 네트워크/타임아웃 → 재시도 정책에 따라 재시도
}
}
그러면 DLQ에 필요한 것은:
- 증가하는 개수에 대한 알림 (0이 아닌 것만이 아님 — "DLQ에 3개 영원히"는 괜찮습니다. "DLQ가 100/분 증가"는 화재입니다).
- 사람이 상류 버그를 수정하고, 데드 레터링된 작업을 재생하고, DLQ를 비울 수 있도록 재생 도구 (CLI 또는 관리 UI).
- 대시보드 (
grafana-dashboard-builder참조).
백오프 및 지터
지수 백오프만으로는 충분하지 않습니다. 12:00:00에 다운스트림 서비스가 일시적으로 중단되어 1000개의 작업이 모두 실패하면, 일반 지수 백오프는 1000개 모두 정확히 12:00:01, 12:00:03, 12:00:07에 재시도합니다 — 다운스트림 서비스를 계속 다운 상태로 유지할 수 있는 무리한 요청 폭주(thundering herd)입니다. 전체 지터(full jitter) 를 추가하십시오:
// AWS 스타일 "전체 지터": delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
BullMQ 권장 기준의 경우, going-to-production 문서는 retryStrategy와 함께 1초에서 20초 사이의 재시도 간격을 제안합니다. (BullMQ 프로덕션)
동시성 및 제한기
작업자별 동시성은 단일 프로세스에서 N개의 작업을 병렬로 실행합니다. 여러 작업자는 이를 곱합니다. 큐 수준 제한기가 없으면 다운스트림에 대한 총 호출을 제한할 수 없습니다:
// BullMQ — 이 큐의 모든 작업자에 대한 전역 속도 제한.
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 전체 fleet에서 초당 100개의 이메일
});
올바른 형태: 다수의 동시 작업자 + 다운스트림 계약 제한을 위한 큐 제한기. Sidekiq에도 동등한 속도 제한기가 있습니다. SQS는 Lambda 동시성 또는 메시지별 속도를 사용합니다.
메모리 관리
BullMQ 프로덕션에서 가장 강조되는 단일 문제점: Redis 인스턴스의 maxmemory-policy noeviction. 다른 것 (allkeys-lru, volatile-lru)은 부하가 걸리면 큐에 대기 중인 작업을 자동으로 제거(evict)합니다. (BullMQ 프로덕션)
또한:
removeOnComplete를 공격적으로 설정 (예:{ age: 3600, count: 1000 }).- DLQ 패턴으로 이동한 후
removeOnFail을 더욱 공격적으로 설정. - Redis AOF 지속성 활성화 —
appendfsync everysec은 BullMQ에 대해 문서화된 최적 지점입니다. (BullMQ 프로덕션) - 작업자
maxRetriesPerRequest: null로 설정하여 일시적인 연결 끊김 동안 ioredis가 예외를 throw하는 것을 방지합니다. (BullMQ 프로덕션) - 큐 측:
enableOfflineQueue: false로 설정하여 프로듀서가 버퍼링 대신 빠르게 실패하도록 합니다.
정상 종료
process.on('SIGTERM', async () => {
await worker.close(); // 수락 중지; 진행 중인 작업 완료.
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* 동일 */ });
BullMQ 프로덕션 가이드는 프로세스를 중지하기 전에 작업자를 닫아야 한다고 명시적으로 언급합니다. 기본 중단 타임아웃은 약 30초입니다. (BullMQ 프로덕션)
큐 vs 워크플로우 결정
Temporal 문서의 유용한 프레임: "메시지 큐가 서비스 간에 데이터를 이동하는 반면, Temporal은 전체 프로세스를 조정합니다... 워크플로우의 위치, 완료된 항목, 보류 중인 항목, 재시도해야 하는 항목을 알고 있습니다." (Temporal 블로그)
flowchart TD
A[백그라운드 작업] --> B{단일 부작용 또는 파이프라인?}
B -->|단일| C[BullMQ / Sidekiq / SQS]
B -->|다단계| D{단계가 초 또는 시간 이상 지속?}
D -->|초| E[BullMQ flows / Sidekiq batches]
D -->|시간+ 또는 사람 개입| F[Temporal / DBOS / Restate]
C --> G{교차 리전 또는 멀티 테넌트 격리 필요?}
G -->|예| H[SQS 또는 Temporal — Redis 단일 클러스터는 불가]
G -->|아니오| I[Redis 클러스터 + AOF + noeviction으로 실행]
안티 패턴
Redis SET-NX만으로 멱등성 구현
증상: Redis 중단 또는 재시작 중에 동일한 작업이 두 번 실행되어 중복 부작용(결제, 이메일, 티켓)이 발생합니다.
진단: Redis는 캐시입니다. 제거, 재시작 또는 파티셔닝될 수 있습니다. Redis에만 존재하는 SET-NX 중복 제거는 최선의 노력(best-effort)입니다.
해결책: 부작용 레코드(예: email_sends.idempotency_key)에 DB 고유 제약 조건이 있는 멱등성 키. 부작용을 생성하는 DB 트랜잭션이 중복 제거 프리미티브입니다.
lockDuration이 핸들러 p99보다 짧음
증상: 중단 작업 로그 항목; 동일한 작업이 두 작업자에 의해 처리됨. 고객 불만.
진단: 기본 lockDuration은 30초입니다. 핸들러가 때때로 45초가 걸립니다. 두 번째 작업자가 이를 가져옵니다.
해결책: p99를 측정하고 lockDuration을 ~3배 p99로 설정하고 긴 핸들러 내에서 job.extendLock()을 호출하십시오. 또는 작업을 분할하십시오.
일반 지수 백오프 (지터 없음)
증상: 다운스트림의 일시적인 중단이 다운스트림 장애가 됩니다. 10k 작업이 복구 서비스를 동시에 강타합니다. 진단: 지터가 없으면 재시도가 상호 연관됩니다. 해결책: 전체 지터 백오프. 최대 지연을 제한하여 DLQ 실패 시간을 제한하십시오.
Redis에서 maxmemory-policy allkeys-lru
증상: 부하가 걸리면 큐가 신비롭게 작업을 잃습니다. 오류가 없습니다. (BullMQ 프로덕션)
진단: Redis가 공간을 확보하기 위해 작업 키를 제거했습니다.
해결책: CONFIG SET maxmemory-policy noeviction을 실행하고 시작 시 이를 확인하십시오. 그렇지 않으면 작업자가 시작을 거부해야 합니다.
시스템 전반에 걸친 정확히 한 번(exactly-once)인 척하기
증상: 고객에게 두 번 청구됨; 동일한 이메일을 두 번 보냄. 엔지니어는 "브로커가 정확히 한 번이다"라고 확신합니다. 진단: FIFO/정확히 한 번 브로커라도 그 보장을 DB + 이메일 공급업체 + Stripe + 원장으로 확장하지 않습니다. 해결책: 큐를 최소 한 번(at-least-once)으로 취급하십시오. 모든 부작용 경계에서 멱등성.
재생 도구가 없는 DLQ
증상: "DLQ에 3,200개의 작업이 있습니다. 그 안에 무엇이 있는지 모릅니다." DLQ는 모니터링되지 않는 묘지가 됩니다. 진단: DLQ를 구축했지만 운영자 도구를 구축하지 않았습니다. 해결책: ID별 재생, 시간 범위별 재생, 확인 후 비움. CLI 또는 관리 UI. 테스트됨.
이벤트 루프에서 오래 차단되는 작업
증상: BullMQ 작업자 프로세스 중단; 하트비트 중지; 잠금 만료; 작업 재실행.
진단: 큐 하트비트와 동일한 프로세스에서 CPU 바운드 작업; 이벤트 루프가 > lockDuration 동안 차단됨.
해결책: CPU 바운드 작업을 위해 자식 프로세스 또는 작업자 스레드를 생성하십시오. 또는 샌드박스 처리된 프로세서를 실행하십시오 (BullMQ는 작업별 프로세스 샌드박싱을 지원합니다).
품질 관문
- 테스트: 혼란 테스트 — 작업 중간에 작업자를 종료하고; 작업이 재실행되고 부작용이 중복되지 않는지 확인.
- 테스트: 재시도 폭풍 테스트 — 60초 동안 다운스트림 실패; 백오프 + 지터가 재시도를 분산시키는지 확인 (메트릭에 무리한 요청 폭주 없음).
- 모든 핸들러는 멱등하며 비즈니스 이벤트에서 파생된 안정적인 멱등성 키를 사용함.
- DB 수준 고유 제약 조건이 in-Redis 중복 제거 (jobId, SET-NX)를 백업함.
-
lockDuration/ 가시성 타임아웃 ≥ 3× 측정된 p99 핸들러 지연 시간, AND 긴 핸들러는 잠금을 연장함. - 영구 오류 (
PermanentError클래스 또는 이에 상응하는 것)는 재시도를 우회하고 DLQ로 바로 이동. - DLQ 알림: 개수 > 0이 아니라 성장률 > 분당 N일 때 페이지.
- DLQ 재생 도구가 존재하고 테스트됨 (단일 + 범위).
- Redis:
maxmemory-policy=noeviction, AOF 활성화 (appendfsync everysec), 작업자 시작 시 확인. (BullMQ 프로덕션) -
removeOnComplete및removeOnFail구성됨. 작업 개수 메트릭이 시간에 따라 추세됨.