Claude Code로 WebSocket 채팅 앱 만들기
Claude Code로 안전한 WebSocket 채팅을 만드는 법: 인증, 재연결, 속도 제한, 브로드캐스트와 실행 가능한 코드.
WebSocket 채팅은 Claude Code로 실시간 기능을 만들 때 좋은 연습 과제입니다. 화면은 단순하지만 실제 서비스에서 필요한 연결 인증, 재연결, 방 단위 브로드캐스트, 속도 제한, 메시지 검증, 장애 확인이 모두 들어갑니다. 단순히 “채팅 앱 만들어 줘”라고 요청하면 보통 인증 없는 데모가 나오므로, 처음부터 안전 조건을 작업 범위에 넣어야 합니다.
이 글은 브라우저 기본 WebSocket API와 Node.js ws 패키지로 복사해서 실행할 수 있는 예제를 만듭니다. WebSocket은 HTTP upgrade 이후 브라우저와 서버가 같은 연결에서 서로 메시지를 보낼 수 있는 방식입니다. 브라우저 API는 MDN WebSocket API와 Writing WebSocket client applications, 프로토콜은 RFC 6455, Node.js 진입점은 HTTP upgrade 이벤트를 기준으로 확인하세요. 서버 라이브러리는 ws README, Origin 검증은 OWASP WebSocket testing guide를 참고합니다. Claude Code 자체는 공식 문서를 기준으로 둡니다.
관련 글로는 Claude Code API 개발, Claude Code 코드 리뷰, 보안 베스트 프랙티스를 함께 보면 좋습니다.
실제로 쓸 수 있는 세 가지 경우
첫째, 작은 커뮤니티나 고객 지원 채팅입니다. 필요한 데이터는 방, 이름, 메시지, 시간, 최근 기록 정도입니다. 하지만 인증과 제한이 없으면 한 사용자가 자동으로 수천 개의 메시지를 보낼 수 있습니다.
둘째, 운영 대시보드의 실시간 알림입니다. 빌드 상태, 결제 이벤트, 재고 변화, 작업 큐 상태를 서버에서 바로 보낼 수 있습니다. 이때 중요한 것은 오래된 이벤트를 어떻게 버릴지입니다. MDN이 설명하듯 브라우저 WebSocket API에는 강한 백프레셔, 즉 받는 쪽이 느릴 때 보내는 쪽을 자동으로 늦추는 기능이 없습니다.
셋째, 학습 콘텐츠나 유료 자료 뒤의 질문방입니다. 독자가 글을 읽은 뒤 질문하고, 운영자는 다음 글이나 템플릿, 상담 페이지로 안내할 수 있습니다. 전환에는 좋지만 개인정보와 로그 정책도 함께 설계해야 합니다.
| 사용 사례 | WebSocket이 맞는 이유 | 먼저 정할 것 |
|---|---|---|
| 작은 채팅 | 양방향 메시지 지연이 낮음 | 인증, 방, 기록 수 |
| 실시간 대시보드 | 서버가 직접 업데이트 전송 | 오래된 이벤트 처리, 재연결 |
| 학습 지원 | 글에서 대화로 자연스럽게 이동 | 로그, 관리, 전환 경로 |
구조
여기서 팬아웃은 한 메시지를 같은 방의 여러 연결로 나누어 보내는 처리입니다. 하트비트는 오래 열린 연결이 아직 살아 있는지 확인하는 과정입니다.
flowchart LR
BrowserA["브라우저 탭 A"] -->|ws:// /chat| Node["Node.js server"]
BrowserB["브라우저 탭 B"] -->|ws:// /chat| Node
Smoke["smoke-client.mjs"] -->|ws:// /chat| Node
Node --> Auth["token and Origin check"]
Node --> Rooms["room registry"]
Rooms --> Fanout["fan-out to room peers"]
Node --> History["last 50 messages"]
Node --> Limit["rate limit and max payload"]
Claude Code에 줄 요청
Node.js 20 이상과 ws 패키지로 최소 WebSocket 채팅을 만들어 주세요.
요구사항:
- server.js, index.html, smoke-client.mjs, package.json만 사용
- http://localhost:8080 에서 채팅 화면 제공
- /chat WebSocket upgrade 단계에서 token과 Origin 검증
- room 단위로 메시지 팬아웃
- 메모리에는 최근 50개 메시지만 유지
- 연결 하나당 10초 20개 메시지 제한
- 메시지 길이는 500자 제한
- 브라우저 클라이언트는 close 후 지수 백오프로 재연결
- 전송 전 readyState와 bufferedAmount 확인
- smoke-client.mjs로 연결과 전송을 확인
금지:
- Socket.IO로 바꾸지 않기
- 인증 없는 연결 허용하지 않기
- JSON 입력을 검증 없이 사용하지 않기
- 메모리 기록을 production persistence처럼 설명하지 않기
로컬 실행
mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start
package.json을 아래처럼 바꿉니다.
{
"type": "module",
"scripts": {
"start": "node server.js",
"smoke": "node smoke-client.mjs"
},
"dependencies": {
"ws": "^8.18.3"
},
"engines": {
"node": ">=20"
}
}
server.js
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket, WebSocketServer } from "ws";
const PORT = Number(process.env.PORT ?? 8080);
const AUTH_TOKEN = process.env.CHAT_TOKEN ?? "dev-token";
const MAX_MESSAGE_LENGTH = 500;
const HISTORY_LIMIT = 50;
const RATE_WINDOW_MS = 10_000;
const RATE_LIMIT = 20;
const CLIENT_FILE = join(fileURLToPath(new URL(".", import.meta.url)), "index.html");
const ALLOWED_ORIGINS = new Set(
(process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT},http://127.0.0.1:${PORT}`)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
);
const rooms = new Map();
const histories = new Map();
const clients = new Map();
const server = createServer(async (request, response) => {
if (request.url === "/" || request.url === "/index.html") {
try {
const html = await readFile(CLIENT_FILE, "utf8");
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(html);
} catch {
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
response.end("index.html was not found");
}
return;
}
if (request.url === "/healthz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, clients: clients.size }));
return;
}
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 2048 });
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
if (url.pathname !== "/chat") {
rejectUpgrade(socket, 404, "Not Found");
return;
}
const origin = request.headers.origin ?? "";
if (!ALLOWED_ORIGINS.has(origin)) {
rejectUpgrade(socket, 403, "Forbidden");
return;
}
const token = url.searchParams.get("token");
if (token !== AUTH_TOKEN) {
rejectUpgrade(socket, 401, "Unauthorized");
return;
}
const context = {
name: cleanName(url.searchParams.get("name")),
room: cleanRoom(url.searchParams.get("room"))
};
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request, context);
});
});
wss.on("connection", (ws, request, context) => {
const client = {
id: randomUUID(),
name: context.name,
room: context.room,
isAlive: true,
rateResetAt: Date.now() + RATE_WINDOW_MS,
messagesInWindow: 0
};
clients.set(ws, client);
joinRoom(ws, client.room);
send(ws, { type: "system", message: `connected as ${client.name}`, clientId: client.id });
send(ws, { type: "history", messages: histories.get(client.room) ?? [] });
broadcast(client.room, { type: "presence", message: `${client.name} joined`, online: roomSize(client.room) });
ws.on("pong", () => {
client.isAlive = true;
});
ws.on("message", (raw, isBinary) => {
if (isBinary) {
ws.close(1003, "text messages only");
return;
}
if (!consumeQuota(client)) {
send(ws, { type: "error", code: "rate_limited", message: "Too many messages. Wait a moment." });
return;
}
const textBody = raw.toString("utf8");
if (textBody.length > MAX_MESSAGE_LENGTH) {
send(ws, { type: "error", code: "too_large", message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less.` });
return;
}
let event;
try {
event = JSON.parse(textBody);
} catch {
send(ws, { type: "error", code: "bad_json", message: "Send JSON such as {\"type\":\"message\",\"text\":\"hi\"}." });
return;
}
if (event.type !== "message") {
send(ws, { type: "error", code: "bad_type", message: "Only message events are accepted." });
return;
}
const text = String(event.text ?? "").trim();
if (!text) {
send(ws, { type: "error", code: "empty", message: "Message text is required." });
return;
}
const message = {
id: randomUUID(),
room: client.room,
from: client.name,
text,
sentAt: new Date().toISOString()
};
remember(client.room, message);
broadcast(client.room, { type: "message", message });
});
ws.on("close", () => {
leaveRoom(ws);
clients.delete(ws);
broadcast(client.room, { type: "presence", message: `${client.name} left`, online: roomSize(client.room) });
});
ws.on("error", (error) => {
console.error("websocket error", error);
});
});
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
const client = clients.get(ws);
if (!client) continue;
if (!client.isAlive) {
ws.terminate();
continue;
}
client.isAlive = false;
ws.ping();
}
}, 30_000);
server.listen(PORT, () => {
console.log(`Chat demo: http://localhost:${PORT}`);
console.log(`Token: ${AUTH_TOKEN}`);
});
process.on("SIGINT", () => {
clearInterval(heartbeat);
wss.close();
server.close(() => process.exit(0));
});
function rejectUpgrade(socket, statusCode, message) {
socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}
function cleanName(value) {
const name = String(value ?? "guest").trim().slice(0, 24);
return /^[\w -]+$/.test(name) ? name : "guest";
}
function cleanRoom(value) {
const room = String(value ?? "lobby").trim().slice(0, 32);
return /^[a-zA-Z0-9_-]+$/.test(room) ? room : "lobby";
}
function joinRoom(ws, room) {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
}
function leaveRoom(ws) {
const client = clients.get(ws);
if (!client) return;
const peers = rooms.get(client.room);
if (!peers) return;
peers.delete(ws);
if (peers.size === 0) rooms.delete(client.room);
}
function roomSize(room) {
return rooms.get(room)?.size ?? 0;
}
function send(ws, payload) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function broadcast(room, payload) {
const peers = rooms.get(room);
if (!peers) return;
const body = JSON.stringify(payload);
for (const peer of peers) {
if (peer.readyState === WebSocket.OPEN) {
peer.send(body);
}
}
}
function remember(room, message) {
const history = histories.get(room) ?? [];
history.push(message);
while (history.length > HISTORY_LIMIT) history.shift();
histories.set(room, history);
}
function consumeQuota(client) {
const now = Date.now();
if (now > client.rateResetAt) {
client.rateResetAt = now + RATE_WINDOW_MS;
client.messagesInWindow = 0;
}
client.messagesInWindow += 1;
return client.messagesInWindow <= RATE_LIMIT;
}
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WebSocket 채팅 데모</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 32px auto; padding: 0 16px; }
label { display: block; margin-top: 12px; }
input, button { font: inherit; padding: 8px; }
#log { border: 1px solid #ccc; min-height: 240px; padding: 12px; overflow: auto; }
.message { margin: 0 0 8px; }
.muted { color: #666; }
.error { color: #b00020; }
</style>
</head>
<body>
<h1>WebSocket 채팅 데모</h1>
<p id="status" class="muted">오프라인</p>
<label>방 <input id="room" value="lobby" /></label>
<label>이름 <input id="name" value="masa" /></label>
<label>토큰 <input id="token" value="dev-token" /></label>
<button id="connect">연결</button>
<button id="disconnect">끊기</button>
<div id="log" aria-live="polite"></div>
<form id="form">
<label>메시지 <input id="message" autocomplete="off" maxlength="500" /></label>
<button type="submit">보내기</button>
</form>
<script>
const statusEl = document.querySelector("#status");
const logEl = document.querySelector("#log");
const formEl = document.querySelector("#form");
const roomEl = document.querySelector("#room");
const nameEl = document.querySelector("#name");
const tokenEl = document.querySelector("#token");
const messageEl = document.querySelector("#message");
const connectEl = document.querySelector("#connect");
const disconnectEl = document.querySelector("#disconnect");
let socket;
let reconnectTimer;
let reconnectAttempt = 0;
let manuallyClosed = false;
const pendingMessages = [];
connectEl.addEventListener("click", connect);
disconnectEl.addEventListener("click", () => {
manuallyClosed = true;
clearTimeout(reconnectTimer);
if (socket) socket.close(1000, "user disconnected");
});
formEl.addEventListener("submit", (event) => {
event.preventDefault();
const text = messageEl.value.trim();
if (!text) return;
const payload = JSON.stringify({ type: "message", text });
if (socket?.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
socket.send(payload);
} else {
pendingMessages.push(payload);
writeLog("소켓이 준비되지 않아 메시지를 대기열에 넣었습니다.", "muted");
}
messageEl.value = "";
});
function connect() {
manuallyClosed = false;
clearTimeout(reconnectTimer);
if (socket && socket.readyState === WebSocket.OPEN) return;
const params = new URLSearchParams({
token: tokenEl.value,
name: nameEl.value,
room: roomEl.value
});
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${location.host}/chat?${params.toString()}`);
setStatus("연결 중");
socket.addEventListener("open", () => {
reconnectAttempt = 0;
setStatus("온라인");
flushPending();
});
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
renderEvent(data);
});
socket.addEventListener("close", (event) => {
setStatus(`종료됨 ${event.code}`);
if (!manuallyClosed) scheduleReconnect();
});
socket.addEventListener("error", () => {
writeLog("WebSocket 오류입니다. 서버 로그를 확인하세요.", "error");
});
}
function scheduleReconnect() {
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
reconnectAttempt += 1;
setStatus(`${delay}ms 후 재연결`);
reconnectTimer = setTimeout(connect, delay);
}
function flushPending() {
while (pendingMessages.length > 0 && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
socket.send(pendingMessages.shift());
}
}
function renderEvent(data) {
if (data.type === "message") {
writeLog(`${data.message.from}: ${data.message.text}`);
} else if (data.type === "history") {
writeLog(`이전 메시지 ${data.messages.length}개를 불러왔습니다.`, "muted");
data.messages.forEach((message) => writeLog(`${message.from}: ${message.text}`));
} else if (data.type === "error") {
writeLog(`${data.code}: ${data.message}`, "error");
} else {
writeLog(data.message ?? JSON.stringify(data), "muted");
}
}
function setStatus(value) {
statusEl.textContent = value;
}
function writeLog(value, className = "message") {
const line = document.createElement("p");
line.className = className;
line.textContent = value;
logEl.append(line);
logEl.scrollTop = logEl.scrollHeight;
}
connect();
</script>
</body>
</html>
smoke-client.mjs
import { WebSocket } from "ws";
const url = new URL("ws://localhost:8080/chat");
url.searchParams.set("token", process.env.CHAT_TOKEN ?? "dev-token");
url.searchParams.set("name", "smoke");
url.searchParams.set("room", "lobby");
const ws = new WebSocket(url, {
headers: {
Origin: "http://localhost:8080"
}
});
const timeout = setTimeout(() => {
console.error("smoke test timed out");
ws.terminate();
process.exit(1);
}, 5000);
ws.on("open", () => {
ws.send(JSON.stringify({ type: "message", text: "hello from smoke test" }));
});
ws.on("message", (raw) => {
const data = JSON.parse(raw.toString("utf8"));
console.log(data);
if (data.type === "message" && data.message.text === "hello from smoke test") {
clearTimeout(timeout);
ws.close(1000, "done");
}
});
ws.on("close", (code, reason) => {
console.log(`closed ${code} ${reason.toString()}`);
process.exit(code === 1000 ? 0 : 1);
});
ws.on("error", (error) => {
clearTimeout(timeout);
console.error(error);
process.exit(1);
});
npm run smoke
실패 사례
| 실패 | 영향 | 대응 |
|---|---|---|
| upgrade에서 인증하지 않음 | 신뢰하지 않는 소켓이 활성화됨 | handleUpgrade 전에 거부 |
| Origin 미검증 | 교차 사이트 WebSocket 탈취 위험 | 허용 Origin 고정 |
| 속도 제한 없음 | 한 연결이 CPU와 메모리를 소모 | 연결, 사용자, IP 단위 제한 |
| 기록을 무제한 메모리에 저장 | 장시간 운영 시 메모리 증가 | 개수, 기간, 저장소 정책 제한 |
| 즉시 재연결 반복 | 장애 복구 중 트래픽 폭증 | 지수 백오프와 지터 사용 |
| 단일 프로세스 팬아웃만 구현 | 여러 서버에서 메시지 누락 | Redis Pub/Sub, NATS, 큐 사용 |
운영과 전환
로그인, 결제, 고객 지원, 개인정보가 섞이면 wss://를 사용하세요. 권한은 연결 시점뿐 아니라 방 입장, 메시지 전송, 삭제 같은 동작마다 다시 확인해야 합니다. 기록은 데이터베이스로 옮기고, 여러 Node 프로세스가 필요하면 Redis Pub/Sub이나 메시지 브로커로 팬아웃을 공유합니다.
혼자 학습한다면 무료 Claude Code 치트시트로 기본 명령과 검증 습관을 잡으세요. 재사용 가능한 프롬프트와 설정 자료가 필요하면 ClaudeCodeLab 제품을 볼 수 있습니다. 팀에서 인증, 감사 로그, Pub/Sub, CI, 리뷰 기준을 실제 저장소에 맞추려면 Claude Code 교육 및 상담으로 연결하는 것이 현실적입니다.
Masa가 이 흐름을 테스트한 결과, server.js 실행 후 http://localhost:8080을 두 탭에서 열면 같은 lobby 방의 메시지가 즉시 양쪽에 보였습니다. npm run smoke도 hello from smoke test를 보내고 다시 받아 성공했습니다. Origin을 바꾸면 403, token을 바꾸면 401이 나왔습니다. 재연결, 속도 제한, Origin 검증, 최근 50개 기록 제한을 처음부터 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, 상담 경로 체크리스트.