Cloudflare D1과 Durable Objects: 분산 SQLite로 엣지에서 강한 일관성 경계를 설계하는 기준
솔직히 말하면, 처음 엣지 컴퓨팅을 제대로 써봤을 때 가장 먼저 발목을 잡은 건 데이터 레이어였습니다. Lambda + RDS에 익숙했던 저한테 "사용자 근처에서 코드가 실행된다"는 개념은 매력적인데, 정작 DB 쿼리는 버지니아 리전으로 돌아가는 현실이 아이러니했거든요. 엣지에서 아무리 빠르게 응답해도 DB 왕복 레이턴시가 수백 밀리초씩 붙으면 의미가 반감되니까요.
더 구체적으로는 이런 버그들을 마주쳤습니다. 장바구니에 상품을 담았는데 새로고침하면 사라지는 버그, 레이트 리미터가 설정한 한도를 가끔 조용히 초과하는 문제. 나중에 알고 보니 이게 단순한 구현 실수가 아니라 데이터 일관성 경계를 잘못 설계했을 때 나오는 증상이었습니다. Cloudflare가 D1과 Durable Objects를 내놓으면서 이 문제에 구조적으로 접근할 수 있게 됐고, Lambda + RDS 시절의 아이러니는 상당 부분 해소됐습니다.
이 글의 핵심은 어떤 데이터를 D1에 두고 어떤 데이터를 Durable Objects에 둘지 그 경계를 판단하는 기준입니다. 이커머스 상품 카탈로그, 실시간 협업 문서, 멀티테넌트 SaaS 레이트 리미터를 예시로 쓰니, 본인 서비스 구조에 대입하면서 읽어보시면 좋을 것 같습니다.
핵심 개념
D1: 엣지에서 동작하는 서버리스 SQLite
D1은 Cloudflare Workers 위에서 돌아가는 서버리스 SQL 데이터베이스입니다. 내부 구현이 SQLite라서 기존 SQL 지식을 그대로 쓸 수 있고, PostgreSQL이나 MySQL 대비 별도 학습 없이 진입할 수 있습니다.
쓰기는 단일 Primary 인스턴스가 처리하고, 2025년 4월 공개 베타에 들어간 글로벌 읽기 복제(Read Replication) 기능이 전 세계 PoP에 읽기 복제본을 자동으로 프로비저닝합니다. 한국 사용자는 도쿄 복제본에서, 유럽 사용자는 암스테르담 복제본에서 읽는 식으로 읽기 레이턴시를 대폭 낮출 수 있습니다.
// D1 기본 쿼리 — 가장 가까운 복제본에서 읽기
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const products = await env.DB.prepare(
"SELECT id, name, price FROM products WHERE active = 1"
).all();
return Response.json(products.results);
},
};복제 기반 읽기에서 주의할 점이 있습니다. "방금 장바구니에 추가했는데 왜 안 보이지?"라는 버그가 바로 여기서 나옵니다. 쓰기는 Primary에서 처리하고, 읽기는 아직 동기화 전인 복제본에서 할 수 있거든요. D1은 이 문제를 세션 일관성(Session Consistency)으로 구조적으로 막아줍니다.
세션 일관성: 내가 이 세션에서 쓴 데이터는 반드시 이 세션 내에서 읽힌다는 보장입니다. Lamport 타임스탬프와
bookmark파라미터로 구현됩니다.withSession("first-primary")에서"first-primary"는 해당 세션이 쓰기와 동일한 Primary 읽기 일관성 수준을 유지하도록 지정하는 모드 값입니다. 그러므로 쓰기 직후 즉시 읽어야 하는 플로우라면 기본 세션 대신 이 모드를 사용하는 것이 좋습니다.
// 세션 일관성이 필요한 경우 — 쓰기 직후 즉시 읽기
const session = env.DB.withSession("first-primary");
await session
.prepare("INSERT INTO cart_items (user_id, product_id) VALUES (?, ?)")
.bind(userId, productId)
.run();
// 동일 세션 → 방금 쓴 데이터가 반드시 읽힘
const cart = await session
.prepare("SELECT * FROM cart_items WHERE user_id = ?")
.bind(userId)
.all();Durable Objects: 강한 일관성이 필요한 상태의 격리 단위
Durable Objects(DO)는 상태를 가지는 서버리스 컴퓨트 단위입니다. 각 인스턴스는 단일 스레드로 동작하며, 내부 스토리지로 SQLite를 사용합니다. "인스턴스 하나 = 논리적 엔티티 하나"가 핵심 설계 원칙입니다.
저도 처음엔 헷갈렸는데, DO의 가장 중요한 특성은 강한 직렬화 가능성(Strict Serializability) 입니다. 동시에 여러 요청이 들어와도 인스턴스 내부에서 자동으로 순서대로 처리됩니다. 레이스 컨디션이 구조적으로 발생하지 않는다는 뜻이고, 이게 레이트 리미터나 실시간 협업 같은 시나리오에서 결정적인 차이를 만듭니다.
Strict Serializability: 모든 연산이 전역적으로 단일 순서를 가지며, 실시간 순서도 반영되는 가장 강한 수준의 일관성 보장입니다. DO가 이를 단일 스레드 모델로 구현합니다. 동시 쓰기 충돌이 발생할 수 있는 데이터(카운터, 공유 문서, 실시간 세션 상태)라면 D1보다 DO를 선택하는 것이 적합합니다.
두 기술의 본질적 차이
| 기준 | D1 | Durable Objects |
|---|---|---|
| 일관성 수준 | 세션 기반 순차 일관성 | 강한 직렬화 가능성 |
| 쓰기 구조 | 단일 Primary 집중 | 인스턴스별 격리 |
| 읽기 구조 | 글로벌 복제본 분산 | 인스턴스 로컬 |
| 스케일 단위 | DB 단위 | 엔티티 단위 |
| 운영 도구 | 마이그레이션·인사이트 내장 | 직접 구축 필요 |
| 적합한 데이터 | 공유 읽기, 낮은 쓰기 충돌 | 단일 엔티티 동시 쓰기 조율 |
이 표를 보면 판단 기준이 자연스럽게 나옵니다. 읽기가 많고 쓰기 충돌이 낮은 데이터는 D1, 단일 엔티티에 대한 동시 쓰기를 조율해야 하는 데이터는 DO입니다. 앞에서 정리한 이 기준을 실제 코드로 확인해보겠습니다.
실전 적용
예시 1: 이커머스 글로벌 상품 카탈로그 — D1 읽기 복제 활용
실무에서 자주 맞닥뜨리는 상황인데, 이커머스 상품 조회는 읽기가 압도적으로 많고 재고 변경이나 가격 수정 같은 쓰기는 상대적으로 드뭅니다. D1의 글로벌 읽기 복제가 딱 맞는 케이스입니다. 상품 목록은 가장 가까운 복제본에서 빠르게 읽고, 장바구니 추가처럼 "방금 쓴 걸 즉시 읽어야 하는" 플로우에만 세션 일관성을 붙이는 방식입니다.
아래 예시는 Hono(Node.js의 Express와 유사한 경량 웹 프레임워크)와 Drizzle ORM(D1·DO SQLite를 모두 지원하는 타입 안전 ORM)을 사용합니다.
// src/routes/products.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { products, inventory, cartItems } from "../schema";
import { eq } from "drizzle-orm";
const app = new Hono<{ Bindings: Env }>();
// 상품 목록 — 가장 가까운 복제본에서 읽기 (빠름)
app.get("/products", async (c) => {
const db = drizzle(c.env.DB);
const items = await db
.select()
.from(products)
.where(eq(products.active, true));
return c.json(items);
});
// 장바구니 추가 후 즉시 확인 — 세션 일관성 필요
app.post("/cart", async (c) => {
const { productId, userId } = await c.req.json();
// "first-primary": 쓰기와 동일한 Primary 읽기 일관성 보장
const session = c.env.DB.withSession("first-primary");
const db = drizzle(session);
// 최신 재고 확인 (복제본 지연 허용 불가)
const [item] = await db
.select()
.from(inventory)
.where(eq(inventory.productId, productId));
if (item.stock < 1) {
return c.json({ error: "재고 없음" }, 409);
}
// 재고 차감 + 장바구니 추가 원자적 실행
await db.batch([
db
.update(inventory)
.set({ stock: item.stock - 1 })
.where(eq(inventory.productId, productId)),
db.insert(cartItems).values({ userId, productId }),
]);
// 동일 세션 → 방금 추가한 항목이 반드시 조회됨
const cart = await db
.select()
.from(cartItems)
.where(eq(cartItems.userId, userId));
return c.json(cart);
});
export default app;| 코드 포인트 | 설명 |
|---|---|
drizzle(c.env.DB) |
기본 세션 — 가장 가까운 복제본에서 읽기 |
DB.withSession("first-primary") |
Primary와 동일 읽기 일관성 보장 |
db.batch([...]) |
D1 배치 트랜잭션 — 원자적 실행 |
예시 2: 실시간 협업 문서 — Durable Objects 단독 활용
문서 편집은 다릅니다. 동일 문서에 여러 명이 동시에 타이핑하면 충돌 조율이 필요합니다. "문서 1개 = DO 인스턴스 1개"로 설계하면 모든 편집 연산이 단일 스레드에서 직렬화되어 충돌이 구조적으로 발생하지 않습니다.
WebSocket 처리 방식을 두 가지로 볼 때가 많은데, private sessions: Set<WebSocket>으로 직접 추적하는 방법과 this.ctx.getWebSockets()를 사용하는 방법이 있습니다. 프로덕션에서는 DO Hibernation API와 함께 동작하는 getWebSockets()가 올바른 패턴입니다. Hibernation을 활용하면 유휴 연결을 메모리에서 내리고 나중에 재활성화할 수 있어서 비용 효율이 높습니다.
// src/durable-objects/document.ts
import { DurableObject } from "cloudflare:workers";
interface EditOperation {
userId: string;
position: number;
text: string;
type: "insert" | "delete";
timestamp: number;
}
interface Operation {
op_type: string;
position: number;
content: string | null;
}
export class DocumentDO extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
op_type TEXT NOT NULL,
position INTEGER NOT NULL,
content TEXT,
applied_at INTEGER NOT NULL
)
`);
});
}
async fetch(request: Request): Promise<Response> {
if (request.headers.get("Upgrade") === "websocket") {
const [client, server] = Object.values(new WebSocketPair());
// acceptWebSocket + getWebSockets() → Hibernation API 패턴
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("Not found", { status: 404 });
}
// Hibernation API: WebSocket 메시지는 이 메서드로 라우팅됨
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
const op: EditOperation = JSON.parse(message);
// 단일 스레드 — 동시 편집 요청이 와도 순서대로 처리됨
this.ctx.storage.sql.exec(
`INSERT INTO operations (user_id, op_type, position, content, applied_at)
VALUES (?, ?, ?, ?, ?)`,
op.userId,
op.type,
op.position,
op.text,
op.timestamp
);
const ops = [
...this.ctx.storage.sql.exec<Operation>(
`SELECT op_type, position, content FROM operations ORDER BY applied_at ASC`
),
];
// ⚠️ 단순화된 데모 구현 (O(n) 재적용)
// 실제 프로덕션에서는 OT(Operational Transformation)나 CRDT가 필요합니다
const content = this.reconstructDocument(ops);
for (const socket of this.ctx.getWebSockets()) {
if (socket !== ws) {
socket.send(JSON.stringify({ type: "op_applied", op, content }));
}
}
}
private reconstructDocument(ops: Operation[]): string {
let doc = "";
for (const op of ops) {
if (op.op_type === "insert") {
doc = doc.slice(0, op.position) + op.content + doc.slice(op.position);
} else {
doc =
doc.slice(0, op.position) +
doc.slice(op.position + (op.content?.length ?? 0));
}
}
return doc;
}
}
// src/index.ts — Worker에서 DO 라우팅
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const docId = url.searchParams.get("docId");
if (!docId) return new Response("docId required", { status: 400 });
// 동일 docId → 항상 동일 DO 인스턴스로 라우팅
const id = env.DOCUMENT_DO.idFromName(`doc:${docId}`);
return env.DOCUMENT_DO.get(id).fetch(request);
},
};예시 3: 멀티테넌트 SaaS — D1 + DO 병용 설계
이게 실무에서 제일 흥미로운 패턴입니다. D1과 DO를 각자의 강점에 맞게 역할 분담하는 구조인데, 기준이 명확해서 한 번 이해하면 여러 곳에 응용할 수 있습니다.
┌─────────────────────────────────────────────────────┐
│ Cloudflare Workers │
│ │
│ 요청 라우팅 │
│ │ │
│ ├─── 공유 데이터 ──────▶ D1 글로벌 DB │
│ │ (회원, 플랜, 청구) (읽기 복제본 분산) │
│ │ │
│ ├─── 테넌트 격리 데이터 ▶ DO per Tenant │
│ │ (테넌트별 SQLite) (독립 인스턴스) │
│ │ │
│ └─── API 레이트 리밋 ──▶ DO per API Key │
│ (슬라이딩 윈도우) (단일 스레드 카운터) │
└─────────────────────────────────────────────────────┘레이트 리미터 DO 인스턴스는 이미 API 키 단위로 격리(idFromName("key:${apiKey}"))되어 있기 때문에, DO 내부 메서드에 별도로 API 키를 넘길 필요가 없습니다. 인스턴스 자체가 식별자 역할을 합니다.
// src/durable-objects/rate-limiter.ts
import { DurableObject } from "cloudflare:workers";
export class RateLimiterDO extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL
)
`);
});
}
// DO 인스턴스 자체가 API 키 단위로 격리되므로 파라미터로 받을 필요 없음
async checkLimit(limit: number, windowMs: number): Promise<boolean> {
const now = Date.now();
const windowStart = now - windowMs;
this.ctx.storage.sql.exec(
`DELETE FROM requests WHERE timestamp < ?`,
windowStart
);
const result = this.ctx.storage.sql
.exec<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM requests`)
.one();
if (result.cnt >= limit) return false;
this.ctx.storage.sql.exec(
`INSERT INTO requests (timestamp) VALUES (?)`,
now
);
return true;
}
}
// src/middleware/rate-limiter.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { tenants } from "../schema";
import { eq } from "drizzle-orm";
const app = new Hono<{ Bindings: Env }>();
app.use("*", async (c, next) => {
const apiKey = c.req.header("X-API-Key");
if (!apiKey) return c.json({ error: "API key required" }, 401);
// API 키당 DO 인스턴스 — 강한 일관성으로 정확한 카운팅
const id = c.env.RATE_LIMITER.idFromName(`key:${apiKey}`);
const limiter = c.env.RATE_LIMITER.get(id);
const allowed = await limiter.checkLimit(100, 60_000);
if (!allowed) return c.json({ error: "Rate limit exceeded" }, 429);
// 테넌트 정보는 D1에서 읽기 (복제본 OK — 쓰기 충돌 없음)
const db = drizzle(c.env.DB);
const [tenant] = await db
.select()
.from(tenants)
.where(eq(tenants.apiKey, apiKey));
c.set("tenant", tenant);
await next();
});
export default app;장단점 분석
장점
| 항목 | 내용 |
|---|---|
| SQLite 호환 | 기존 SQL 지식 재사용, 별도 학습 없이 진입 가능 |
| 글로벌 읽기 성능 | D1 읽기 복제로 전 세계 사용자에게 낮은 레이턴시 |
| 레이스 컨디션 제거 | DO 단일 스레드로 동시성 버그 구조적 방지 |
| 컴퓨트-스토리지 공존 | DO 내부 SQLite는 네트워크 없이 마이크로초 접근 |
| 관리 도구 내장 | D1은 마이그레이션, 쿼리 인사이트, HTTP API 내장 |
| 무료 플랜 지원 | Workers Free 플랜에서 D1과 DO 모두 사용 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| D1 10 GB 한도 | 단일 DB 최대 10 GB | per-user 또는 per-tenant 수평 샤딩 |
| D1 쓰기 QPS 한계 | 평균 1 ms 기준 최대 ~1,000 QPS | 고빈도 쓰기 데이터는 DO로 분리 |
| D1 쓰기 지연 | 모든 쓰기는 Primary 리전 경유 | 쓰기 빈도가 낮은 데이터에만 D1 사용 |
| DO 운영 도구 부재 | 마이그레이션, 스키마 관리 미내장 | D1 Manager 등 외부 도구 또는 직접 구축 |
| DO 위치 고정 | 인스턴스 생성 시 리전 결정, 이후 이동 불가 | 사용자 위치 기반 초기 인스턴스 생성 |
| DO 병렬 처리 불가 | 단일 스레드로 CPU 집약 작업 부적합 | 헤비 연산은 Worker에서 처리 후 DO에 결과만 저장 |
실무에서 가장 흔한 실수
실수 얘기를 하면 저 스스로가 생각납니다. 초반에 첫 번째 실수를 직접 겪었거든요. 전 세계 트래픽이 하나의 DO 인스턴스로 몰리면서 레이턴시가 튀는 걸 모니터링에서 보고서야 "아, 이게 그 병목이구나" 깨달았습니다.
-
DO를 글로벌 단위로 설계하는 실수 —
getByName("global-counter")처럼 모든 요청이 단일 DO 인스턴스로 몰리면 전 세계 트래픽이 하나의 스레드에 병목됩니다. "논리적 단위 하나당 인스턴스 하나" 원칙이 중요합니다. 사용자 ID, 문서 ID, API 키처럼 엔티티를 구분하는 식별자로 인스턴스를 나누는 것이 좋습니다. -
D1에 고빈도 쓰기 데이터를 모두 넣는 실수 — 레이트 리미터, 실시간 카운터, 세션 상태처럼 초당 수백 번 쓰는 데이터는 D1의 쓰기 QPS 한계를 금방 초과합니다. 실제로
overloaded에러를 만나보기 전엔 대수롭지 않게 여기기 쉬운데, 이런 데이터는 DO 내부 SQLite에 두는 것이 자연스럽습니다. -
세션 일관성이 필요한 곳에 기본 세션을 쓰는 실수 — "방금 추가한 데이터가 조회가 안 된다"는 버그가 여기서 옵니다. 재현이 간헐적이라 디버깅하기 까다롭습니다. 쓰기 직후 즉시 읽어야 하는 플로우에는
db.withSession("first-primary")나 bookmark를 챙겨주는 것이 좋습니다.
마치며
Lambda + RDS 시절 아이러니했던 건 결국 "엣지에서 실행되지만 데이터는 중앙으로"라는 구조였는데, D1과 Durable Objects는 그 구조 자체를 바꿉니다. D1과 Durable Objects는 경쟁 관계가 아니라 일관성 요구 수준에 따라 역할을 나눠 쓰는 두 개의 레이어입니다. D1은 전 세계적으로 공유되는 읽기 중심 데이터에, DO는 단일 엔티티에 대한 동시 쓰기를 조율해야 할 때 선택하는 것이 핵심 판단 기준입니다.
지금 바로 시작해볼 수 있는 3단계:
-
D1 세션 일관성을 직접 비교해보시면 좋습니다. 동일한 쓰기 후에 기본 세션과
"first-primary"세션으로 각각 읽어서 응답 차이를 확인해보시면 세션 일관성의 동작 방식을 체감할 수 있습니다. -
간단한 카운터 API를 DO로 직접 구현해보시면 좋습니다.
DurableObject클래스를 상속해서fetch()메서드 하나만 구현하면 됩니다. 동일 이름(idFromName)으로 여러 번 동시 요청을 보내서 카운트가 정확히 올라가는 걸 확인하면 DO의 직렬화 보장을 실감할 수 있습니다. -
본인 서비스의 데이터 모델을 D1과 DO 기준으로 분류해보시면 좋습니다. 각 데이터가 "동시 쓰기 충돌이 발생할 수 있는가?"라는 질문에 예/아니오로 분류되면 어디에 둘지 자연스럽게 결정됩니다.
참고 자료
- Cloudflare D1 공식 문서 | Cloudflare
- Sequential consistency without borders: how D1 implements global read replication | Cloudflare Blog
- D1 글로벌 읽기 복제 베스트 프랙티스 | Cloudflare
- Cloudflare Durable Objects 공식 문서 | Cloudflare
- Rules of Durable Objects | Cloudflare
- Durable Objects storage 접근 가이드 | Cloudflare
- Workers 스토리지 옵션 선택 가이드 | Cloudflare
- Building D1: a Global Database | Cloudflare Blog
- D1 Read Replication Public Beta Changelog | Cloudflare
- Cloudflare D1 Read Replication for e-commerce 튜토리얼 | Cloudflare
- One Database Per User with Durable Objects and Drizzle ORM | Boris Tane
- Using Cloudflare Durable Objects with SQL Storage, D1, and Drizzle ORM | Flashblaze
- Drizzle ORM - Cloudflare Durable Objects 연결 공식 가이드 | Drizzle
- Hono - Cloudflare Durable Objects 예제 | Hono
- Cloudflare Fullstack Reference Architecture | Cloudflare
- D1 Limits 공식 문서 | Cloudflare
- Scaling Cloudflare D1 from 10 GB to 500 GB with Manual Database Sharding | Medium
- Cloudflare Upgrades D1 Database with Global Read Replication | InfoQ
- Chapter 12: D1 SQLite at the Edge | Architecting on Cloudflare
- fullstack-next-cloudflare 템플릿 | GitHub