개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Backend

Cloudflare Durable Objects + Hono로 실시간 협업 기능 구현하기

WebSocket은 연결이 유지되어야 한다. Worker는 요청이 끝나면 메모리가 사라진다. 이 두 조건이 충돌하는 지점에서 Durable Objects가 등장한다. 각 인스턴스가 전 세계에서 단 하나만 실행되며, 지속적인 인메모리 상태와 내장 SQLite 스토리지를 함께 가진다. 무상태 서버리스의 확장성과 상태 있는 서버의 일관성을 동시에 갖는 조합이다.

이 글에서는 Hono 프레임워크와 Durable Objects를 조합해 실시간 협업 기능을 구현하는 패턴을 다룬다. 단순한 채팅방 예제를 넘어, 연결별 격리 상태 저장, Hibernation API를 활용한 비용 최적화, 그리고 실무에서 자주 마주치는 트러블슈팅 포인트까지 정리했다. 라우터-상태 분리(Router-State Separation) 패턴이 핵심이며, Worker가 라우팅과 인증을 맡고 Durable Object가 상태 관리를 전담하는 역할 분리를 코드와 함께 살펴본다.


핵심 개념

라우터-상태 분리 패턴

Durable Objects의 본질은 동일한 ID로 get()을 호출하면 전 세계 어디서든 같은 인스턴스에 도달한다는 단일 인스턴스 보장이다. Worker 전역 변수는 인스턴스마다 독립적이고 공유 보장이 없지만, Durable Object는 ID가 동일하면 항상 같은 객체를 가리킨다.

아키텍처를 한 줄로 요약하면: Worker는 무상태 라우터, Durable Object는 상태를 가진 엔티티.

다이어그램 1

Worker는 요청을 받아 env.ROOM.idFromName(roomId)로 결정론적 ID를 생성하고, 인증 검증 후 검증된 사용자 정보를 커스텀 헤더에 담아 env.ROOM.get(id).fetch()로 포워딩한다. 실제 WebSocket 연결과 메시지 처리는 Durable Object 안에서만 일어난다.

여기서 중요한 세부사항이 있다. 많은 예제에서 Worker 인증 미들웨어가 토큰을 검증하고도 stub.fetch(c.req.raw)로 원본 요청만 그대로 전달한다. 그러면 Durable Object는 클라이언트가 쿼리 파라미터로 제출한 값을 그대로 신뢰하게 되고, 클라이언트가 ?userId=admin을 임의로 붙이면 Worker 인증을 통과한 뒤에도 신원 위조가 가능하다. Worker에서 검증한 userId를 X-User-Id 헤더에 담아 전달하고, DO는 이 헤더만 신뢰하는 구조가 필요하다.

WebSocket Hibernation API — 비용의 핵심

ctx.acceptWebSocket(ws)를 호출하면 런타임이 해당 연결을 휴면 가능 상태로 표시한다. 메시지가 없는 구간에는 Durable Object가 메모리에서 제거되어 과금이 발생하지 않으며, 다음 메시지가 도착하는 순간 런타임이 객체를 재생성하고 webSocketMessage() 핸들러로 라우팅한다.

다이어그램 2

중요한 점은 클라이언트 측 WebSocket 연결은 Hibernation 중에도 끊기지 않는다는 것이다. 클라이언트는 아무것도 모른 채 연결이 살아있다고 인식한다.

연결별 격리 상태 — serializeAttachment

Hibernation과 함께 반드시 알아야 할 API가 serializeAttachment()/deserializeAttachment()다. 객체가 재생성되면 인메모리 변수는 초기화되지만, WebSocket 객체에 직렬화해서 붙여둔 첨부 데이터는 유지된다.

이 데이터가 "안전한 컨텍스트"가 되려면 신뢰할 수 있는 출처에서 온 값을 담아야 한다. 이후 코드에서 볼 수 있듯, Worker가 검증한 X-User-Id 헤더에서 읽은 값을 serializeAttachment에 담아야 클라이언트가 쿼리 파라미터로 위조한 값이 섞여들 여지가 없다.

Wrangler 설정과 바인딩

wrangler.toml 설정이 정확하지 않으면 Durable Object가 아예 트리거되지 않는다. 특히 class_name과 실제 export된 클래스 이름이 다를 때 에러 메시지 없이 조용히 실패하는 사례가 이슈 트래커에 종종 보고된다.

toml
name = "realtime-collab"
main = "src/index.ts"
compatibility_date = "2024-09-23"
 
[[durable_objects.bindings]]
name = "ROOM"
class_name = "Room"
 
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Room"]

