WebSocket의 핸드셰이크, 프레이밍 구조, 양방향 통신의 강점과 상태 관리의 복잡성을 분석합니다. AI 채팅에서의 생성 중단, Socket.IO, 스케일링 전략을 다룹니다.
2장에서 살펴본 SSE는 단방향이라는 근본적 한계가 있습니다. 서버에서 클라이언트로만 데이터가 흐르죠. 그런데 실시간 AI 채팅에서는 다음과 같은 상황이 발생합니다.
이런 시나리오에서는 클라이언트와 서버가 동시에 데이터를 주고받을 수 있는 양방향 통신이 필요합니다. WebSocket은 바로 이 문제를 해결하기 위해 설계된 프로토콜입니다.
WebSocket 연결은 일반 HTTP 요청으로 시작되어, 프로토콜 업그레이드를 통해 WebSocket 연결로 전환됩니다.
[클라이언트 요청]
GET /ws/chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
[서버 응답]
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=핸드셰이크가 완료되면, 같은 TCP 연결 위에서 양쪽이 자유롭게 메시지를 보낼 수 있습니다. HTTP의 요청-응답 패턴에서 완전히 벗어나는 것입니다.
Sec-WebSocket-Key와 Sec-WebSocket-Accept는 보안을 위한 것이 아닙니다. 프록시가 일반 HTTP 응답을 WebSocket 응답으로 오해하는 것을 방지하기 위한 프로토콜 검증 메커니즘입니다.
WebSocket은 자체 프레이밍(Framing) 프로토콜을 가지고 있습니다. HTTP와 달리 매우 경량입니다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (0 or 4 bytes) |
+-------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+최소 오버헤드는 2바이트(마스킹 없는 서버 to 클라이언트)에 불과합니다. SSE의 data: 접두사와 \n\n 구분자보다 효율적이며, 바이너리 데이터도 직접 전송할 수 있습니다.
| 프레임 타입 | Opcode | 용도 |
|---|---|---|
| Text | 0x1 | UTF-8 텍스트 전송 |
| Binary | 0x2 | 바이너리 데이터 전송 |
| Close | 0x8 | 연결 종료 |
| Ping | 0x9 | 연결 유지 확인 (서버 발신) |
| Pong | 0xA | Ping에 대한 응답 |
WebSocket이 AI 시스템에서 빛나는 구체적인 시나리오를 살펴보겠습니다.
사용자가 AI의 응답 도중 "중단" 버튼을 누르는 경우입니다. SSE에서는 연결을 끊는 것만 가능하지만, WebSocket에서는 중단 메시지를 보내고 서버가 정리 작업을 수행한 뒤 확인 응답을 보낼 수 있습니다.
interface WSMessage {
type: "chat" | "cancel" | "token" | "done" | "error";
payload: string;
requestId: string;
}
class AIChatClient {
private ws: WebSocket;
private currentRequestId: string | null = null;
constructor(url: string) {
this.ws = new WebSocket(url);
this.ws.onmessage = this.handleMessage.bind(this);
}
sendMessage(content: string) {
this.currentRequestId = crypto.randomUUID();
const msg: WSMessage = {
type: "chat",
payload: content,
requestId: this.currentRequestId,
};
this.ws.send(JSON.stringify(msg));
}
cancelGeneration() {
if (this.currentRequestId) {
const msg: WSMessage = {
type: "cancel",
payload: "",
requestId: this.currentRequestId,
};
this.ws.send(JSON.stringify(msg));
}
}
private handleMessage(event: MessageEvent) {
const msg: WSMessage = JSON.parse(event.data);
switch (msg.type) {
case "token":
appendTokenToUI(msg.payload);
break;
case "done":
finishResponse();
break;
case "error":
showError(msg.payload);
break;
}
}
}여러 사용자가 동시에 AI와 대화하며, 서로의 입력과 AI 응답을 실시간으로 관찰하는 시나리오입니다. 화상 회의에서 AI 비서가 참여하는 경우를 생각해볼 수 있습니다.
WebSocket의 양방향 통신은 강력하지만, 대가가 따릅니다. 연결이 유상태라는 점입니다.
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
// 연결 상태 저장소
const connections = new Map<
string,
{
ws: WebSocket;
userId: string;
sessionId: string;
lastPing: number;
}
>();
wss.on("connection", (ws, request) => {
const connectionId = crypto.randomUUID();
connections.set(connectionId, {
ws,
userId: extractUserId(request),
sessionId: extractSessionId(request),
lastPing: Date.now(),
});
// 하트비트 설정
const heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
ws.on("pong", () => {
const conn = connections.get(connectionId);
if (conn) conn.lastPing = Date.now();
});
ws.on("close", () => {
clearInterval(heartbeat);
connections.delete(connectionId);
cleanupSession(connectionId);
});
ws.on("error", (error) => {
console.error(`Connection ${connectionId} error:`, error);
clearInterval(heartbeat);
connections.delete(connectionId);
});
});
// 좀비 연결 정리 (60초 이상 무응답)
setInterval(() => {
const now = Date.now();
for (const [id, conn] of connections) {
if (now - conn.lastPing > 60000) {
conn.ws.terminate();
connections.delete(id);
}
}
}, 30000);WebSocket 연결은 서버 메모리를 점유합니다. 하트비트를 통한 좀비 연결 감지, 연결 수 제한, 메모리 모니터링은 프로덕션에서 필수입니다. 서버 재시작 시 모든 연결이 끊어지므로, 클라이언트 측 재연결 로직도 반드시 구현해야 합니다.
Socket.IO는 WebSocket을 감싸는 가장 인기 있는 라이브러리입니다. 자동 재연결, 룸(Room), 네임스페이스, 폴백 전송(HTTP Long-Polling) 등을 제공합니다.
import { Server } from "socket.io";
const io = new Server(server, {
cors: { origin: "*" },
transports: ["websocket", "polling"],
});
// 네임스페이스로 AI 채팅 분리
const aiChat = io.of("/ai-chat");
aiChat.on("connection", (socket) => {
// 룸을 활용한 세션 관리
socket.on("join-session", (sessionId: string) => {
socket.join(sessionId);
});
socket.on("chat-message", async (data) => {
const { sessionId, message } = data;
// 같은 세션의 모든 참여자에게 사용자 메시지 브로드캐스트
aiChat.to(sessionId).emit("user-message", {
userId: socket.id,
message,
});
// AI 응답 스트리밍
for await (const token of streamLLMResponse(message)) {
aiChat.to(sessionId).emit("ai-token", { token });
}
aiChat.to(sessionId).emit("ai-done");
});
socket.on("cancel-generation", (data) => {
cancelLLMRequest(data.sessionId);
aiChat.to(data.sessionId).emit("generation-cancelled");
});
});| 사용 적합 | 사용 부적합 |
|---|---|
| 룸 기반 그룹 통신이 필요할 때 | 단순한 서버 to 클라이언트 스트리밍 |
| 다양한 브라우저 호환성이 필요할 때 | 성능이 최우선일 때 |
| 빠른 프로토타이핑 | 네이티브 앱 클라이언트가 주력일 때 |
| 자동 재연결 + 상태 복구가 중요할 때 | 경량 연결이 필요할 때 |
Socket.IO는 자체 프로토콜을 사용하므로, 반드시 Socket.IO 클라이언트 라이브러리가 필요합니다. 표준 WebSocket 클라이언트와는 호환되지 않는다는 점을 유의하세요.
WebSocket의 유상태 특성은 스케일링을 복잡하게 만듭니다. 연결이 특정 서버에 묶여 있기 때문입니다.
로드밸런서가 같은 클라이언트의 요청을 항상 같은 서버로 보내는 방식입니다.
여러 서버에 분산된 클라이언트들에게 메시지를 브로드캐스트해야 할 때, Redis Pub/Sub를 활용합니다.
import { createClient } from "redis";
import { WebSocketServer } from "ws";
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
const wss = new WebSocketServer({ port: 8080 });
const localConnections = new Map<string, WebSocket>();
// 로컬 WebSocket 메시지를 Redis로 발행
wss.on("connection", (ws) => {
const id = crypto.randomUUID();
localConnections.set(id, ws);
ws.on("message", async (data) => {
const msg = JSON.parse(data.toString());
// 다른 서버의 클라이언트에게도 전달
await publisher.publish(
`session:${msg.sessionId}`,
JSON.stringify(msg)
);
});
ws.on("close", () => localConnections.delete(id));
});
// Redis에서 메시지를 수신하여 로컬 클라이언트에 전달
await subscriber.pSubscribe("session:*", (message) => {
const msg = JSON.parse(message);
for (const [, ws] of localConnections) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
});서버를 배포하거나 스케일 다운할 때, 기존 연결을 안전하게 종료해야 합니다.
// 그레이스풀 셧다운
process.on("SIGTERM", async () => {
// 1. 로드밸런서에서 제거 (새 연결 차단)
await deregisterFromLoadBalancer();
// 2. 기존 연결에 종료 예고
for (const [id, conn] of connections) {
conn.ws.send(
JSON.stringify({
type: "server-shutdown",
reconnectAfter: 5000,
})
);
}
// 3. 진행 중인 스트리밍이 완료될 때까지 대기 (최대 30초)
const deadline = Date.now() + 30000;
while (connections.size > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// 4. 남은 연결 강제 종료
for (const [, conn] of connections) {
conn.ws.close(1001, "Server shutting down");
}
process.exit(0);
});| 기준 | SSE | WebSocket |
|---|---|---|
| 통신 방향 | 단방향 | 양방향 |
| 프로토콜 | HTTP | 자체 (HTTP 업그레이드) |
| 데이터 형식 | 텍스트 | 텍스트 + 바이너리 |
| 자동 재연결 | 내장 | 직접 구현 |
| 상태 관리 | 무상태 | 유상태 |
| 스케일링 | 단순 | 복잡 |
| 프록시 호환 | 우수 | 추가 설정 필요 |
| 오버헤드 | 약간 높음 (텍스트) | 매우 낮음 (2바이트) |
| AI 적합 시나리오 | 토큰 스트리밍 | 실시간 채팅, 협업, 음성 |
"둘 중 하나"가 아닙니다. 프로덕션 AI 시스템에서는 LLM 토큰 출력에 SSE를, 사용자 간 실시간 협업에 WebSocket을 함께 사용하는 하이브리드 접근이 일반적입니다.
이번 장에서는 WebSocket의 프로토콜 구조부터 AI 시스템에서의 활용, 그리고 스케일링 전략까지를 살펴보았습니다.
다음 장에서는 클라이언트-서버 통신을 넘어, 백엔드 서비스 간 고성능 통신을 위한 gRPC Streaming을 다룹니다. HTTP/2 기반의 4가지 스트리밍 모드와 Protobuf 직렬화가 마이크로서비스 추론 파이프라인에서 어떻게 활용되는지 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
HTTP/2 기반 gRPC의 4가지 스트리밍 모드, Protobuf 직렬화, 마이크로서비스 간 추론 파이프라인 구현을 다룹니다. gRPC-Web의 제약과 Python/Go 구현 예제를 포함합니다.
HTTP 기반 단방향 스트리밍 프로토콜인 SSE의 동작 원리, EventSource API, 자동 재연결 메커니즘을 분석하고 Next.js와 FastAPI에서의 LLM 토큰 스트리밍 구현을 다룹니다.
OpenAI, Anthropic, Google의 스트리밍 API 차이를 비교하고, 구조화된 출력의 파셜 파싱, React 스트리밍 UI 렌더링, Vercel AI SDK 활용법을 다룹니다.