Durable Objects overloaded 에러 완전 정복: 초당 1,000 요청 소프트 리밋과 싱글 스레드 병목 피하기

Durable Objects(DO)는 “전역적으로 유일한 인스턴스 하나가 강한 일관성을 보장한다”는 매력적인 약속을 합니다. 채팅방, 게임 세션, 결제 상태 머신처럼 순서가 중요한 도메인에 딱 맞는 모델이죠. 문제는 이 약속이 성립하는 이유 — DO 인스턴스 하나는 물리적으로 단일 스레드에서 동작한다는 사실을 잊고 설계할 때 터집니다. 트래픽이 늘면서 갑자기 502와 함께 overloaded 에러가 쏟아지고, 느린 외부 API 하나 때문에 같은 방을 쓰는 다른 사용자들까지 지연되고, alarm() 재시도 로직이 결제를 두 번 청구하는 사고로 이어지는 식입니다.
이 글은 이 세 가지 함정을 공식 문서 수치와 함께 재현 가능한 코드로 짚습니다.
핵심 요약
- DO 인스턴스 하나에는 초당 1,000 요청 소프트 리밋이 있고, 같은 오브젝트에 10초 윈도우 안에 요청이 몰리면
Too many requests for the same object within a 10 second window에러가 발생합니다.await fetch()는 input gate를 열어 다른 요청과 인터리빙을 허용하지만,await storage.*연산과blockConcurrencyWhile()은 완전히 직렬화되어 진짜 헤드 오브 라인 블로킹을 만듭니다.- alarm()은 2초부터 시작하는 지수 백오프로 최대 6회까지만 자동 재시도합니다. 재시도 중 실행된 side effect(결제, 웹훅)에 멱등성이 없으면 중복 실행됩니다.
- SQLite 기반 DO는 오브젝트 1개당 Free 1GB / Paid 10GB 저장 한도가 있고, 초과 시 쓰기가
SQLITE_FULL로 실패합니다. 2026년 7월 20일부터 대시보드에 네임스페이스 단위 Total storage 차트가 추가됐습니다.
DO는 왜 싱글 스레드인가, 그리고 순차 처리가 만드는 지연
Durable Object의 핵심 계약은 “같은 ID로 생성된 오브젝트는 전 세계에서 단 하나의 물리적 위치에서만 실행되고, 그곳으로 가는 모든 요청은 그 인스턴스가 순서대로 처리한다”는 것입니다. Cloudflare 공식 문서는 이 특성을 예약 시스템 예시로 설명합니다.
“all booking requests for a venue must be serialized to prevent double-booking”
락(lock)이나 분산 트랜잭션 없이 이중 예약을 막을 수 있는 이유가 바로 이 직렬화입니다. 하지만 이건 공짜가 아닙니다. 같은 오브젝트로 향하는 모든 동기 실행은 단일 JS 스레드의 타임라인 위에서 줄을 서야 합니다. 아주 단순한 카운터 DO를 봅시다.
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
async fetch(req) {
let count = (await this.ctx.storage.get("count")) ?? 0;
count += 1;
await this.ctx.storage.put("count", count);
return Response.json({ count });
}
}
이 코드 자체는 무해해 보이지만, 같은 id로 초당 수백 건의 요청이 몰리면 이야기가 달라집니다. Cloudflare의 실전 가이드는 오브젝트 하나의 처리량을 이렇게 못박습니다.
단일 Durable Object는 연산 복잡도에 따라 대략 초당 500~1,000 요청 정도를 처리할 수 있다.
즉 “DO는 무한히 스케일된다”는 건 절반만 맞는 말입니다. 오브젝트 개수는 무한히 늘릴 수 있지만, 오브젝트 하나의 처리량은 CPU 코어 하나의 한계를 못 넘습니다. 채팅 앱에서 방마다 DO를 하나씩 두는 설계는 이 한계 안에서는 훌륭하지만, “전역 카운터 하나”, “전역 레이트 리미터 하나”처럼 모든 트래픽을 오브젝트 하나로 모으는 순간 병목이 됩니다.
‘Overloaded’ 에러, 정확한 발동 조건과 재현 방법
공식 소프트 리밋과 4가지 에러 메시지
Cloudflare 공식 limits 문서는 이렇게 명시합니다.
“An individual Object has a soft limit of 1,000 requests per second.” “A Durable Object that receives too many requests will, after attempting to queue them, return an overloaded error to the caller.”
“소프트 리밋”이라는 표현이 중요합니다. 1,000 req/s를 넘는 즉시 차단되는 하드 리밋이 아니라, 넘는 순간부터 큐잉을 시도하다가 큐 자체가 감당 못 할 때 에러를 던지는 구조입니다. 실제로 트러블슈팅 문서를 보면 overloaded 에러는 원인에 따라 4가지 메시지로 세분화되어 있습니다.
Durable Object is overloaded. Too many requests queued.— 큐에 쌓인 요청 개수 자체가 너무 많을 때Durable Object is overloaded. Too much data queued.— 큐에 쌓인 요청들의 데이터 총량이 너무 클 때Durable Object is overloaded. Requests queued for too long.— 큐에서 가장 오래 기다린 요청의 대기 시간이 초과됐을 때Durable Object is overloaded. Too many requests for the same object within a 10 second window.— 같은 오브젝트에 10초 윈도우 안에서 몰린 요청 수가 극단적으로 많을 때만 발생하는, 가장 심한 과부하 신호
마지막 메시지가 이 글 제목에서 말한 “10초 윈도우” 조건입니다. 문서는 이 메시지가 “다른 overloaded 메시지들을 대체하지 않으며, 더 극단적인 과부하 상황에서만 반환된다”고 설명합니다. 즉 여러분이 이 메시지를 본다면 이미 상당히 심각한 수준의 트래픽 쏠림이 발생했다는 뜻입니다.
또 하나 놓치기 쉬운 원인은 요청 수 자체가 아니라 I/O 지연입니다. 에러 핸들링 문서는 이렇게 씁니다.
인프라 예외의 원인은 “단일 Durable Object로 몰리는 과도한 요청” 뿐 아니라 “느리거나 과도한 I/O로 인해 요청이 큐에 쌓이는 것”도 포함된다.
즉 초당 요청 수가 1,000건보다 훨씬 적어도, 각 요청이 느린 외부 API를 기다리며 오래 걸리면 큐가 쌓이고 overloaded가 뜰 수 있습니다.
재현해보기
프로덕션에 실제로 부하를 걸어보는 건 위험하니, 스테이징 환경에서 재현하는 코드를 준비했습니다. 핵심은 같은 DO id로 짧은 시간에 요청을 몰아넣는 것입니다.
// worker.js — 부하 발생용 엔드포인트 (스테이징 전용)
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (url.pathname === "/hammer") {
const id = env.COUNTER.idFromName("shared-hot-object");
const stub = env.COUNTER.get(id);
const batch = Array.from({ length: 500 }, () =>
stub.fetch("https://do/increment").catch((e) => e)
);
const results = await Promise.allSettled(batch);
const overloaded = results.filter(
(r) => r.status === "fulfilled" && r.value?.status === 500
);
return Response.json({ sent: batch.length, overloaded: overloaded.length });
}
return new Response("ok");
},
};
주의할 점이 있습니다. Workers의 subrequest 한도는 Free 플랜 요청당 50개, Paid 플랜 요청당 10,000개이고, 응답 헤더를 기다리는 동안 동시에 열 수 있는 아웃바운드 연결은 플랜과 무관하게 6개로 제한됩니다. 즉 Promise.all로 수천 개를 한 번에 쏘는 것처럼 보여도 실제로는 6개씩 순차적으로 처리되며, Free 플랜에서는 요청 하나당 50개 이상 보낼 수도 없습니다. 진짜로 1,000 req/s를 만들고 싶다면 /hammer 엔드포인트를 autocannon이나 hey 같은 외부 부하 도구로 여러 Worker 호출을 동시에 때리는 방식이 훨씬 현실적입니다.
# 외부에서 동시성 100으로 10초간 /hammer 호출
npx autocannon -c 100 -d 10 https://your-worker.workers.dev/hammer
이렇게 하면 각 Worker 호출이 500개씩 같은 DO로 fetch를 보내고, 동시 호출이 겹치면서 실제로 Too many requests for the same object within a 10 second window 에러를 관찰할 수 있습니다. 에러 객체에는 .overloaded 속성이 붙어 있으므로, 클라이언트 쪽에서 이렇게 구분해야 합니다.
try {
const resp = await stub.fetch(req);
return resp;
} catch (e: any) {
if (e.overloaded) {
// 재시도하면 과부하가 더 심해진다 — 재시도 금지, 즉시 실패 처리
return new Response("busy", { status: 503 });
}
if (e.retryable) {
// 멱등 요청이라면 지수 백오프로 재시도 가능
}
throw e;
}
공식 문서도 명확히 경고합니다. .overloaded가 true인 에러를 재시도하면 “과부하를 악화시키고 전체 에러율을 높인다”고 말이죠.
느린 외부 API 호출이 만드는 헤드 오브 라인 블로킹
Input Gate와 Output Gate가 실제로 하는 일
여기서부터가 가장 오해가 많은 부분입니다. “DO가 싱글 스레드니까 await fetch()로 외부 API를 부르면 그 오브젝트로 가는 다른 모든 요청이 멈춘다”고 생각하기 쉬운데, 정확하지 않습니다. Cloudflare는 input gate / output gate라는 메커니즘으로 이를 세분화합니다.
- Input gate는 동기 JavaScript 실행이 진행되는 동안에만 새 이벤트(들어오는 요청, fetch 응답)를 막습니다.
await fetch()나 KV 스토리지 메서드를 기다리는 동안은 input gate가 열려 다른 요청이 끼어들어 실행될 수 있습니다.- 반면 스토리지 연산(
storage.get/put/transaction등)을 기다리는 동안은 input gate가 닫힌 채로 유지되어, 다른 요청이 끼어들 수 없습니다. - Output gate는 나가는 응답과 fetch 요청을 보류시켜, 대기 중인 스토리지 쓰기가 끝날 때까지 클라이언트가 응답을 받지 못하게 합니다 — “Message sent” 응답은 데이터가 안전하게 저장된 뒤에만 나갑니다.
즉 단순히 외부 API 하나를 await한다고 오브젝트 전체가 멈추지는 않습니다. 진짜로 다른 요청까지 물고 늘어지는 헤드 오브 라인 블로킹은 다음 세 가지 패턴에서 발생합니다.
진짜 위험한 패턴 3가지
1) blockConcurrencyWhile() 안에 느린 외부 호출을 넣는 경우. 이 메서드는 콜백이 끝날 때까지 콜백이 만든 이벤트를 제외한 모든 이벤트를 큐에 묶어둡니다. 흔한 실수는 생성자에서 “캐시를 미리 데워두자”며 외부 API를 부르는 패턴입니다.
export class RoomState extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
// 안티패턴: 느린 외부 API를 초기화 블록 안에서 기다림
this.config = await fetch("https://config-service.example.com/room").then((r) => r.json());
});
}
}
이 config 서비스가 느려지거나 잠깐 죽으면, 이 오브젝트로 가는 모든 요청이 콜백이 끝날 때까지 대기합니다. 공식 문서는 blockConcurrencyWhile에 30초 타임아웃이 있고, 초과하면 “Durable Object가 리셋된다”고 명시합니다. 콜백이 예외를 던져도 마찬가지로 오브젝트가 종료·리셋됩니다. 권장 사항은 명확합니다 — “콜백은 가능한 한 적은 일을 해야 전체 요청 처리량이 좋아진다.” SQLite 기반 스토리지는 연산이 원자적이므로 일반적인 요청 처리에서는 blockConcurrencyWhile이 거의 필요 없고, 주 용도는 생성자에서의 스키마 마이그레이션 정도로 한정하는 게 안전합니다.
2) 스토리지 연산이 몰리는 경우. await fetch()와 달리 await this.ctx.storage.get(...) 같은 호출은 input gate를 열지 않습니다. 즉 스토리지 I/O가 원인이면 요청이 진짜로 줄을 섭니다. 여러 키를 개별적으로 get()하는 대신 배치로 묶는 게 여기서 실질적인 처방입니다.
// 느림: N번의 개별 왕복, 그동안 input gate가 계속 닫힘
for (const key of keys) {
await this.ctx.storage.get(key);
}
// 빠름: 한 번의 왕복
const values = await this.ctx.storage.get(keys); // keys: Array<string>
3) 동기 CPU 연산. 큰 JSON을 파싱하거나 무거운 루프를 돌리는 동안은 애초에 await할 지점이 없으므로 input gate와 무관하게 그냥 이벤트 루프 자체가 막힙니다. DO 안에서 대용량 페이로드를 직접 처리하는 로직은 Worker 쪽으로 옮기거나 청크 단위로 쪼개는 것이 정석입니다.
해결 패턴: 요청을 여러 DO 인스턴스로 분산하는 샤딩
앞선 두 섹션의 결론은 하나로 모입니다 — 오브젝트 하나의 용량을 늘릴 수는 없으니, 오브젝트 개수를 늘려야 한다. 공식 가이드는 “모든 채팅방을 처리하는 전역 Durable Object 하나”를 명시적인 안티패턴으로 꼽으며, 필요한 오브젝트 수를 이렇게 공식화합니다.
필요한 DO 개수 = (초당 총 요청 수) / (DO 하나가 처리 가능한 요청 수)
예를 들어 게임 세션 서비스가 초당 50만 요청을 받고 오브젝트 하나가 초당 5001,000건을 처리한다면, **전역 코디네이터 1개가 아니라 5001,000개의 세션별 DO**가 필요하다는 뜻입니다. 전역 레이트 리미터를 DO 하나로 구현하는 것도 같은 이유로 위험합니다 — 모든 트래픽이 한 지점으로 몰리는 “chokepoint”가 되어 스케일이 안 됩니다.
실전에서는 사용자 ID, 룸 ID, 테넌트 ID 같은 자연스러운 파티션 키로 샤딩합니다.
// 안티패턴: 전역 싱글턴
const id = env.RATE_LIMITER.idFromName("global");
// 개선: 사용자 단위로 샤딩 — 사용자마다 독립된 오브젝트
const id = env.RATE_LIMITER.idFromName(`user:${userId}`);
// 더 세밀하게: 해시로 N개 버킷에 분산 (전역 카운터를 근사치로 집계할 때)
function shardId(key, shardCount = 64) {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return `shard:${hash % shardCount}`;
}
const id = env.COUNTER.idFromName(shardId(userId));
물론 트레이드오프가 있습니다. 샤딩하면 “정확한 전역 합계”가 필요한 로직(예: 전체 동시 접속자 수)은 더 이상 오브젝트 하나에서 즉시 읽을 수 없습니다. 이런 경우엔 각 샤드가 자신의 카운트를 alarm으로 주기적으로 상위 집계용 DO에 보고하는 팬인(fan-in) 패턴으로 절충하는 것이 일반적입니다 — 실시간 정확도 대신 수 초 단위의 근사치를 받아들이는 것이죠.
alarm()의 지수 백오프 재시도와 멱등성 없는 코드가 만드는 중복 실행 버그
“최대 6회 재시도”가 함정인 이유
alarm()은 DO 안에서 신뢰할 수 있는 예약 실행을 제공하는 API입니다. 공식 문서는 그 신뢰성의 조건을 이렇게 규정합니다.
“The alarm() handler has guaranteed at-least-once execution and will be retried upon failure using exponential backoff, starting at 2 second delays for up to 6 retries.”
즉 2초 → 4초 → 8초 → 16초 → 32초 → 64초 간격으로 최대 6번만 자동 재시도합니다. 여기서 정말 중요한 문장이 하나 더 있습니다.
“If an unexpected error terminates the Durable Object, the alarm() handler may be re-instantiated on another machine. Following a short delay, the alarm() handler will run from the beginning on the other machine.”
이 문장이 중복 실행 버그의 핵심입니다. alarm()이 예외를 던졌을 때만 재시도되는 게 아니라, DO 프로세스 자체가 (인프라 장애, 리소스 초과, 예기치 못한 종료 등으로) 죽으면 처음부터 다시 실행됩니다. 즉 alarm() 핸들러 중간에 “결제 호출은 성공했지만 그 다음 상태 저장 직전에 프로세스가 죽는” 시나리오가 실제로 가능하고, 이 경우 재실행된 alarm()은 이미 성공한 결제 호출을 다시 수행합니다.
// 위험한 패턴 — 멱등성 없음
async alarm() {
const order = await this.ctx.storage.get("pendingOrder");
if (!order) return;
// ① 이 fetch가 성공한 직후, ②로 가기 전에 프로세스가 죽으면?
await fetch("https://payments.example.com/charge", {
method: "POST",
body: JSON.stringify(order),
});
// ② 여기 도달하지 못하면 다음 실행에서 ①이 다시 일어난다
await this.ctx.storage.delete("pendingOrder");
}
문서는 deleteAlarm()을 alarm() 핸들러 안에서 호출하면 “best-effort로 재시도를 막을 수는 있지만 보장되지는 않는다”고 명시합니다. 즉 deleteAlarm()을 멱등성 대책으로 쓸 수 없습니다.
멱등성을 보장하는 패턴
가장 실용적인 방법은 외부 호출 자체를 멱등하게 만드는 것과 호출 전에 상태를 먼저 커밋해두는 것을 함께 쓰는 겁니다.
async alarm(alarmInfo) {
const order = await this.ctx.storage.get("pendingOrder");
if (!order || order.status === "charged") return;
if (alarmInfo?.isRetry) {
console.log(`retry #${alarmInfo.retryCount}, resuming order ${order.id}`);
}
// 1. 외부 호출 전에 "처리 중" 상태를 먼저 스토리지에 커밋
// (order.idempotencyKey는 최초 생성 시 한 번만 발급)
await fetch("https://payments.example.com/charge", {
method: "POST",
headers: { "Idempotency-Key": order.idempotencyKey },
body: JSON.stringify(order),
});
// 2. 성공 후에만 완료 상태로 전이
order.status = "charged";
await this.ctx.storage.put("pendingOrder", order);
await this.ctx.storage.delete("pendingOrder"); // 정리
}
핵심은 세 가지입니다.
- 외부 API에 멱등성 키를 전달한다. Stripe 등 결제 API 대부분이
Idempotency-Key헤더를 지원하므로, 같은 키로 두 번 호출해도 실제 청구는 한 번만 일어나게 만들 수 있습니다. alarmInfo.isRetry/retryCount로 재시도 여부를 로깅해서, 나중에 중복 실행이 의심될 때 추적할 수 있게 만듭니다.- 예외를 직접 잡아 자체적으로
setAlarm()을 다시 거는 방식(무한 재시도)을 쓴다면, 그만큼 멱등성 설계의 중요도가 더 커진다는 점을 기억해야 합니다. “6번 넘게 재시도하고 싶다”는 요구 자체가 “그 side effect가 정말 멱등한가”를 다시 묻게 만드는 신호입니다.
SQLite 기반 DO의 저장 용량 한도와 모니터링
Free/Paid 한도
Durable Objects는 이제 기본적으로 SQLite 기반 스토리지를 씁니다. 공식 limits 문서 기준 한도는 다음과 같습니다.
- 오브젝트 1개당 저장 용량: Free 플랜 1GB, Workers Paid 플랜 10GB
- 계정 전체 저장 용량: Free 플랜 계정 전체 5GB 합산, Paid 플랜은 사실상 무제한(오브젝트별 10GB 한도만 적용)
- 키/값 크기: 키와 값을 합쳐 2MB를 넘을 수 없음
- SQL 제약: 테이블당 최대 컬럼 100개, 문자열/BLOB/행 최대 크기 2MB, SQL 문 최대 길이 100KB
한도를 넘으면 어떻게 될까요. 공식 문서는 정확히 이렇게 설명합니다.
오브젝트가 최대 저장 한도(Paid 10GB, Free 1GB)에 도달하면,
INSERT,UPDATE,put(),sql.exec()같은 쓰기 연산은database or disk is full: SQLITE_FULL에러로 실패한다.SELECT,get(),list()같은 읽기 연산과DELETE는 계속 동작해서, 공간을 확보할 수 있게 해준다.
즉 한도 초과는 서비스 전체 중단이 아니라 “쓰기만 막히고 삭제로 복구 가능한” 형태입니다. 처리 코드는 이렇게 짜는 것이 안전합니다.
try {
this.ctx.storage.sql.exec(
"INSERT INTO my_table (key, value) VALUES (?, ?)",
key,
value,
);
} catch (e) {
if (e.message.includes("SQLITE_FULL")) {
// 저장 한도 도달 — 읽기/삭제는 여전히 가능
// 오래된 데이터를 정리하거나 호출자에게 의미 있는 에러를 반환
}
throw e;
}
용량을 미리 확인하는 방법
한도에 도달한 뒤 처리하는 것보다, 도달하기 전에 알람으로 잡는 게 훨씬 낫습니다. SQLite 스토리지 API는 현재 DB 크기를 바이트 단위로 바로 읽을 수 있는 프로퍼티를 제공합니다.
async fetch(req) {
const sizeBytes = this.ctx.storage.sql.databaseSize;
const limitBytes = 10 * 1024 * 1024 * 1024; // Paid 플랜 10GB
if (sizeBytes > limitBytes * 0.8) {
console.warn(`storage at ${(sizeBytes / limitBytes * 100).toFixed(1)}% of limit`);
// 알림 전송, 오래된 로우 정리 alarm 예약 등
}
// ...
}
계정 전체를 조망하고 싶다면, Cloudflare는 2026년 7월 20일에 Durable Objects 대시보드에 네임스페이스 단위 “Total storage” 차트를 추가했습니다. 시간당 보고된 최대 저장량을 보여주는 차트로, 증가 추세를 확인하거나 데이터 정리가 실제로 효과가 있었는지, 예상치 못한 사용량 급증이 있었는지 확인하는 용도로 쓸 수 있습니다. 다만 이 차트는 SQLite 기반 네임스페이스에만 적용되고, 개별 오브젝트 단위(ID/name별) 추적은 아직 지원하지 않으므로, 특정 오브젝트가 한도에 가까운지는 여전히 위 databaseSize 코드처럼 오브젝트 안에서 직접 확인해야 합니다.
마무리: 프로덕션 체크리스트
정리하면, DO를 프로덕션에 올리기 전에 최소한 다음을 점검하는 것을 권합니다.
- 트래픽이 몰릴 수 있는 키(전역 카운터, 전역 락, 전역 레이트 리미터)를 하나의 DO id로 몰아넣고 있지 않은지 확인한다.
blockConcurrencyWhile()콜백 안에 외부 API 호출이나 느린 연산이 들어 있지 않은지 확인한다 — 30초 타임아웃과 예외 시 리셋을 기억한다.- 클라이언트/Worker 쪽에서
.overloaded와.retryable을 구분해서 처리하고, overloaded는 재시도하지 않는다. - alarm() 안의 side effect(결제, 웹훅, 재고 차감)가 멱등한지 다시 점검하고, 가능하면 외부 API의 idempotency key를 활용한다.
- SQLite 기반 DO라면
databaseSize를 주기적으로 체크하거나 대시보드의 Total storage 차트로 계정 전체 추세를 모니터링한다.
Durable Objects는 “강한 일관성”과 “무한 확장성”을 동시에 주지 않습니다. 일관성은 오브젝트 하나의 단일 스레드 직렬화에서 나오고, 확장성은 그 오브젝트를 얼마나 잘 쪼개느냐에서 나옵니다. 이 트레이드오프를 설계 단계에서 인지하고 있으면, 위에서 다룬 함정들은 대부분 사전에 피할 수 있는 것들입니다.