WebSocket 완전 정복 · 2부. 중급: 프로토콜 내부와 설계 패턴 · 16편 중 7편
이전 편: 6편 — 프레임 구조
6편에서 opcode 0x8·0x9·0xA가 제어 프레임이라는 것까지 봤습니다. 이번 편은 그 셋을 실제로 씁니다 — 죽은 연결을 찾아내는 Ping/Pong 하트비트를 서버가 주도해야 하는 이유와 주기를 정하는 기준, 그리고 끊길 때 이유를 전하는 Close 코드입니다.
Ping/Pong — 하트비트는 서버가 주도한다
Ping을 받으면 같은 페이로드로 Pong을 응답해야 합니다. 브라우저는 서버 Ping에 자동 응답하지만 JS에서 Ping을 보내는 API는 없으므로 하트비트는 서버가 주도합니다.
하트비트가 필요한 이유:
- TCP는 상대가 사라져도 한참 모를 수 있음(half-open 연결).
- NAT·프록시·LB가 유휴 연결을 조용히 끊음.
import { WebSocketServer, WebSocket } from "ws";
type LiveSocket = WebSocket & { isAlive?: boolean };
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws: LiveSocket) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
const interval = setInterval(() => {
for (const ws of wss.clients as Set<LiveSocket>) {
if (!ws.isAlive) { ws.terminate(); continue; } // 응답 없음 → 강제 종료
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(interval));주기는 경로상 가장 짧은 유휴 타임아웃보다 짧게. Nginx proxy_read_timeout 기본 60초, 많은 클라우드 LB 기본값도 60초 전후 → 20~30초가 흔함.
Close 코드
| 코드 | 의미 |
|---|---|
| 1000 | 정상 종료 |
| 1001 | 떠남 (페이지 이동, 서버 재시작) |
| 1002 | 프로토콜 오류 |
| 1003 | 지원하지 않는 데이터 |
| 1005 | 상태 코드 없음 (예약, 전송 금지) |
| 1006 | 비정상 종료. Close 프레임 없이 끊김 (예약, 전송 금지) |
| 1007 | 잘못된 페이로드 (예: 깨진 UTF-8) |
| 1008 | 정책 위반 (인증 실패 등) |
| 1009 | 메시지가 너무 큼 |
| 1011 | 서버 내부 오류 |
| 4000~4999 | 애플리케이션 정의 |
1006은 실무에서 가장 흔합니다. 원인은 네트워크 단절, 프록시 타임아웃, 서버 크래시 등 다양하므로 인프라 타임아웃부터 의심하세요. 재연결하면 안 되는 상황(인증 만료 등)은 4000번대 코드로 알려주면 클라이언트 로직이 깔끔해집니다.
더 깊이
- 풀스택 개발자를 위한 네트워크 14장 — 실전 트러블슈팅: 증상별 원인 사전에 "정확히 N초마다 WebSocket 끊김"이 올라 있고, 그 앞에 아래 계층부터 위로 좁혀 가는 진단 순서가 있습니다. 1006이 떴을 때 어디부터 볼지 정하는 데 씁니다.
- 풀스택 개발자를 위한 네트워크 10장 — 인프라 구성요소: 프록시, 로드밸런서, CDN, 게이트웨이: 하트비트가 이겨야 하는 유휴 타임아웃이 요청 경로의 어느 화살표에 걸려 있는지, 프록시·LB·CDN 전체 경로를 봅니다.
- 14편 — 인프라: Nginx, 로드밸런서, 그리고 OS 한계: 이 편에서 "기본 60초"라고만 적은
proxy_read_timeout을 설정 단위로 다루고, 타임아웃을 늘려도 하트비트를 유지해야 하는 이유와 L7·L4 로드밸런서의 조건으로 이어집니다.