new_classes 대신 new_sqlite_classes를 사용해야 SQLite 기반 Durable Object로 생성된다. 스토리지 사용량 기반 과금과 인스턴스당 스토리지 한도가 있으니, 프로덕션 적용 전에 공식 Pricing 문서와 Limits 문서에서 현재 수치를 확인해두는 것이 좋다.


실전 적용

Worker 라우터 — 인증과 포워딩

typescript
// src/index.ts
import { Hono } from 'hono';
import { Room } from './room';
 
export { Room };
 
type Env = {
  ROOM: DurableObjectNamespace;
};
 
const app = new Hono<{ Bindings: Env }>();
 
app.use('/room/*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '');
  if (!token) return c.json({ error: 'Unauthorized' }, 401);
 
  const userId = verifyToken(token).sub;
  c.set('userId', userId);
  await next();
});
 
app.get('/room/:roomId/ws', async (c) => {
  const roomId = c.req.param('roomId');
  const userId = c.get('userId');
 
  const id = c.env.ROOM.idFromName(roomId);
  const stub = c.env.ROOM.get(id);
 
  // 검증된 userId를 헤더에 주입 — .set()으로 클라이언트 제공 값을 덮어씀
  const headers = new Headers(c.req.raw.headers);
  headers.set('X-User-Id', userId);
 
  return stub.fetch(new Request(c.req.raw, { headers }));
});
 
export default app;

headers.set()을 사용하는 이유가 있다. append()를 쓰면 클라이언트가 미리 X-User-Id를 붙여 전송했을 때 두 값이 공존하게 된다. .set()은 기존 값을 덮어써서 Worker가 검증한 값만 DO에 전달된다.

Worker는 정말 이게 전부다. 인증 확인 후 X-User-Id를 주입해 DO로 포워딩한다.

실시간 채팅방 — Room 클래스 전체 구현

typescript
// src/room.ts
import { DurableObject } from 'cloudflare:workers';
 
interface SessionMeta {
  userId: string;
  username: string;
}
 
interface Env {}
 
export class Room extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
 
    // 인스턴스 = 단일 방이므로 room_id 컬럼 불필요
    this.ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          id         INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id    TEXT    NOT NULL,
          username   TEXT    NOT NULL,
          content    TEXT    NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }
 
  async fetch(request: Request): Promise<Response> {
    if (request.headers.get('Upgrade') !== 'websocket') {
      return new Response('WebSocket expected', { status: 426 });
    }
 
    // Worker가 주입한 헤더에서만 userId를 읽음 — 쿼리 파라미터는 신뢰하지 않음
    const userId = request.headers.get('X-User-Id');
    if (!userId) {
      return new Response('Missing user identity', { status: 400 });
    }
 
    const url = new URL(request.url);
    const username = url.searchParams.get('username') ?? 'Guest';
 
    const { 0: client, 1: server } = new WebSocketPair();
 
    // Hibernation 활성화 — 이 한 줄이 없으면 유휴 연결도 과금됨
    this.ctx.acceptWebSocket(server);
 
    // Worker가 검증한 userId 기반이므로 클라이언트 위조 불가
    const meta: SessionMeta = { userId, username };
    server.serializeAttachment(meta);
 
    // 신규 연결자에게 최근 메시지 히스토리 전송
    this.sendHistory(server);
 
    this.broadcast(
      { type: 'join', userId, username, timestamp: Date.now() },
      server,
    );
 
    return new Response(null, { status: 101, webSocket: client });
  }
 
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    // 바이너리 메시지는 이 구현에서 지원하지 않음
    if (typeof message !== 'string') {
      ws.send(JSON.stringify({ type: 'error', message: 'Binary messages not supported' }));
      return;
    }
 
    const meta = ws.deserializeAttachment() as SessionMeta;
 
    let parsed: { content?: unknown };
    try {
      parsed = JSON.parse(message);
    } catch {
      ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
      return;
    }
 
    const content = parsed.content;
    if (typeof content !== 'string' || content.trim() === '') return;
 
    // storage.sql.exec()는 동기 API — await 불필요
    this.ctx.storage.sql.exec(
      `INSERT INTO messages (user_id, username, content, created_at)
       VALUES (?, ?, ?, ?)`,
      meta.userId,
      meta.username,
      content,
      Date.now(),
    );
 
    this.broadcast({
      type: 'message',
      userId: meta.userId,
      username: meta.username,
      content,
      timestamp: Date.now(),
    });
  }
 
  async webSocketClose(ws: WebSocket, _code: number) {
    const meta = ws.deserializeAttachment() as SessionMeta;
    this.broadcast(
      { type: 'leave', userId: meta.userId, username: meta.username, timestamp: Date.now() },
      ws,
    );
  }
 
  async webSocketError(ws: WebSocket, _error: unknown) {
    // webSocketClose가 자동 호출되지 않는 경우가 있으므로 별도 처리
    const meta = ws.deserializeAttachment() as SessionMeta | null;
    if (meta) {
      this.broadcast(
        { type: 'leave', userId: meta.userId, username: meta.username, timestamp: Date.now() },
        ws,
      );
    }
  }
 
  private sendHistory(ws: WebSocket) {
    const rows = this.ctx.storage.sql
      .exec(
        `SELECT user_id, username, content, created_at
         FROM messages
         ORDER BY created_at DESC
         LIMIT 50`,
      )
      .toArray();
 
    ws.send(JSON.stringify({ type: 'history', messages: rows.reverse() }));
  }
 
  private broadcast(data: unknown, exclude?: WebSocket) {
    const payload = JSON.stringify(data);
    for (const conn of this.ctx.getWebSockets()) {
      if (conn !== exclude) {
        try {
          conn.send(payload);
        } catch {
          // 이미 닫힌 연결은 무시
        }
      }
    }
  }
}

