[CS300 #155] 웹소켓 — HTTP 로 시작해 양방향 통로로 바뀌는 연결
컴퓨터공학 300 주제 시리즈의 155번째 글이다. 전체 지도는 여기.
한 줄 요약
웹소켓은 HTTP 요청으로 시작해 101 Switching Protocols 응답과 함께 같은 TCP 연결을 양방향 메시지 통로로 바꾸는 프로토콜이다. 그 뒤로는 작은 프레임 헤더만 붙은 메시지를 서버와 클라이언트가 아무 때나 보낸다.
왜 필요한가
HTTP 는 클라이언트가 묻고 서버가 답하는 구조다. 채팅, 주식 시세, 협업 편집, 게임처럼 서버가 먼저 말해야 하는 서비스에는 맞지 않는다. 예전에는 주기적으로 묻는 폴링이나, 응답을 일부러 붙잡아 두는 롱 폴링으로 버텼다. 요청마다 헤더가 오가고 지연도 컸다.
웹소켓은 연결 하나를 열어 두고 양쪽이 자유롭게 보낸다. 대신 연결을 오래 유지해야 하므로 프록시 타임아웃, 로드 밸런싱, 재접속 같은 운영 문제가 따라온다. 이 글은 프로토콜과 함께 그 문제들을 다룬다.
핵심 개념
업그레이드 핸드셰이크
웹소켓은 RFC 6455 에서 정의됐다. 클라이언트는 평범한 HTTP/1.1 GET 으로 시작한다.
GET /chat HTTP/1.1
Host: server.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=
Sec-WebSocket-Key는 클라이언트가 만든 무작위 16바이트의 base64 값이다.- 서버는 이 값에 고정 GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11를 이어 붙여 SHA-1 을 구하고 base64 로 인코딩해Sec-WebSocket-Accept로 돌려준다. - 이것은 보안 인증이 아니다. “이 서버가 정말 웹소켓을 이해하고 응답했다”는 확인이다. 웹소켓을 모르는 서버나 캐시가 엉뚱한 응답을 웹소켓 성공으로 오인하지 않게 한다.
- 101 응답 뒤부터 같은 TCP 연결 위로는 HTTP 가 아니라 웹소켓 프레임이 흐른다.
- 암호화된 웹소켓은
wss://로, TLS 위에서 같은 과정을 거친다.
프레임 구조
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| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (0 또는 4바이트) | Payload ... |
- FIN: 메시지의 마지막 조각인지. 큰 메시지는 여러 프레임으로 나눌 수 있다.
- opcode: 0x1 텍스트(UTF-8), 0x2 바이너리, 0x0 이어지는 조각, 0x8 닫기, 0x9 ping, 0xA pong.
- 길이: 125 이하면 7비트에 바로, 126 이면 뒤 16비트에, 127 이면 뒤 64비트에 쓴다.
- 마스킹: 클라이언트 → 서버 프레임은 반드시 4바이트 무작위 키로 XOR 마스킹해야 하고, 서버 → 클라이언트 프레임은 마스킹하지 않는다. 악의적인 웹 페이지가 웹소켓 데이터를 HTTP 요청처럼 꾸며 중간 캐시를 오염시키는 공격을 막기 위해서다. 암호화 목적이 아니다.
작은 메시지의 오버헤드는 서버 → 클라이언트 2바이트, 클라이언트 → 서버 6바이트다. 요청마다 수백 바이트 헤더가 붙는 HTTP 폴링과 비교하면 훨씬 가볍다.
제어 프레임과 종료
- ping/pong: 생존 확인이다. 한쪽이 ping 을 보내면 다른 쪽은 같은 데이터로 pong 을 보낸다. 중간 장비의 유휴 타임아웃을 피하는 keepalive 로도 쓴다.
- close: 상태 코드(예: 1000 정상 종료, 1001 떠남)를 담아 보내고, 상대도 close 로 답한 뒤 TCP 를 닫는다.
웹소켓이 아닌 선택지
| 방식 | 방향 | 특징 |
|---|---|---|
| 폴링 | 클라이언트 → 서버 반복 | 단순. 지연·낭비 큼 |
| 롱 폴링 | 응답을 붙잡아 둠 | HTTP 그대로. 재연결 비용 |
| Server-Sent Events | 서버 → 클라이언트 단방향 | HTTP 응답 스트림. 자동 재연결 내장 |
| 웹소켓 | 양방향 | 가장 유연. 운영 부담 큼 |
서버가 알림만 밀어 주면 되는 경우(진행률, 로그 꼬리 보기)는 SSE 가 더 단순할 수 있다.
직접 해 보기
핸드셰이크의 Accept 계산과 프레임 인코딩·디코딩을 구현하고, RFC 6455 에 실린 예시 값과 맞는지 확인한다.
import base64, hashlib, os, struct
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def accept_key(client_key):
return base64.b64encode(hashlib.sha1((client_key + GUID).encode()).digest()).decode()
print("Accept:", accept_key("dGhlIHNhbXBsZSBub25jZQ==")) # RFC 6455 의 예시
def encode_frame(payload, opcode=0x1, mask=False):
b0 = 0x80 | opcode # FIN=1
n = len(payload)
if n <= 125: hdr = struct.pack("!BB", b0, (0x80 if mask else 0) | n)
elif n < 65536: hdr = struct.pack("!BBH", b0, (0x80 if mask else 0) | 126, n)
else: hdr = struct.pack("!BBQ", b0, (0x80 if mask else 0) | 127, n)
if not mask:
return hdr + payload
key = os.urandom(4)
return hdr + key + bytes(b ^ key[i % 4] for i, b in enumerate(payload))
def decode_frame(buf):
b0, b1 = buf[0], buf[1]
fin, opcode, masked, n = b0 >> 7, b0 & 0x0F, b1 >> 7, b1 & 0x7F
off = 2
if n == 126: n = struct.unpack("!H", buf[2:4])[0]; off = 4
elif n == 127: n = struct.unpack("!Q", buf[2:10])[0]; off = 10
key = buf[off:off+4] if masked else None
off += 4 if masked else 0
data = buf[off:off+n]
if masked: data = bytes(b ^ key[i % 4] for i, b in enumerate(data))
return fin, opcode, masked, data
# RFC 6455 5.7 절 예시: 서버가 보낸 마스킹 없는 "Hello"
print("서버 프레임:", encode_frame(b"Hello").hex())
print("RFC 예시 마스킹 프레임 해독:", decode_frame(bytes.fromhex("818537fa213d7f9f4d5158")))
client = encode_frame("안녕".encode(), mask=True)
fin, op, masked, data = decode_frame(client)
print("클라이언트 프레임 길이", len(client), "| opcode", op, "| masked", masked, "|", data.decode())
big = encode_frame(b"x" * 300)
print("300바이트 메시지 헤더 길이:", len(big) - 300)
Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
서버 프레임: 810548656c6c6f
RFC 예시 마스킹 프레임 해독: (1, 1, 1, b'Hello')
클라이언트 프레임 길이 12 | opcode 1 | masked 1 | 안녕
300바이트 메시지 헤더 길이: 4
Accept 값과 마스킹 없는 “Hello” 프레임 81 05 48 65 6c 6c 6f 는 RFC 6455 본문의 예시와 같다. “안녕”은 UTF-8 로 6바이트이고, 헤더 2바이트와 마스킹 키 4바이트가 붙어 12바이트가 됐다. 300바이트 메시지는 길이 126 표시와 16비트 확장 길이를 써서 헤더가 4바이트다.
현업에서는
- 리버스 프록시(nginx 등)는 기본 설정으로
Upgrade와Connection헤더를 백엔드에 넘기지 않는 경우가 많다. 웹소켓 경로에는 이 헤더를 전달하도록 따로 설정해야 한다. 증상은 “핸드셰이크가 400 이나 200 으로 끝난다”이다. - 프록시의 읽기 타임아웃(예: 60초)보다 오래 조용하면 연결이 끊긴다. 주기적인 ping 을 보내거나 타임아웃을 늘린다.
- 연결이 한 백엔드에 오래 붙어 있으므로, 배포로 파드를 내리면 그 연결이 모두 끊긴다. 클라이언트는 지수 백오프로 재접속하도록 만들고, 여러 서버 인스턴스가 메시지를 나누려면 Redis pub/sub 같은 별도 버스를 둔다.
- 쿠버네티스 Ingress 컨트롤러도 대부분 웹소켓을 지원하지만, 타임아웃 기본값이 짧을 수 있어 어노테이션으로 늘리는 일이 흔하다.
확인 문제
- 웹소켓 연결이 HTTP 요청으로 시작하는 이유는 무엇인가?
Sec-WebSocket-Accept계산이 보안 인증이 아닌 이유는?- 클라이언트 → 서버 프레임만 마스킹하는 이유는?
- 200바이트 텍스트 메시지를 서버가 보낼 때 프레임 헤더는 몇 바이트인가?
- 서버가 진행률만 알려 주면 되는 기능에 웹소켓 대신 고려할 만한 기술과 그 장점은?
풀이
- 기존 HTTP 포트(80/443)와 프록시·방화벽 인프라를 그대로 통과하고, 같은 서버에서 HTTP 와 함께 제공할 수 있기 때문이다.
- 누구나 계산할 수 있는 공개 공식이라 비밀이 없다. 목적은 상대가 웹소켓 프로토콜을 이해하는 서버라는 확인이다.
- 악의적인 페이지의 스크립트가 보낸 데이터가 중간 프록시나 캐시에 HTTP 요청처럼 해석돼 캐시를 오염시키는 공격을 막기 위해서다.
- 200 은 125 를 넘고 65,536 보다 작으므로 2바이트 + 16비트 확장 길이 2바이트 = 4바이트. 서버 프레임이라 마스킹 키는 없다.
- Server-Sent Events. 일반 HTTP 응답 스트림이라 프록시 호환이 쉽고, 브라우저가 자동 재연결을 해 준다.
더 읽을거리 (References)
- RFC 6455, The WebSocket Protocol: https://www.rfc-editor.org/rfc/rfc6455.html
- RFC 8441, Bootstrapping WebSockets with HTTP/2: https://www.rfc-editor.org/rfc/rfc8441.html
- MDN, The WebSocket API (WebSockets): https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
- MDN, Server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events