소개
일반적인 HTTP 요청은 연결을 열고 응답 하나를 받은 뒤 종료됩니다. WebSocket은 첫 번째 HTTP 요청을 양방향 연결로 업그레이드하고 연결을 계속 유지하므로, 서버는 변경이 발생하는 즉시 업데이트를 보낼 수 있습니다. 채팅 메시지, 협업 커서, 실시간 주문 현황은 모두 이러한 실시간 경로의 이점을 얻습니다.
Durable Object는 각 룸에 하나의 조정 지점을 제공합니다. 진입점 Worker는 planning과 같은 검증된 룸 이름을 안정적인 객체 식별자로 변환합니다. 선택된 객체는 해당 룸의 WebSocket 연결을 수락하고, 들어오는 모든 메시지를 검증한 뒤 승인된 업데이트를 자체 연결 클라이언트에만 브로드캐스트합니다. 다른 이름은 다른 객체를 선택하므로 support는 planning의 트래픽을 받을 수 없습니다.
이 실습에서는 의도적으로 표준 WebSocket API를 사용하고 활성 소켓 집합을 메모리에 유지합니다. 이를 통해 O06에서 WebSocket Hibernation과 연결 첨부 정보를 소개하기 전에 연결 및 브로드캐스트 동작을 확인할 수 있습니다. SQLite는 간단한 메시지 기록을 저장하여 잘못된 입력이 영구 상태를 변경하지 않았음을 증명할 수 있게 합니다. 단, 열린 소켓 자체를 영구적으로 만들지는 않습니다.
프로토콜을 구현하고, 제공된 클라이언트 두 개를 하나의 룸에 연결하며, 세 번째 클라이언트를 다른 룸에 연결합니다. 그런 다음 유효한 브로드캐스트를 확인하고, 잘못된 입력을 거부하며, Cloudflare에서 테스트를 반복합니다. 이후 브라우저 클라이언트와 Dashboard를 살펴보고 정확히 일회용 리소스를 삭제합니다.
새 VM마다 자체 Wrangler 인증이 필요합니다. O01~O04에서 안정적인 Durable Object 이름, 바인딩, RPC, SQLite 기반 상태를 이미 이해하고 있어야 합니다. 설정 과정에서는 /home/labex/project/room-broadcast에 Node.js 22.22.0, 프로젝트 로컬 Wrangler 4.132.0, ws 테스트 클라이언트를 설치합니다. 브라우저와 테스트 클라이언트는 제공하지만, Worker 작성, Cloudflare 인증, 배포는 수행하지 않습니다.
VM을 인증하고 룸 네임스페이스 선언하기
이 단계에서는 새 VM을 인증하고 실시간 룸에 사용할 SQLite 기반 Durable Object 클래스를 하나 선언합니다.
준비된 프로젝트로 이동하고, 고정된 Wrangler 버전을 확인한 뒤 VM을 인증합니다.
cd /home/labex/project/room-broadcast
npx wrangler --version
npx wrangler login --device --browser=false
Wrangler 버전은 4.132.0이어야 합니다. 브라우저에서 표시된 Cloudflare URL을 열고 짧은 코드를 입력한 다음, 올바른 학습 계정을 확인하고 인증합니다. 브라우저가 Wrangler에 접근 권한을 부여하며, VM으로 비밀번호를 전송하지는 않습니다.
안전한 식별 필드만 읽고, 확인한 계정을 선택한 뒤 일회용 Worker 이름을 고유하게 생성합니다.
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o05-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
전용 학습 계정의 표시 이름이 다르면, 방금 확인한 이름으로 바꿉니다. 이제 구성을 작성합니다.
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"durable_objects": { "bindings": [
{ "name": "ROOMS", "class_name": "RoomBroadcast" }
] },
"exports": {
"RoomBroadcast": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
ROOMS 바인딩은 Worker가 클래스 네임스페이스로 들어가는 경로입니다. getByName("planning")을 호출하면 항상 같은 논리적 룸을 선택하고, getByName("support")를 호출하면 독립적인 객체를 선택합니다. export 설정은 선택된 각 룸에 별도의 SQLite 저장 공간을 제공합니다. 배포하기 전에는 클라우드 리소스가 생성되지 않습니다.
검증된 WebSocket 프로토콜 구현하기
이 단계에서는 간단한 메시지 계약을 정의하고, WebSocket 메시지를 수락하고 브로드캐스트하는 룸 객체를 구현합니다.
초기 요청에는 Upgrade: websocket 헤더가 있어야 합니다. 업그레이드가 완료되면 메시지는 새 HTTP 요청이 아니라 프레임으로 전달됩니다. 클라이언트는 프레임 안에 어떤 텍스트든 보낼 수 있으므로 JSON 파싱은 첫 번째 검사일 뿐입니다. 영구 상태를 변경하기 전에 예상한 type, 비어 있지 않고 길이가 제한된 text 필드 하나, 그리고 예상하지 못한 필드가 없는지까지 검증해야 합니다.
공유 프로토콜 헬퍼를 생성합니다.
cat > src/protocol.js <<'JS'
const ROOM_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
export function parseRoomPath(pathname) {
const match = pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
if (!match || !ROOM_PATTERN.test(match[1])) return null;
return { room: match[1], action: match[2] };
}
export function parseClientMessage(raw) {
if (typeof raw !== "string" || raw.length > 512) return { ok: false };
let value;
try { value = JSON.parse(raw); } catch { return { ok: false }; }
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false };
const keys = Object.keys(value).sort();
if (keys.length !== 2 || keys[0] !== "text" || keys[1] !== "type") return { ok: false };
if (value.type !== "update" || typeof value.text !== "string") return { ok: false };
const text = value.text.trim();
if (text.length < 1 || text.length > 80) return { ok: false };
return { ok: true, text };
}
JS
진입점 Worker와 Durable Object 클래스를 생성합니다.
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { CLIENT_HTML } from "./client-html.js";
import { parseClientMessage, parseRoomPath } from "./protocol.js";
const json = (body, status = 200) => Response.json(body, { status });
export class RoomBroadcast extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sessions = new Set();
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
});
}
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.add(server);
server.addEventListener("message", event => this.receive(server, event.data));
const forget = () => this.sessions.delete(server);
server.addEventListener("close", forget);
server.addEventListener("error", forget);
server.send(JSON.stringify({ type: "ready" }));
return new Response(null, { status: 101, webSocket: client });
}
receive(sender, raw) {
const message = parseClientMessage(raw);
if (!message.ok) {
sender.send(JSON.stringify({
type: "error",
code: "invalid_message",
detail: "Send only {type: update, text: 1-80 characters}."
}));
return;
}
const createdAt = Date.now();
const row = this.ctx.storage.sql.exec(`
INSERT INTO messages (text, created_at)
VALUES (?, ?)
RETURNING sequence
`, message.text, createdAt).one();
const update = JSON.stringify({
type: "update",
sequence: row.sequence,
text: message.text,
createdAt
});
for (const socket of this.sessions) {
try { socket.send(update); } catch { this.sessions.delete(socket); }
}
console.log(JSON.stringify({ event: "room_update", sequence: row.sequence, connected: this.sessions.size }));
}
async getState() {
const messages = this.ctx.storage.sql.exec(`
SELECT sequence, text, created_at AS createdAt
FROM messages ORDER BY sequence
`).toArray();
return {
messageCount: messages.length,
latestSequence: messages.at(-1)?.sequence ?? 0,
messages
};
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/" && request.method === "GET") {
return new Response(CLIENT_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
}
const route = parseRoomPath(url.pathname);
if (!route) return json({ error: "not_found" }, 404);
if (route.action === "connect") {
if (request.method !== "GET" || (request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
return env.ROOMS.getByName(route.room).fetch(request);
}
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
const state = await env.ROOMS.getByName(route.room).getState();
return json({ room: route.room, ...state });
}
};
JS
WebSocketPair는 하나의 연결에 대한 클라이언트 측과 서버 측을 생성합니다. HTTP 101 응답과 함께 클라이언트 측을 반환하면 업그레이드가 완료되고, server.accept()는 표준 서버 측 소켓을 시작합니다. 메모리의 sessions 집합은 의도적으로 하나의 객체 인스턴스 범위에만 존재합니다. 안정적인 룸 이름이 각 룸의 집합을 전역으로 합치지 않도록 분리합니다.
결정론적인 프로토콜 테스트를 실행하고, 배포하지 않은 상태로 Wrangler에 빌드를 검사하게 합니다.
npm test
npx wrangler deploy --dry-run
테스트 네 개가 모두 통과해야 합니다. dry run은 Worker 모듈과 바인딩 구성을 검사하며, 실제 소켓 동작은 이후의 실행 단계에서 확인합니다.
하나의 룸에서 업데이트 브로드캐스트하기
이 단계에서는 Worker를 로컬에서 실행하고, 하나의 업데이트가 같은 룸을 공유하는 두 클라이언트에는 도달하지만 다른 룸의 클라이언트에는 도달하지 않음을 확인합니다.
Wrangler를 백그라운드 작업으로 시작합니다. 출력을 파일로 리디렉션하면 터미널을 읽기 쉽게 유지할 수 있고, 저장한 작업 ID를 사용하면 나중에 정확한 프로세스만 종료할 수 있습니다.
mkdir -p .labex/local-state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | grep -o '<title>[^<]*</title>'
제공된 클라이언트 프로그램은 실제 WebSocket 연결 세 개를 엽니다. planning에 두 개, support에 하나를 연결합니다. 첫 번째 planning 클라이언트에서 업데이트 하나를 보내고 세 클라이언트에서 제한된 범위의 증거를 기다립니다.
node tools/room-clients.mjs http://127.0.0.1:8787 planning support broadcast | tee .labex/local-broadcast.json
sender와 peer 객체에는 같은 sequence: 1과 텍스트가 포함되어야 합니다. otherUpdates는 0이어야 합니다. state 섹션에는 planning에 영구 저장된 메시지 하나와 support에 저장된 메시지 0개가 독립적으로 표시됩니다. 이는 설계의 두 가지 핵심을 보여 줍니다. 안정적인 같은 이름이 처음 두 클라이언트를 하나로 묶고, 다른 이름이 세 번째 클라이언트를 브로드캐스트 범위 밖에 둡니다.
상태가 변경되기 전에 잘못된 메시지 거부하기
이 단계에서는 JSON으로는 유효하지만 애플리케이션 입력으로는 잘못된 프레임을 보내고, 전후의 영구 상태를 비교합니다.
빈 text 필드가 중요한 차이입니다. JSON 파싱은 성공하지만 룸 프로토콜은 이를 거부합니다. 같은 로컬 객체를 대상으로 제공된 두 번째 단계를 실행합니다.
node tools/room-clients.mjs http://127.0.0.1:8787 planning support invalid | tee .labex/local-invalid.json
보낸 클라이언트만 invalid_message 코드가 포함된 오류를 받으며, peerErrors는 0으로 유지됩니다. before와 after 기록은 메시지 하나로 동일해야 합니다. 따라서 잘못된 클라이언트는 행을 추가하거나 sequence를 증가시키거나 오류를 룸 전체 브로드캐스트로 바꿀 수 없습니다.
두 룸의 상태를 직접 읽습니다.
curl --silent --fail http://127.0.0.1:8787/rooms/planning/state | jq
curl --silent --fail http://127.0.0.1:8787/rooms/support/state | jq
첫 번째 응답에는 메시지 하나가 표시되고, 두 번째 응답에는 메시지가 없어야 합니다. 테스트 후 클라이언트가 연결을 끊더라도 HTTP 상태 읽기 결과가 기준이 됩니다.
Cloud WebSocket 클라이언트 배포 및 실행하기
이 단계에서는 로컬 런타임을 중지하고 같은 코드를 배포한 뒤, Cloudflare를 통해 세 클라이언트 계약을 반복합니다.
앞에서 기록한 로컬 작업만 종료한 다음 배포합니다.
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
배포 과정에서는 먼저 Worker를 생성하고 RoomBroadcast 네임스페이스를 조정합니다. 홈페이지가 성공적으로 열렸다고 해서 상태 라우트가 준비되었다는 뜻은 아닙니다. 따라서 비어 있는 룸의 상태를 조회하여 정확한 JSON 계약을 확인한 뒤, 짧은 안정화 시간을 둡니다.
for attempt in $(seq 1 30); do
READY="$(curl --silent --show-error "$APP_URL/rooms/cloud-observer/state" || true)"
test "$(printf '%s' "$READY" | jq -r '.messageCount // -1' 2>/dev/null)" = 0 && break
sleep 2
done
test "$(printf '%s' "$READY" | jq -r .messageCount)" = 0
sleep 5
고유한 클라우드 룸을 사용하여 같은 실제 WebSocket 클라이언트를 실행합니다.
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support broadcast | tee .labex/cloud-broadcast.json
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support invalid | tee .labex/cloud-invalid.json
클라우드 출력은 로컬 개발과 같은 동작을 보여야 합니다. 두 planning 클라이언트는 sequence 1을 받고, support 클라이언트는 업데이트를 받지 않으며, 잘못된 입력으로도 기록이 변경되지 않아야 합니다.
출력된 APP_URL을 브라우저에서 엽니다. Connect three clients를 선택한 다음 Send planning update를 선택합니다. Client A와 B에는 같은 새로운 update가 표시되고, Client C에는 ready 메시지만 표시되어야 합니다. Send malformed update를 선택하고 Client A에만 오류가 나타나는지 확인합니다. 결과 확인이 끝나면 Disconnect clients를 선택하고 세 카드 모두 Closed를 표시할 때까지 기다립니다. 이렇게 해야 페이지를 떠나기 전에 WebSocket 종료 핸드셰이크가 완료됩니다. 이 페이지는 제공된 관찰용 클라이언트이며, 승인 여부를 판단하는 기준은 Node 프로브와 백엔드 검사입니다.
브라우저 클라이언트와 Durable Object 검사하기
이 단계에서는 런타임 증거를 Cloudflare Dashboard와 연결하고, 변경하지 않은 코드를 다시 배포한 뒤에도 영구적인 룸 기록이 유지되는지 확인합니다.
브라우저 데모를 연결된 상태로 유지하면서 세 카드의 내용을 충분히 확인합니다. 두 planning 카드는 룸 범위 브로드캐스트를 보여 주는 증거이고, 조용한 support 카드도 그만큼 중요합니다. support 카드가 조용하다는 것은 식별자 경계를 넘어 메시지가 전달되지 않았음을 보여 줍니다.

Cloudflare Dashboard에서 Workers & Pages를 열고 .labex/run-name에 저장된 정확한 이름을 선택한 뒤 바인딩을 확인합니다. ROOMS는 RoomBroadcast를 가리켜야 합니다. 그런 다음 Durable Objects를 열고 <your-worker>_RoomBroadcast라는 이름의 네임스페이스를 선택한 뒤 Overview에서 Storage: SQL을 확인합니다.


네임스페이스의 Logs 탭을 엽니다. 브라우저 또는 Node 프로브와 관련된 최근 성공 행을 선택합니다. 구조화된 room_update 애플리케이션 메시지는 메시지 텍스트를 기록하지 않고 sequence와 현재 연결 수를 보고합니다. Dashboard에 로그가 요청 이후 도착할 수 있으므로, 런타임 응답과 독립적인 검사를 기준으로 사용합니다.
변경하지 않은 코드를 다시 배포합니다. 열린 WebSocket 연결은 실시간 전송 경로이므로 배포 후에도 유지된다고 보장되지 않습니다. 반면 SQLite 기록은 이름이 지정된 객체에 속하므로 유지되어야 합니다.
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/cloud-planning/state" | jq
curl --silent --fail "$APP_URL/rooms/cloud-support/state" | jq
planning 룸에는 여전히 sequence 1인 메시지 하나가 표시되어야 하며, support는 계속 비어 있어야 합니다. 생성된 접미사, 타임스탬프, Dashboard 트래픽 합계는 테스트 예시와 다를 수 있습니다.
룸 네임스페이스 삭제하기
이 단계에서는 정확히 지정된 일회용 Durable Object 네임스페이스와 Worker를 삭제한 뒤, LabEx가 두 리소스가 모두 사라졌는지 확인할 수 있도록 VM 인증을 유지합니다.
저장된 이름이 labex-c10-o05-로 시작하는지 확인합니다. 상태를 저장하지 않는 정리 진입점을 생성합니다.
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o05-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
같은 Worker와 계정을 위한 정리 구성을 생성합니다. state: "deleted" tombstone은 이 실습의 클래스 네임스페이스와 그 안의 일회용 기록만 삭제합니다.
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"exports": {
"RoomBroadcast": { "type": "durable-object", "state": "deleted" }
}
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
조정 결과에 Deleted: RoomBroadcast가 표시되어야 합니다. 남은 상태 비저장 Worker를 삭제합니다. 삭제는 되돌릴 수 없으므로 Wrangler가 확인을 요청합니다. 표시된 이름이 $RUN 값과 정확히 일치하는지 확인한 뒤 진행합니다.
npx wrangler delete --config wrangler.cleanup.jsonc
프롬프트에서 y를 입력하고 Enter 키를 누릅니다. 명령은 생성된 Worker 이름 뒤에 Successfully deleted를 표시하며 완료되어야 합니다.
이 단계 마지막에 검사할 수 있도록 이 VM의 인증을 유지합니다. Wrangler가 여전히 인증된 세션을 보고하는지 확인합니다.
npx wrangler whoami --json | jq '{loggedIn, authType}'
JSON에는 "loggedIn": true가 포함되어야 합니다. 이제 LabEx가 선택한 계정을 조회하여 Worker와 Durable Object 네임스페이스가 모두 사라졌음을 확인할 수 있습니다. 네트워크 오류나 인증 오류만으로는 정리가 완료되었다고 볼 수 없습니다.
이 VM의 Wrangler 인증 취소하기
이 단계에서는 클라우드 리소스 삭제가 확인된 후, 이 새 VM에만 저장된 OAuth 인증을 취소합니다.
wrangler logout은 로컬 인증 정보를 삭제합니다. 일반적인 사람이 읽는 출력은 모호할 수 있으므로 구조화된 whoami --json 검사가 중요합니다. loggedIn 필드가 최종 결과를 판단하는 기준입니다.
npx wrangler logout
npx wrangler whoami --json
최종 JSON에는 "loggedIn": false가 포함되어야 합니다. 이 작업은 브라우저의 Cloudflare 학습 계정을 삭제하거나 로그아웃하지 않습니다. 이 VM이 인증된 Wrangler 요청을 추가로 보내지 못하게 할 뿐입니다.
요약
HTTP 요청을 WebSocket으로 업그레이드하고, 검증된 룸 이름을 독립적인 Durable Object로 라우팅했으며, 승인된 업데이트 하나를 같은 룸의 두 클라이언트에 브로드캐스트하고 다른 룸은 격리했습니다. JSON 파싱과 애플리케이션 검증을 분리하고, 잘못된 입력이 브로드캐스트 상태와 SQLite 기록을 모두 변경하지 않음을 확인했습니다. Cloudflare에서 동작을 반복하고, 브라우저와 Dashboard 화면을 검사했으며, 재배포 후에도 기록이 유지되는지 확인하고, 정확한 일회용 네임스페이스를 삭제했습니다.
재사용할 수 있는 설계 원칙은 다음과 같습니다. 상태를 선택하거나 변경하기 전에 입력을 검증하고, 각 실시간 그룹을 고유한 안정적 객체 식별자로 조정하며, 활성 연결과 영구적인 애플리케이션 기록을 서로 별개의 대상으로 취급합니다.



