Cloudflare Durable Objects로 상태 있는 서버리스 만들기

Cloudflare Workers로 API를 만들다 보면 어느 순간 이런 벽에 부딪힙니다. “실시간 조회수를 세고 싶은데, 이 상태를 대체 어디에 둬야 하지?” 🤔
Workers는 무상태(stateless)입니다. 요청이 들어올 때마다 새로 뜨고 처리가 끝나면 사라지기 때문에, 요청과 요청 사이에 무언가를 기억하지 못합니다. 그래서 채팅방, 실시간 협업 문서, 온라인 게임 방, 조회수 카운터, 레이트 리미터처럼 “여러 요청이 같은 상태를 공유하고 조율해야 하는” 기능을 만들려고 하면 곧바로 막히게 됩니다.
그럼 Workers KV 같은 저장소에 넣으면 되지 않을까요? 아쉽게도 KV는 최종 일관성(eventual consistency)을 따르기 때문에, 두 요청이 동시에 조회수를 1씩 올리면 서로의 갱신을 덮어써서 값이 하나만 오르는 일이 생깁니다. “읽고 → 1 더하고 → 쓰기”를 원자적으로 조율해줄 주체가 없거든요.
Cloudflare Durable Objects는 바로 이 문제를 풀기 위한 도구입니다. 최근에는 사용자마다 대화를 기억하고 스스로 깨어나 일하는 AI 에이전트(agent)의 실행 기반으로도 주목받고 있죠. 이번 글에서는 Durable Objects가 어떤 개념인지, 어떻게 코드로 상태를 다루고 실시간 통신과 예약 작업까지 붙이는지 살펴본 뒤, 왜 에이전트와 잘 맞는지도 짚어보겠습니다.
Durable Objects란?
Durable Object는 고유한 이름을 가진 실행 객체와 전용 저장 공간을 하나로 묶은 서비스입니다. 같은 이름으로 접근하면 전 세계 어디서 요청하든 항상 같은 인스턴스 하나로 연결됩니다. 그리고 그 인스턴스에 들어오는 모든 요청은 단일 스레드에서 순서대로(직렬로) 처리됩니다.
이 두 가지 성질이 핵심입니다. 전역에서 유일한 인스턴스이기 때문에 상태가 한 곳에 모이고, 요청이 직렬로 처리되기 때문에 경쟁 상태(race condition) 없이 안전하게 값을 읽고 쓸 수 있습니다. 앞에서 KV로는 안 됐던 “동시에 조회수 올리기”가 Durable Objects에서는 그냥 됩니다. 같은 카운터로 향하는 요청이 줄을 서서 하나씩 처리되니까요.
Workers, KV와 비교하면 위치가 좀 더 분명해집니다.
| Workers | Workers KV | Durable Objects | |
|---|---|---|---|
| 상태 | 없음 (무상태) | 전역 키-값 저장 | 인스턴스별 전용 저장 |
| 일관성 | — | 최종 일관성 | 강한 일관성 |
| 인스턴스 | 요청마다 새로 | — | 이름당 전역에 하나 |
| 대표 용도 | API 라우팅, 로직 | 캐시, 설정값 | 채팅방, 카운터, 상태 조율 |
이 모델은 분산 시스템에서 오래 검증된 액터 모델(Actor model)과 거의 같습니다. 각 Durable Object가 하나의 액터가 되어, 메시지(요청)를 받아 자기만의 단일 스레드 안에서 처리하고, 필요하면 다른 액터에게 메시지를 보내는 구조죠. Erlang이나 Akka를 써보셨다면 익숙한 사고방식일 거예요.
그래서 Durable Objects를 설계할 때 가장 중요한 질문은 “무엇을 하나의 객체로 볼 것인가”입니다. 채팅방 하나, 게임 세션 하나, 문서 하나, 사용자 한 명처럼 조율이 필요한 단위 하나를 객체 하나로 잡는 것이 정석입니다.
어떻게 동작하나?
동작 흐름은 생각보다 단순합니다.
클라이언트 요청
-> Cloudflare Worker
-> idFromName("room-1") // 이름 -> 고유 ID
-> get(id) // ID -> 인스턴스 스텁(stub)
-> Durable Object 인스턴스 (전용 스토리지 포함)
Worker가 요청을 받으면 idFromName()에 이름을 넘겨 고유한 ID를 얻고, get()으로 그 ID에 해당하는 인스턴스의 스텁(stub)을 가져옵니다.
여기서 이름이 곧 신원이라는 점이 중요합니다.
"room-1"이라는 같은 이름을 쓰면 서울에서 접근하든 런던에서 접근하든 언제나 동일한 인스턴스로 연결됩니다.
스텁은 실제 인스턴스를 가리키는 원격 핸들입니다. 스텁의 메서드를 호출하면 그 호출이 인스턴스로 전달되어 실행되고 결과가 돌아옵니다. 인스턴스가 지구 반대편에 있어도, 우리 코드에서는 그냥 객체의 메서드를 부르는 것처럼 쓸 수 있어요.
기본 코드
가장 간단한 조회수 카운터로 시작해보겠습니다.
Durable Object는 cloudflare:workers가 제공하는 DurableObject 클래스를 상속해서 정의합니다.
import { DurableObject } from "cloudflare:workers";
interface Env {
COUNTER: DurableObjectNamespace<Counter>;
}
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// 인스턴스가 처음 만들어질 때 테이블을 준비
this.ctx.storage.sql.exec(
`CREATE TABLE IF NOT EXISTS counter (name TEXT PRIMARY KEY, value INTEGER)`,
);
}
// 공개 메서드는 그대로 RPC 엔드포인트가 됨
async increment(name: string): Promise<number> {
const row = this.ctx.storage.sql
.exec<{ value: number }>(
`INSERT INTO counter (name, value) VALUES (?, 1)
ON CONFLICT(name) DO UPDATE SET value = value + 1
RETURNING value`,
name,
)
.one();
return row.value;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1) || "home";
// 이름이 같으면 전 세계에서 언제나 같은 인스턴스
const id = env.COUNTER.idFromName(key);
const stub = env.COUNTER.get(id);
// 스텁의 메서드를 원격 호출 (RPC)
const value = await stub.increment(key);
return Response.json({ key, value });
},
};
여기서 눈여겨볼 부분은 Worker가 stub.increment()를 마치 로컬 메서드처럼 호출한다는 점입니다.
이것이 원격 프로시저 호출(RPC) 방식인데요.
2024-04-03 이후의 호환성 날짜(compatibility date)를 쓰는 프로젝트라면, 예전처럼 fetch() 핸들러에 요청을 실어 보내는 대신 이렇게 메서드를 직접 부르는 편이 훨씬 깔끔하고 타입 안정성도 좋습니다.
그리고 increment() 안에서 “읽고 더하고 쓰는” 과정이 한 SQL 문으로 처리되지만, 설령 여러 단계로 나눠 쓰더라도 안전합니다.
같은 카운터로 오는 호출이 이 인스턴스 안에서 직렬로 실행되므로, 두 요청이 값을 동시에 건드려 덮어쓰는 일이 애초에 일어나지 않기 때문이에요.
설정 파일
Durable Object를 Worker에 연결하려면 Wrangler 설정에 바인딩과 마이그레이션을 추가해야 합니다.
{
"name": "my-counter",
"main": "src/index.ts",
"compatibility_date": "2026-07-01",
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
}
durable_objects.bindings의 name은 Worker 코드에서 env.COUNTER로 접근할 때 쓰는 이름이고, class_name은 우리가 정의한 클래스 이름입니다.
migrations의 new_sqlite_classes가 조금 낯설 수 있는데요.
이 항목이 해당 클래스를 SQLite 스토리지 백엔드로 만들어줍니다.
신규 Durable Object는 SQLite 백엔드가 권장되며, 이 백엔드에서만 ctx.storage.sql의 SQL API를 쓸 수 있습니다.
클래스를 추가하거나 이름을 바꾸는 등 구조가 달라질 때마다 새로운 tag로 마이그레이션을 하나씩 쌓아 올리면 됩니다.
로컬에서 돌려보는 것도 간단합니다.
bunx wrangler dev
상태를 저장하는 두 가지 층
Durable Objects의 상태는 성격이 다른 두 층으로 나뉩니다. 이 차이를 이해해야 데이터를 잃지 않는 코드를 짤 수 있어요.
| 종류 | 속도 | 지속성 | 용도 |
|---|---|---|---|
| 인메모리 (클래스 속성) | 가장 빠름 | 인스턴스 정지/충돌 시 사라짐 | 캐시, 활성 연결 관리 |
| SQLite 스토리지 | 빠름 | 재시작에도 유지됨 | 실제 데이터 보관 |
인메모리 상태는 클래스의 필드에 그냥 값을 담아두는 방식이라 제일 빠릅니다. 다만 인스턴스가 유휴 상태로 메모리에서 내려가거나, 코드 배포로 재시작되거나, 처리되지 않은 예외로 죽으면 몽땅 사라집니다. 그래서 잃으면 안 되는 데이터는 반드시 SQLite 스토리지에 먼저 써야 합니다.
// 나쁜 예: 카운터를 클래스 필드에만 저장 -> 재시작하면 0으로 초기화
export class Counter extends DurableObject<Env> {
count = 0; // 인메모리라서 언젠가 날아감
increment() {
return ++this.count;
}
}
SQLite 스토리지는 각 Durable Object마다 전용 데이터베이스를 하나씩 갖는 구조입니다.
ctx.storage.sql.exec()로 평범한 SQL을 실행할 수 있고, 인덱스와 트랜잭션도 그대로 쓸 수 있어요.
같은 SQLite를 서버리스로 쓴다는 점에서 Cloudflare D1과 닮았지만, D1이 여러 Worker가 공유하는 하나의 데이터베이스라면, Durable Objects의 SQLite는 인스턴스마다 격리된 나만의 데이터베이스라는 점이 다릅니다.
그래서 “채팅방 하나당 메시지 테이블 하나”처럼 자연스럽게 데이터를 나눌 수 있습니다.
인메모리 캐시는 어디까지나 SQLite에 있는 데이터를 빠르게 다시 꺼내기 위한 보조 수단으로만 쓰는 것이 안전합니다.
실시간 통신: WebSocket
Durable Objects가 특히 빛나는 영역이 실시간 통신입니다. 하나의 인스턴스가 수많은 클라이언트의 WebSocket 연결을 동시에 붙들고 서로를 중개할 수 있거든요. 채팅방을 예로 들면, 방 하나를 Durable Object 하나로 잡고 그 방의 모든 참가자 연결을 한 인스턴스가 관리하는 식입니다.
이때 Hibernation(휴면) WebSocket API를 쓰는 것이 권장됩니다. 연결은 유지한 채로 인스턴스가 잠들 수 있어서, 아무도 말하지 않는 동안에는 과금이 멈추기 때문이에요.
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
async fetch(request: Request): Promise<Response> {
const [client, server] = Object.values(new WebSocketPair());
// Hibernation API로 연결을 수락 -> 유휴 시 잠들어도 연결은 유지
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
// 메시지가 오면 호출됨 (휴면에서 깨어난 뒤에도 동작)
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// 보낸 사람을 뺀 모든 접속자에게 브로드캐스트
for (const client of this.ctx.getWebSockets()) {
if (client !== ws) {
client.send(message);
}
}
}
// 연결이 끊기면 호출됨
webSocketClose(ws: WebSocket, code: number, reason: string) {
ws.close(code, reason);
}
}
일반적인 WebSocket 서버였다면 addEventListener로 이벤트를 직접 구독해야 했을 텐데요.
Hibernation API에서는 webSocketMessage, webSocketClose 같은 메서드를 클래스에 구현해두면 런타임이 알아서 콜백으로 불러줍니다.
인스턴스가 휴면에 들어갔다 깨어나도 이 메서드들이 계속 호출되기 때문에, 접속자가 아무 말 없이 몇 시간을 있어도 서버 비용 걱정 없이 방을 열어둘 수 있습니다.
접속 중인 모든 연결은 ctx.getWebSockets()로 한 번에 가져올 수 있어서, 위 예제처럼 메시지를 다른 참가자들에게 뿌리는 브로드캐스트를 몇 줄로 구현할 수 있어요.
알람으로 예약 작업 돌리기
Durable Objects는 요청이 없어도 스스로 깨어나 일할 수 있습니다.
알람(Alarms) API로 미래의 특정 시각을 예약해두면, 그 시각에 런타임이 alarm() 메서드를 호출해주거든요.
export class Reminder extends DurableObject {
// 지금부터 ms 밀리초 뒤에 alarm()이 실행되도록 예약
async scheduleIn(ms: number) {
await this.ctx.storage.setAlarm(Date.now() + ms);
}
// 예약한 시각이 되면 런타임이 호출
async alarm() {
// 여기서 배치 처리, 정리 작업, 알림 발송 등을 수행
console.log("예약된 작업 실행!");
// 알람은 자동으로 반복되지 않으므로, 주기 작업이라면 다시 예약
// await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
}
한 가지 기억할 점은 알람이 한 번 울리고 나면 자동으로 다시 예약되지 않는다는 것입니다.
주기적으로 돌려야 한다면 alarm() 안에서 setAlarm()을 한 번 더 호출해 다음 실행을 걸어줘야 합니다.
이 기능을 인메모리 상태나 SQLite 스토리지와 조합하면, 일정 시간 동안 메시지를 모았다가 한꺼번에 처리하는 배치, 큐, 간단한 워크플로 같은 것도 별도의 스케줄러 인프라 없이 만들 수 있습니다.
AI 에이전트가 Durable Objects를 찾는 이유
요즘 Durable Objects가 다시 주목받는 이유는 AI 에이전트 때문인데요. 에이전트를 서버에 올리려고 하면 갖춰야 할 조건이 꽤 많습니다. 누구의 에이전트인지 구분되어야 하고, 지난 대화와 작업 내역을 기억해야 합니다. 모델의 응답을 실시간으로 흘려보내야 하고, 사용자가 자리를 비운 사이에도 정해진 시각에 깨어나 일해야 하죠. 게다가 사용자가 수십만 명이어도 대부분의 에이전트는 대부분의 시간을 쉬고 있으니, 쉬는 동안에는 비용이 들지 않아야 합니다.
눈치채셨겠지만 이 조건들은 앞에서 살펴본 기능과 하나씩 맞아떨어집니다.
idFromName(userId)로 사용자마다 에이전트 하나를 두면 신원이 생기고, 인스턴스 전용 SQLite가 기억이 되며, WebSocket과 알람이 실시간 응답과 예약 작업을 맡습니다.
휴면 덕분에 아무도 말을 걸지 않는 에이전트는 과금되지 않고요.
일반 서버였다면 세션 저장소, 메시지 브로커, 스케줄러를 따로 붙여야 했을 일을 객체 하나가 해내는 셈입니다.
사용자별 대화 기록을 기억하는 아주 작은 에이전트를 만들어 보겠습니다. 모델 호출에는 Workers AI 바인딩을 사용했습니다.
import { DurableObject } from "cloudflare:workers";
interface Env {
AI: Ai;
CHAT_AGENT: DurableObjectNamespace<ChatAgent>;
}
type Message = { role: "user" | "assistant"; content: string };
export class ChatAgent extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(
`CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL
)`,
);
}
async chat(text: string): Promise<string> {
this.save({ role: "user", content: text });
// 지금까지 나눈 대화를 통째로 모델에 넘김
const messages = this.ctx.storage.sql
.exec<Message>(`SELECT role, content FROM messages ORDER BY id`)
.toArray();
const result = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages,
});
const answer = result.response ?? "";
this.save({ role: "assistant", content: answer });
return answer;
}
private save({ role, content }: Message) {
this.ctx.storage.sql.exec(
`INSERT INTO messages (role, content) VALUES (?, ?)`,
role,
content,
);
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userId = new URL(request.url).searchParams.get("user") ?? "guest";
// 사용자 한 명 = 에이전트 하나
const agent = env.CHAT_AGENT.get(env.CHAT_AGENT.idFromName(userId));
const answer = await agent.chat(await request.text());
return Response.json({ answer });
},
};
wrangler.jsonc에는 앞에서 본 Durable Object 바인딩과 마이그레이션에 더해 "ai": { "binding": "AI" }를 추가하면 됩니다.
대화 기록이 에이전트마다 격리된 SQLite에 쌓이기 때문에, 사용자가 며칠 뒤에 돌아와도, 그사이 코드를 새로 배포해도 대화가 그대로 이어집니다.
에이전트를 만들 때 주의할 점
이렇게 간단한 에이전트에도 함정이 하나 숨어 있습니다.
앞에서 요청이 직렬로 처리된다고 했는데요.
정확히 말하면 한 번에 한 요청의 코드만 실행될 뿐, await로 외부 호출을 기다리는 동안에는 다른 요청이 끼어들 수 있습니다.
Durable Objects의 입력 게이트(input gate)는 스토리지 작업 중에만 다른 이벤트를 막아주고, fetch()나 모델 호출 같은 외부 I/O를 기다리는 동안에는 문을 열어 두거든요.
대형 언어 모델(LLM)의 응답은 수 초에서 수십 초가 걸리니, 사용자가 다른 탭에서 메시지를 하나 더 보내면 두 번째 chat()이 첫 번째 답변이 저장되기도 전에 대화 기록을 읽어 버립니다.
그 결과 두 번째 답변은 첫 번째 답변을 모른 채 만들어지고, 저장 순서가 뒤섞이기도 하죠.
에이전트처럼 한 차례(turn)씩 대화가 오가야 하는 경우라면 차례를 줄 세우는 편이 안전합니다.
export class ChatAgent extends DurableObject<Env> {
private queue: Promise<void> = Promise.resolve();
chat(text: string): Promise<string> {
// 앞 차례가 끝나야 다음 차례가 시작됨
const turn = this.queue.then(() => this.reply(text));
this.queue = turn.then(
() => {},
() => {},
);
return turn;
}
// 기존 chat()의 본문을 그대로 옮김
private async reply(text: string): Promise<string> {
// ...
}
}
큐가 인메모리에 있어도 괜찮은 이유는, 인스턴스가 내려갈 때 진행 중인 차례도 함께 사라지기 때문입니다. 메시지는 이미 SQLite에 저장되어 있으니 다시 깨어났을 때 이어가면 됩니다.
또 하나 기억할 점은 인스턴스가 메모리에서 내려가는 시점입니다. 공식 문서에 따르면 요청이나 WebSocket 메시지, 알람이 70~140초가량 없으면 인스턴스가 축출(eviction)되는데, LLM 응답을 스트리밍하거나 여러 단계의 도구 호출을 이어가는 도중이라고 예외는 아닙니다. 요청 하나를 받아 처리하고 끝내는 구조라면 문제가 없지만, 요청이 끝난 뒤에도 에이전트가 혼자 작업을 이어가는 구조라면 알람으로 주기적으로 깨워 두거나, 단계마다 진행 상황을 SQLite에 남겨 중간부터 재개할 수 있게 만들어야 합니다.
이런 세부 사항을 매번 직접 챙기기는 번거로운데요.
Cloudflare Agents SDK는 Durable Objects 위에서 에이전트를 만들 때 반복되는 일을 묶어 둔 프레임워크입니다.
알람 기반 하트비트로 축출을 막는 keepAlive(), SQLite에 저장되는 예약 작업 schedule(), 클라이언트와 상태를 실시간으로 동기화하는 기능까지 기본으로 제공합니다.
원격 MCP 서버를 만드는 McpAgent도 같은 구조라서 클라이언트 세션마다 Durable Object 하나가 상태를 유지하고, Cloudflare OS는 워크스페이스 하나를 Durable Object 하나로 표현합니다.
결국 Cloudflare의 에이전트 관련 제품은 거의 모두 “에이전트 하나 = Durable Object 하나”라는 공식 위에 서 있습니다.
언제 쓰면 좋을까?
Durable Objects는 모든 상황을 위한 만능 저장소가 아닙니다. 읽기가 대부분인 캐시나 설정값이라면 Workers KV가, 여러 Worker가 공유하는 관계형 데이터라면 Cloudflare D1이 더 단순하고 잘 맞습니다.
Durable Objects가 진짜 필요한 순간은 “여러 요청이나 연결이 한 지점에 모여 조율되어야 할 때”입니다.
우선 실시간 협업이 대표적입니다. 채팅방, 공동 편집 문서, 화이트보드처럼 여러 사용자가 같은 상태를 실시간으로 주고받아야 하는 서비스에 딱 맞습니다.
동시성 조율이 필요한 기능에도 잘 어울립니다. 정확한 조회수 카운터, 재고 차감, 사용자별 레이트 리미터처럼 “동시에 들어오는 요청을 순서대로 처리해야 하는” 로직을 락(lock) 없이 안전하게 구현할 수 있어요.
그리고 앞에서 살펴본 AI 에이전트처럼 사용자나 세션마다 기억과 실행 흐름을 따로 가져야 하는 서비스에도 잘 맞습니다.
에이전트와 직접 관련이 없는 제품들도 내부적으로 Durable Objects를 바탕으로 합니다. Cloudflare Containers는 컨테이너 인스턴스마다 Durable Object를 하나씩 붙여 사이드카처럼 생명주기를 관리하고, Cloudflare Sandbox도 샌드박스 하나하나를 Durable Object로 표현합니다. “인스턴스별로 격리된 상태와 단일 조율 지점”이라는 성질이 이런 제품들의 바탕이 되는 셈이죠.
마치며
Durable Objects는 무상태였던 서버리스 세계에 “전역에서 유일한 인스턴스”라는 조율 지점을 더해줍니다. 같은 이름은 언제나 같은 인스턴스로 모이고, 그 안에서 요청이 직렬로 처리되며, SQLite 스토리지에 상태가 안전하게 남습니다.
여기에 WebSocket과 알람까지 얹으면 실시간 채팅, 협업 편집, 게임 방, 예약 배치 작업을 별도의 상태 서버나 스케줄러 없이 Cloudflare 위에서 온전히 만들 수 있습니다. 사용자마다 기억을 갖고 스스로 깨어나는 AI 에이전트가 Durable Objects를 바탕으로 삼는 것도 같은 이유입니다. 다만 모델 호출을 기다리는 동안에는 다른 요청이 끼어들 수 있다는 점만큼은 꼭 기억해 두세요. Workers로 API를 만들다가 “이 상태를 어디에 두지?”라는 벽을 만났다면, 그때가 바로 Durable Objects를 꺼낼 순간입니다.
더 자세한 내용은 Cloudflare Durable Objects 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0