코드에서 주목할 점 몇 가지를 짚어둔다.

room_id 컬럼을 제거했다. Durable Object 인스턴스는 단일 방에 대응하고 인스턴스 간 스토리지는 격리된다. 같은 인스턴스 안에서 room_id로 필터링하는 건 아무 의미가 없는 중복이다.

storage.sql.exec()는 동기 API다. await을 붙여도 런타임은 망가지지 않지만 Promise를 반환하는 것처럼 읽혀 독자를 오해하게 만든다. 위 코드에서는 await을 제거했다.

webSocketError 핸들러를 추가했다. Hibernation API는 webSocketMessage, webSocketClose 외에 webSocketError도 지원한다. 에러 발생 시 webSocketClose가 자동으로 호출되지 않는 경우가 있으므로, 세 핸들러를 모두 구현해야 연결 해제 처리가 안전하다.

Yjs CRDT로 동시 편집 지원

채팅방은 메시지가 순서대로 브로드캐스트되기만 하면 되지만, 구글 독스처럼 여러 사람이 동시에 같은 문서를 편집하는 상황은 다르다. 두 사람이 동시에 같은 위치를 수정하면 누구의 변경이 우선인지 결정해야 하는데, 단순 last-write-wins 방식은 한쪽 편집을 덮어쓴다.

CRDT(Conflict-free Replicated Data Type)는 이 충돌을 수학적으로 해결하는 데이터 구조다. 어떤 순서로 변경이 도착하든 최종 상태가 동일하게 수렴되므로, 네트워크 지연이나 패킷 순서가 달라져도 모든 클라이언트가 같은 문서 상태를 보게 된다.

y-durableobjects는 Yjs CRDT를 Cloudflare Workers 환경에 맞게 재작성한 라이브러리다. Node.js 의존성 없이 동작하며, 클래스 상속 한 줄로 WebSocket 처리와 문서 동기화 전체를 담아낸다.

typescript
// src/collab-document.ts
import { DurableObject } from 'cloudflare:workers';
import { YDurableObjects } from 'y-durableobjects';
 
export class CollabDocument extends YDurableObjects(DurableObject) {
  // fetch, webSocketMessage, webSocketClose 모두 믹스인이 처리
  // 충돌 해소는 Yjs CRDT가 자동으로 담당
}

Worker 라우터는 문서 ID를 키로 DO를 조회한다.

typescript
// src/index.ts
app.get('/doc/:docId/ws', async (c) => {
  const id = c.env.COLLAB_DOC.idFromName(c.req.param('docId'));
  return c.env.COLLAB_DOC.get(id).fetch(c.req.raw);
});

더 깊이 들어가고 싶다면 y-durableobjects GitHub과 Toonsquare Tech의 구현 사례가 좋은 출발점이다.


장단점 분석

항목 장점 단점 / 고려사항
일관성 단일 인스턴스 보장 — 분산 락·레이스 컨디션 없음 단일 병목 — 연결이 집중되면 과부하
레이턴시 300+ PoP 엣지 로케이션에서 실행 최초 생성 위치에 고정 — 원거리 사용자는 지연
비용 Hibernation으로 유휴 구간 과금 없음 스토리지 사용량 기반 과금 있음
스토리지 내장 SQLite, 외부 DB 불필요 인스턴스당 스토리지 한도 있음 (공식 Limits 문서 확인)
콜드 스타트 V8 isolate 기반, 콜드 스타트 최소화 —
이식성 — Cloudflare 전용 API에 깊게 결합
디버깅 wrangler dev로 로컬 시뮬레이션 가능 로컬 시뮬레이션이 완전하지 않아 스테이징 환경 권장

