Cloudflare Workers + D1 + Queues로 단일 파일에서 팬아웃과 재시도 구성하기
서버리스에서 비동기 작업을 붙이려다 한 번쯤 막힌 경험이 있을 겁니다. HTTP 요청을 받아 즉시 응답을 내려보내야 하는데, 그 뒤에서는 이메일을 보내거나 웹훅을 여러 곳에 쏘거나 파일을 후처리해야 합니다. 기존 서버라면 백그라운드 스레드나 큐 워커를 따로 띄우면 그만이지만, 서버리스에서는 요청이 끝나는 순간 실행 컨텍스트도 함께 사라집니다.
Cloudflare Workers + Queues + D1 조합은 이 문제를 꽤 우아하게 풉니다. 하나의 Worker 파일에 HTTP 핸들러(프로듀서)와 큐 소비 핸들러(컨슈머)를 함께 두고, 런타임이 두 역할을 분리해서 실행해줍니다. 코드베이스는 단일 저장소로 유지하면서 비동기 처리, 팬아웃, 재시도, Dead Letter Queue까지 한 스택에서 구현할 수 있습니다. 2024~2025년에 걸쳐 Queues가 Durable Objects 기반으로 전면 재구축되면서 처리량과 동시 컨슈머 확장성이 대폭 향상되었고, 이제 실무에 얹기에 무리가 없는 시점이 왔습니다.
이 글에서는 wrangler.toml 설정부터 시작해서 팬아웃 패턴 구현, 재시도 로직과 DLQ 연결, D1으로 잡 상태 추적과 멱등성 확보까지 실제 코드와 함께 살펴봅니다.
세 서비스가 맞물리는 구조
먼저 각 서비스의 역할을 짚어보겠습니다.
Cloudflare Workers는 V8 Isolate 기반의 서버리스 함수 플랫폼입니다. 전 세계 PoP에서 실행되고, 신규 Isolate 초기화가 수 밀리초 수준이라 Cold Start 오버헤드가 사실상 무시 가능합니다. 다만 CPU 타임에는 상한이 있는데, Workers 공식 한도 문서 기준으로 유료(Standard) 플랜의 CPU 타임 한도는 요청당 30초입니다. 5분짜리 무거운 잡을 통째로 처리하려는 설계라면 이 지점에서 이미 바운더리를 넘습니다.
Cloudflare Queues는 Workers에 네이티브로 통합된 관리형 메시지 큐입니다. At-Least-Once 전달을 보장하고, 배치 처리와 재시도, Dead Letter Queue를 기본으로 제공합니다. 컨슈머 인스턴스는 큐 적체에 따라 문서 기준으로 상당 규모까지 수평 확장되지만, 상한 자체는 플랜과 큐 설정에 따라 달라지는 soft limit이라는 점을 기억해두는 편이 좋습니다.
Cloudflare D1은 Workers에 내장된 서버리스 SQLite 데이터베이스입니다. 바인딩으로 직접 연결되어 네트워크 홉이 없고 ACID 트랜잭션을 지원합니다. 잡 상태를 추적하고 멱등성을 확보하는 데 잘 맞습니다.
Cloudflare가 정리한 Workers Best Practices도 "요청 경로에서 무거운 작업은 Queues나 Workflows로 분리하라"는 원칙과 함께, D1·Queues 같은 바인딩이 네트워크 홉 없이 in-process로 참조된다는 점을 강조합니다.
요청을 받은 즉시 202를 반환하고, 실제 무거운 작업은 큐를 통해 비동기로 처리합니다. 클라이언트는 jobId를 받아 별도로 상태를 폴링할 수 있습니다. D1 기록과 큐 발행의 순서는 뒤에서 다시 짚습니다.
단일 파일에 프로듀서와 컨슈머 함께 두기
가장 먼저 wrangler.toml에서 바인딩을 선언합니다. 참고로 이 글의 코드는 @cloudflare/workers-types가 설치되어 있다고 가정합니다. 표준 lib.dom.d.ts만 쓰는 환경이라면 Request.json()의 제네릭 표기나 Queue, D1Database 같은 전역 타입이 그대로 잡히지 않습니다.
name = "my-worker"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
[[queues.producers]]
binding = "MY_QUEUE"
queue = "my-job-queue"
[[queues.consumers]]
queue = "my-job-queue"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
dead_letter_queue = "my-job-dlq"
[[queues.consumers]]
queue = "my-job-dlq"
max_batch_size = 5
max_batch_timeout = 30
[[d1_databases]]
binding = "DB"
database_name = "jobs-db"
database_id = "<your-database-id>"max_batch_size = 10은 한 번에 최대 10개의 메시지를 묶어 컨슈머에 전달하고, max_batch_timeout = 5는 메시지가 10개 미만이더라도 5초가 지나면 배치를 플러시합니다. D1 쓰기 횟수를 줄여 비용을 최적화할 때 유용합니다.
D1 스키마부터 만들어봅니다. 뒤에서 사용할 webhook_deliveries 테이블도 같이 넣어둡니다.
-- migrations/0001_create_jobs.sql
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
payload TEXT,
error TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id TEXT NOT NULL,
event_type TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);그리고 Worker 파일입니다. 프로듀서와 컨슈머를 한 파일에 두면서, 큐 이름으로 메인 큐와 DLQ를 분기하고, 멱등성 처리까지 메인 흐름에 통합했습니다.
// src/worker.ts
export interface Env {
MY_QUEUE: Queue<JobMessage>;
DB: D1Database;
}
export interface JobMessage {
id: string;
type: 'send_email' | 'webhook_fanout' | 'process_file';
payload: unknown;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const body = (await req.json()) as {
type: JobMessage['type'];
payload: unknown;
};
const job: JobMessage = {
id: crypto.randomUUID(),
type: body.type,
payload: body.payload,
};
// D1과 Queues는 단일 트랜잭션으로 묶이지 않으므로 D1을 먼저 기록
await env.DB.prepare(
'INSERT INTO jobs (id, type, status, payload) VALUES (?, ?, ?, ?)'
)
.bind(job.id, job.type, 'pending', JSON.stringify(job.payload))
.run();
await env.MY_QUEUE.send(job);
return Response.json({ jobId: job.id }, { status: 202 });
},
async queue(
batch: MessageBatch<JobMessage>,
env: Env
): Promise<void> {
if (batch.queue === 'my-job-dlq') {
await handleDlqBatch(batch, env);
return;
}
await handleMainBatch(batch, env);
},
};저도 처음엔 fetch와 queue 핸들러가 같은 파일에 있어도 되는 건지 헷갈렸습니다. Cloudflare 런타임은 HTTP 요청이 오면 fetch를, 큐에 메시지가 쌓이면 queue를 각각 독립적으로 호출합니다. 코드베이스는 하나이지만 런타임이 역할을 분리해 실행해주는 구조이고, batch.queue 프로퍼티로 어느 큐에서 온 배치인지 구분할 수 있습니다.
컨슈머 본체: 멱등성과 상태 업데이트 실패까지 처리
메인 컨슈머는 세 가지를 동시에 만족해야 합니다. (1) 같은 메시지가 두 번 와도 두 번 처리되지 않을 것, (2) 작업 자체 실패와 상태 기록 실패를 구분할 것, (3) 재시도 시 지연을 주어 Retry Storm을 피할 것.
async function handleMainBatch(
batch: MessageBatch<JobMessage>,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const job = message.body;
// 낙관적 잠금: pending 상태일 때만 processing으로 전환
const claim = await env.DB.prepare(
`UPDATE jobs
SET status = 'processing', updated_at = unixepoch()
WHERE id = ? AND status = 'pending'`
)
.bind(job.id)
.run();
// 다른 인스턴스가 이미 잡았거나 완료된 경우 — 중복 처리 방지
if (claim.meta.changes === 0) {
message.ack();
continue;
}
let processed = false;
try {
await processJob(job, env);
processed = true;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
await safeUpdateStatus(env, job.id, 'pending', errorMsg);
// 지수 백오프 지연 재시도 — attempts는 메시지 메타에서 제공
message.retry({ delaySeconds: backoffSeconds(message.attempts) });
continue;
}
try {
await env.DB.prepare(
`UPDATE jobs
SET status = 'done', error = NULL, updated_at = unixepoch()
WHERE id = ?`
)
.bind(job.id)
.run();
message.ack();
} catch (dbErr) {
// 작업은 성공했지만 상태 기록만 실패 — 메시지는 ack해서 재실행을 막고,
// 상태 복구는 뒤의 보상 로직이 스캔으로 처리
console.error('status update failed after success', job.id, dbErr);
message.ack();
}
void processed;
}
}
async function safeUpdateStatus(
env: Env,
id: string,
status: string,
error: string | null
): Promise<void> {
try {
await env.DB.prepare(
`UPDATE jobs
SET status = ?, error = ?, updated_at = unixepoch()
WHERE id = ?`
)
.bind(status, error, id)
.run();
} catch (e) {
console.error('status update failed', id, e);
}
}
function backoffSeconds(attempts: number): number {
// 10s, 20s, 40s ... 최대 300s
return Math.min(300, 10 * 2 ** Math.max(0, attempts - 1));
}
async function processJob(job: JobMessage, env: Env): Promise<void> {
switch (job.type) {
case 'send_email':
await sendEmail(job.payload, env);
break;
case 'webhook_fanout':
await fanoutWebhooks(job.payload as WebhookFanoutPayload, env);
break;
case 'process_file':
await processFile(job.payload, env);
break;
default:
throw new Error(`Unknown job type: ${(job as JobMessage).type}`);
}
}여기서 두 가지가 초안과 달라졌습니다.
첫째, 낙관적 잠금(WHERE id = ? AND status = 'pending')을 메인 흐름 안에 넣었습니다. UPDATE가 원자적으로 실행되므로, changes === 0이면 이미 다른 컨슈머 인스턴스가 잡았거나 완료된 것으로 보고 그냥 ack()합니다. At-Least-Once 환경에서 중복 처리 방지의 최소 방어선입니다.
둘째, processJob 성공 이후의 D1 UPDATE 실패를 retry()로 처리하지 않습니다. 작업이 이미 외부 세계에 영향을 준 상태(이메일 발송 완료 등)에서 재시도가 걸리면 중복 실행이 발생합니다. 이때는 메시지를 ack()하고 상태 복구는 아래의 보상 로직에 맡깁니다.
message.retry({ delaySeconds: ... })로 지수 백오프를 주는 것도 실무에서는 반드시 챙겨야 하는 지점입니다. 외부 API가 죽었는데 즉시 재시도만 반복하면 회복 중인 상대 시스템을 다시 밀어버리는 Retry Storm이 됩니다.
팬아웃 패턴: 하나의 이벤트를 여러 곳에 동시에 전달하기
팬아웃이라는 말이 두 가지 맥락에서 쓰입니다. 하나는 플랫폼 레벨 팬아웃 — 큐에 메시지가 많이 쌓이면 Cloudflare 런타임이 컨슈머 Worker 인스턴스를 자동으로 수평 확장해 병렬 처리하는 것입니다. 다른 하나는 애플리케이션 레벨 팬아웃 — 컨슈머 내에서 하나의 이벤트를 여러 엔드포인트에 동시에 전송하는 것입니다.
결제 완료 이벤트를 Slack, 내부 서비스, 서드파티 훅 세 곳에 동시에 전달하는 구조를 보겠습니다.
interface WebhookEndpoint {
id: string;
url: string;
}
interface WebhookFanoutPayload {
eventType: string;
eventData: unknown;
endpoints: WebhookEndpoint[];
}
async function fanoutWebhooks(
payload: WebhookFanoutPayload,
env: Env
): Promise<void> {
// Promise.allSettled: 하나가 실패해도 나머지는 계속 전송
const results = await Promise.allSettled(
payload.endpoints.map((endpoint) =>
sendWebhook(endpoint, payload.eventType, payload.eventData)
)
);
// D1 batch API로 여러 INSERT를 한 번의 왕복으로 처리
const stmt = env.DB.prepare(
'INSERT INTO webhook_deliveries (endpoint_id, event_type, status, error) VALUES (?, ?, ?, ?)'
);
await env.DB.batch(
results.map((result, i) =>
stmt.bind(
payload.endpoints[i].id,
payload.eventType,
result.status === 'fulfilled' ? 'delivered' : 'failed',
result.status === 'rejected' ? String(result.reason) : null
)
)
);
}
async function sendWebhook(
endpoint: WebhookEndpoint,
eventType: string,
data: unknown
): Promise<void> {
const res = await fetch(endpoint.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: eventType, data, timestamp: Date.now() }),
});
if (!res.ok) {
throw new Error(`Webhook failed: ${res.status} ${res.statusText}`);
}
}Promise.allSettled를 쓰는 이유가 중요합니다. Promise.all을 쓰면 하나가 실패할 때 전체 팬아웃이 실패로 처리되어 메시지 재시도가 발생합니다. 그러면 이미 성공한 엔드포인트에 중복 전송이 일어날 수 있습니다. allSettled로 받아 엔드포인트별 결과를 D1에 기록해두면, 실패한 엔드포인트만 골라 별도 재시도 잡으로 밀어넣는 흐름을 만들 수 있습니다.
D1의 batch() API로 여러 INSERT를 한 번의 왕복으로 처리하는 것도 눈여겨볼 만합니다. D1은 쓰기 왕복이 곧 지연이자 비용이라 이 최적화가 팬아웃 규모가 커질수록 체감됩니다.
재시도와 DLQ: 실패를 데이터로 만들기
max_retries = 3으로 설정하면 컨슈머에서 예외가 발생하거나 message.retry()를 호출할 때마다 재시도 횟수가 차감됩니다. 횟수를 소진하면 dead_letter_queue에 설정한 별도 큐로 메시지가 이동합니다.
앞서 wrangler.toml에 이미 my-job-dlq 컨슈머를 하나 더 선언해두었고, 최상위 queue 핸들러에서 batch.queue === 'my-job-dlq'로 분기시켰습니다. DLQ 배치 처리는 아래처럼 이어집니다.
async function handleDlqBatch(
batch: MessageBatch<JobMessage>,
env: Env
): Promise<void> {
for (const message of batch.messages) {
await env.DB.prepare(
`UPDATE jobs
SET status = 'dead', updated_at = unixepoch()
WHERE id = ?`
)
.bind(message.body.id)
.run();
await notifyOpsTeam(message.body); // 개념적 예시: Slack, PagerDuty 등
// DLQ 메시지도 반드시 ack — 하지 않으면 재처리 루프에 빠짐
message.ack();
}
}솔직히 DLQ에서 ack()를 빠뜨리는 실수를 한 번쯤 하게 됩니다. DLQ도 큐이기 때문에 소비하지 않으면 메시지가 계속 쌓이고, DLQ 컨슈머 자체가 재시도 루프에 빠질 수 있습니다.
흔들리는 두 지점: 보상 트랜잭션과 processing 고착
지금까지의 코드에는 아직 두 개의 구멍이 남아 있습니다. 이걸 메꾸지 않으면 프로덕션에서 반드시 마주치게 됩니다.
하나. D1 INSERT는 성공했는데 큐 발행이 실패한 잡
프로듀서에서 D1과 Queues를 원자적으로 묶을 방법이 없기 때문에, jobs 테이블에는 pending이 남아 있지만 큐에는 대응 메시지가 없는 상태가 생길 수 있습니다.
둘. processing으로 옮긴 뒤 컨슈머가 크래시한 잡
낙관적 잠금으로 processing까지 갔는데 그 사이 인스턴스가 죽으면, 이 잡은 아무도 다시 잡지 않습니다. updated_at이 오래된 processing 레코드가 좀비처럼 남습니다.
두 케이스 모두 Cron Trigger로 주기적으로 스캔해 재발행하는 보상(Compensation) 로직으로 해결합니다.
# wrangler.toml에 추가
[triggers]
crons = ["*/5 * * * *"]// 같은 Worker 파일에 scheduled 핸들러 추가
export default {
async fetch(/* ... */) { /* 위와 동일 */ },
async queue(/* ... */) { /* 위와 동일 */ },
async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(recoverStuckJobs(env));
},
};
async function recoverStuckJobs(env: Env): Promise<void> {
// 1) 큐 발행이 유실된 pending — 생성된 지 2분 넘게 pending인 잡
const pendingStuck = await env.DB.prepare(
`SELECT id, type, payload FROM jobs
WHERE status = 'pending'
AND created_at < unixepoch() - 120
LIMIT 100`
).all<{ id: string; type: JobMessage['type']; payload: string }>();
// 2) processing으로 고착된 잡 — 10분 넘게 갱신 없는 잡을 pending으로 되돌림
await env.DB.prepare(
`UPDATE jobs
SET status = 'pending', updated_at = unixepoch()
WHERE status = 'processing'
AND updated_at < unixepoch() - 600`
).run();
const stuckProcessing = await env.DB.prepare(
`SELECT id, type, payload FROM jobs
WHERE status = 'pending'
AND updated_at >= unixepoch() - 30
LIMIT 100`
).all<{ id: string; type: JobMessage['type']; payload: string }>();
const candidates = [
...(pendingStuck.results ?? []),
...(stuckProcessing.results ?? []),
];
for (const row of candidates) {
await env.MY_QUEUE.send({
id: row.id,
type: row.type,
payload: JSON.parse(row.payload),
});
}
}같은 잡이 큐에 두 번 들어갈 가능성이 있지만, 컨슈머의 낙관적 잠금이 두 번째 실행을 곧바로 걷어냅니다. 임계값(2분, 10분)은 서비스 SLA에 맞게 조정하면 됩니다.
트레이드오프: 이 스택이 맞는 상황과 맞지 않는 상황
잘 맞는 부분
| 항목 | 내용 |
|---|---|
| 인프라 무관리 | 큐·데이터베이스·컴퓨트 모두 Cloudflare가 관리, 별도 서버·컨테이너 불필요 |
| 네이티브 통합 | Workers ↔ Queues ↔ D1은 바인딩으로 직접 연결, 네트워크 홉 없음 |
| 자동 스케일링 | 큐 적체에 따라 컨슈머 인스턴스가 자동으로 수평 확장 |
| 배치 처리 | max_batch_size·max_batch_timeout으로 D1 쓰기 왕복 최소화 |
| 단일 코드베이스 | 프로듀서·컨슈머·DLQ·스캔이 한 파일, 운영 복잡도 낮음 |
| 빌트인 재시도·DLQ | 설정만으로 재시도 횟수·지연·Dead Letter Queue 구성 가능 |
아쉬운 부분
| 한계 | 세부 내용 |
|---|---|
| At-Least-Once만 보장 | Exactly-Once나 FIFO 순서 보장이 필요하면 다른 선택지가 낫습니다 |
| 메시지 크기 제한 | 대용량 페이로드는 R2에 저장하고 큐에는 참조 ID만 전달하는 패턴 필요 |
| CPU 타임 상한 | Standard 플랜 기준 요청당 30초, 장시간 작업은 Workflows로 위임 |
| D1 쓰기 병목 가능성 | 초당 수천 건의 상태 갱신이 필요한 워크로드는 별도 검증 필요 |
| 벤더 잠금 | Workers·D1·Queues 모두 Cloudflare 전용 API에 의존 |
| 로컬 개발 환경 | Wrangler 시뮬레이션이 프로덕션과 완전히 동일하지는 않음 |
2025년부터 정식 출시된 Cloudflare Workflows도 함께 언급해둘 만합니다. 단계 간 상태를 유지하거나 실패한 특정 단계만 재시도해야 하는 다단계 작업은 Queues보다 Workflows가 더 자연스럽습니다. 단순한 팬아웃·버퍼링·이벤트 소비는 Queues, 다단계 의존 작업은 Workflows로 나눠 생각하면 판단이 쉬워집니다.
마무리
fetch와 queue를 한 파일에 두는 게 단순한 미학적 취향이 아니라, 잡 스키마·핸들러·보상 로직이 한 저장소·한 배포 단위로 묶여 오퍼레이션 비용이 눈에 띄게 낮아진다는 게 이 스택의 진짜 이점입니다. 대신 At-Least-Once 위에서 굴러가는 만큼, 낙관적 잠금과 Cron 기반 보상 스캔은 옵션이 아니라 기본기라고 보는 편이 안전합니다. 잡이 단일 단계이고 상태 그래프가 단순하다면 이 조합을 그대로 쓰고, 단계 간 의존과 부분 재시도가 필요해지는 순간이 오면 Workflows로 갈아타면 됩니다.
참고 자료
- Cloudflare Workers Limits
- Cloudflare Queues — Batching, Retries and Delays
- Cloudflare Queues — Dead Letter Queues
- Cloudflare Queues — How Queues Works
- Cloudflare Queues JavaScript APIs
- Cloudflare D1 공식 문서
- Cloudflare Workers Best Practices
- Cloudflare Workers Cron Triggers
- Cloudflare Workflows 공식 문서
- Cloudflare 블로그 — How We Built Cloudflare Queues