NestBook / Cloudflare + D1

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’

**다음 챕터 연결** — 애플리케이션이 완성됐다면 이제 운영 품질을 높일 차례다. 마지막 챕터에서는 **Cache API로 응답 캐싱**, **Analytics Engine으로 사용자 지표 수집**, **Tail Workers로 요청 로그 중앙화**, 그리고 **비용 계산** 방법을 다룬다.