본문 바로가기

WebSocket과 실시간 통신

HTTP의 요청과 응답으로 실시간을 흉내 내는 폴링과 롱 폴링, SSE의 비용을 비교하고, WebSocket이 HTTP 연결을 업그레이드해 양방향 메시지를 주고받는 과정을 RFC의 예제 값으로 따라갑니다. 프레임과 마스킹, 브로드캐스트에서 느린 수신자를 다루는 방법, SSE의 이벤트 형식, 프록시와 Origin 검사 같은 운영의 주의점을 정리합니다.

HTTP는 클라이언트가 요청을 보내야 서버가 응답하는 구조입니다. 서버가 아무 때나 먼저 브라우저에 데이터를 보낼 수는 없습니다. 그래서 채팅 메시지, 실시간 알림, 주식 시세, 여러 사람이 함께 고치는 문서처럼 서버 쪽에서 생긴 일을 바로 전해야 하는 기능은 요청과 응답만으로는 만들기 어렵습니다. 이 글에서는 HTTP로 이를 흉내 내는 방법들과, 처음부터 양방향 통신을 위해 만든 WebSocket을 비교합니다.

HTTP로 실시간을 흉내 내는 방법들

가장 단순한 방법은 폴링(polling)입니다. 클라이언트가 몇 초마다 "새 소식 있나요?"라고 묻습니다. 구현은 쉽지만 대부분의 응답이 "없다"이고, 그때마다 HTTP 헤더가 오갑니다. 사용자 1만 명이 2초마다 묻고 요청과 응답의 헤더가 합쳐 800바이트라면, 새 소식이 하나도 없어도 초당 5000개의 요청과 4,000,000바이트의 헤더가 오갑니다. 묻는 간격을 늘리면 비용은 줄지만 그만큼 소식이 늦게 도착합니다.

롱 폴링(long polling)은 서버가 새 소식이 생길 때까지 응답을 미루는 방식입니다. 빈 응답이 줄어 낭비가 적지만, 응답을 하나 받을 때마다 클라이언트가 다시 요청을 열어야 하고, 그 사이에 생긴 소식을 놓치지 않으려면 서버가 따로 보관해야 합니다.

SSE(server-sent events)는 HTTP 응답 하나를 끝내지 않고 열어 둔 채, 서버가 생기는 대로 이벤트를 이어서 씁니다. 서버에서 클라이언트로 가는 한 방향의 흐름에 잘 맞습니다. WebSocket은 한 번 연결을 맺으면 같은 연결에서 양쪽이 아무 때나 메시지를 보낼 수 있는 별도의 프로토콜입니다.

WebSocket은 HTTP 연결을 업그레이드한다

WebSocket(RFC 6455)은 보통의 HTTP/1.1 요청으로 시작합니다. 클라이언트가 Upgrade: websocket 헤더를 붙인 요청을 보내고, 서버가 101 Switching Protocols로 답하면 그 TCP 연결은 그때부터 HTTP가 아니라 WebSocket의 프레임을 주고받는 통로가 됩니다. 기존의 HTTP 포트와 프록시를 그대로 쓸 수 있도록 고안된 방식입니다. wss://라면 이 모든 것이 TLS 연결 위에서 일어납니다.

HTTP/1.1 Upgrade 뒤 같은 연결의 WebSocket 메시지

같은 HTTP/1.1 연결에서 Upgrade를 요청합니다. 서버도 독립적으로 메시지를 보낼 수 있습니다. 각 단계의 값과 전환 이유를 비교합니다.

WebSocket · HTTP/1.1 경로

101 전환 뒤에는, 양쪽이 독립적으로 메시지를 보냅니다

HTTP/1.1 Upgrade에 성공하고 클라이언트가 핸드셰이크 응답을 검증한 예입니다. 이후 두 메시지는 요청·응답 한 쌍일 필요가 없습니다. HTTP/2·3의 Extended CONNECT는 이 그림에 포함하지 않습니다.

