Alert
이 글은 AI 코딩 에이전트의 도움을 받아 작성되었습니다
TL;DR
- WebSocket은 HTTP 업그레이드를 통해 단일 TCP 연결에서 양방향 통신을 제공하는 프로토콜
- Sec-WebSocket-Key + SHA-1 해싱은 캐시 오염을 “방지”가 아니라 클라이언트가 “탐지”하는 구조
- HTTP 업그레이드 과정이 TCP slow start를 사전에 워밍업하여 성능 이점 확보
- 클라이언트→서버 메시지만 XOR 마스킹하는 이유는 캐시 포이즈닝 공격 방지
- HTTP/2의 멀티플렉싱이 WebSocket보다 효율적인 경우가 많아, 서버→클라이언트 단방향은 SSE가 대안
Source
1. WebSocket 이전 - 실시간 통신의 문제
2005년에 채팅 앱을 만든다고 생각해보자. HTTP는 요청-응답 모델이다. 브라우저가 물어봐야 서버가 대답한다. 서버가 먼저 “새 메시지 왔어”라고 알려줄 수 없다.
개발자들은 이를 해결하기 위해 여러 우회 방법을 사용했다.
Polling
브라우저: 새 메시지 있어?
서버: 없어.
브라우저: 새 메시지 있어?
서버: 없어.
브라우저: 새 메시지 있어?
서버: 있어! 여기.
몇 초마다 서버에 물어보는 방식. 동작은 하지만, 아무 일도 없을 때도 대역폭과 서버 자원을 소비한다.
Long Polling
브라우저가 요청을 보내면 서버가 새 데이터가 생길 때까지 연결을 열어둔다. 데이터가 오면 응답 후 즉시 재연결. Polling보다 낫지만, HTTP 연결을 계속 끊고 다시 만드는 비용이 있다.
WebSocket의 등장
연결을 한 번 열어두고, 양쪽이 원할 때 메시지를 보내면 어떨까? 이것이 WebSocket의 핵심 아이디어다.
- 하나의 TCP 연결
- 전이중(full-duplex) 통신
- 요청-응답 순서에 얽매이지 않음
2. HTTP 업그레이드 핸드셰이크
WebSocket은 처음부터 새로운 연결을 만들지 않는다. 일반 HTTP 요청으로 시작한다.
클라이언트 요청
GET /chat HTTP/1.1
Host: 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=서버가 101 Switching Protocols를 보내면, 이 시점부터 HTTP가 아닌 WebSocket 프로토콜로 대화한다. 기존 TCP 연결은 그대로 유지된다. 새로운 핸드셰이크는 필요 없다.
겉보기에는 단순하지만, 여기에 숨겨진 설계 결정이 여러 가지 있다.
3. Sec-WebSocket-Key - 투명 프록시 캐시 탐지
클라이언트가 랜덤 문자열을 보내고, 서버가 다른 문자열을 돌려보내는 이 과정이 왜 필요할까?
투명 프록시 문제
인터넷에는 **투명 프록시(transparent proxy)**라는 것이 있다. 클라이언트와 서버 사이에서 응답을 캐싱해서 속도를 높여주는 중개 서버다. ISP나 기업 네트워크에 흔히 존재하며, 사용자가 그 존재를 모를 수도 있다.
일반 HTTP에서는 프록시가 캐시된 응답을 돌려줘도 “좀 오래된 데이터가 보이네” 수준으로 끝난다. 다음 요청에서 새 데이터를 받으면 되니까. 하지만 WebSocket은 handshake 이후 지속적인 양방향 연결로 전환된다. 캐시된 응답으로 handshake가 통과해 버리면 클라이언트는 연결이 됐다고 착각하지만, 실제로 받아줄 서버가 없어서 이후 모든 통신이 블랙홀로 빠진다. 그래서 WebSocket은 handshake 단계에서 엄격하게 검증하고, 의심스러우면 빨리 실패시키는 방식을 택했다.
검증 과정
1. 클라이언트가 매 연결마다 랜덤 키를 생성해서 서버에 전송
2. 서버가 키 + 매직 GUID를 이어붙여서 SHA-1 해시 → Base64 인코딩
3. 해시 결과를 Sec-WebSocket-Accept로 반환
4. 클라이언트도 같은 연산(자기 키 + 매직 GUID → SHA-1 → Base64)을 수행
5. 서버 응답과 자기 계산 결과를 대조 → 일치하면 연결 성립
캐시 "방지"가 아니라 "탐지"
이 메커니즘은 프록시의 캐싱 자체를 막지는 않는다. 프록시는 어떤 응답이든 캐시할 수 있다. 핵심은 캐시된 응답이 돌아와도 클라이언트가 걸러낼 수 있다는 점이다. 매번 클라이언트 키가 랜덤이기 때문에, 이전 연결의 캐시된 해시 값은 새 키에 대한 해시와 일치할 수 없다.
handshake 실패 후 어떻게 되나?
해시가 불일치하면 연결은 그냥 실패로 끝난다. WebSocket 스펙에 재시도 메커니즘은 없고, 재연결 로직은 애플리케이션 코드의 책임이다. 근본적으로 투명 프록시가 WebSocket을 이해하지 못해서 생기는 문제이므로, 같은 프록시를 거치면 재시도해도 또 실패할 수 있다. 이것이 실무에서
wss://(TLS 위의 WebSocket)를 권장하는 이유다. TLS로 암호화하면 프록시가 응답 내용을 볼 수 없어 캐싱 자체가 불가능해진다.
해싱과 매직 GUID의 역할 분리
이 과정에서 SHA-1 해싱과 매직 GUID는 각각 다른 역할을 한다.
- SHA-1 해싱 — 캐시 탐지의 핵심. 단순 에코나 캐시된 응답으로는 올바른 해시를 만들 수 없다. 서버가 키를 그대로 돌려보내는 규칙이었다면, 프록시가 이전 응답을 재사용했을 때 우연히 통과할 수 있다.
- 매직 GUID — 프로토콜 식별자.
258EAFA5-E914-47DA-95CA-C5AB0DC85B11이라는 값이 RFC 6455에 하드코딩되어 있어서, WebSocket을 구현한 모든 클라이언트와 서버가 동일한 값을 사용한다. 비밀이 아니라 공개된 상수다. 이 값이 없어도 캐시 탐지 자체는 가능하지만, GUID를 넣어서 “이 서버가 WebSocket 프로토콜을 의도적으로 구현했는가”를 명시적으로 구분한다.
SHA-1은 단방향
클라이언트가 서버의 해시를 “복호화”하는 것이 아니다. SHA-1은 단방향 함수라 해시에서 원본을 복원할 수 없다. 클라이언트는 자기가 보낸 키로 같은 연산을 직접 수행해서 결과를 대조하는 것이다.
4. HTTP 업그레이드의 숨겨진 이점 - TCP Slow Start 워밍업
TCP는 새 연결을 열 때 slow start라는 메커니즘을 사용한다. 처음에는 적은 양의 데이터를 보내고, ACK을 받으면서 점진적으로 전송량을 늘린다. 혼잡 제어를 위한 합리적인 설계지만, 새 연결은 항상 느리게 시작한다.
연결 직후: [적은 데이터] → ACK → [조금 더] → ACK → [더 많이] → ...
←── 혼잡 윈도우(congestion window) 성장 ──→
WebSocket이 HTTP 업그레이드를 사용하는 것은 단순히 프로토콜 형식을 맞추기 위한 것이 아니다. HTTP 요청-응답 과정에서 오가는 바이트들이 TCP 혼잡 윈도우를 키운다.
업그레이드가 완료되고 실제 WebSocket 프레임을 보내기 시작할 때, TCP 연결은 이미 워밍업이 된 상태다. 0에서 시작하지 않는다.
만약 WebSocket이 독자 프로토콜이었다면
별도 포트에서 별도 핸드셰이크로 연결을 열었다면, TCP slow start 비용을 두 번 지불해야 했다. 한 번은 TCP 연결 자체, 한 번은 WebSocket 핸드셰이크. HTTP를 재사용함으로써 이 비용을 한 번으로 줄인다.
5. 클라이언트 마스킹 - 캐시 포이즈닝 방지
WebSocket에는 비대칭적인 규칙이 있다.
| 방향 | 마스킹 |
|---|---|
| 클라이언트 → 서버 | 필수 (랜덤 4바이트 키로 XOR) |
| 서버 → 클라이언트 | 불필요 |
왜 클라이언트만 마스킹해야 할까?
캐시 포이즈닝 공격 시나리오
1. 악성 웹페이지가 취약한 서버에 WebSocket 연결을 열음
2. WebSocket 프레임을 교묘하게 조작해서 유효한 HTTP 응답처럼 보이게 만듦
3. WebSocket을 이해하지 못하는 투명 프록시가 이 바이트들을 보고
"이건 jQuery.js에 대한 HTTP 응답이네?" 하고 캐시
4. 다음 사용자가 jQuery를 요청하면 프록시가 공격자의 악성 코드를 서빙
이것이 **캐시 포이즈닝(cache poisoning)**이다.
XOR 마스킹으로 방지
클라이언트가 보내는 모든 데이터를 랜덤 키로 XOR 마스킹하면, 공격자가 실제로 전선에 어떤 바이트가 흐를지 예측할 수 없다. 유효한 HTTP 응답처럼 보이는 페이로드를 만들 수 없게 된다.
서버→클라이언트는 마스킹이 필요 없는데, 서버는 신뢰할 수 있는 존재이기 때문이다. 서버가 자기 캐시를 오염시킬 이유가 없다.
6. 포트 80/443 사용 - 방화벽 우회
WebSocket은 HTTP/HTTPS와 같은 포트 80과 443을 사용한다. 이것은 우연이 아니라 의도적인 설계다.
기업 방화벽은 일반적으로 웹 트래픽(80, 443) 외에는 전부 차단한다. WebSocket이 8080 같은 별도 포트를 사용했다면, 실제로 필요한 환경의 절반에서 차단됐을 것이다.
게다가 연결이 HTTP로 시작하기 때문에, 패킷 내용까지 검사하는(DPI) 방화벽도 정상적인 HTTP 요청으로 인식한다. 업그레이드 시점에는 이미 연결이 성립된 상태다.
실용적인 프로토콜 설계
WebSocket은 문 앞에서 HTTP인 척 하고, 안에 들어간 뒤에 진짜 자기 모습을 드러내는 전략을 쓴다. 기존 인프라와의 호환성을 위한 현실적인 설계 결정이다.
7. WebSocket의 한계 - 멀티플렉싱 부재
앱에 채팅, 알림, 실시간 주가 세 가지 실시간 기능이 있다고 하자. WebSocket에서는 세 개의 별도 연결을 열어야 할 수 있다. 각각이 별도 TCP 연결이고, 별도 핸드셰이크, 별도 slow start, 서버의 별도 메모리 할당이 필요하다.
HTTP/2의 멀티플렉싱
HTTP/2는 하나의 TCP 연결 위에 여러 독립 스트림을 다중화할 수 있다. 서버가 원할 때 데이터를 보낼 수 있고(Server-Sent Events), 여러 기능이 하나의 연결을 공유한다.
| 비교 | WebSocket | HTTP/2 + SSE |
|---|---|---|
| 연결 | 기능당 별도 TCP 연결 가능 | 하나의 TCP 연결로 다중화 |
| 방향 | 양방향 (full-duplex) | 서버→클라이언트 (SSE) |
| 적합한 경우 | 게임, 협업 편집 등 양방향 필수 | 알림, 대시보드 등 서버 푸시 |
| 복잡도 | 낮음 (단순 API) | 중간 |
언제 WebSocket을 쓸까
- 양방향 통신이 필수일 때: 실시간 게임, 협업 문서 편집, 채팅 등 클라이언트와 서버가 예측 불가능하게 서로 데이터를 보내는 경우
- 서버→클라이언트 단방향 업데이트만 필요하면 HTTP/2 + SSE가 더 단순하고 효율적인 경우가 많다
8. 정리 - 5가지 설계 결정
| # | 설계 결정 | 해결하는 문제 |
|---|---|---|
| 1 | Sec-WebSocket-Key + 매직 GUID | 투명 프록시 캐시 탐지 + 프로토콜 식별 |
| 2 | HTTP 업그레이드로 시작 | TCP slow start 비용 절약 |
| 3 | 클라이언트→서버 XOR 마스킹 | 캐시 포이즈닝 공격 차단 |
| 4 | 포트 80/443 사용 | 방화벽/프록시 호환성 확보 |
| 5 | 멀티플렉싱 미지원 | HTTP/2가 이 영역을 더 잘 처리 |
WebSocket은 겉보기에는 단순하지만, 파고 들면 실제 인터넷 환경(프록시, 방화벽, 캐시)의 문제를 해결하기 위한 정교한 설계가 들어 있다. 각 결정이 단순한 기술 선택이 아니라, 현실 세계의 제약을 고려한 트레이드오프라는 점이 핵심이다.
관련 노트
- 소켓 - 소켓의 기본 개념과 종류 (UDS, TCP/UDP)
- REST vs gRPC vs GraphQL - API 통신 방식 비교 - WebSocket을 포함한 API 통신 방식 전체 비교