본문 바로가기

TIL

WebSocket — 서버가 먼저 말을 거는 양방향 채널

웹앱이 서버와 통신하는 기본 방식은 HTTP다. 클라이언트(브라우저)가 요청을 보내고 서버가 응답한다. 이 구조에서는 클라이언트가 먼저 묻지 않으면 서버가 데이터를 보낼 수 없다.

실시간이 필요한 상황을 생각해보자. 서버에서 명령 실행 결과가 나오는 대로 브라우저에 스트리밍하거나, 다른 사용자의 이벤트를 즉시 받아야 하는 경우다. HTTP로 이를 흉내 내려면 클라이언트가 짧은 간격으로 계속 요청을 보내야 한다(폴링). 업데이트가 없어도 요청이 나가고, 업데이트가 생겨도 다음 폴링 때까지 기다려야 한다. 낭비와 지연이 동시에 생긴다.

WebSocket은 이 구조를 바꾼다.

HTTP에서 WebSocket으로 — 업그레이드 핸드셰이크

WebSocket 연결은 HTTP 요청으로 시작한다. 클라이언트가 특별한 헤더를 담은 HTTP 요청을 보낸다.

GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

서버가 이를 수락하면 101 Switching Protocols로 응답하고, 이후 그 연결은 HTTP가 아닌 WebSocket 프로토콜로 동작한다. 연결이 유지된 채로 양쪽이 언제든 데이터를 보낼 수 있는 채널이 열린다.

이 과정을 업그레이드 핸드셰이크라 한다. 결과적으로 WebSocket은 HTTP 위에서 시작해 HTTP를 벗어난 연결이다. 표준 HTTP 포트(80, 443)를 그대로 쓰므로 방화벽 설정을 바꾸지 않아도 된다.

연결이 열리면 양쪽은 프레임(frame) 단위로 데이터를 주고받는다. 텍스트 프레임과 바이너리 프레임을 구분한다. 연결을 닫을 때는 닫기 프레임을 보내 양쪽이 깔끔하게 정리할 수 있다.

ws와 wss — 암호화 여부

WebSocket은 두 가지 스킴을 쓴다. ws://는 평문이고, wss://는 TLS로 암호화된다. HTTP의 http://https://에 대응한다.

wss://는 TLS 위에서 WebSocket이 동작하는 것이다. 데이터가 암호화되어 중간에서 읽거나 변조할 수 없다. 브라우저에서 신뢰하는 인증서를 쓰는 한 연결 설정은 HTTPS와 같은 방식이다.

혼합 콘텐츠 — HTTPS 페이지에서 ws://가 막히는 이유

HTTPS로 서빙된 페이지에서 ws:// URL로 WebSocket 연결을 시도하면 브라우저가 차단한다. 혼합 콘텐츠(mixed content) 정책 때문이다.

혼합 콘텐츠란 HTTPS 페이지가 HTTP 또는 암호화되지 않은 자원을 불러오는 상황이다. 페이지 자체는 암호화되었지만 그 안에서 평문 통신이 일어나면 보안이 무의미해진다. 브라우저는 이를 막는다.

WebSocketnew WebSocket("ws://...")으로 만들면 생성자가 동기 예외를 던진다. try/catch 없이 이 예외가 콜백이나 이펙트 안에서 발생하면 React 앱이 흰 화면이 되는 것처럼 예상치 못한 크래시로 이어질 수 있다.

해결 방법은 두 가지다. 로컬 에이전트를 HTTPS로 올리거나, 페이지를 HTTP로 서빙하거나다. 개발 중 HTTP로 페이지를 서빙하면 ws:// 연결도 허용된다. 프로덕션에서 HTTPS 페이지라면 로컬이라도 wss://를 써야 한다. 이 경우 자체 서명 인증서를 쓰되 브라우저에 신뢰 등록을 해두거나, 역방향 프록시를 거치는 방법을 쓴다.

이 조건을 코드에서 처리하는 한 가지 방법은 페이지 프로토콜을 확인해 기본 URL 스킴을 결정하는 것이다.

const SECURE = location.protocol === "https:";
const DEFAULT_URL = SECURE ? "wss://127.0.0.1:8787" : "ws://127.0.0.1:8787";

그리고 생성자 호출을 try/catch로 감싼다. 잘못된 URL이나 혼합 콘텐츠 위반으로 생성자가 던지면 앱이 죽지 않고 "연결 불가" 상태로 처리할 수 있다.

자동 재연결

WebSocket 연결은 네트워크 상태나 서버 재시작으로 언제든 끊길 수 있다. 연결이 끊기면 onclose 이벤트가 발생한다. 자동 재연결은 이 이벤트에서 일정 시간 후 다시 연결을 시도하는 것이다.

기본 패턴은 이렇다.

function connect() {
  const ws = new WebSocket(url);
  ws.onopen  = () => { /* 연결됨 */ };
  ws.onclose = () => setTimeout(connect, 1500);  // 1.5초 후 재시도
  ws.onerror = () => ws.close();  // 오류 → close 이벤트로 이어짐
}
connect();