HTTP/1.1 Upgrade 뒤 같은 연결의 WebSocket 메시지HTTP/1.1 WebSocket 핸드셰이크가 성공한 뒤 클라이언트와 서버가 각각 텍스트 메시지를 보내는 예시입니다. HTTP/2와 HTTP/3의 Extended CONNECT 경로는 제외합니다. 애플리케이션 메시지 두 개가 반드시 요청과 응답 한 쌍이라는 뜻은 아닙니다.GET /chat HTTP/1.1Upgrade: websocket101 Switching Protocols클라이언트가 Accept 검증텍스트 메시지클라이언트 → 서버 · 마스킹텍스트 메시지서버 → 클라이언트클라이언트서버
HTTP/1.1 Upgrade 뒤 같은 연결의 WebSocket 메시지클라이언트가HTTP/1.1 GET과Upgrade를보내고 서버의101응답을Accept값으로검증한뒤, 클라이언트→서버마스킹텍스트와서버→클라이언트텍스트를보내는예입니다. 아래두메시지는의무적인요청·응답쌍이아닙니다. 시간은위에서아래로읽고 HTTP/2·3의ExtendedCONNECT와실제시간간격은제외합니다.클라이언트서버GET /chatHTTP/1.1Upgrade: websocket101Switching ProtocolsAccept 검증텍스트 메시지클라이언트 → 서버마스킹텍스트 메시지서버 → 클라이언트

위에서 아래로 시간 순서이며 간격은 실제 시간이 아닙니다. 청록색은 101 전환 응답입니다. 서버→클라이언트 메시지의 점선은 방향 구분이며 응답 의무를 뜻하지 않습니다. Accept와 클라이언트 마스킹은 사용자 인증·암호화가 아니며 wss는 TLS를 사용합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 Upgrade 요청부터 서버 메시지까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명클라이언트가 HTTP/1.1 GET과 Upgrade를 보내고 서버의 101 응답을 Accept 값으로 검증한 뒤, 클라이언트→서버의 마스킹 텍스트와 서버→클라이언트의 텍스트를 보내는 예입니다. 아래 두 메시지는 의무적인 요청·응답 쌍이 아닙니다. 시간은 위에서 아래로 읽고 HTTP/2·3의 Extended CONNECT와 실제 시간 간격은 제외합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Upgrade 요청

같은 HTTP/1.1 연결에서 Upgrade를 요청합니다.

  • 연결·메시지 1: GET /chat
  • 연결·메시지 2: Upgrade websocket
  • 클라이언트가 /chat 요청과 WebSocket 전환 헤더를 송신
101 확인

101 응답과 예상 Accept 값을 검증합니다.

  • 연결·메시지 1: 101
  • 연결·메시지 2: 예상 Accept 일치
  • 검증한 성공 응답만 전환으로 수락
클라이언트 메시지

클라이언트가 마스킹한 메시지를 보냅니다.

  • 연결·메시지 1: client→server masking
  • WebSocket 메시지 전달; HTTP 요청 형태는 끝남
서버 메시지

서버도 독립적으로 메시지를 보낼 수 있습니다.

  • 연결·메시지 1: server→client text
  • 의무적인 요청·응답 쌍이 아님; wss의 TLS 조건은 별도

HTTP/1.1 Upgrade 뒤 같은 연결의 WebSocket 메시지

같은 HTTP/1.1 연결에서 Upgrade를 요청합니다. 서버도 독립적으로 메시지를 보낼 수 있습니다. 각 단계의 값과 전환 이유를 비교합니다.

WebSocket · HTTP/1.1 경로

101 전환 뒤에는, 양쪽이 독립적으로 메시지를 보냅니다

HTTP/1.1 Upgrade에 성공하고 클라이언트가 핸드셰이크 응답을 검증한 예입니다. 이후 두 메시지는 요청·응답 한 쌍일 필요가 없습니다. HTTP/2·3의 Extended CONNECT는 이 그림에 포함하지 않습니다.

