Redis와 WebSocket 서버 없이 엣지에서 채팅·분산 락 구현하기 — Cloudflare Durable Objects 실전 가이드
서버리스로 채팅 기능을 붙이려고 했을 때, 저도 처음엔 Redis Pub/Sub에 Socket.io 서버 하나 띄우는 방법부터 떠올렸습니다. 그게 익숙한 패턴이었으니까요. 그런데 Workers 위에서 그 스택을 올리려는 순간 바로 벽에 부딪혔습니다. Workers는 무상태(stateless) 함수라 연결 상태를 직접 들고 있을 수 없고, Pub/Sub용 Redis 인스턴스를 어딘가에 별도로 두면 엣지의 레이턴시 이점이 사라집니다. Redis까지 네트워크 홉이 생기는 순간 엣지에서 실행하는 의미가 절반쯤 날아가거든요.
Cloudflare Durable Objects(DO) 는 이 문제를 뒤집는 방식으로 접근합니다. "무상태 Worker에 상태를 어떻게 붙일까"가 아니라, 처음부터 영구 상태와 전용 SQLite를 가진 단일 인스턴스를 만들어버립니다. 채팅 룸 하나 = DO 인스턴스 하나, 문서 하나 = DO 인스턴스 하나. 해당 룸에 대한 모든 요청이 단일 인스턴스 하나로 직렬화되기 때문에, Redis의 SETNX나 Lua 스크립트로 힘들게 만들던 분산 락이 구조적으로 불필요해집니다.
이 글에서는 DO의 핵심 동작 원리부터, WebSocket Hibernation을 활용한 채팅 룸 구현, SQLite 기반 분산 락까지 실제 코드와 함께 살펴봅니다. 2025년 4월부터 Free 플랜에서도 DO를 쓸 수 있게 됐고, SQLite 스토리지 백엔드도 GA로 정식 제공됩니다. 단, SQLite 사용량 과금이 2026년 1월부터 적용되고 있으니, 도입 전 공식 pricing 페이지에서 플랜별 한도를 반드시 확인하세요.
핵심 개념
DO는 "엔티티당 하나의 싱글스레드 프로세스"다
일반 Workers는 요청마다 독립적인 V8 인스턴스에서 실행됩니다. 상태를 공유하려면 KV, D1, Redis 같은 외부 스토어가 필요하죠. DO는 다릅니다. 전역적으로 유일한 ID를 가진 단일 인스턴스가 생성되고, 그 인스턴스가 자체 메모리와 SQLite를 보유하면서 단일 스레드로 요청을 처리합니다.
단일 스레드 실행이 중요한 이유는 race condition 제거입니다. 두 사람이 동시에 같은 채팅 룸에 메시지를 보내도, 해당 룸의 DO 인스턴스는 요청을 하나씩 처리합니다. 별도의 락 없이도 읽기-수정-쓰기 사이클이 깨지지 않습니다.
여러 Workers 엣지 노드에서 들어오는 요청이 모두 하나의 DO로 수렴합니다. Cloudflare 네트워크가 ID 기반으로 올바른 인스턴스로 라우팅해주고, 그 인스턴스 내부에서는 직렬 처리가 보장됩니다.
idFromName()이 전역 라우팅을 결정하는 방식
DO 인스턴스는 idFromName("room-123")처럼 이름 기반으로 ID를 생성합니다. 이 ID는 콘텐츠 어드레싱(content-addressed) 방식으로 동작합니다. 같은 이름을 넣으면 전 세계 어느 Worker 엣지 노드에서 호출하더라도 항상 동일한 ID가 나옵니다. Cloudflare 네트워크는 이 ID를 기준으로 요청을 인스턴스 하나로 수렴시킵니다.
결과적으로 idFromName(roomId)를 쓰는 코드는 "이 룸 ID에 해당하는 인스턴스가 지구 어딘가 하나 있고, 요청은 반드시 그 인스턴스로 간다"는 보장을 받습니다. 여기서 파생되는 핵심 설계 원칙이 있습니다. DO를 전체 앱에서 하나만 쓰면 안 됩니다. 채팅 룸, 문서, 주문처럼 자연스러운 엔티티를 키로 인스턴스를 분산해야 합니다. 이 원칙을 어기는 실수는 장단점 섹션에서 다시 다룹니다.
내장 SQLite — 네트워크 홉 없는 스토리지
각 DO 인스턴스는 전용 SQLite DB를 가지며 sql.exec()로 직접 SQL을 실행할 수 있습니다. 같은 인스턴스 내에서 스토리지 접근이 일어나기 때문에 외부 DB 서버로 향하는 네트워크 홉이 없습니다. SQLite 백엔드를 사용하려면 wrangler.toml에서 해당 클래스를 new_sqlite_classes에 등록해야 합니다.
# wrangler.toml
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[durable_objects]
new_sqlite_classes = ["ChatRoom"]export class ChatRoom extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
body TEXT NOT NULL,
ts INTEGER NOT NULL
)
`);
}
}WebSocket Hibernation — 연결은 유지하되 과금은 줄인다
채팅 룸처럼 클라이언트가 연결은 맺고 있지만 메시지를 보내지 않는 유휴 구간이 길면, DO가 메모리를 점유하는 동안 Duration 과금이 계속 발생합니다.
Hibernation은 이 문제를 해결합니다. DO가 일정 시간 유휴 상태면 메모리에서 내려오지만 WebSocket 연결 자체는 Cloudflare 네트워크가 대신 보유합니다. 새 메시지가 오면 DO가 재활성화되어 처리합니다.
Hibernation을 활성화하려면 addEventListener 방식이 아닌, 클래스 메서드로 WebSocket 이벤트 핸들러를 정의해야 합니다.
export class ChatRoom extends DurableObject {
// Hibernation API — 클래스 메서드 방식으로만 활성화됩니다
async webSocketOpen(ws: WebSocket) { /* 새 연결 수립 시 최초 1회 */ }
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { /* 메시지 수신 */ }
async webSocketClose(ws: WebSocket, code: number, reason: string) { /* 연결 종료 */ }
async webSocketError(ws: WebSocket, error: unknown) { /* 오류 발생 */ }
// 이렇게 하면 Hibernation이 꺼집니다
// server.addEventListener('message', handler) ← 사용하지 마세요
}webSocketOpen은 연결이 처음 수립될 때 한 번 호출됩니다. Hibernation 재활성화 시에는 호출되지 않으므로, 신규 접속자에게 히스토리를 전송하는 등 "연결 초기화" 로직을 여기에 두기 적합합니다.
실전 적용
시나리오 1: 실시간 채팅 룸
각 룸은 독립적인 DO 인스턴스가 되고, 해당 룸의 모든 WebSocket 연결이 그 인스턴스에서 관리됩니다.
// chat-room.ts
import { DurableObject } from "cloudflare:workers";
interface Env {
CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}
interface SessionMeta {
userId: string;
username: string;
}
export class ChatRoom extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
body TEXT NOT NULL,
ts INTEGER NOT NULL
)
`);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/websocket") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("WebSocket 업그레이드 필요", { status: 426 });
}
const userId = url.searchParams.get("userId") ?? crypto.randomUUID();
const username = url.searchParams.get("username") ?? "익명";
const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
// Hibernation 활성화 — acceptWebSocket 사용
this.ctx.acceptWebSocket(server);
// Hibernation 사이클 사이에도 세션 정보 보존
server.serializeAttachment({ userId, username } satisfies SessionMeta);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("Not Found", { status: 404 });
}
// 연결 수립 직후 최초 1회 호출 — 히스토리 전송에 적합한 시점
async webSocketOpen(ws: WebSocket) {
const history = [...this.ctx.storage.sql.exec(
"SELECT user, body, ts FROM messages ORDER BY id DESC LIMIT 50"
)].reverse();
ws.send(JSON.stringify({ type: "history", messages: history }));
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const meta = ws.deserializeAttachment() as SessionMeta;
const { body } = JSON.parse(message as string);
const ts = Date.now();
this.ctx.storage.sql.exec(
"INSERT INTO messages (user, body, ts) VALUES (?, ?, ?)",
meta.username,
body,
ts
);
const payload = JSON.stringify({ type: "message", user: meta.username, body, ts });
for (const peer of this.ctx.getWebSockets()) {
try {
peer.send(payload);
} catch {
// 이미 닫힌 연결은 무시
}
}
}
async webSocketClose(ws: WebSocket, code: number) {
ws.close(code, "연결 종료");
}
async webSocketError(ws: WebSocket, _error: unknown) {
ws.close(1011, "서버 오류");
}
}// worker.ts — 라우터 역할
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") ?? "general";
// 같은 roomId → 전 세계 어디서 호출해도 항상 같은 DO 인스턴스
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
return stub.fetch(request);
},
};클라이언트는 Worker를 통해 처음 WebSocket 연결을 맺고, 이후 메시지는 Cloudflare 네트워크가 직접 해당 DO 인스턴스로 전달합니다.
Redis Pub/Sub이나 별도 WebSocket 게이트웨이 없이 팬아웃이 DO 내부에서 완결됩니다.
시나리오 2: 분산 락 구현
DO의 단일 스레드 특성을 이용하면 분산 락을 깔끔하게 만들 수 있습니다.
라우팅 전략을 먼저 결정해야 합니다. 여기서는 리소스당 DO 인스턴스 하나 방식을 사용합니다(idFromName(resource)). 각 인스턴스는 자신이 담당하는 락 하나만 관리하므로 SQLite 테이블도 단순하게 구성합니다.
# wrangler.toml에 추가
[[durable_objects.bindings]]
name = "DISTRIBUTED_LOCK"
class_name = "DistributedLock"
[durable_objects]
new_sqlite_classes = ["ChatRoom", "DistributedLock"]// distributed-lock.ts
import { DurableObject } from "cloudflare:workers";
interface Env {
DISTRIBUTED_LOCK: DurableObjectNamespace<DistributedLock>;
}
export class DistributedLock extends DurableObject {
private static readonly DEFAULT_TTL_MS = 30_000;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// 인스턴스당 락 하나 — 단일 행 테이블
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS lock_state (
owner TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const action = url.pathname.slice(1);
const { owner, ttlMs } = await request.json<{ owner: string; ttlMs?: number }>();
switch (action) {
case "acquire": return Response.json(await this.acquire(owner, ttlMs));
case "release": return Response.json(await this.release(owner));
case "renew": return Response.json(await this.renew(owner, ttlMs));
default: return new Response("Not Found", { status: 404 });
}
}
async acquire(
owner: string,
ttlMs = DistributedLock.DEFAULT_TTL_MS
): Promise<{ acquired: boolean }> {
const expiresAt = Date.now() + ttlMs;
const acquired = await this.ctx.storage.transaction(async () => {
const existing = [...this.ctx.storage.sql.exec(
"SELECT expires_at FROM lock_state LIMIT 1"
)][0] as { expires_at: number } | undefined;
if (existing && existing.expires_at > Date.now()) {
return false;
}
// 만료됐거나 없는 경우 — 교체 삽입
this.ctx.storage.sql.exec("DELETE FROM lock_state");
this.ctx.storage.sql.exec(
"INSERT INTO lock_state (owner, expires_at) VALUES (?, ?)",
owner,
expiresAt
);
return true;
});
if (acquired) {
// TTL 만료 시 알람으로 자동 정리 — 외부 cron 불필요
await this.ctx.storage.setAlarm(expiresAt + 100);
}
return { acquired };
}
async release(owner: string): Promise<{ released: boolean }> {
return await this.ctx.storage.transaction(async () => {
const existing = [...this.ctx.storage.sql.exec(
"SELECT owner FROM lock_state LIMIT 1"
)][0] as { owner: string } | undefined;
if (!existing || existing.owner !== owner) {
return { released: false };
}
this.ctx.storage.sql.exec("DELETE FROM lock_state");
return { released: true };
});
}
async renew(
owner: string,
ttlMs = DistributedLock.DEFAULT_TTL_MS
): Promise<{ renewed: boolean }> {
const expiresAt = Date.now() + ttlMs;
const result = [...this.ctx.storage.sql.exec(
"UPDATE lock_state SET expires_at = ? WHERE owner = ? RETURNING owner",
expiresAt,
owner
)];
if (result.length > 0) {
// 갱신된 만료 시간에 맞춰 알람도 재설정
await this.ctx.storage.setAlarm(expiresAt + 100);
return { renewed: true };
}
return { renewed: false };
}
async alarm() {
this.ctx.storage.sql.exec(
"DELETE FROM lock_state WHERE expires_at <= ?",
Date.now()
);
}
}락 획득부터 TTL 만료까지의 흐름입니다.
클라이언트가 비정상 종료되어 release를 보내지 못해도 TTL 30초 후 알람이 발화해 락이 자동 해제됩니다. renew()도 알람을 재설정하므로, 갱신 후 비정상 종료가 발생해도 갱신된 만료 시점에 정리가 보장됩니다.
시나리오 3: Workers RPC로 타입 안전한 DO 호출
HTTP로 DO를 호출하는 방식 대신, Workers RPC를 사용하면 다른 Worker에서 DO 메서드를 직접 메서드 호출 형태로 쓸 수 있습니다. 둘 중 어느 방식을 선택할지는 상황에 따라 다릅니다.
| 기준 | HTTP fetch | Workers RPC |
|---|---|---|
| 타입 안전성 | 수동 직렬화/역직렬화 필요 | 컴파일 타임 타입 체크 |
| 호출 오버헤드 | JSON 직렬화 + HTTP 파싱 | 직렬화 최소화 |
| 에러 처리 | HTTP 상태 코드 분기 | 예외를 그대로 catch |
| 적합한 상황 | 외부 노출이나 다국어 환경 | 같은 Cloudflare 서비스 내부 호출 |
RPC를 사용할 때는 DO 메서드가 반드시 public이어야 합니다. Workers RPC 스텁은 public 메서드만 노출합니다.
// lock-service.ts — RPC 노출용 퍼사드
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
DISTRIBUTED_LOCK: DurableObjectNamespace<DistributedLock>;
}
export class LockService extends WorkerEntrypoint<Env> {
async acquire(resource: string, owner: string, ttlMs?: number) {
const stub = this.env.DISTRIBUTED_LOCK.get(
this.env.DISTRIBUTED_LOCK.idFromName(resource)
);
return stub.acquire(owner, ttlMs);
}
async release(resource: string, owner: string) {
const stub = this.env.DISTRIBUTED_LOCK.get(
this.env.DISTRIBUTED_LOCK.idFromName(resource)
);
return stub.release(owner);
}
async renew(resource: string, owner: string, ttlMs?: number) {
const stub = this.env.DISTRIBUTED_LOCK.get(
this.env.DISTRIBUTED_LOCK.idFromName(resource)
);
return stub.renew(owner, ttlMs);
}
}// 호출하는 Worker 쪽
export default {
async fetch(request: Request, env: Env) {
// HTTP 직렬화 없이 타입 안전한 호출
const { acquired } = await env.LOCK_SERVICE.acquire("order-42", "worker-abc");
if (!acquired) {
return new Response("락 획득 실패", { status: 409 });
}
// ... 임계 구역 처리 ...
await env.LOCK_SERVICE.release("order-42", "worker-abc");
return new Response("완료");
},
};장단점 분석
어떤 상황에서 DO가 빛나는가
| 항목 | 내용 |
|---|---|
| Redis·WebSocket 서버 제거 | 별도 인프라 없이 상태 관리와 실시간 통신이 가능해 운영 복잡도가 크게 줄어듭니다 |
| 구조적 race condition 제거 | 단일 스레드 실행으로 공유 상태 접근이 직렬화됩니다. 별도 락 로직이 필요 없습니다 |
| 제로 레이턴시 SQLite | 동일 인스턴스 내에서 스토리지 접근이 일어나므로 네트워크 홉이 없습니다 |
| WebSocket Hibernation | 유휴 시 DO가 메모리에서 제거되어 Duration 과금이 중단되지만 연결은 유지됩니다 |
| 알람 내장 | 세션 만료, 락 TTL 정리 등을 외부 cron 없이 처리할 수 있습니다 |
| Free 플랜 포함 | 2025년 4월부터 DO가 무료 플랜에 포함됩니다. 한도 상세는 pricing 페이지를 확인하세요 |
주의해야 할 상황
| 항목 | 내용 |
|---|---|
| 단일 인스턴스 처리량 한계 | 하나의 DO 인스턴스는 처리량에 상한이 있습니다. 공식 문서에서 현행 한도를 확인하고, 트래픽이 하나의 엔티티로 극단적으로 집중되는 구조라면 샤딩이나 다른 접근을 검토하세요 |
| 메모리 128MB 제한 | Workers 전반의 제약으로, 인메모리 대용량 캐시로 쓰기는 어렵습니다 |
| Cloudflare 플랫폼 종속 | 타 클라우드나 자체 호스팅 환경에서는 실행할 수 없습니다 |
| SQLite 과금 | 2026년 1월부터 적용 중입니다. 장기 대용량 저장이 필요하다면 D1이나 R2 병행을 고려하세요 |
| 지역 배치 | DO는 기본적으로 첫 요청 위치 근처에 생성됩니다. location hint로 대략적인 지역을 유도할 수 있으며, 세부 동작은 공식 문서를 참고하세요 |
실무에서 자주 보이는 실수
안티패턴 1: DO를 글로벌 싱글턴으로 쓰기
// 모든 요청이 하나의 인스턴스로 몰림 — 병목
const id = env.CHAT_ROOM.idFromName("global-singleton");
// 엔티티를 키로 인스턴스를 분산 — 이렇게 써야 합니다
const id = env.CHAT_ROOM.idFromName(roomId);안티패턴 2: blockConcurrencyWhile()의 잘못된 사용
blockConcurrencyWhile과 transaction()은 목적이 다른 도구입니다. 스토리지 원자성이 필요하다면 transaction()을 써야 합니다. blockConcurrencyWhile은 외부 I/O가 포함된 초기화 구간처럼, DO가 다른 Promise를 처리할 수 있는 시점에 새 요청의 진입 자체를 막아야 할 때 씁니다. 큰 작업을 blockConcurrencyWhile로 감싸면 그 안에서는 다른 요청이 전혀 처리되지 않으므로 전체 throughput이 저하됩니다.
// 스토리지 원자성은 transaction()으로
await this.ctx.storage.transaction(async () => {
const row = [...this.ctx.storage.sql.exec("SELECT count FROM counters")][0];
this.ctx.storage.sql.exec(
"UPDATE counters SET count = ?",
(row?.count as number ?? 0) + 1
);
});
// blockConcurrencyWhile의 올바른 사용처 — 외부 I/O를 포함한 초기화
async initialize() {
await this.ctx.blockConcurrencyWhile(async () => {
// 외부 API에서 설정을 받아오는 동안 다른 요청이 진입하지 못하게 차단
const config = await fetch("https://config.example.com/settings").then(r => r.json());
this.config = config;
});
}안티패턴 3: Hibernation 없이 장시간 연결 유지
server.addEventListener('message', handler) 패턴을 쓰면 Hibernation이 비활성화되고 연결이 살아 있는 동안 계속 Duration 과금이 발생합니다. webSocketMessage, webSocketOpen, webSocketClose, webSocketError 클래스 메서드 방식으로만 Hibernation이 활성화됩니다.
DO vs 기존 스택 비교
| 시나리오 | 기존 접근 | DO 사용 시 |
|---|---|---|
| 실시간 채팅 | Redis Pub/Sub + Socket.io 서버 | DO 인스턴스 하나로 브로드캐스트 처리 |
| 분산 락 | Redis SETNX + Lua 스크립트 | transaction() + 알람으로 TTL 처리 |
| 세션 상태 | 외부 KV / 세션 DB | DO 인메모리 + SQLite |
| 레이트 리미터 | Upstash Redis | 사용자 ID별 DO 인스턴스 |
| 협업 편집 | 전용 연산 변환 서버 + Redis | DO로 편집 이벤트 직렬화 |
마치며
DO의 핵심 가치는 "엔티티당 하나의 싱글스레드 인스턴스"라는 단순한 원칙에서 나옵니다. 이 원칙 하나가 race condition 문제, 분산 락 문제, 실시간 브로드캐스트 문제를 동시에 풀어냅니다. Redis와 별도 WebSocket 서버를 유지하면서 복잡하게 조율하던 것들이 DO 위에서는 구조적으로 단순해집니다.
도입을 고민 중이라면 다음 질문이 판단의 기준이 됩니다.
DO가 자연스럽게 맞는 경우: 채팅 룸, 문서, 주문처럼 엔티티 경계가 명확하고 그 단위로 트래픽이 분산되는 구조라면 DO가 잘 맞습니다. 이미 Cloudflare Workers를 쓰고 있다면 추가 인프라 없이 상태 관리를 붙일 수 있어 도입 비용이 낮습니다.
신중하게 따져봐야 할 경우: 트래픽이 하나의 엔티티에 극단적으로 집중되는 구조라면 단일 인스턴스 처리량 한계에 부딪힐 수 있습니다. Cloudflare 이외 환경에서도 실행해야 한다면 플랫폼 종속이 실질적인 제약이 됩니다. SQLite 과금이 2026년 1월부터 적용 중이므로 대용량 장기 저장이 필요한 경우에는 D1이나 R2와의 조합도 검토하세요.
어느 쪽이든, 무료 플랜에서 로컬 개발 환경을 올려 직접 짜보는 것이 가장 빠른 판단 방법입니다.
참고 자료
- Cloudflare Durable Objects 공식 개요
- Zero-latency SQLite storage in every Durable Object
- Use WebSockets · Cloudflare Durable Objects docs
- Rules of Durable Objects
- Durable Objects: Easy, Fast, Correct — Choose three
- SQLite-backed Durable Object Storage API
- GitHub - losfair/dlock
- Liveblocks & Cloudflare 고객 사례
- Durable Objects on Workers Free plan
- Cloudflare Durable Objects Pricing
- Deploy a real-time chat application