WebSocket: 하나의 연결로 양방향 실시간 통신을 제공하는 프로토콜
웹소켓(WebSocket)은 하나의 TCP 연결에서 클라이언트와 서버가 독립적으로 메시지를 주고받는 전이중(Full-duplex) 통신 프로토콜이다. HTTP 핸드셰이크(Handshake)로 연결을 시작한 뒤 웹소켓 프레임(Frame)을 교환하므로, 반복적인 HTTP 요청 없이 실시간 데이터를 전달할 수 있다. 채팅, 알림, 협업 편집처럼 서버가 즉시 데이
By 송민성
1. 개념
웹소켓(WebSocket)은 클라이언트와 서버 사이에 지속적인 연결을 만들고, 양쪽이 원하는 시점에 메시지를 전송할 수 있게 하는 응용 계층 프로토콜(Application-layer protocol)이다.
일반적인 HTTP 통신은 클라이언트가 요청해야 서버가 응답한다.
클라이언트 ── 요청 ──> 서버
클라이언트 <─ 응답 ─── 서버웹소켓은 연결이 만들어진 뒤 양쪽이 독립적으로 메시지를 보낸다.
클라이언트 ── 메시지 ──> 서버
클라이언트 <─ 메시지 ─── 서버
클라이언트 <─ 메시지 ─── 서버
클라이언트 ── 메시지 ──> 서버웹소켓의 주요 특성은 다음과 같다.
- 전이중 통신(Full-duplex communication): 송신과 수신을 동시에 수행
- 지속 연결(Persistent connection): 메시지마다 새 연결을 만들지 않음
- 메시지 기반(Message-oriented): 텍스트 또는 바이너리 메시지 경계를 제공
- 서버 푸시(Server push): 클라이언트 요청 없이 서버가 데이터 전송 가능
- 순서 보장: TCP 위에서 동작하므로 메시지가 전송 순서대로 도착
웹소켓 주소는 다음 두 스킴(Scheme)을 사용한다.
ws://: 암호화하지 않은 연결wss://: 전송 계층 보안(TLS)으로 암호화한 연결
운영 환경에서는 통신 내용을 보호하기 위해 wss://를 사용한다.
2. 왜 사용하는가
HTTP 폴링(Polling)은 클라이언트가 일정한 간격으로 서버에 새 데이터가 있는지 요청한다.
클라이언트: 새 데이터 있나요?
서버: 없습니다.
클라이언트: 새 데이터 있나요?
서버: 없습니다.
클라이언트: 새 데이터 있나요?
서버: 있습니다.데이터가 없어도 요청과 응답이 반복되므로 HTTP 헤더 처리, 네트워크 전송, 서버 작업이 발생한다. 폴링 간격을 길게 잡으면 응답이 늦고, 짧게 잡으면 불필요한 요청이 많아진다.
장기 폴링(Long polling)은 서버가 새 데이터가 생길 때까지 HTTP 응답을 미룬다. 일반 폴링보다 요청 수는 적지만, 응답이 끝날 때마다 다음 요청을 다시 만들어야 한다.
서버 전송 이벤트(Server-Sent Events, SSE)는 서버에서 클라이언트로 이어지는 단방향 스트림을 제공한다. 알림이나 진행 상태처럼 서버에서 브라우저로만 데이터를 보내면 SSE가 더 단순하다.
웹소켓은 다음 조건에 맞을 때 사용한다.
- 클라이언트와 서버가 모두 자주 메시지를 보냄
- 수백 밀리초 이하의 짧은 전달 지연이 필요함
- 연결 상태를 유지하며 연속적인 이벤트를 교환함
- 매 메시지의 HTTP 요청·응답 비용을 줄여야 함
데이터가 가끔 바뀌거나 요청에 대한 응답만 필요하다면 일반 HTTP API가 더 단순하다.
3. 동작 원리
연결 수립
웹소켓 연결은 일반적으로 HTTP/1.1 업그레이드(Upgrade) 요청으로 시작한다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: 무작위로 생성한 값
Sec-WebSocket-Version: 13
Origin: https://example.com서버가 연결을 허용하면 상태 코드 101 Switching Protocols로 응답한다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 서버가 계산한 값서버는 Sec-WebSocket-Key에 규격이 정한 고정 문자열을 붙이고 SHA-1 해시(Hash)를 계산한 뒤 Base64로 인코딩해 Sec-WebSocket-Accept를 만든다. 이 과정은 서버가 웹소켓 업그레이드 요청을 이해했는지 확인하기 위한 것이며 인증이나 암호화를 제공하지 않는다.
업그레이드가 끝나면 같은 TCP 연결에서 HTTP 메시지 대신 웹소켓 프레임을 교환한다.
프레임과 메시지
웹소켓 프레임(Frame)은 다음 정보를 포함한다.
- 메시지가 끝났는지 나타내는 비트
- 텍스트, 바이너리, 종료, 핑, 퐁을 구분하는 연산 코드(Opcode)
- 페이로드 길이
- 마스킹 키(Masking key)
- 실제 데이터인 페이로드(Payload)
하나의 메시지는 하나 이상의 프레임으로 분할될 수 있다. 애플리케이션은 보통 완성된 텍스트 또는 바이너리 메시지를 받으므로 프레임 분할을 직접 처리하지 않는다.
브라우저에서 서버로 보내는 프레임은 반드시 마스킹(Masking)된다. 서버에서 브라우저로 보내는 프레임은 마스킹하지 않는다. 마스킹은 암호화가 아니며, 중간 장치가 웹소켓 데이터를 기존 HTTP 메시지로 잘못 해석하는 문제를 막기 위한 규칙이다.
연결 유지와 종료
웹소켓은 다음 제어 프레임(Control frame)을 사용한다.
- 핑(Ping): 상대 연결이 살아 있는지 확인
- 퐁(Pong): 핑에 대한 응답
- 종료(Close): 상태 코드와 함께 정상 종료 요청
TCP 연결이 조용한 상태로 오래 유지되면 프록시, 로드 밸런서, 방화벽이 유휴 연결(Idle connection)을 제거할 수 있다. 서버는 주기적인 핑과 응답 제한 시간을 사용해 끊어진 연결을 찾는다.
정상 종료에서는 한쪽이 종료 프레임을 보내고, 상대도 종료 프레임으로 응답한 뒤 TCP 연결을 닫는다.
4. 코드 예제
다음 예제는 Deno의 기본 웹소켓 API로 만든 에코 서버다. 별도 패키지가 필요하지 않다.
const clients = new Set<WebSocket>();
Deno.serve({ port: 8080 }, (request) => {
const url = new URL(request.url);
if (url.pathname !== "/ws") {
return new Response("WebSocket endpoint: /ws", { status: 404 });
}
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return new Response("WebSocket 연결이 필요합니다.", { status: 426 });
}
const { socket, response } = Deno.upgradeWebSocket(request);
socket.addEventListener("open", () => {
clients.add(socket);
socket.send(JSON.stringify({
type: "system",
message: "연결되었습니다.",
}));
});
socket.addEventListener("message", (event) => {
const message = JSON.stringify({
type: "message",
data: event.data,
receivedAt: new Date().toISOString(),
});
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
socket.addEventListener("close", () => {
clients.delete(socket);
});
socket.addEventListener("error", () => {
clients.delete(socket);
socket.close();
});
return response;
});
console.log("ws://localhost:8080/ws 에서 실행 중");서버를 실행한다.
deno run --allow-net server.ts다음 클라이언트를 별도 터미널에서 실행한다.
const socket = new WebSocket("ws://localhost:8080/ws");
socket.addEventListener("open", () => {
console.log("서버에 연결됨");
socket.send("첫 번째 메시지");
});
socket.addEventListener("message", (event) => {
console.log("수신:", event.data);
socket.close(1000, "테스트 완료");
});
socket.addEventListener("close", (event) => {
console.log("연결 종료:", event.code, event.reason);
});
socket.addEventListener("error", (event) => {
console.error("연결 오류:", event);
});클라이언트를 실행한다.
deno run --allow-net client.ts실무에서는 문자열 형식에 의존하지 않고 메시지 종류와 데이터를 명시한다.
type ClientMessage =
| { type: "chat"; roomId: string; text: string }
| { type: "typing"; roomId: string; active: boolean };
const message: ClientMessage = {
type: "chat",
roomId: "room-42",
text: "안녕하세요",
};
socket.send(JSON.stringify(message));서버는 역직렬화(Deserialization)한 데이터를 신뢰하지 말고 구조와 권한을 검사해야 한다.
5. 시간 복잡도 또는 성능 특성
길이가 m바이트인 메시지를 한 연결로 전송할 때 애플리케이션과 런타임이 데이터를 읽고 복사하는 비용은 일반적으로 O(m)이다.
연결 수가 C이고 같은 m바이트 메시지를 모든 연결에 개별 전송하면 다음 비용이 발생한다.
- 순회 연산:
O(C) - 전체 데이터 처리량:
O(C × m) - 연결 상태 저장 공간:
O(C) - 연결별 버퍼 한도가
b일 때 최대 버퍼 공간:O(C × b)
웹소켓 프레임은 작은 고정 헤더와 길이에 따른 추가 필드를 사용한다. 연결을 만든 뒤에는 매 메시지마다 전체 HTTP 요청 헤더를 보내지 않으므로, 작고 빈번한 메시지에서 폴링보다 프로토콜 부가 비용이 작다.
웹소켓은 TCP를 사용하므로 패킷 손실이 발생하면 뒤의 데이터도 손실된 패킷이 재전송될 때까지 기다린다. 이를 전송 계층의 선두 차단(Head-of-line blocking)이라고 한다.
브라우저의 WebSocket.send()는 전송 완료를 기다리지 않고 데이터를 내부 버퍼에 넣을 수 있다. bufferedAmount가 계속 증가하면 생산 속도가 네트워크 송신 속도보다 빠르다는 뜻이다.
const MAX_BUFFERED_BYTES = 1024 * 1024;
function sendSafely(socket: WebSocket, data: string): boolean {
if (socket.readyState !== WebSocket.OPEN) {
return false;
}
if (socket.bufferedAmount > MAX_BUFFERED_BYTES) {
return false;
}
socket.send(data);
return true;
}6. 실무 사용 사례
채팅과 상담
사용자가 보낸 메시지를 서버가 같은 대화방의 연결에 즉시 전달한다. 읽음 상태, 입력 중 상태, 접속 상태도 같은 연결로 교환할 수 있다.
실시간 알림
주문 상태, 배포 결과, 장애 알림처럼 서버에서 발생한 이벤트를 연결된 사용자에게 즉시 보낸다. 클라이언트가 서버로 자주 데이터를 보낼 필요가 없다면 SSE도 비교 대상이다.
공동 편집
문서 변경 연산, 커서 위치, 사용자 참여 상태를 지속적으로 교환한다. 웹소켓은 전송 통로만 제공하므로 변경 충돌 해결은 운용 변환(Operational Transformation)이나 충돌 없는 복제 자료형(CRDT) 같은 별도 알고리즘이 담당한다.
대시보드와 모니터링
주가, 로그, 서버 지표, 작업 진행률을 연속적으로 갱신한다. 화면이 처리할 수 있는 속도보다 이벤트가 빠르면 표본 추출(Sampling), 묶음 처리(Batching), 갱신 빈도 제한이 필요하다.
온라인 게임
플레이어 입력과 게임 상태를 양방향으로 전송할 수 있다. 다만 TCP의 재전송과 순서 보장 때문에 지연 민감도가 높은 게임에서는 웹 전송 프로토콜(WebTransport)이나 UDP 기반 프로토콜을 검토한다.
7. 주의할 점
인증과 권한 검사
브라우저의 기본 WebSocket 생성자는 임의의 HTTP 인증 헤더를 직접 설정하는 기능을 제공하지 않는다. 세션 쿠키, 짧은 수명의 접속 토큰, 또는 연결 직후 인증 메시지를 사용할 수 있다.
연결 인증만으로 끝내면 안 된다. 각 메시지에서 사용자가 해당 대화방, 문서, 채널에 접근할 권한이 있는지 검사해야 한다.
출처 검증
웹소켓 연결에도 브라우저 쿠키가 포함될 수 있다. 공격 사이트가 사용자의 브라우저에서 웹소켓 연결을 시도할 수 있으므로 서버는 Origin 헤더를 허용 목록과 비교해야 한다.
재연결 처리
모바일 네트워크 전환, 서버 재시작, 프록시 제한으로 연결은 언제든 끊길 수 있다. 클라이언트는 지수 백오프(Exponential backoff)와 무작위 지연(Jitter)을 적용해 재연결해야 한다.
재연결만으로 누락된 메시지가 복구되지는 않는다. 메시지 순번, 마지막 수신 위치, 재조회 API를 함께 설계해야 한다.
역압과 메시지 제한
느린 클라이언트에 데이터를 계속 보내면 송신 버퍼가 커져 메모리를 소모한다. 다음 제한이 필요하다.
- 최대 메시지 크기
- 연결별 송신 버퍼 한도
- 초당 메시지 수 제한
- 인증 전 허용 메시지 수 제한
- 느린 연결의 종료 기준
수평 확장
서버가 여러 대라면 한 서버의 메모리에만 연결 목록을 저장해서는 다른 서버의 사용자에게 메시지를 보낼 수 없다. Redis 발행·구독(Pub/Sub), 메시지 브로커(Message broker), 스트림 처리 시스템으로 서버 사이의 이벤트를 전달한다.
로드 밸런서는 웹소켓 업그레이드와 긴 연결을 지원해야 한다. 연결별 상태가 특정 서버 메모리에 있다면 고정 세션(Sticky session)이 필요할 수 있지만, 공유 저장소나 외부 메시지 계층을 사용하면 의존도를 줄일 수 있다.
8. 핵심 정리
- 웹소켓은 하나의 TCP 연결에서 양방향 전이중 통신을 제공한다.
- HTTP 업그레이드로 연결한 뒤 텍스트 또는 바이너리 프레임을 교환한다.
- 채팅, 협업 편집, 실시간 알림처럼 서버와 클라이언트가 자주 데이터를 주고받을 때 적합하다.
- 연결 유지, 재연결, 인증, 권한 검사, 역압은 애플리케이션이 직접 설계해야 한다.
- 단방향 서버 푸시만 필요하면 SSE, 요청·응답만 필요하면 일반 HTTP API가 더 단순하다.