컴퓨터공학 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 컨트롤러도 대부분 웹소켓을 지원하지만, 타임아웃 기본값이 짧을 수 있어 어노테이션으로 늘리는 일이 흔하다.

확인 문제

  1. 웹소켓 연결이 HTTP 요청으로 시작하는 이유는 무엇인가?
  2. Sec-WebSocket-Accept 계산이 보안 인증이 아닌 이유는?
  3. 클라이언트 → 서버 프레임만 마스킹하는 이유는?
  4. 200바이트 텍스트 메시지를 서버가 보낼 때 프레임 헤더는 몇 바이트인가?
  5. 서버가 진행률만 알려 주면 되는 기능에 웹소켓 대신 고려할 만한 기술과 그 장점은?

풀이

  1. 기존 HTTP 포트(80/443)와 프록시·방화벽 인프라를 그대로 통과하고, 같은 서버에서 HTTP 와 함께 제공할 수 있기 때문이다.
  2. 누구나 계산할 수 있는 공개 공식이라 비밀이 없다. 목적은 상대가 웹소켓 프로토콜을 이해하는 서버라는 확인이다.
  3. 악의적인 페이지의 스크립트가 보낸 데이터가 중간 프록시나 캐시에 HTTP 요청처럼 해석돼 캐시를 오염시키는 공격을 막기 위해서다.
  4. 200 은 125 를 넘고 65,536 보다 작으므로 2바이트 + 16비트 확장 길이 2바이트 = 4바이트. 서버 프레임이라 마스킹 키는 없다.
  5. Server-Sent Events. 일반 HTTP 응답 스트림이라 프록시 호환이 쉽고, 브라우저가 자동 재연결을 해 준다.

더 읽을거리 (References)