HTTP/1.1 Upgrade 뒤 같은 연결의 WebSocket 메시지HTTP/1.1 WebSocket 핸드셰이크가 성공한 뒤 클라이언트와 서버가 각각 텍스트 메시지를 보내는 예시입니다. HTTP/2와 HTTP/3의 Extended CONNECT 경로는 제외합니다. 애플리케이션 메시지 두 개가 반드시 요청과 응답 한 쌍이라는 뜻은 아닙니다.GET /chat HTTP/1.1Upgrade: websocket101 Switching Protocols클라이언트가 Accept 검증텍스트 메시지클라이언트 → 서버 · 마스킹텍스트 메시지서버 → 클라이언트클라이언트서버
HTTP/1.1 Upgrade 뒤 같은 연결의 WebSocket 메시지클라이언트가HTTP/1.1 GET과Upgrade를보내고 서버의101응답을Accept값으로검증한뒤, 클라이언트→서버마스킹텍스트와서버→클라이언트텍스트를보내는예입니다. 아래두메시지는의무적인요청·응답쌍이아닙니다. 시간은위에서아래로읽고 HTTP/2·3의ExtendedCONNECT와실제시간간격은제외합니다.클라이언트서버GET /chatHTTP/1.1Upgrade: websocket101Switching ProtocolsAccept 검증텍스트 메시지클라이언트 → 서버마스킹텍스트 메시지서버 → 클라이언트

위에서 아래로 시간 순서이며 간격은 실제 시간이 아닙니다. 청록색은 101 전환 응답입니다. 서버→클라이언트 메시지의 점선은 방향 구분이며 응답 의무를 뜻하지 않습니다. Accept와 클라이언트 마스킹은 사용자 인증·암호화가 아니며 wss는 TLS를 사용합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 Upgrade 요청부터 서버 메시지까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명클라이언트가 HTTP/1.1 GET과 Upgrade를 보내고 서버의 101 응답을 Accept 값으로 검증한 뒤, 클라이언트→서버의 마스킹 텍스트와 서버→클라이언트의 텍스트를 보내는 예입니다. 아래 두 메시지는 의무적인 요청·응답 쌍이 아닙니다. 시간은 위에서 아래로 읽고 HTTP/2·3의 Extended CONNECT와 실제 시간 간격은 제외합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Upgrade 요청

같은 HTTP/1.1 연결에서 Upgrade를 요청합니다.

  • 연결·메시지 1: GET /chat
  • 연결·메시지 2: Upgrade websocket
  • 클라이언트가 /chat 요청과 WebSocket 전환 헤더를 송신
101 확인

101 응답과 예상 Accept 값을 검증합니다.

  • 연결·메시지 1: 101
  • 연결·메시지 2: 예상 Accept 일치
  • 검증한 성공 응답만 전환으로 수락
클라이언트 메시지

클라이언트가 마스킹한 메시지를 보냅니다.

  • 연결·메시지 1: client→server masking
  • WebSocket 메시지 전달; HTTP 요청 형태는 끝남
서버 메시지

서버도 독립적으로 메시지를 보낼 수 있습니다.

  • 연결·메시지 1: server→client text
  • 의무적인 요청·응답 쌍이 아님; wss의 TLS 조건은 별도

클라이언트는 요청에 무작위 값을 base64로 바꾼 Sec-WebSocket-Key를 넣고, 서버는 그 값 뒤에 정해진 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11을 붙여 SHA-1 해시를 구한 뒤 base64로 바꾼 값을 Sec-WebSocket-Accept로 돌려줍니다. 다음 코드는 RFC 6455 §1.3의 예제 키로 이 값을 계산합니다.

import base64
import hashlib

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

def accept_value(key):
    return base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()

print(accept_value("dGhlIHNhbXBsZSBub25jZQ=="))
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

결과는 RFC에 실린 값과 같습니다. 키를 base64로 풀지 않고 문자열 그대로 이어 붙인다는 점에 주의합니다. 이 값은 서버가 WebSocket을 정말 이해하고 답했는지, 엉뚱한 HTTP 서버나 캐시가 답한 것이 아닌지를 확인하는 장치입니다. 공개된 계산이어서 누구나 만들 수 있으므로 사용자를 인증하거나 내용을 암호화하는 기능은 아닙니다. 브라우저는 상태 코드가 101이어도 이 값이 기대와 다르면 연결을 받아들이지 않습니다. HTTP/2와 HTTP/3에서는 Upgrade 대신 확장된 CONNECT 메서드로 스트림 하나를 WebSocket에 씁니다(RFC 8441, RFC 9220). 다만 클라이언트와 서버, 사이의 프록시가 모두 지원해야 합니다.

