Claude Code WebSocket 실시간 구현: 인증, 재연결, heartbeat, room 라우팅
Claude Code로 안전한 WebSocket 실시간 기능을 만드는 방법. 인증, 재연결, heartbeat, room, 검증과 리뷰까지 다룹니다.
WebSocket은 서버가 어떤 변화를 알게 된 순간 화면도 같이 바뀌어야 할 때 유용합니다. Claude Code 작업의 진행 상태, 리뷰룸의 새 댓글, 팀원의 room 참여, 운영 알림처럼 지연이 사용자 경험을 망치는 기능에 잘 맞습니다. 하지만 Claude Code가 만들어 준 초안이 동작한다고 해서 바로 공개할 수 있는 것은 아닙니다.
실무에서 자주 깨지는 부분은 인증 핸드셰이크, 재연결 폭주, 모바일 네트워크 단절, 잘못된 JSON, 느린 수신자, room 권한 누락, 감사 로그 부재입니다. 이 글에서는 Node.js ws 서버, 브라우저 재연결 클라이언트, 메시지 schema 검증, heartbeat, 테스트 클라이언트, Claude Code 리뷰 prompt를 복사해서 실행할 수 있는 형태로 정리합니다.
기본 API는 MDN WebSocket, 상태 확인은 readyState, 종료 처리는 close를 확인하세요. Claude Code의 작업 흐름은 Claude Code common workflows가 기준점이 됩니다.
WebSocket을 선택할 때
WebSocket은 오래 유지되는 양방향 채널입니다. HTTP API, 데이터베이스 저장, 결제, 큐, 감사 로그를 대체하지 않습니다. 빠른 동기화에는 WebSocket을 쓰고, 중요한 비즈니스 이벤트는 여전히 서버에서 영속화해야 합니다.
| 선택지 | 적합한 용도 | 피해야 할 용도 | Claude Code 지시 |
|---|---|---|---|
| WebSocket | 채팅, 협업 UI, 작업 진행률, room 방송 | 가끔 오는 알림, 검색 가능한 이력 | 인증, 재연결, heartbeat, backpressure 포함 |
| Server-Sent Events | 서버에서 브라우저로만 보내는 알림 | 브라우저가 자주 보내는 조작 | 이벤트 ID와 타입 설계 |
| Polling | 관리자 화면의 주기적 상태 확인 | 낮은 지연이 필요한 UX | 캐시와 rate limit 추가 |
| HTTP API | 저장, 결제, 감사, 관리 작업 | typing 표시나 presence | 동일한 권한 체크 재사용 |
실용적인 사용 사례는 세 가지 이상입니다. 첫째, 긴 Claude Code 작업의 “분석 중”, “테스트 중”, “diff 확인 중” 상태를 실시간으로 보여 주는 진행 스트림입니다. 둘째, 리뷰 화면에서 댓글과 읽음 상태를 즉시 반영하는 협업 room입니다. 셋째, 고객이나 팀 단위로 운영 알림을 방송하는 대시보드입니다. 교육 현장에서는 강사가 수강생별 실습 상태를 실시간으로 볼 때도 사용할 수 있습니다.
구조
flowchart LR
Browser["Browser client"] -->|"auth message + join room"| Server["Node ws server"]
Server --> Validator["JSON schema validator"]
Server --> Rooms["room registry"]
Server --> Audit["audit log / database"]
Rooms -->|"broadcast"| Browser
Server -->|"ping"| Browser
Browser -->|"auto pong / reconnect"| Server
Claude["Claude Code review loop"] --> Server
backpressure는 수신자가 처리하지 못해 송신 버퍼가 계속 커지는 상태입니다. MDN은 기존 WebSocket API가 backpressure를 자동으로 적용하지 않는다고 설명하므로, 구현에서 bufferedAmount를 보고 위험한 연결을 닫는 판단이 필요합니다.
실패 조건까지 prompt에 넣기
처음부터 기능명보다 제약을 명확히 전달하세요.
Create a Node.js ws realtime WebSocket foundation.
Requirements:
- Browser sends an auth message after connect; join/chat are forbidden before auth
- Clients join by roomId and broadcasts only go to the same room
- Validate JSON messages by type and close invalid messages with 1008
- Implement heartbeat with server ping and pong tracking
- Close slow consumers with 1013 when bufferedAmount is too high
- Browser client uses exponential backoff with jitter
- Do not put secrets in URL query strings
- Do not treat WebSocket delivery as an audit log replacement
실행 가능한 Node ws 서버
로컬 실행:
npm init -y
npm install ws
node server.mjs
server.mjs를 만듭니다. 운영 환경에서는 고정 DEV_TOKEN 대신 짧게 만료되는 ticket, session cookie, 또는 gateway에서 검증한 identity를 사용하세요.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { WebSocket, WebSocketServer } from "ws";
const PORT = Number(process.env.PORT || 8080);
const DEV_TOKEN = process.env.DEV_TOKEN || "dev-token";
const AUTH_TIMEOUT_MS = 5000;
const HEARTBEAT_INTERVAL_MS = 30000;
const MAX_BUFFERED_BYTES = 1024 * 1024;
const rooms = new Map();
const server = createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("WebSocket server is running\n");
});
const wss = new WebSocketServer({ server });
function isRoomId(value) {
return typeof value === "string" && /^[a-zA-Z0-9:_-]{1,64}$/.test(value);
}
function validateClientMessage(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, error: "message must be an object" };
}
if (typeof value.type !== "string") {
return { ok: false, error: "type is required" };
}
if (value.type === "auth") {
if (typeof value.token !== "string" || value.token.length < 8) {
return { ok: false, error: "auth.token is invalid" };
}
return { ok: true, value: { type: "auth", token: value.token } };
}
if (value.type === "join") {
if (!isRoomId(value.roomId)) return { ok: false, error: "roomId is invalid" };
return { ok: true, value: { type: "join", roomId: value.roomId } };
}
if (value.type === "chat") {
if (!isRoomId(value.roomId)) return { ok: false, error: "roomId is invalid" };
if (typeof value.text !== "string" || value.text.length < 1 || value.text.length > 1000) {
return { ok: false, error: "text must be 1-1000 chars" };
}
return {
ok: true,
value: {
type: "chat",
roomId: value.roomId,
requestId: typeof value.requestId === "string" ? value.requestId.slice(0, 100) : randomUUID(),
text: value.text,
},
};
}
if (value.type === "ping") return { ok: true, value: { type: "ping" } };
return { ok: false, error: `unsupported type: ${value.type}` };
}
function decodeMessage(raw) {
if (raw.length > 8192) return { ok: false, error: "message too large" };
try {
return validateClientMessage(JSON.parse(raw.toString("utf8")));
} catch {
return { ok: false, error: "invalid JSON" };
}
}
function sendJson(ws, payload) {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
ws.close(1013, "slow consumer");
return;
}
ws.send(JSON.stringify(payload));
}
function joinRoom(ws, roomId) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.rooms.add(roomId);
}
function leaveAllRooms(ws) {
for (const roomId of ws.rooms) {
const members = rooms.get(roomId);
if (!members) continue;
members.delete(ws);
if (members.size === 0) rooms.delete(roomId);
}
ws.rooms.clear();
}
function broadcast(roomId, payload) {
const members = rooms.get(roomId);
if (!members) return;
for (const client of members) sendJson(client, payload);
}
wss.on("connection", (ws) => {
ws.id = randomUUID();
ws.user = null;
ws.rooms = new Set();
ws.isAlive = true;
const authTimer = setTimeout(() => {
if (!ws.user) ws.close(4401, "auth required");
}, AUTH_TIMEOUT_MS);
ws.on("pong", () => {
ws.isAlive = true;
});
ws.on("message", (raw) => {
const decoded = decodeMessage(raw);
if (!decoded.ok) {
ws.close(1008, decoded.error);
return;
}
const msg = decoded.value;
if (!ws.user) {
if (msg.type !== "auth" || msg.token !== DEV_TOKEN) {
ws.close(4403, "auth failed");
return;
}
ws.user = { id: `user-${ws.id.slice(0, 8)}` };
clearTimeout(authTimer);
sendJson(ws, { type: "auth_ok", connectionId: ws.id, userId: ws.user.id });
return;
}
if (msg.type === "join") {
joinRoom(ws, msg.roomId);
sendJson(ws, { type: "joined", roomId: msg.roomId });
return;
}
if (msg.type === "chat") {
if (!ws.rooms.has(msg.roomId)) {
ws.close(1008, "join room before chat");
return;
}
const event = {
type: "chat",
id: randomUUID(),
requestId: msg.requestId,
roomId: msg.roomId,
userId: ws.user.id,
text: msg.text,
sentAt: new Date().toISOString(),
};
console.log(JSON.stringify({ audit: true, event }));
broadcast(msg.roomId, event);
return;
}
if (msg.type === "ping") sendJson(ws, { type: "pong", sentAt: new Date().toISOString() });
});
ws.on("close", () => {
clearTimeout(authTimer);
leaveAllRooms(ws);
});
});
setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
leaveAllRooms(ws);
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL_MS).unref();
server.listen(PORT, () => {
console.log(`WebSocket server listening on ws://localhost:${PORT}`);
});
브라우저 클라이언트: 재연결과 backoff
브라우저의 WebSocket 생성자는 임의의 Authorization 헤더를 붙일 수 없습니다. 그래서 브라우저 앱은 HTTPS로 짧은 ticket을 받은 뒤, wss 연결 직후 첫 auth 메시지에 담아 보내는 방식이 현실적입니다. secret을 query string에 넣으면 proxy, access log, 분석 도구에 남을 수 있습니다.
<!doctype html>
<html lang="ko">
<body>
<form id="form">
<input id="text" autocomplete="off" placeholder="message" />
<button>send</button>
</form>
<pre id="log"></pre>
<script>
const roomId = "room:demo";
const token = "dev-token";
const log = document.querySelector("#log");
const form = document.querySelector("#form");
const input = document.querySelector("#text");
let socket;
let attempt = 0;
let stopped = false;
function write(line) {
log.textContent += `${line}\n`;
}
function reconnectDelay() {
const base = Math.min(1000 * 2 ** attempt, 10000);
attempt += 1;
return base + Math.floor(Math.random() * 300);
}
function send(payload) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
if (socket.bufferedAmount > 512 * 1024) {
write("send skipped: browser buffer is high");
return false;
}
socket.send(JSON.stringify(payload));
return true;
}
function connect() {
socket = new WebSocket("ws://localhost:8080");
write("connecting");
socket.addEventListener("open", () => {
attempt = 0;
send({ type: "auth", token });
send({ type: "join", roomId });
});
socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
write(`${msg.type}: ${JSON.stringify(msg)}`);
});
socket.addEventListener("close", (event) => {
write(`closed code=${event.code} reason=${event.reason}`);
if (!stopped) window.setTimeout(connect, reconnectDelay());
});
socket.addEventListener("error", () => {
write("socket error");
});
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
send({ type: "chat", roomId, requestId: crypto.randomUUID(), text });
input.value = "";
});
window.addEventListener("beforeunload", () => {
stopped = true;
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000, "page unload");
});
connect();
</script>
</body>
</html>
메시지 schema를 따로 리뷰하기
전체 서버보다 schema를 먼저 Claude Code에 검토시키세요. schema가 흐리면 큰 payload, 알 수 없는 type, room 위조가 쉽게 들어옵니다.
export function validateClientMessage(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, error: "message must be an object" };
}
if (value.type === "join" && /^[a-zA-Z0-9:_-]{1,64}$/.test(value.roomId)) {
return { ok: true, value: { type: "join", roomId: value.roomId } };
}
if (value.type === "chat" && /^[a-zA-Z0-9:_-]{1,64}$/.test(value.roomId)) {
const text = typeof value.text === "string" ? value.text.trim() : "";
if (text.length >= 1 && text.length <= 1000) {
return { ok: true, value: { type: "chat", roomId: value.roomId, text } };
}
}
return { ok: false, error: "invalid realtime message" };
}
Heartbeat와 느린 수신자
heartbeat는 연결이 살아 있는지 확인합니다. ws 서버는 ping을 보내고, 브라우저는 protocol 수준의 pong을 자동으로 돌려줍니다.
setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30000);
느린 수신자는 연결은 남아 있지만 메시지를 처리하지 못하는 클라이언트입니다. 전송 전에 bufferedAmount를 보고, 너무 크면 1013으로 닫고 나중에 재연결하게 합니다.
테스트 클라이언트
다른 브라우저를 열지 않고 두 번째 참가자를 만들 수 있습니다.
import { WebSocket } from "ws";
const ws = new WebSocket("ws://localhost:8080");
const roomId = "room:demo";
ws.on("open", () => {
ws.send(JSON.stringify({ type: "auth", token: "dev-token" }));
ws.send(JSON.stringify({ type: "join", roomId }));
setInterval(() => {
ws.send(JSON.stringify({
type: "chat",
roomId,
requestId: crypto.randomUUID(),
text: `hello ${new Date().toISOString()}`,
}));
}, 3000);
});
ws.on("message", (data) => {
console.log(data.toString());
});
node test-client.mjs를 실행하고 서버를 재시작해 브라우저가 backoff로 다시 연결되는지 확인하세요.
자주 발생하는 함정
ws://example.com?token=...처럼 URL에 secret을 넣어 log나 screenshot에 남기는 것.JSON.parse만 하고 type, 크기, room, 문자열 길이를 검증하지 않는 것.readyState를 확인하지 않고send하는 것.- heartbeat가 없어 모바일 단절 뒤 죽은 연결을 계속 보관하는 것.
- WebSocket 방송을 감사 로그로 착각하는 것. 중요한 이벤트는 반드시 별도 저장해야 합니다.
- Claude Code에 “채팅 만들어줘”라고만 말해 인증, 재연결, backpressure, close code, 테스트를 빠뜨리는 것.
Claude Code 리뷰 prompt
Review this WebSocket implementation before release.
Scope:
- server.mjs
- browser client
- message schema validator
Check:
1. Can a client join/chat before auth?
2. Can secrets leak into URLs, logs, or error messages?
3. Can a client spoof roomId and send to another room?
4. Can reconnect/backoff create a connection storm?
5. Does heartbeat remove dead connections?
6. Are slow consumers handled with bufferedAmount?
7. Is there an audit log outside WebSocket delivery?
Return:
- P0/P1/P2 findings first
- Reproduction steps
- Minimal patch
- Missing tests
관련 주제는 Claude Code security best practices, code review checklist, webhook implementation도 함께 보세요.
교육, 템플릿, 상담
이 예시는 로컬 PoC에는 충분합니다. 팀에 적용하려면 CLAUDE.md, 리뷰 규칙, close code 정책, 감사 저장소, 장애 시 rollback까지 정해야 합니다. ClaudeCodeLab은 실무 도입을 위한 Claude Code training and consultation과 templates and product guides를 제공합니다.
정리
WebSocket은 Claude Code 진행 스트림, 리뷰 room, 협업 도구, 운영 알림에 잘 맞습니다. 하지만 연결이 된다는 사실만으로 안전한 것은 아닙니다. 인증 핸드셰이크, room routing, 메시지 검증, 재연결, heartbeat, backpressure, 감사 로그를 하나의 설계로 다루어야 합니다.
이 글의 구현을 로컬에서 직접 시험한 결과, auth 이전의 chat은 거부되었고 서버를 재시작해도 브라우저가 지수 backoff로 다시 연결되었습니다. room 방송도 같은 room 안에만 유지되었습니다. 다만 buffer 임계값, close code 문구, 감사 이벤트 저장 위치는 팀 상황에 맞춰 조정해야 했습니다. Masa의 실감은 분명합니다. Claude Code는 정상 경로 생성보다 실패 모드를 의심하는 리뷰 역할을 맡겼을 때 품질을 더 크게 끌어올립니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.