실무에서 흔한 실수

실수 1 — acceptWebSocket 생략

new WebSocketPair()로 연결을 만들고 this.ctx.acceptWebSocket(server) 없이 101 응답을 반환하면 Hibernation이 활성화되지 않아 유휴 시간도 전부 과금된다.

실수 2 — serializeAttachment 없이 인메모리 Map 사용

typescript
// 위험한 패턴 — 휴면 후 재생성 시 초기화됨
private sessions = new Map<WebSocket, SessionMeta>();
 
async webSocketMessage(ws: WebSocket, msg: string) {
  const meta = this.sessions.get(ws); // 재생성 후에는 undefined
}

Hibernation으로 객체가 재생성되면 sessions Map은 비어있다. 연결별 상태는 반드시 ws.serializeAttachment()로 WebSocket 객체에 직접 첨부해야 한다.

실수 3 — 단일 DO에 과도한 연결 집중

트래픽이 몰리면 Durable Object is overloaded. Too many requests for the same object within a 10 second window 에러가 발생한다. 100명 이상의 채팅방이라면 roomId-shard-0, roomId-shard-1처럼 물리적으로 분할하는 샤딩 전략을 검토해야 한다.

실수 4 — wrangler.toml 바인딩 불일치

toml
[[durable_objects.bindings]]
name = "ROOM"
class_name = "RoomHandler"  # 실제 export 이름과 다름
typescript
// src/room.ts
export class Room extends DurableObject {}  // class_name과 불일치

class_name이 실제 export된 클래스 이름과 정확히 일치하지 않으면 에러 메시지 없이 조용히 실패한다.

Durable Objects 선택 기준

다이어그램 3


마치며

Durable Objects가 매력적인 이유는 "서버리스이지만 상태가 있는" 단위를 전 세계 엣지에서 실행할 수 있기 때문이다. Worker는 라우팅과 인증만 맡고, Durable Object는 특정 방·문서·세션의 모든 상태와 연결을 단일 인스턴스 안에서 책임진다. Hibernation으로 유휴 연결 비용을 제거하고, serializeAttachment로 재생성 후에도 연결별 컨텍스트를 복원한다.

패턴 자체는 단순하다. 엔티티 ID로 DO를 조회하고, WebSocket을 DO 안에서 수락하고, 브로드캐스트한다. 이 세 레이어가 자리를 잡으면 나머지는 도메인 로직을 얹는 과정이다.

관심이 있다면 npm create cloudflare@latest -- --template cloudflare/templates/worker-hono로 Hono + Workers 템플릿을 생성하고, 이 글의 Room 클래스를 붙여넣은 뒤 wrangler dev로 돌려보면 된다. Workers Free 플랜에서도 SQLite 기반 Durable Objects가 지원되므로, 프로토타입 단계에서는 비용 걱정 없이 시작할 수 있는 환경이 갖춰져 있다.


참고 자료

  • Cloudflare Durable Objects 공식 문서
  • WebSocket Hibernation API 공식 가이드
  • WebSocket Hibernation 서버 예제 (공식)
  • Hono 공식 문서 — WebSocket Helper
  • Hono 공식 예제 — Cloudflare Durable Objects
  • Durable Objects에서 Zero-latency SQLite 스토리지
  • Build Real-Time Apps With Cloudflare, Hono, Durable Objects — DZone
  • Creating a WebSocket server in Hono with Durable Objects — Fiberplane
  • How To Build Serverless WebSockets With Cloudflare, Hono, And Durable Objects — Volito
  • y-durableobjects — Yjs on Cloudflare Workers (GitHub)
  • PartyKit / partyserver (GitHub)
  • Building a Real-Time Collaborative Editor with CRDT and Durable Objects — Medium/Toonsquare
  • Cloudflare acquires PartyKit — 공식 블로그
  • Durable Objects Limits 공식 문서
  • Durable Objects Pricing 공식 문서
#Cloudflare Durable Objects#Hono#WebSocket#Cloudflare Workers#실시간 협업
공유하기

목차