프레임과 마스킹

업그레이드 뒤에는 메시지가 프레임에 실려 오갑니다. 첫 바이트에는 메시지의 마지막 조각인지를 나타내는 FIN 비트와 프레임의 종류(opcode)가, 둘째 바이트에는 마스킹 여부와 페이로드의 길이가 들어갑니다(RFC 6455 §5.2).

opcode뜻
0x0앞에서 시작한 메시지의 이어지는 조각
0x1UTF-8 텍스트 메시지
0x2바이너리 메시지
0x8연결 종료
0x9, 0xAPing과 그 응답 Pong

길이는 125바이트까지는 둘째 바이트에 바로 적고, 그보다 길면 126을 적고 뒤의 2바이트에, 65,535바이트보다 길면 127을 적고 뒤의 8바이트에 적습니다. 클라이언트가 서버로 보내는 프레임은 반드시 4바이트의 마스킹 키로 페이로드를 XOR해서 보내야 합니다. 다음 코드는 RFC 6455 §5.7의 예제 프레임들을 만들어 봅니다.

import struct

def build_frame(opcode, payload, mask_key=None):
    header = bytes([0x80 | opcode])                                  # FIN=1, 이 프레임이 메시지의 끝
    mask_bit = 0x80 if mask_key else 0
    n = len(payload)
    if n <= 125:
        header += bytes([mask_bit | n])
    elif n <= 0xFFFF:
        header += bytes([mask_bit | 126]) + struct.pack("!H", n)
    else:
        header += bytes([mask_bit | 127]) + struct.pack("!Q", n)
    if mask_key:
        payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))   # 4바이트 키로 XOR
        header += mask_key
    return header + payload

print(build_frame(0x1, b"Hello").hex(" "))                              # 서버 → 클라이언트
print(build_frame(0x1, b"Hello", bytes.fromhex("37fa213d")).hex(" "))   # 클라이언트 → 서버
print(build_frame(0x2, bytes(256))[:4].hex(" "))                        # 256바이트 바이너리의 머리
81 05 48 65 6c 6c 6f
81 85 37 fa 21 3d 7f 9f 4d 51 58
82 7e 01 00

첫 줄은 마스킹 없는 텍스트 Hello로, 0x81은 FIN과 텍스트, 0x05는 길이 5입니다. 둘째 줄은 같은 메시지를 클라이언트가 보낸 것으로, 길이 바이트의 최상위 비트가 켜져 0x85가 되고, 마스킹 키 37 fa 21 3d 뒤에 XOR한 페이로드가 옵니다. H(0x48)와 0x37을 XOR하면 0x7f가 되는 식입니다. 셋째 줄은 256바이트의 바이너리 메시지로, 길이 자리에 126(0x7e)이 오고 실제 길이 256이 뒤의 2바이트(01 00)에 적혔습니다. 세 결과 모두 RFC의 예와 같습니다.

마스킹은 내용을 숨기는 암호화가 아닙니다. 키가 프레임 안에 그대로 들어 있기 때문입니다. 마스킹의 목적은 WebSocket을 모르는 중간의 캐시나 프록시가 브라우저의 스크립트가 만든 바이트를 HTTP 요청으로 착각하는 공격을 막는 것으로, 페이지가 정한 바이트가 회선 위에 그대로 나타나지 않게 합니다. Ping과 Close 같은 제어 프레임은 쪼갤 수 없고 페이로드가 125바이트를 넘을 수 없습니다.

브로드캐스트와 느린 수신자

채팅방처럼 한 사람의 메시지를 여러 사람에게 보내는 서버에서는, 접속한 연결의 목록을 돌며 하나씩 보냅니다. 이때 11장 2편의 스레드 서버와 같은 문제가 비동기 서버에서도 생깁니다. 한 수신자의 네트워크가 느려 보내기가 오래 걸리면, 하나씩 차례로 보내는 코드에서는 그 뒤의 모든 수신자가 기다립니다. 다음 코드는 받는 데 0.3초가 걸리는 느린 수신자가 맨 앞에 있을 때, 차례로 보내는 방식과 동시에 보내는 방식의 받은 순서를 비교합니다.

import asyncio

