프론트엔드 통신 방식 (3): WebSocket 심화 — 핸드셰이크부터 재연결·확장까지
앞 편에서 단방향 실시간(SSE)을 다뤘습니다. 하지만 채팅, 협업 편집, 멀티플레이어, 실시간 거래처럼 클라이언트와 서버가 대등하게, 자주, 즉시 대화해야 하는 경우가 있습니다. 이때 등장하는 것이 WebSocket입니다.
WebSocket은 "양방향 실시간"의 대표 기술이지만, 동시에 가장 많은 운영 함정을 가진 기술이기도 합니다. 연결을 만드는 것은 쉽지만, 그 연결을 끊기지 않게 유지하고, 끊겼을 때 복구하고, 서버를 여러 대로 늘리는 것은 전혀 다른 난이도입니다.
이번 편에서는 WebSocket을 동작 원리 → 클라이언트 구현 → 수평 확장 순으로, 시니어 프론트엔드가 실제로 부딪히는 문제 중심으로 파고듭니다.
이 글은 프론트엔드 통신 방식 시리즈의 3편입니다.
- 1편: 전체 지도와 선택 축
- 2편: HTTP 위에서의 실시간 — Polling, Long Polling, SSE
- 3편: WebSocket 심화 (현재 글)
- 4편: gRPC와 gRPC-Web, Protocol Buffers
- 5편: GraphQL과 통신 방식 최종 선택 가이드
한눈에 보면
- WebSocket은 HTTP로 **핸드셰이크(Upgrade)**한 뒤, 같은 TCP 연결을 양방향 메시지 채널로 전환합니다.
- 한번 연결되면 HTTP 요청-응답이 아니라 프레임 단위 메시지가 양쪽으로 자유롭게 흐릅니다.
- 진짜 어려움은 연결이 아니라 유지입니다. 하트비트, 자동 재연결, 백오프, 메시지 큐잉을 직접 만들어야 합니다.
- WebSocket은 무상태가 아니므로 수평 확장 시 sticky session과 pub/sub(Redis 등) 백플레인이 필요합니다.
- WebSocket은 HTTP 캐시·CDN의 이점을 포기합니다. 즉 모든 부담을 애플리케이션이 진다는 뜻입니다.
- 그래서 시니어의 질문은 "쓸 수 있는가"가 아니라 "이 양방향성이 그 운영 비용을 정당화하는가"입니다.
WebSocket은 어떻게 시작되는가: 핸드셰이크
WebSocket의 영리한 점은 HTTP로 시작한다는 것입니다. 새 포트를 열 필요 없이, 기존 HTTP(S) 포트에서 "이 연결을 WebSocket으로 바꾸자"고 협상합니다.
클라이언트가 보내는 핸드셰이크 요청은 평범한 HTTP GET에 특별한 헤더가 붙은 것입니다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13서버가 동의하면 101 Switching Protocols로 응답합니다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=sequenceDiagram
participant C as Client
participant S as Server
C->>S: HTTP GET + Upgrade: websocket
S-->>C: 101 Switching Protocols
Note over C,S: 이제 같은 TCP 연결이 양방향 채널로 전환
C->>S: 메시지 프레임
S->>C: 메시지 프레임
C->>S: 메시지 프레임 (언제든 양쪽 가능)101 이후로는 더 이상 HTTP가 아닙니다. 같은 TCP 연결 위에서 WebSocket 프레임이라는 가벼운 단위로 메시지가 오갑니다. 이 점이 중요합니다. 핸드셰이크만 HTTP고, 그 뒤로는 HTTP 의미(메서드, 상태 코드, 캐시)가 전부 사라집니다. 그래서 REST의 무상태·캐시 이점도 함께 사라집니다.
wss://는 TLS 위의 WebSocket으로, HTTPS와 같은 보안을 제공합니다. 운영에서는 항상wss를 써야 합니다. 이유는 HTTPS 동작 원리 글의 맥락과 같습니다.
프레임과 메시지: 무엇이 오가는가
핸드셰이크 후 데이터는 프레임(frame) 단위로 전송됩니다. 프레임에는 텍스트/바이너리 구분, 길이, 그리고 연결을 닫거나(close) 살아있음을 확인하는(ping/pong) 제어 프레임이 있습니다.
프론트엔드 입장에서 직접 프레임을 다룰 일은 거의 없지만, 두 가지는 알아두면 디버깅에 큰 도움이 됩니다.
- 메시지는 텍스트 또는 바이너리. JSON 문자열을 주로 보내지만,
ArrayBuffer/Blob로 바이너리도 보낼 수 있습니다. - ping/pong은 제어 프레임이다. 연결이 살아있는지 확인하는 용도이며, 뒤에서 다룰 하트비트의 기반입니다.
클라이언트 기본: 브라우저 WebSocket API
브라우저는 WebSocket 전역 객체를 제공합니다.
const ws = new WebSocket('wss://example.com/chat');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
console.log('받음:', msg);
};
ws.onclose = (event) => {
console.log('닫힘', event.code, event.reason);
};
ws.onerror = () => {
console.warn('에러 — 곧 onclose가 따라온다');
};여기까지는 쉽습니다. 문제는 이 코드가 이상적인 네트워크에서만 동작한다는 점입니다. 실제 사용자는 지하철에서 터널을 지나고, 와이파이가 끊기고, 노트북을 닫았다 엽니다. 운영 가능한 WebSocket 클라이언트는 여기서부터 시작입니다.
진짜 어려운 부분: 연결을 살아있게 유지하기
SSE는 브라우저가 자동 재연결과 이벤트 ID 이어받기를 무료로 줬습니다. WebSocket은 그 모든 것을 직접 만들어야 합니다. 이것이 두 기술의 가장 큰 실무 차이입니다.
운영 가능한 클라이언트가 갖춰야 할 것:
- 하트비트(heartbeat) — 유휴 연결이 프록시·방화벽에 의해 조용히 끊기는 것을 감지/예방
- 자동 재연결 + 지수 백오프 — 끊기면 다시 붙되, 서버를 폭격하지 않도록 간격을 늘려가며
- 전송 큐 — 연결이 끊긴 동안 보낸 메시지를 잃지 않도록 버퍼링
- 재연결 후 상태 복구 — 다시 붙으면 방 재입장, 놓친 메시지 동기화
이를 담은 최소한의 견고한 클라이언트 예제입니다.
type Listener = (data: unknown) => void;
class ResilientSocket {
private ws?: WebSocket;
private queue: string[] = [];
private retries = 0;
private heartbeatTimer?: ReturnType<typeof setInterval>;
private closedByUser = false;
constructor(
private url: string,
private onMessage: Listener
) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retries = 0; // 성공했으니 백오프 초기화
this.flushQueue(); // 끊긴 동안 쌓인 메시지 전송
this.startHeartbeat();
};
this.ws.onmessage = (e) => {
if (e.data === 'pong') return; // 하트비트 응답은 소비
this.onMessage(JSON.parse(e.data));
};
this.ws.onclose = () => {
this.stopHeartbeat();
if (!this.closedByUser) this.scheduleReconnect();
};
this.ws.onerror = () => this.ws?.close();
}
private scheduleReconnect() {
// 지수 백오프 + 지터: 1s, 2s, 4s ... 최대 30s
const base = Math.min(1000 * 2 ** this.retries, 30_000);
const jitter = base * 0.2 * Math.random();
this.retries += 1;
setTimeout(() => this.connect(), base + jitter);
}
private startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send('ping');
}, 25_000); // 프록시 유휴 타임아웃보다 짧게
}
private stopHeartbeat() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
}
private flushQueue() {
while (this.queue.length && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(this.queue.shift()!);
}
}
send(data: unknown) {
const payload = JSON.stringify(data);
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(payload);
else this.queue.push(payload); // 끊겨 있으면 큐에 보관
}
close() {
this.closedByUser = true;
this.stopHeartbeat();
this.ws?.close();
}
}이 코드의 모든 줄에는 실제 장애 경험이 담겨 있습니다.
- 지수 백오프 + 지터가 없으면, 서버가 잠깐 죽었다 살아날 때 수만 명의 클라이언트가 동시에 재접속을 시도해 서버를 다시 죽입니다(thundering herd).
- 하트비트 간격은 중간 프록시/로드밸런서의 유휴 타임아웃보다 짧아야 합니다. 보통 60초 타임아웃이면 25~30초 간격으로 보냅니다.
- 전송 큐가 없으면 사용자가 끊긴 순간 누른 "전송"이 조용히 사라집니다.
여기에 더해, 재연결은 새로운 연결이라는 점이 또 다른 함정을 만듭니다. 다시 붙은 뒤 서버가 "이 사람이 아까 그 사람"임을 알도록 인증을 다시 하고, 마지막으로 받은 메시지 ID 이후를 동기화해야 합니다. SSE가 Last-Event-ID로 공짜로 해주던 일을 직접 설계해야 합니다.
실무에서는 이 모든 걸 손으로 만들기보다 Socket.IO 같은 라이브러리가 재연결·하트비트·폴백을 묶어 제공합니다. 다만 "라이브러리가 무엇을 대신 해주는지"를 알아야 장애를 디버깅할 수 있어, 위 원리를 이해하는 것이 먼저입니다.
메시지 신뢰성: WebSocket이 보장하지 않는 것
WebSocket은 TCP 위에 있으므로 같은 연결 안에서는 순서가 보장되고 메시지가 누락되지 않습니다. 하지만 다음은 보장하지 않습니다.
- 연결이 끊겼다 다시 붙는 사이의 메시지. 그 사이 서버가 보낸 것은 사라집니다.
- 전달 확인(at-least-once / exactly-once). 보냈는데 상대가 정말 받았는지는 별도 ack 설계가 필요합니다.
- 여러 탭/기기 간 일관성. 같은 사용자의 다른 연결과의 동기화는 애플리케이션 몫입니다.
그래서 채팅 같은 시스템은 보통 각 메시지에 단조 증가 ID를 부여하고, 클라이언트가 마지막 수신 ID를 들고 재연결 시 "그 이후"를 요청하는 패턴을 씁니다. WebSocket은 실시간 전달을 빠르게 해줄 뿐, 신뢰성의 원천은 결국 서버의 영속 저장소라는 점을 잊으면 안 됩니다.
인증: WebSocket에는 헤더를 붙이기 어렵다
SSE의 EventSource처럼, 브라우저의 WebSocket 생성자도 커스텀 헤더(Authorization)를 설정할 수 없습니다. 그래서 토큰 기반 인증을 쓰는 팀이 가장 먼저 부딪힙니다. 실무에서 쓰이는 방법과 트레이드오프는 이렇습니다.
- 쿠키 인증:
wss://핸드셰이크는 HTTP 요청이므로 같은 출처면 쿠키가 자동 전송됩니다. 가장 깔끔하지만, CSRF 관점을 함께 봐야 합니다(토큰 저장 전략 참고). - 쿼리 파라미터 토큰:
wss://host/ws?token=.... 동작하지만 토큰이 접속 로그와 프록시 로그에 남습니다. 단명 토큰(수십 초 만료)을 쓰면 위험을 줄일 수 있습니다. - Sec-WebSocket-Protocol 트릭: 생성자의 두 번째 인자(서브프로토콜)에 토큰을 실어 보내는 기법. 헤더로 가긴 하지만 본래 용도가 아니라 서버 협상 처리가 필요합니다.
- 연결 후 첫 메시지로 인증: 일단 연결하고 첫 프레임으로 토큰을 보낸 뒤, 인증 전까지는 어떤 요청도 처리하지 않는 방식. 가장 유연하지만 "미인증 연결"이 잠깐 존재하는 상태를 서버가 관리해야 합니다.
// 연결 후 첫 메시지 인증 + 만료 시 재인증
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token: getAccessToken() }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'auth_required') {
// 토큰 만료로 서버가 재인증을 요구 — 갱신 후 다시 전송
refreshToken().then((t) => ws.send(JSON.stringify({ type: 'auth', token: t })));
}
};여기서 장기 연결만의 함정이 하나 더 있습니다. 연결은 몇 시간씩 살아있는데 토큰은 15분이면 만료됩니다. 그래서 WebSocket 인증은 "연결 시 한 번"이 아니라 연결 수명 동안 토큰을 갱신·재전송하는 흐름까지 설계해야 합니다. REST에서 매 요청 토큰을 새로 싣던 것과 다른 점입니다.
메시지 프로토콜은 직접 설계해야 한다
REST는 URL과 메서드가 곧 "무엇을 하는 요청인지"를 말해줍니다. WebSocket은 그냥 바이트 스트림이라, "이 메시지가 뭔지"를 표현하는 봉투(envelope)를 직접 정의해야 합니다. 이 설계를 대충 하면 나중에 확장할 때 큰 비용을 치릅니다.
견고한 봉투는 보통 이런 형태입니다.
interface Envelope<T = unknown> {
v: 1; // 프로토콜 버전 — 호환성 깨는 변경 대비
type: string; // 'chat.message' | 'presence.update' ...
id: string; // 클라이언트 생성 메시지 ID (ack·중복 제거용)
ts: number; // 타임스탬프
payload: T;
}type네임스페이싱(chat.message,presence.update)으로 기능이 늘어도 분기가 깔끔합니다.v(버전) 가 핵심입니다. 클라이언트는 캐시되어 한참 옛 버전이 떠 있을 수 있는데, 서버가 메시지 형식을 바꾸면 구버전 클라이언트가 깨집니다. 버전을 봉투에 박아두면 서버가 구버전을 감지해 다르게 처리하거나 업데이트를 유도할 수 있습니다.
전달 보장은 ack와 중복 제거로 직접 만든다
WebSocket은 "보냈다"를 알려줄 뿐 "상대가 받았다"를 보장하지 않습니다. 그래서 신뢰성이 필요한 메시지(채팅 전송 등)는 애플리케이션 레벨 ack를 직접 구현합니다.
class ReliableSender {
private pending = new Map<string, { env: Envelope; timer: ReturnType<typeof setTimeout> }>();
constructor(private socket: ResilientSocket) {}
send(type: string, payload: unknown) {
const id = crypto.randomUUID();
const env: Envelope = { v: 1, type, id, ts: Date.now(), payload };
this.transmit(env);
}
private transmit(env: Envelope) {
this.socket.send(env);
// ack가 일정 시간 안 오면 재전송 (재연결 후에도 동작)
const timer = setTimeout(() => this.transmit(env), 5000);
this.pending.set(env.id, { env, timer });
}
onAck(id: string) {
const p = this.pending.get(id);
if (p) {
clearTimeout(p.timer);
this.pending.delete(id);
}
}
}이 패턴의 결과는 at-least-once 전달입니다. 즉 재전송 때문에 같은 메시지가 두 번 도착할 수 있으므로, 받는 쪽은 id 기준으로 중복 제거(dedup) 를 해야 합니다. 최근 본 ID 집합을 들고 있다가 이미 본 것이면 무시하는 식입니다. "정확히 한 번(exactly-once)"은 분산 시스템에서 매우 비싸므로, 실무는 보통 at-least-once + 멱등 처리로 푼다는 점이 중요합니다.
빠른 서버, 느린 클라이언트: bufferedAmount
서버가 클라이언트에게 초당 수백 메시지를 밀어붙이는데 클라이언트의 네트워크가 느리면, 보내지 못한 데이터가 브라우저 내부 송신 버퍼에 쌓입니다. 이를 무시하면 메모리가 커지고 지연이 누적됩니다. WebSocket.bufferedAmount로 이 백프레셔를 감지할 수 있습니다.
function sendIfDrained(ws: WebSocket, data: string) {
// 송신 버퍼가 너무 차 있으면 보내지 않고 건너뛴다(또는 합친다)
const HIGH_WATER_MARK = 1 << 20; // 1MB
if (ws.bufferedAmount > HIGH_WATER_MARK) return false;
ws.send(data);
return true;
}특히 클라이언트→서버 방향에서 사용자가 대량 이벤트(예: 커서 이동, 드로잉)를 만들 때, 보낼 수 있을 때만 보내고 그 사이 값은 합치는 전략이 필수입니다. SSE 편에서 본 coalescing이 양방향에서도 똑같이 적용됩니다.
수평 확장: WebSocket이 진짜 비싼 이유
여기가 시니어가 가장 중요하게 보는 지점입니다. REST는 무상태라 서버를 늘리면 로드밸런서가 알아서 분산합니다. WebSocket은 다릅니다.
문제의 핵심: 사용자 A는 서버 1에, 사용자 B는 서버 2에 연결되어 있는데, A가 B에게 메시지를 보내야 한다.
graph TD
A[User A] --- S1[WebSocket Server 1]
B[User B] --- S2[WebSocket Server 2]
S1 --- R[(Redis Pub/Sub)]
S2 --- R
R -.->|메시지 중계| S2
S1 -.->|발행| R서버 1은 사용자 B와 연결이 없으므로 직접 보낼 수 없습니다. 그래서 두 가지 인프라가 필요해집니다.
1. Sticky Session (연결 고정)
WebSocket은 장기 연결이므로, 재연결을 포함한 같은 클라이언트의 트래픽이 같은 서버로 가도록 로드밸런서를 설정해야 합니다. 안 그러면 핸드셰이크와 그 뒤 프레임이 다른 서버로 갈라질 수 있습니다.
2. Pub/Sub 백플레인 (서버 간 메시지 중계)
서버 1이 받은 메시지를 Redis Pub/Sub(또는 Kafka, NATS 등)에 발행하면, 같은 채널을 구독하는 서버 2가 받아서 자기에게 연결된 사용자 B에게 전달합니다. 즉 WebSocket 서버들끼리 메시지를 공유하는 별도 계층이 필요합니다.
이 두 가지가 의미하는 바는 분명합니다. WebSocket을 제대로 확장하는 순간, REST에는 없던 상태 저장 인프라(연결 고정 + pub/sub + 종종 presence 추적)가 따라온다는 것입니다. 이것이 "WebSocket은 비싸다"의 실체입니다. 기능이 비싼 게 아니라 운영이 비쌉니다.
presence(접속 상태)는 생각보다 어렵다
"누가 지금 온라인인가"를 보여주는 presence는 흔한 요구지만, 확장 환경에서 까다롭습니다. 사용자는 여러 서버에 흩어져 있고, 연결이 비정상 종료되면(노트북 닫기 등) onclose가 늦거나 안 올 수 있어 "유령 온라인"이 생깁니다.
실무 패턴은 TTL 기반입니다. 하트비트가 올 때마다 Redis에 online:userId를 짧은 만료(예: 30초)로 갱신하고, 키가 자연 만료되면 오프라인으로 간주합니다. onclose에만 의존하지 않는 것이 핵심입니다.
// 서버: 하트비트 수신 시 presence 갱신 (개념)
function onHeartbeat(userId: string) {
redis.set(`online:${userId}`, '1', 'EX', 30); // 30초 TTL
redis.publish('presence', JSON.stringify({ userId, status: 'online' }));
}
// 키가 만료되면(=하트비트 끊김) 다른 노드들이 오프라인으로 처리이처럼 WebSocket의 부가 기능 하나하나가 별도의 분산 상태 설계를 부른다는 점이 비용의 본질입니다.
그래서 WebSocket을 언제 쓰는가
지금까지의 부담을 알고 나면 기준이 명확해집니다. WebSocket은 다음이 모두 참일 때 정당화됩니다.
- 클라이언트도 서버만큼 자주, 즉시 보내야 한다 (단방향이면 SSE).
- 낮은 지연이 사용자 경험을 좌우한다 (폴링/롱폴링으로는 부족).
- 그 가치가 재연결·확장·디버깅 비용을 넘어선다.
전형적 적합 사례: 채팅, 협업 문서(커서·동시 편집), 멀티플레이어 게임, 실시간 트레이딩, 라이브 입찰.
반대로 다음은 WebSocket이 과한 경우입니다.
- 알림·피드처럼 서버가 보여주기만 하면 됨 → SSE
- 상태 조회를 가끔 갱신 → 조건부 폴링
- 가끔 보내고 자주 받음 → SSE(받기) + REST(보내기)
| 상황 | 더 나은 선택 |
|---|---|
| 알림, 진행률, 대시보드 | SSE |
| 처리 상태 가끔 갱신 | 조건부 폴링 |
| 채팅, 협업, 게임 | WebSocket |
| AI 토큰 스트리밍 | SSE |
| 가끔 보내고 자주 받음 | SSE + REST |
미리 알아두면 좋은 함정들
여기서부터는 직접 운영하며 겪은 사례가 아니라, 명세와 자료에서 공통적으로 지적되는 함정들입니다. 단정하기보다 "구조상 왜 그렇게 되는지"를 따라가 보겠습니다.
함정 1: 배포가 곧 대규모 동시 재접속(thundering herd)
WebSocket 서버를 교체하면 그 서버에 붙어 있던 모든 클라이언트의 연결이 동시에 끊깁니다. 이때 클라이언트들이 즉시 한꺼번에 재접속을 시도하면, 새로 뜬 서버가 부팅되자마자 접속 폭주를 맞습니다. 재연결에 백오프가 있어도 지터(jitter)가 없으면 모두가 같은 타이밍에 몰려 효과가 없습니다.
대응은 (1) 재연결에 무작위 지터를 더하고(앞의 ResilientSocket 참고), (2) 서버가 종료 전 클라이언트에게 분산 재접속을 유도하며 천천히 연결을 비우는(graceful drain) 것입니다. 핵심은, 장기 연결에서는 배포가 곧 대규모 동시 재접속 이벤트라는 점입니다. 무상태 REST에는 없는 운영 과제입니다.
함정 2: 로드밸런서가 sticky하지 않으면 간헐적으로 실패한다
로드밸런서가 라운드로빈으로 요청을 분산하면, 핸드셰이크와 이후 트래픽이 다른 노드로 갈라질 수 있습니다. 여기에 pub/sub 백플레인까지 없으면 다른 노드에 붙은 사용자에게는 메시지가 닿지 않습니다. 증상이 "가끔" 나타나기 때문에 재현과 디버깅이 특히 어렵습니다.
핵심은, WebSocket은 "서버를 늘렸다"로 끝이 아니라 sticky session + pub/sub가 한 세트라는 점입니다. 둘 중 하나라도 빠지면 재현되지 않는 간헐적 버그로 나타나기 쉽습니다.
함정 3: 정리하지 않은 연결은 메모리 누수가 된다
클라이언트가 떠났는데 서버가 연결 객체와 구독, 타이머를 정리하지 않으면 유령 연결이 쌓이며 메모리가 천천히 차오릅니다. 구조를 보면 당연합니다. 연결 하나가 구독 핸들·타이머·송신 버퍼를 붙잡고 있다면, 정리되지 않은 연결 N개는 그 자원을 N배로 그대로 누적합니다. 끊김을 감지하지 못하면 이 N이 시간이 갈수록 단조 증가합니다.
핵심은, 장기 연결에서 명시적 정리(cleanup)는 안전망이 아니라 필수라는 점입니다. onclose/abort에서 구독 해제와 타이머 정리를 빠뜨리면 트래픽이 늘수록 문제가 커집니다. presence처럼 onclose가 안 올 수 있는 경우까지 TTL로 방어해야 합니다.
함정 4: 사실은 WebSocket이 필요 없었던 경우
알림 배지처럼 서버가 보여주기만 하면 되는 기능에 WebSocket을 쓰면, 정작 클라이언트가 서버로 보내는 것은 없는데 재연결·확장·인증 갱신을 전부 떠안게 됩니다. SSE였다면 자동 재연결과 이벤트 ID를 공짜로 받고 훨씬 적은 코드로 끝났을 일입니다.
핵심은, 가장 비싼 실수는 버그가 아니라 애초에 틀린 도구 선택이라는 점입니다. "양방향이 진짜 필요한가"를 먼저 묻지 않으면 그 비용은 운영 내내 따라옵니다.
자주 하는 오해
1. WebSocket을 열기만 하면 실시간이 끝난다
아닙니다. 여는 건 시작일 뿐이고, 하트비트·재연결·백오프·전송 큐·상태 복구를 갖춰야 비로소 운영 가능합니다. SSE가 공짜로 주던 것을 전부 직접 만들어야 합니다.
2. WebSocket은 메시지 전달을 보장한다
같은 연결 안에서만 순서·무손실이 보장됩니다. 끊김 구간의 메시지, 전달 확인, 멀티 기기 일관성은 별도 설계가 필요하고, 신뢰성의 원천은 서버 저장소입니다.
3. 서버만 늘리면 확장된다
아닙니다. sticky session과 pub/sub 백플레인이 없으면 서로 다른 서버에 붙은 사용자끼리 메시지가 닿지 않습니다.
4. 실시간이면 WebSocket이 정답이다
아닙니다. "실시간"의 대부분은 단방향이고, 그때는 SSE가 더 단순하고 안정적입니다. 양방향·저지연이 모두 필요할 때만 WebSocket이 정당화됩니다.
정리하면
WebSocket은 양방향 실시간의 강력한 도구지만, 연결을 여는 쉬움과 운영하는 어려움의 간극이 큰 기술입니다.
짧게 정리하면:
- HTTP 핸드셰이크(101) 후 같은 연결을 양방향 프레임 채널로 전환합니다. 그 순간 HTTP의 캐시·무상태 이점은 사라집니다.
- 진짜 일은 유지입니다. 하트비트, 지수 백오프 재연결, 전송 큐, 재연결 후 동기화를 직접 만들어야 합니다.
- 확장하려면 sticky session + pub/sub 백플레인이 필요합니다. 이것이 WebSocket 운영 비용의 본질입니다.
- 그래서 선택의 질문은 "가능한가"가 아니라 **"이 양방향성이 그 비용을 정당화하는가"**입니다.
지금까지는 텍스트·JSON 중심의 웹 표준 기술들을 봤습니다. 4편에서는 결이 다른 세계 — 바이너리와 강한 계약, 코드 생성으로 무장한 gRPC — 와 그것이 브라우저에서 왜 까다로운지를 다룹니다.
