왜 Durable Objects로 채팅방·대기열을 관리해야 할까요
실시간 채팅방이나 티켓팅 대기열은 사용자 수가 늘수록 동시성 충돌, 순서 보장, 상태 유실 문제가 쉽게 드러납니다. 서버를 여러 대 늘려도 세션이 분산되면 누가 어디에 줄 섰는지 일관되게 추적하기 어렵습니다.
Cloudflare Durable Objects는 특정 키(예: room-123, queue-abc)에 대해 단일 실행 컨텍스트를 제공해 상태를 한곳에서 직렬화합니다. 즉, 같은 방·같은 대기열의 모든 요청이 한 객체로 모여 순서와 일관성을 보장합니다.
처음 적용한다면 “Redis 락을 어떻게 걸지”, “웹소켓 브로드캐스트는 어디서 유지하지” 같은 고민을 줄일 수 있습니다. 전역에 분산된 에지에서 실행되지만, 각 객체 단위로는 원자적 업데이트가 가능해 초보자도 비교적 단순한 모델로 시작할 수 있습니다.
이 글은 채팅방 참가자 목록, 메시지 브로드캐스트, 대기열 순번 갱신처럼 흔한 패턴을 Durable Objects로 구성하는 기본 흐름을 설명합니다. 실무 입문자가 바로 적용할 수 있도록 최소한의 설계 원칙과 주의점을 함께 다룹니다.
Durable Objects 핵심 개념을 채팅방·대기열 사례로 이해하기
Durable Objects는 키마다 “단일 스레드 상태 관리자”가 한 개씩 붙는 구조입니다. 같은 키를 쓰는 요청은 한 줄로 들어와 순서대로 실행되므로, 채팅방은 메시지 순서를, 대기열은 입장 순번을 자연스럽게 보존합니다.
채팅방에서는 room-123 같은 키로 객체를 할당하고, 참가자 목록과 최근 메시지를 객체 메모리·스토리지에 보관합니다. 그러면 웹소켓 연결 관리와 브로드캐스트가 한곳에서 일어나 중복 전송이나 레이스 컨디션을 줄일 수 있습니다.
대기열은 queue-abc를 키로 삼아 입장 요청을 직렬화하고, 토큰 발급과 타임아웃 처리를 동일한 시계로 통제합니다. 멀티 리전 환경에서도 같은 대기열 키만 같으면 한 객체가 책임지므로 순번 건너뛰기나 중복 할당 위험이 낮아집니다.
초보 단계에서는 다음 체크리스트로 개념을 고정하면 이해가 빠릅니다.
- 키 설계: 방/대기열 단위로 명확한 네이밍
- 일관성: 같은 키 요청은 항상 같은 객체로 라우팅
- 상태 범위: “그 키에 필요한 최소 상태”만 보관
- 이벤트 흐름: 들어옴 → 검증 → 상태 갱신 → 브로드캐스트/응답
Durable Object 실무 체크리스트 요약
방/대기열 단위 Durable Object는 아래 흐름을 지키면 시행착오를 줄입니다. 키 설계→네임스페이스 바인딩→라우팅→웹소켓/대기열 로직 순서를 권장합니다.
- 키: room-{id}, queue-{slug}처럼 충돌 없는 규칙
- 구성: wrangler.toml의 durable_objects, migrations 선언
- 라우팅: 방/대기열 요청을 object stub으로 프록시
- 상태: in-memory(세션)와 storage(영속) 분리
- 타임아웃: 유휴 연결 정리, 토큰 만료 주기 설정
- 에러: 생성 실패, 중복 입장, 역순 메시지 대비
아래 예시는 채팅 브로드캐스트와 대기열 티켓 발급의 최소 골격입니다. 실서비스에서는 인증, 레이트리밋, 재연결 처리를 추가하세요.
export class RoomDO {
constructor(state, env){ this.state=state; this.env=env; this.clients=new Set(); }
async fetch(req){
const url = new URL(req.url);
if (url.pathname.endsWith('/ws')) {
const pair = new WebSocketPair(); const [client, server] = Object.values(pair);
server.accept(); this.clients.add(server);
server.addEventListener('message', e => this.broadcast(e.data));
server.addEventListener('close', ()=> this.clients.delete(server));
return new Response(null, { status: 101, webSocket: client });
}
if (url.pathname.endsWith('/enqueue') && req.method==='POST'){
const n = (await this.state.storage.get('seq')) ?? 0;
const next = n + 1; await this.state.storage.put('seq', next);
return Response.json({ ticket: next });
}
return new Response('not found', {status:404});
}
broadcast(msg){ for (const c of this.clients) try{ c.send(msg);} catch{} }
}
핵심은 같은 키 요청이 한 객체에서 직렬 처리된다는 점입니다. 브로드캐스트는 메모리 Set로, 순번은 storage의 원자 증가로 충돌을 줄입니다.
흔한 실수와 선택 기준: 동시성, 지역성, 비용, 대체안 비교
Durable Objects를 처음 쓰면 키 설계를 느슨하게 잡아 한 객체에 너무 많은 방/대기열을 몰아넣는 실수가 잦습니다. 객체는 단일 실행 컨텍스트이므로 과도한 팬인(연결/요청 집중)은 지연과 메모리 압박을 유발합니다.
지역성(geolocation) 착오도 많습니다. 객체는 키 해시에 따라 특정 위치에 고정되므로, 글로벌 사용자라면 방/대기열을 지역별로 샤딩하거나, 읽기 트래픽은 캐시·KV로 분산하는 보조 경로가 필요합니다.
비용과 내구성 관점에서는 in-memory 상태와 storage 간 경계를 명확히 두세요. 연결 목록, 최근 수십 개 메시지는 메모리, 영구 순번·오더링 메타데이터는 storage에 두고, 스냅샷 주기를 짧게 설정해 재시작 시 손실을 줄입니다.
선택 기준은 다음을 권장합니다.
- 순서 보장·뮤텍스가 핵심이면 Durable Objects를 우선 검토
- 초저지연 브로드캐스트 대규모 팬아웃이면 Pub/Sub+KV와 혼합
- 단순 캐싱·비강결 상태면 KV/Cache로 해결
- 기존 서버/Redis 락과 비교 시 운영 복잡도가 낮은 쪽을 선택
마지막으로 비교 관점 요약입니다. Durable Objects는 “키별 단일 스레드”로 설계 단순성이 강점입니다.
반면 멀티키 트랜잭션, 대규모 팬아웃은 별도 채널(Pub/Sub, WebSocket 중계)을 붙여 보완하는 구성이 현실적입니다.
마무리 요약과 바로 실행할 다음 단계
이 글의 핵심은 “방/대기열을 키로 분리하고, 각 키를 Durable Object로 직렬화해 순서와 일관성을 확보한다”입니다. 메모리(세션)와 스토리지(영속)를 나눠 관리하고, 지역성·부하를 키 설계로 조절하면 초반 품질을 안정화할 수 있습니다.
바로 시도하려면 최소 골격부터 점진적으로 붙이세요. 아래 체크리스트로 처음 경로를 정리합니다.
- wrangler.toml에 durable_objects, migrations 선언
- room-{id}/queue-{id} 키 규칙 결정 및 라우팅 매핑
- in-memory 참가자 목록/대기열 리스트, storage에는 메시지 오더·티켓 시퀀스
- 유휴 연결 정리, 티켓 만료·재발급 규칙 추가
- 인증과 레이트리밋, 재연결 핸들러 후순위 반영
빠른 smoke test를 위해 방 1개와 대기열 1개만 만들고, 동시 접속 10~50명 수준에서 메시지 순서와 티켓 중복 여부를 점검하세요. 운영 전에는 지역별 키 샤딩과 스냅샷 주기를 조정하고, 비용·지연을 주 단위로 모니터링하는 절차를 마련하는 것이 안전합니다.
최소 구현으로 방 브로드캐스트와 티켓 시퀀스 다루기
채팅방은 연결 집합 관리와 브로드캐스트, 대기열은 단일 시퀀스 발급이 핵심입니다. 예시는 최소 골격이며, 인증·요청 제한·장애 복구는 별도 설계가 필요합니다.
채팅방은 동일 방 키로 연결된 클라이언트에 메시지를 중계합니다. 상태는 메모리에, 최근 히스토리는 스토리지에 스냅샷합니다.
export class RoomDO {
#clients = new Set();
constructor(state, env){ this.state = state; this.env = env; }
async fetch(req) {
const { pathname, searchParams } = new URL(req.url);
if (pathname.endsWith("/ws")) {
const [client, server] = Object.values(new WebSocketPair());
await this.handleSocket(server, searchParams.get("u")||"anon");
return new Response(null, { status: 101, webSocket: client });
}
if (pathname.endsWith("/history")) {
const history = (await this.state.storage.get("hist")) || [];
return Response.json(history.slice(-20));
}
return new Response("not found", { status: 404 });
}
async handleSocket(ws, user) {
ws.accept();
this.#clients.add(ws);
ws.addEventListener("message", async (e) => {
const msg = { t: Date.now(), user, text: String(e.data) };
for (const c of this.#clients) try { c.send(JSON.stringify(msg)); } catch {}
const hist = (await this.state.storage.get("hist")) || [];
hist.push(msg); if (hist.length > 200) hist.shift();
await this.state.storage.put("hist", hist);
});
ws.addEventListener("close", () => this.#clients.delete(ws));
}
}
핵심은 동일 방 키 요청이 한 객체로 직렬화돼 메시지 순서가 보존된다는 점입니다. 유휴 연결 정리와 히스토리 길이 제한으로 메모리 누수를 막으세요.
대기열은 단일 시퀀스를 증가시켜 티켓을 발급합니다. 만료·재발급 규칙을 스토리지에 저장해 재시작 시 일관성을 유지합니다.
export class QueueDO {
constructor(state, env){ this.state = state; }
async fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith("/ticket")) {
const now = Date.now();
const data = (await this.state.storage.get("meta")) || { seq: 0 };
data.seq += 1;
const ticket = { no: data.seq, exp: now + 60_000 };
await this.state.storage.put("meta", data);
await this.state.storage.put(`t:${ticket.no}`, ticket, { expirationTtl: 120 });
return Response.json(ticket);
}
return new Response("not found", { status: 404 });
}
}
포인트는 증가 연산이 객체 내부에서 원자적으로 실행돼 중복 번호가 없다는 것입니다. 운영 시 만료 검사, 재시도 백오프, 초과 트래픽 차단을 함께 고려하세요.