class FakeClient:
    def __init__(self, name, delay, log):
        self.name, self.delay, self.log = name, delay, log

    async def send(self, message):
        await asyncio.sleep(self.delay)                  # 느린 수신자는 받는 데 오래 걸린다
        self.log.append(self.name)

async def sequential(clients, message):
    for c in clients:
        await c.send(message)                            # 앞의 전송이 끝나야 다음으로 넘어간다

async def concurrent(clients, message):
    await asyncio.gather(*(c.send(message) for c in clients))

async def main():
    for strategy in (sequential, concurrent):
        log = []
        clients = [FakeClient("A(느림)", 0.3, log), FakeClient("B", 0, log), FakeClient("C", 0, log)]
        await strategy(clients, "hi")
        print(f"{strategy.__name__}: 받은 순서 {log}")

asyncio.run(main())
sequential: 받은 순서 ['A(느림)', 'B', 'C']
concurrent: 받은 순서 ['B', 'C', 'A(느림)']

차례로 보내면 B와 C는 A가 끝나기를 0.3초 동안 기다렸다가 받지만, 동시에 보내면 B와 C는 바로 받고 A만 늦습니다. 동시에 보내도 문제가 끝나지는 않습니다. 느린 수신자에게 보낼 메시지가 계속 쌓이면 서버의 메모리가 늘어나므로, 수신자마다 대기열의 상한을 두고 넘치면 오래된 메시지를 버리거나 연결을 끊는 정책이 필요합니다. 많이 쓰는 Python 라이브러리인 websockets의 브로드캐스트 문서도 이 선택지들을 비교합니다. 실제 채팅 서버라면 여기에 사용자 인증, 끊긴 사용자가 다시 접속했을 때 놓친 메시지를 다시 보내는 방법, 메시지마다 붙이는 ID로 중복을 거르는 방법도 정해야 합니다.

한 방향이면 SSE로 충분하다

모든 실시간 기능에 WebSocket이 필요한 것은 아닙니다. 알림이나 피드, 작업의 진행률처럼 서버에서 클라이언트로만 흐르는 데이터라면, 평범한 HTTP 응답을 Content-Type: text/event-stream으로 열어 두고 이벤트를 이어 쓰는 SSE(HTML 표준)로 충분합니다. 이벤트는 data:로 시작하는 줄들로 이루어지고, 빈 줄 하나가 이벤트의 끝입니다.

def sse_event(data, event_id=None, event=None):
    lines = []
    if event_id is not None:
        lines.append(f"id: {event_id}")
    if event:
        lines.append(f"event: {event}")
    lines += [f"data: {line}" for line in data.split("\n")]   # 여러 줄은 줄마다 data:를 붙인다
    return "\n".join(lines) + "\n\n"                          # 빈 줄이 이벤트의 끝이다

print(sse_event("주문 접수", event_id=41, event="order"), end="")
print(sse_event("첫 줄\n둘째 줄", event_id=42), end="")
id: 41
event: order
data: 주문 접수

id: 42
data: 첫 줄
data: 둘째 줄

브라우저의 EventSource는 연결이 끊기면 스스로 다시 연결하면서, 마지막으로 받은 이벤트의 id를 Last-Event-ID 헤더에 담아 보냅니다. 서버가 이벤트에 ID를 붙이고 보관해 두었다가 그 뒤의 이벤트부터 다시 보내 주면 끊긴 동안의 이벤트를 놓치지 않습니다. 다만 다시 보내 주는 일은 서버가 구현해야 합니다. SSE는 보통의 HTTP 응답이므로 HTTP/2 위에서 연결 하나에 여러 스트림으로 실을 수 있고, 프록시와 인증도 HTTP 그대로 쓸 수 있습니다.

방식방향데이터다시 연결하기맞는 곳
WebSocket양방향텍스트와 바이너리애플리케이션이 구현한다채팅, 게임, 공동 편집
SSE서버에서 클라이언트로UTF-8 텍스트브라우저가 돕고, 놓친 이벤트는 서버가 다시 보낸다알림, 피드, 진행률
롱 폴링요청마다 한 번무엇이든응답마다 새로 요청한다위의 둘을 쓸 수 없는 환경

