Durable Objects
Workers의 한계 — 왜 Durable Objects가 필요한가
일반 Workers는 무상태(stateless) 다. 요청이 끝나면 메모리가 사라지고, 다음 요청은 완전히 새로운 인스턴스에서 시작된다. 이 때문에 아래 시나리오는 Workers 단독으로 구현할 수 없다.
- 실시간 채팅 — 여러 사용자의 WebSocket 연결을 같은 객체가 관리해야 한다
- 분산 카운터 — 동시에 수십 개의 요청이 카운터를 올릴 때 레이스 컨디션이 발생한다
- 방(room) 개념 — 같은 방의 사용자끼리만 메시지를 공유해야 한다
Durable Objects(DO) 는 이 문제를 해결한다. 이름(ID)으로 유일하게 식별되는 상태 있는 객체로, 전 세계에서 특정 DO를 가리키면 항상 같은 단일 인스턴스에 연결된다.
일반 Workers (무상태): Durable Objects (상태 있음):
요청 A → 인스턴스 1 요청 A ─┐
요청 B → 인스턴스 2 요청 B ─┤→ DO "room-123" (단일 인스턴스)
요청 C → 인스턴스 3 요청 C ─┘
(메모리 공유 불가) (메모리 & WebSocket 공유 가능)
기본 구조
Durable Object는 클래스로 정의한다.
// src/counter.ts
export class Counter implements DurableObject {
private count: number = 0
private state: DurableObjectState
constructor(state: DurableObjectState, env: Env) {
this.state = state
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/increment') {
this.count++
// 영구 저장 — DO가 재시작돼도 유지
await this.state.storage.put('count', this.count)
return Response.json({ count: this.count })
}
if (url.pathname === '/value') {
// 재시작 후 복구
this.count = (await this.state.storage.get<number>('count')) ?? 0
return Response.json({ count: this.count })
}
return new Response('Not Found', { status: 404 })
}
}
# wrangler.toml
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
[[migrations]]
tag = "v1"
new_classes = ["Counter"]
// src/index.ts — Workers가 DO에 요청 위임
export { Counter } from './counter' // ← DO 클래스를 반드시 re-export
type Env = { COUNTER: DurableObjectNamespace }
export default {
async fetch(request: Request, env: Env) {
// 이름으로 DO 인스턴스 ID 생성 (같은 이름 = 같은 인스턴스)
const id = env.COUNTER.idFromName('global-counter')
const stub = env.COUNTER.get(id)
// DO에 요청 전달
return stub.fetch(request)
}
}
스토리지 API
DO의 스토리지는 this.state.storage 로 접근한다. SQLite 기반으로 Workers KV와 달리 강한 일관성(strong consistency) 을 보장한다.
// 키-값 읽기/쓰기
await this.state.storage.put('key', value)
const value = await this.state.storage.get<string>('key')
// 여러 키 한 번에
await this.state.storage.put({ key1: 'a', key2: 'b' })
const map = await this.state.storage.list() // Map<string, unknown>
// 삭제
await this.state.storage.delete('key')
await this.state.storage.deleteAll()
// 트랜잭션 — 원자적 읽기/수정/쓰기
await this.state.storage.transaction(async (txn) => {
const count = (await txn.get<number>('count')) ?? 0
await txn.put('count', count + 1)
})
WebSocket — 실시간 채팅방 구현
Durable Objects의 가장 강력한 사용 사례다. 같은 방 ID를 가진 DO 인스턴스가 모든 WebSocket 연결을 관리한다.
// src/chat-room.ts
export class ChatRoom implements DurableObject {
private sessions: WebSocket[] = []
private state: DurableObjectState
constructor(state: DurableObjectState, env: Env) {
this.state = state
// 재시작 후 WebSocket 세션 복구
this.state.getWebSockets().forEach((ws) => this.sessions.push(ws))
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get('Upgrade')
if (upgradeHeader !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 })
}
// WebSocket 업그레이드 — Cloudflare가 직접 처리
const { 0: client, 1: server } = new WebSocketPair()
this.state.acceptWebSocket(server)
this.sessions.push(server)
return new Response(null, { status: 101, webSocket: client })
}
// WebSocket 메시지 수신
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const text = typeof message === 'string' ? message : new TextDecoder().decode(message)
const data = JSON.parse(text) as { name: string; message: string }
const broadcast = JSON.stringify({
name: data.name,
message: data.message,
timestamp: new Date().toISOString(),
})
// 현재 연결된 모든 클라이언트에게 브로드캐스트
for (const session of this.sessions) {
try {
session.send(broadcast)
} catch {
// 끊어진 연결 제거
this.sessions = this.sessions.filter((s) => s !== session)
}
}
}
// 연결 종료
async webSocketClose(ws: WebSocket) {
this.sessions = this.sessions.filter((s) => s !== ws)
}
}
// src/index.ts
export { ChatRoom } from './chat-room'
type Env = { CHAT_ROOM: DurableObjectNamespace }
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url)
// /room/123 → 방 ID "123"에 해당하는 DO 인스턴스로 라우팅
const roomId = url.pathname.split('/')[2] ?? 'default'
const id = env.CHAT_ROOM.idFromName(roomId)
const stub = env.CHAT_ROOM.get(id)
return stub.fetch(request)
}
}
클라이언트 연결:
const ws = new WebSocket('wss://my-worker.example.com/room/general')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log(`[${data.name}] ${data.message}`)
}
ws.send(JSON.stringify({ name: '나모', message: '안녕하세요!' }))
분산 락 패턴
여러 Workers 인스턴스가 동시에 같은 리소스에 접근할 때 DO를 락으로 사용한다.
// src/lock.ts
export class DistributedLock implements DurableObject {
private locked = false
private state: DurableObjectState
constructor(state: DurableObjectState, env: Env) {
this.state = state
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/acquire') {
if (this.locked) {
return Response.json({ acquired: false }, { status: 409 })
}
this.locked = true
// 30초 후 자동 해제 (데드락 방지)
this.state.storage.setAlarm(Date.now() + 30_000)
return Response.json({ acquired: true })
}
if (url.pathname === '/release') {
this.locked = false
await this.state.storage.deleteAlarm()
return Response.json({ released: true })
}
return new Response('Not Found', { status: 404 })
}
// 알람 — 30초 지나면 자동 락 해제
async alarm() {
this.locked = false
}
}
DO ID 생성 방식
// 방식 1 — 이름으로 (같은 이름 = 항상 같은 인스턴스)
const id = env.MY_DO.idFromName('unique-resource-name')
// 방식 2 — 랜덤 ID (새 인스턴스 생성)
const id = env.MY_DO.newUniqueId()
// 방식 3 — 직렬화된 ID 문자열로 복원 (DB에 저장 후 재사용)
const idStr = id.toString()
const restored = env.MY_DO.idFromString(idStr)
사용 사례:
- 채팅방 →
idFromName('room-' + roomId)— 방 이름으로 항상 같은 DO - 사용자 세션 →
idFromName('user-' + userId)— 사용자별 상태 격리 - 새 문서 →
newUniqueId()— 매번 새 인스턴스
요금 & 제한
| 항목 | 무료 | 유료 |
|---|---|---|
| 요청 수 | 100만 회/월 | $0.15/100만 |
| 스토리지 | 1 GB | $0.20/GB/월 |
| 웹소켓 메시지 | 포함 | 포함 |
| CPU 시간 | 요청당 최대 30초 | 동일 |
주의 — DO는 Workers보다 비용이 높다. 전역 상태나 WebSocket이 반드시 필요한 경우에만 사용한다. 단순한 읽기/쓰기는 D1으로 충분하다.
핵심 정리
import KeyPoints from ’../../components/KeyPoints.astro’
<KeyPoints points={[ “Durable Objects = 이름으로 식별되는 단일 인스턴스 — 전 세계 어디서 호출해도 같은 객체에 연결”, “state.storage는 강한 일관성 보장 — KV와 달리 레이스 컨디션 없이 카운터·락 구현 가능”, “state.acceptWebSocket()으로 WebSocket 관리 — 채팅방, 실시간 협업의 핵심 패턴”, “idFromName(‘key’)는 결정론적 — 같은 키면 항상 같은 DO, DB에 저장 후 idFromString()으로 복원 가능”, “전역 상태·WebSocket이 필요 없으면 D1으로 충분 — DO는 꼭 필요한 곳에만 사용” ]} />
import Callout from ’../../components/Callout.astro’