onerror에서 바로 재연결하지 않고 close를 명시적으로 호출하는 이유는, WebSocket은 오류 후 반드시 onclose가 뒤따르기 때문이다. 재연결 로직을 onclose 한 곳에만 두면 오류 경로와 정상 종료 경로가 같은 곳으로 합쳐져 중복이 없다.

컴포넌트나 훅이 언마운트될 때는 재연결 시도를 멈춰야 한다. 이미 해제된 컴포넌트에서 setState가 호출되는 것을 막기 위해 closed 플래그를 두고 onclose에서 확인한다.

지수 백오프(exponential backoff)를 더하면 서버가 오랫동안 내려가 있을 때 불필요한 재시도를 줄일 수 있다. 1.5초, 3초, 6초로 간격을 늘려가다가 상한(예: 30초)에서 고정하는 방식이다. 로컬 개발 환경에서 에이전트가 잠깐 내려갔다가 다시 올라올 때처럼 빠른 재연결이 예상되는 경우에는 고정 짧은 간격이 더 편리하다.

브라우저와 셸의 분리

브라우저 환경에는 보안 샌드박스가 있다. 자바스크립트 코드는 로컬 파일 시스템에 직접 접근하거나 셸 명령을 실행할 수 없다. 카메라로 손동작을 인식하더라도, 그 인식 결과로 실제 명령을 실행하려면 브라우저 밖의 에이전트가 필요하다.

이 구조에서 WebSocket이 다리 역할을 한다. 브라우저 앱은 손동작을 인식해 "명령 A를 실행하라"는 메시지를 WebSocket으로 보낸다. 로컬에서 실행 중인 Node.js 에이전트가 이 메시지를 받아 실제 셸 명령을 실행하고, 출력 스트림을 WebSocket으로 다시 흘려보낸다. 인식은 브라우저가, 실행은 로컬 프로세스가 담당한다.

이 경우 에이전트는 인증 토큰을 검증해 연결을 허용한다. 연결 URL에 토큰을 담아 에이전트가 확인하면, 모르는 페이지나 프로세스가 로컬 에이전트에 연결해 임의 명령을 실행하는 것을 막는다.

WebSocket의 한계와 후속 기술

네이티브 WebSocket API는 2011년(RFC 6455) 이후 핵심 기능이 거의 바뀌지 않았다. 자동 재연결 기능은 표준에 없다. 연결이 끊기면 클라이언트가 직접 재연결 로직을 구현해야 하는 것이 현재도 마찬가지다.

이 한계를 보완하려는 시도가 두 방향에서 진행됐다.

WebSocketStream. Chrome만 구현한 실험적 API다. 기존 WebSocket과 달리 Streams API를 기반으로 동작해, 수신 속도보다 처리 속도가 느릴 때 자동으로 전송 속도를 줄이는 배압(backpressure) 처리가 된다. 그러나 타 브라우저 구현 계획이 없고 비표준으로 남아 있어 프로덕션에서 쓰기 어렵다.

WebTransport. 더 주목할 변화다. HTTP/3(QUIC 전송 계층) 위에서 동작하는 저수준 API로, Chrome 97+, Edge 98+, Firefox 114+, Safari 26.4+에서 지원된다. WebSocket이 단일 양방향 스트림 하나를 제공하는 데 비해, WebTransport는 여러 스트림을 동시에 열 수 있고, 순서 보장이 없는 빠른 데이터그램(datagram) 전송도 지원한다.

WebSocket WebTransport
전송 계층 TCP QUIC (HTTP/3)
스트림 수 단일 양방향 다중 단방향·양방향
비신뢰 전송 없음 데이터그램 지원
순서 보장 항상 스트림별 선택
브라우저 지원 전체 ~75% (2026 기준)
자동 재연결 없음 없음

WebTransport가 WebSocket을 완전히 대체하지는 않는다. API 구조 자체가 달라 마이그레이션이 단순하지 않고, 서버 구현체도 WebSocket만큼 보편적이지 않다. 지금 시점에서 WebSocket은 범용 실시간 통신의 안전한 기본값이고, WebTransport는 고빈도 데이터 스트리밍이나 실시간 협업처럼 낮은 지연과 다중 채널이 필요한 곳에서 선택지가 된다.

이점과 트레이드오프

이점은 서버가 먼저 데이터를 보낼 수 있다는 것이다. 폴링 없이 이벤트 발생 즉시 클라이언트가 받는다. 연결이 열려 있는 동안 요청-응답 오버헤드 없이 메시지를 주고받을 수 있어 고빈도 통신에 효율적이다. 2011년부터 표준이 안정적으로 유지돼 서버 구현체와 클라이언트 라이브러리가 풍부하다.

감수하는 것도 있다. 상태가 있는 연결이라 서버가 연결을 기억해야 한다. HTTP는 요청마다 독립적이지만 WebSocket은 연결이 살아 있는 동안 서버에 상태가 유지된다. 연결이 많아지면 서버 부담이 커진다. 자동 재연결이 없어 끊김 처리를 코드로 직접 다뤄야 한다. 다중 채널이나 비신뢰 전송이 필요해지면 WebTransport 쪽을 검토하게 된다.