운영에서 주의할 것

오래 열려 있는 연결은 그 사이의 장비들과 부딪힙니다. 프록시와 로드 밸런서는 일정 시간 조용한 연결을 끊는데, 예를 들어 nginx는 프록시한 연결에서 60초 동안 아무것도 읽지 못하면 끊는 것이 기본입니다. 그래서 WebSocket은 Ping과 Pong을, SSE는 주석 줄 같은 빈 이벤트를 그보다 짧은 간격으로 보내 연결을 살려 둡니다. nginx 같은 리버스 프록시가 WebSocket을 넘겨 주게 하려면 Upgrade와 Connection 헤더를 뒤쪽 서버로 전달하도록 따로 설정해야 하고, SSE는 프록시가 응답을 모았다가 한 번에 보내지 않도록 버퍼링을 꺼야 이벤트가 바로 도착합니다.

보안도 따로 챙겨야 합니다. 브라우저는 WebSocket 연결에 CORS를 적용하지 않고, 쿠키는 그대로 실어 보냅니다. 그래서 다른 사이트의 스크립트도 로그인한 사용자의 쿠키로 내 WebSocket 서버에 연결할 수 있으므로, 서버는 업그레이드 요청의 Origin 헤더가 내 사이트인지 반드시 확인해야 합니다. 연결을 맺은 뒤에는 인증이 만료되어도 연결이 계속 살아 있을 수 있으므로, 토큰의 만료에 맞춰 연결을 끊거나 다시 인증하게 합니다.

Socket.IO 같은 라이브러리는 이런 일들, 곧 다시 연결하기, 생존 확인, 방(room)으로 수신자 묶기, 받았다는 확인을 대신 해 줍니다. 다만 Socket.IO는 WebSocket 위에 자기의 프로토콜을 얹은 것이어서 보통의 WebSocket 클라이언트와는 바로 통하지 않고, 기본 설정으로는 HTTP 롱 폴링으로 먼저 연결한 뒤 WebSocket으로 업그레이드합니다. 양쪽이 모두 Socket.IO를 쓰는지, 표준 WebSocket만 필요한지를 먼저 정해야 합니다.

더 깊이: Close 프레임의 상태 코드와 1006

WebSocket 연결을 정상으로 닫을 때는 양쪽이 Close 프레임을 주고받고, 그 프레임에 닫는 이유를 나타내는 2바이트 상태 코드를 담을 수 있습니다. RFC 6455 §7.4는 1000(정상 종료), 1001(서버가 내려가거나 브라우저가 페이지를 떠남), 1002(프로토콜 오류), 1009(처리하기에 너무 큰 메시지), 1011(서버의 예기치 못한 오류) 같은 코드를 정해 두었습니다. 3000부터 3999까지는 라이브러리와 프레임워크가 IANA에 등록해 쓰고, 4000부터 4999까지는 애플리케이션끼리 미리 약속해 쓰는 사설 범위입니다.

1005와 1006, 1015는 프레임에 적어 보내면 안 되는 예약 값입니다. 받은 Close 프레임에 코드가 없으면 애플리케이션은 1005를, Close 프레임을 받지 못한 채 연결이 끊기면 1006을, TLS 핸드셰이크가 실패해 닫히면 1015를 보게 됩니다. 그래서 애플리케이션이 받은 1006은 상대가 보낸 이유가 아니라 Close 프레임 없이 연결이 사라졌다는 뜻이며, 앞에서 본 프록시의 유휴 시간 초과나 네트워크의 단절처럼 그 아래 계층에서 원인을 찾아야 합니다.