핵심 개념라우터-상태 분리 패턴WebSocket Hibernation API — 비용의 핵심연결별 격리 상태 — serializeAttachmentWrangler 설정과 바인딩실전 적용Worker 라우터 — 인증과 포워딩실시간 채팅방 — Room 클래스 전체 구현Yjs CRDT로 동시 편집 지원장단점 분석실무에서 흔한 실수마치며참고 자료

추천 포스트

Bun 2.0 완전 가이드 — 내장 SQLite·S3 클라이언트·HTML 번들러로 외부 패키지 없이 프로덕션 풀스택 서버 구축하기
Backend

Bun 2.0 완전 가이드 — 내장 SQLite·S3 클라이언트·HTML 번들러로 외부 패키지 없이 프로덕션 풀스택 서버 구축하기

Node.js 프로젝트에서 package.json 을 열어보면 항상 비슷한 패턴이 보입니다. better sqlite3 , @aws sdk/client s3 , webpack 혹은 vite , jest 또는 vitest ... 이런 의존성 하나하나가 쌓일수록 node…

2026년 07월 14일읽는 데 20분
tRPC v11이 Next.js App Router에서 REST를 대체하는 방식 — 코드 생성 없는 end-to-end 타입 안전성과 서버 스트리밍
Backend

tRPC v11이 Next.js App Router에서 REST를 대체하는 방식 — 코드 생성 없는 end-to-end 타입 안전성과 서버 스트리밍

REST API 엔드포인트를 하나 추가할 때마다 반복하는 작업이 있습니다. 서버에 라우트 파일을 만들고, 요청·응답 타입을 별도로 정의하고, 클라이언트 fetch 래퍼를 작성하고, 두 파일 사이에서 타입이 어긋나지 않았는지 직접 확인하는 과정입니다. 엔드포인트가 늘어날…

2026년 07월 13일읽는 데 22분
Deno 2 실전 가이드 — Node.js 호환 모드·내장 KV·Queues로 외부 인프라 없이 서버리스 백엔드 구축하기
Backend

Deno 2 실전 가이드 — Node.js 호환 모드·내장 KV·Queues로 외부 인프라 없이 서버리스 백엔드 구축하기

Deno 2가 2024년 10월에 공개됐을 때 백엔드 개발자들이 가장 먼저 던진 질문은 하나였습니다. 기존 npm 생태계와 얼마나 자연스럽게 공존하는가. 이전 버전의 Deno는 "npm이 안 된다"는 이유 하나로 실무 후보에서 곧바로 제외됐으니까요. 이 글은 공식 문서…

2026년 07월 13일읽는 데 23분
Temporal 클러스터 없이 엣지에서 내결함성 워크플로를 — Cloudflare Workflows의 재시도·인간 승인·타임아웃 처리 방식
Backend

Temporal 클러스터 없이 엣지에서 내결함성 워크플로를 — Cloudflare Workflows의 재시도·인간 승인·타임아웃 처리 방식

서버리스 환경에서 장기 실행 작업을 다루다 보면 공통된 문제를 마주칩니다. 요청이 실패했을 때 처음부터 다시 시작해야 할까, 아니면 중단된 지점부터 재개할 수 있을까. 내구성 있는 실행(Durable Execution) 이라는 개념이 이 고민의 답인데, 전통적으로는 T…

2026년 07월 12일읽는 데 21분
Node.js 네이티브 TypeScript 실행 완전 가이드
Backend

Node.js 네이티브 TypeScript 실행 완전 가이드

기준 버전: Node.js 24 LTS. Node.js 26에서 달라지는 동작(enum 처리 변화 등)은 본문 중 별도 구획에서 다룹니다. node app.ts 가 그냥 된다는 게 어느 시점부터 사실이 되었습니다. 빌드 없이, 추가 도구 없이, 플래그 없이. Node.…

2026년 07월 12일읽는 데 19분
BullMQ로 구축하는 프로덕션 작업 큐 — 우선순위, 재시도 백오프, Dead Letter Queue를 TypeScript로 구현하기
Backend

BullMQ로 구축하는 프로덕션 작업 큐 — 우선순위, 재시도 백오프, Dead Letter Queue를 TypeScript로 구현하기

API 핸들러가 이메일 발송이나 결제 요청을 직접 처리하면, 외부 서비스 응답 지연이 곧 HTTP 타임아웃으로 이어집니다. 비동기 작업 큐는 이 결합을 끊습니다. API는 잡을 큐에 넣는 즉시 응답하고, 실제 처리는 백그라운드 워커가 맡습니다. 그런데 단순히 "큐에 넣…

2026년 07월 12일읽는 데 18분