정리

  • 폴링은 빈 응답과 헤더의 비용이 크고, 롱 폴링은 응답마다 다시 요청해야 하며, SSE는 서버에서 클라이언트로의 흐름에, WebSocket은 양방향 메시지에 맞습니다.
  • WebSocket은 HTTP/1.1의 Upgrade 요청과 101 응답으로 시작하며, Sec-WebSocket-Accept는 서버가 WebSocket을 이해했는지 확인할 뿐 인증이나 암호화가 아닙니다.
  • 프레임의 길이는 125바이트까지는 바로, 그보다 길면 2바이트나 8바이트의 확장 길이로 적고, 클라이언트가 보내는 프레임은 4바이트 키로 마스킹해야 합니다.
  • 브로드캐스트를 차례로 보내면 느린 수신자 하나가 모두를 늦추므로, 동시에 보내고 수신자마다 대기열의 상한을 둡니다.
  • SSE는 data: 줄과 빈 줄로 이벤트를 나누고, id와 Last-Event-ID로 끊긴 동안의 이벤트를 다시 받을 수 있지만 다시 보내는 일은 서버가 구현합니다.
  • 프록시의 유휴 시간과 업그레이드, 버퍼링 설정을 맞추고, WebSocket 서버는 Origin을 확인해 다른 사이트의 연결을 막습니다.

연습 문제

1. 프레임의 크기

확장 없이 UTF-8로 125바이트인 텍스트를 클라이언트가 프레임 하나로 보내면 프레임은 몇 바이트인가요? 본문이 126바이트라면 어떻게 되나요? 같은 126바이트를 Ping의 페이로드로 보낼 수 있나요?

해설 보기

125바이트는 둘째 바이트에 길이를 바로 적으므로 기본 헤더 2바이트, 마스킹 키 4바이트, 본문 125바이트로 131바이트입니다. 126바이트는 길이 자리에 126을 적고 실제 길이를 뒤의 2바이트에 적으므로 2 + 2 + 4 + 126 = 134바이트가 됩니다. 마스킹 키는 페이로드의 길이에 들어가지 않습니다. Ping은 제어 프레임이어서 페이로드가 125바이트를 넘을 수 없고 쪼갤 수도 없으므로, 126바이트를 Ping으로 보낼 수는 없습니다.

2. Accept 값이 맞았다면

어느 개발자가 Sec-WebSocket-Key를 먼저 base64로 풀어 얻은 바이트 뒤에 정해진 문자열을 붙여 해시를 구했습니다. 브라우저는 이 서버와 연결할 수 있나요? 또 서버의 101 응답과 Accept 값이 모두 맞았다면, 이 사용자가 로그인했다고 볼 수 있나요?

해설 보기

연결할 수 없습니다. 규칙은 base64 문자열을 풀지 않고 그대로 정해진 문자열과 이어 붙이는 것이므로, 바이트로 풀어서 계산한 값은 브라우저가 기대하는 값과 달라 브라우저가 연결을 거부합니다. RFC의 예제 키로 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=가 나오는지 확인하면 바로 알 수 있습니다. Accept 값이 맞았다는 것은 서버가 WebSocket의 핸드셰이크를 이해했다는 뜻일 뿐이고, 누구나 계산할 수 있는 값이므로 사용자의 로그인과는 관계가 없습니다. 로그인 여부는 업그레이드 요청에 실린 쿠키나 토큰을 서버가 따로 확인해야 합니다.

3. 누가 먼저 받는가

채팅 서버에 A, B, C가 접속해 있고, 서버는 보낸 사람을 뺀 나머지에게 접속 목록을 돌며 하나씩 차례로 보냅니다. A가 메시지를 보내면 누가 받나요? 접속 목록이 Python의 set이라면 B와 C 가운데 누가 먼저 받는지 정할 수 있나요? B의 네트워크가 아주 느리다면 C에게는 어떤 일이 생기나요?

해설 보기

B와 C가 받고, 보낸 A는 자기 메시지를 받지 않습니다. set은 순서를 약속하지 않으므로 B와 C 가운데 누가 먼저 받을지는 코드만으로 정할 수 없습니다. 순서가 중요하다면 접속 순서를 기억하는 자료 구조를 쓰거나, 메시지에 번호를 붙여 받는 쪽이 정렬하게 해야 합니다. 차례로 보내는 코드에서 B가 먼저 순회되고 B의 네트워크가 느리다면, C는 B의 전송이 끝날 때까지 기다리므로 메시지를 늦게 받습니다. 동시에 보내면 이 기다림은 없어지지만, B에게 쌓이는 메시지에는 상한을 두어야 합니다.

다음 장 CDN에서는 사용자와 가까운 곳에 응답을 복사해 두는 CDN이 어떻게 캐시하고, 언제 원본 서버에 묻는지 살펴봅니다.