- Published on
WebSocket 프레임 구조 — opcode와 마스킹
- Authors

- Name
- Nostrss
- Github
- Github

WebSocket 프로토콜 시리즈의 두 번째 레슨. 레슨 1에서 101 이후 "HTTP가 아닌 것"이 흐른다고 했다. 그것의 정체:
ws.send("Hello")한 줄이 와이어에 놓는 실제 바이트를 본다.
핵심 답부터
101 이후 모든 데이터는 **프레임(frame)**이라는 단위로 흐른다. 프레임은 최소 2바이트짜리 헤더 + 페이로드로 구성된 바이너리 구조다. HTTP처럼 텍스트 헤더(Content-Type: ...)를 매번 보내는 게 아니라, 비트 단위로 꽉 채운 작은 헤더만 붙는다 — 메시지당 오버헤드가 수백 바이트에서 2~14바이트로 줄어드는 것이 WebSocket 효율의 실체다.
프레임 헤더 — RFC의 그림 그대로 읽기
아래는 RFC 6455 §5.2에 실린 원문 다이어그램이다. RFC를 읽는 연습을 위해 원문 그대로 가져왔다 (한 줄이 32비트 = 4바이트):
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| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
첫 2바이트에 모든 제어 정보가 압축돼 있다:
| 필드 | 크기 | 의미 |
|---|---|---|
| FIN | 1비트 | 1이면 이 프레임이 메시지의 마지막 조각. 0이면 뒤에 조각이 더 온다 — 큰 메시지를 청크로 나눠 보내는 것을 단편화(fragmentation) 라 한다 |
| RSV1–3 | 각 1비트 | 확장 협상이 없으면 반드시 0. 확장은 핸드셰이크의 Sec-WebSocket-Extensions 헤더로 협상되며, 그렇게 협상된 permessage-deflate가 RSV1을 쓴다 — 후속 레슨 |
| opcode | 4비트 | 페이로드의 해석 방법 (아래 표) |
| MASK | 1비트 | 페이로드가 마스킹됐는가. 클라→서버는 항상 1 |
| Payload len | 7비트 | 0–125면 그 값이 실제 길이. 126이면 다음 2바이트가 길이(16비트 unsigned), 127이면 다음 8바이트가 길이(64비트 unsigned, 최상위 비트는 0이어야 한다) |
| Masking-key | 0 또는 4바이트 | MASK=1일 때만 존재. 프레임마다 새로 뽑는 무작위 값 |
7비트로는 최대 125까지만 표현되므로, 126과 127을 "다음 N바이트를 길이로 읽어라"라는 탈출 코드 로 예약한 것이다. 짧은 메시지는 2바이트 헤더로 끝내고, 긴 메시지만 비용을 내는 가변 길이 설계다.
예제로 읽기 — RFC가 직접 주는 "Hello" (§5.7)
다이어그램만으로는 어려우니 실물로 풀어보자. RFC 6455 §5.7에 실린 예제 그대로, 클라이언트가 ws.send("Hello") 를 실행하면 와이어에 이 11바이트가 놓인다:
81 85 37 fa 21 3d 7f 9f 4d 51 58
앞에서부터 한 바이트씩 읽으면 (0x 표기는 16진수, 한 자리 = 4비트):
| 바이트 | 2진수 | 해석 |
|---|---|---|
81 | 1 000 0001 | FIN=1 (마지막 조각) · RSV 000 · opcode=0x1 (Text) |
85 | 1 0000101 | MASK=1 (마스킹됨) · 길이=5 (125 이하이므로 이 값이 실제 길이 — 확장 길이 바이트 없음) |
37 fa 21 3d | — | 마스킹 키 4바이트 (이 프레임을 위해 무작위로 뽑힌 값) |
7f 9f 4d 51 58 | — | 마스킹된 페이로드 5바이트 (아래에서 복원) |
서버는 받은 페이로드를 페이로드[i] XOR 키[i mod 4] 로 복원한다. 공식을 두 부분으로 나눠 보자.
XOR(⊕)이 하는 일. 두 바이트를 겹쳐 놓고 비트 자리마다 비교해서, 다르면 1, 같으면 0 을 쓰는 연산이다. 첫 번째 바이트를 실제로 계산해보면:
마스킹된 값 7f = 0111 1111
⊕ 키[0] 37 = 0011 0111
─────────────────────────────
복원된 값 48 = 0100 1000 = 'H' ← 자리마다: 다르면 1, 같으면 0
XOR의 결정적 성질은 같은 값으로 두 번 XOR하면 원래대로 돌아온다는 것이다 (A ⊕ 키 ⊕ 키 = A). 클라이언트가 48 ⊕ 37 = 7f 로 마스킹 한 것을, 서버가 똑같이 7f ⊕ 37 = 48 로 복원 한다 — 마스킹과 복원이 완전히 동일한 연산 하나인 이유이고, §5.3이 "같은 알고리즘이 양방향에 적용된다"고 쓴 이유다.
i mod 4 가 하는 일. 키는 4바이트뿐인데 페이로드는 얼마든지 길 수 있다. 그래서 페이로드의 i번째 바이트에는 키의 i mod 4 번째(= i를 4로 나눈 나머지) 바이트를 짝지어, 키를 0,1,2,3,0,1,2,3,… 순으로 돌려쓴다. "Hello"는 5바이트라서 마지막 바이트(i=4)에서 키[0]이 재사용되는 것을 아래 표에서 볼 수 있다:
| i | 마스킹된 값 | XOR | 키[i mod 4] | = | 복원 | 문자 |
|---|---|---|---|---|---|---|
| 0 | 7f | ⊕ | 37 (키[0]) | = | 48 | H |
| 1 | 9f | ⊕ | fa (키[1]) | = | 65 | e |
| 2 | 4d | ⊕ | 21 (키[2]) | = | 6c | l |
| 3 | 51 | ⊕ | 3d (키[3]) | = | 6c | l |
| 4 | 58 | ⊕ | 37 (키[0] 재사용) | = | 6f | o |
같은 "Hello"를 서버가 클라이언트로 보낼 때는 마스킹이 금지되므로 훨씬 단순하다 — MASK 비트가 0이 되고(85 → 05), 키 4바이트가 사라지며, 페이로드가 날것 그대로 실린다:
81 05 48 65 6c 6c 6f ← 7바이트. "Hello"가 hex로 그대로 보인다
48이 왜 'H'인가? Text 프레임의 페이로드는 UTF-8이고, UTF-8은 ASCII의 상위 호환이라 0~127 범위(영문·숫자)는 ASCII 표 그대로 1바이트다 (72 = 0x48 = 'H'). 범위를 벗어나면 달라진다: "안녕"은 글자당 3바이트씩 총 6바이트 (
ec 95 88 eb 85 95). Payload len이 세는 것은 글자 수가 아니라 바이트 수다.
이 두 프레임(클라→서버 11바이트, 서버→클라 7바이트)을 나란히 놓고 읽을 수 있다면 다이어그램은 정복한 것이다.
opcode — 프레임의 종류
| opcode | 이름 | 분류 | 비고 |
|---|---|---|---|
0x0 | Continuation | 데이터 | 단편화된 메시지의 이어지는 조각 (첫 조각 제외) |
0x1 | Text | 데이터 | 페이로드는 반드시 유효한 UTF-8. ws.send("문자열") 이 이것 |
0x2 | Binary | 데이터 | ws.send(ArrayBuffer/Blob) 이 이것 |
0x3–0x7 | (예약) | 데이터 | 미래의 데이터 프레임용 예약 |
0x8 | Close | 컨트롤 | 종료 핸드셰이크 — 레슨 3에서 상세히 |
0x9 / 0xA | Ping / Pong | 컨트롤 | keepalive. Ping을 받으면 Pong 응답이 의무 — 레슨 4에서 상세히 |
0xB–0xF | (예약) | 컨트롤 | 미래의 컨트롤 프레임용 예약 |
용어 정리: opcode는 필드, 컨트롤 프레임은 분류. DOM 이벤트에 비유하면 정확하다 — opcode는
event.type처럼 모든 프레임에 있는 칸 이고,0x1·0x9는"click"·"keydown"같은 값 이며, "컨트롤 프레임"은 "마우스 이벤트"처럼 값들의 묶음을 부르는 통칭 이다. 즉 opcode 값이 0x80xF 범위인 프레임을 컨트롤 프레임(연결 제어용), 0x00x7이면 데이터 프레임(내용물 운반용)이라 부른다. 받는 쪽 코드도if (opcode >= 0x8)같은 분기 하나로 두 갈래 처리를 가른다.
컨트롤 프레임(0x8~0xF)에는 두 가지 강한 제약이 있다: 페이로드 125바이트 이하, 그리고 컨트롤 프레임 자신을 단편화하는 것 금지 (§5.5). 주의 — 데이터 메시지의 단편화는 §5.4가 허용하는 정상 동작이고, 금지되는 것은 ping/close 같은 컨트롤 프레임을 여러 조각으로 쪼개는 것이다. 즉 컨트롤 프레임은 FIN=0으로 시작할 수 없고, 언제나 프레임 하나 = 완결이다. 덕분에 컨트롤 프레임은 단편화된 데이터 메시지의 조각들 사이에 끼어들 수 있다 — 긴 파일을 보내는 중에도 ping이 지나갈 수 있는 이유다.
ping은 어떻게 "끼어드는가"
전제부터: WebSocket은 TCP 연결 하나 위에서 프레임이 한 줄로 순서대로 흐른다. 추월 차선은 없다. 프레임 하나가 전송을 시작하면 끝날 때까지 다른 것이 나갈 수 없으므로, 만약 100MB 메시지를 프레임 하나 로 보내면 그동안 이 연결은 완전히 막힌다 — keepalive ping이 몇 분씩 못 나가서 타임아웃으로 끊기는 사태가 벌어진다.
단편화가 이 문제를 푼다. 큰 메시지를 여러 프레임으로 쪼개면 조각과 조각 사이가 끼어들 수 있는 틈이 되고, 보내는 쪽은 그 틈에 컨트롤 프레임을 밀어 넣을 수 있다. 단편화의 규칙은 첫 조각(FIN=0 + 실제 opcode) → 중간 조각(FIN=0 + 0x0) → 마지막 조각(FIN=1 + 0x0)이고, 와이어에 실제로 흐르는 순서는 이렇다:
프레임 ① FIN=0, opcode=0x2 (Binary) 파일 조각 1 ← 메시지 A 시작
프레임 ② FIN=0, opcode=0x0 (Cont.) 파일 조각 2 ← 메시지 A 계속
프레임 ③ FIN=1, opcode=0x9 (Ping) ≤125B ← 끼어든 ping (그 자체로 완결)
프레임 ④ FIN=1, opcode=0x0 (Cont.) 파일 조각 3 ← 메시지 A 종료
받는 쪽은 프레임 헤더의 opcode만 보고 두 갈래로 처리한다: 0x0(continuation)이면 조립 중인 메시지 A의 버퍼에 이어 붙이고, 컨트롤 opcode(0x8~)면 조립 중인 버퍼와 무관하게 즉시 처리한다(ping이면 pong 응답). ③이 지나가도 A의 조립은 아무 영향을 받지 않고, ④가 오면 A가 완성된다.
이 두 갈래 처리가 안전하게 성립하는 것이 정확히 §5.5의 두 제약 덕분이다:
- 단편화 금지 → 컨트롤 프레임은 항상 프레임 하나로 완결된다. 받는 쪽이 "이건 A의 조각인가?"를 고민할 필요가 없다 — opcode가 0x0이 아니므로 즉시 구분되고, 따로 조립할 것도 없다. 만약 ping을 쪼갤 수 있다면 그 두 번째 조각은 opcode 0x0이 될 텐데, continuation에는 소속 식별자가 없어 A의 조각과 구분이 불가능해진다 — 금지가 필요한 이유다.
- ≤125바이트 → 끼어들어도 A의 전송을 지연시키는 시간이 미미하다. 크기 제한이 없다면 "끼어든 프레임" 자체가 새로운 차선 점거자가 된다.
반대로, 데이터 메시지끼리는 끼어들 수 없다. §5.4는 "한 메시지의 조각들 사이에 다른 메시지의 조각을 끼우는 것"을 금지한다. 이유는 헤더 구조에 있다: continuation 프레임(0x0)에는 "어느 메시지의 조각인지" 식별자가 없다. 조립 중인 메시지는 항상 하나뿐이라는 규칙이 있어야 받는 쪽이 0x0을 어디에 붙일지 알 수 있다. 즉 WebSocket에는 멀티플렉싱이 없고, 컨트롤 프레임만이 유일한 우선 차선이다 — HTTP/2가 스트림 ID를 프레임마다 붙여 이 한계를 푼 것과 대비된다.
브라우저가 숨기는 것. 단편화(FIN=0 → continuation → FIN=1)는 브라우저 API에서 보이지 않는다. WHATWG 표준에 따라 브라우저는 조각을 모두 조립한 뒤 완성된 메시지 단위로만
onmessage를 발생시킨다. DevTools Network 탭의 Messages도 프레임이 아니라 메시지를 보여준다 — 프레임 층을 실제로 보려면 Wireshark가 필요하다.
마스킹 — 왜 브라우저→서버 방향만?
규칙은 비대칭이다 (§5.3):
- 클라이언트 → 서버: 반드시 마스킹 (TLS를 써도!). 알고리즘: 프레임마다 무작위 4바이트 키를 뽑아
페이로드[i] XOR 키[i mod 4] - 서버 → 클라이언트: 마스킹 금지. 마스킹된 프레임을 받으면 클라이언트는 연결을 끊어야 한다
XOR 한 번이 암호화일 리 없다 — 키가 프레임 안에 같이 실려 있으니 누구든 풀 수 있다. 목적은 기밀성이 아니라 중간 장비 보호다 (§10.3): 악성 스크립트가 페이로드에 가짜 HTTP 요청처럼 보이는 바이트를 실어 보내면, 프로토콜을 모르는 낡은 프록시가 그걸 진짜 HTTP로 오해하고 응답을 캐시할 수 있다(캐시 오염 공격). 무작위 마스킹은 공격자가 와이어에 놓일 바이트를 선택할 수 없게 만들어 이를 차단한다.
스펙 원문으로 보는 공격과 방어 (§10.3)
이 캐시 오염 공격은 가설이 아니다. 스펙은 프로토콜 개발 중 실제 배포된 프록시들을 상대로 실험해 성공했다고 기록하고 있다 ([TALKING] 논문, "Talking to Yourself for Fun and Profit"):
The general form of the attack was to establish a connection to a server under the "attacker's" control, perform an UPGRADE on the HTTP connection ... and subsequently send data over that UPGRADEd connection that looked like a GET request for a specific known resource ... The remote server would respond with something that looked like a response to the fake GET request, and this response would be cached by a nonzero percentage of deployed intermediaries, thus poisoning the cache.
— 번역: 공격자 소유 서버로 연결해 UPGRADE를 수행한 뒤, 그 연결 위로 잘 알려진 리소스에 대한 GET 요청처럼 보이는 데이터를 보낸다. 서버가 가짜 응답을 돌려주면, 배포된 중간 장비 중 일부가 이를 진짜 HTTP 응답으로 오해하고 캐시한다 — 캐시가 오염된다. 이후 같은 캐시를 쓰는 다른 사용자들이 그 "리소스"(예: 광고 네트워크의 스크립트)를 요청하면 공격자의 악성 스크립트를 받게 된다.
왜 하필 마스킹이 방어책인가? 스펙은 "HTTP처럼 안 보이는 프레임 헤더를 앞에 붙이는 것만으로는 부족하다"고 먼저 못박는다 — 세상의 모든 비표준 프록시가 낯선 헤더를 만났을 때 안전하게 동작하는지 전수 검증할 방법이 없기 때문이다. 그래서 택한 방어가 바이트 선택권 자체를 빼앗는 것 이다:
Thus, the defense adopted is to mask all data from the client to the server, so that the remote script (attacker) does not have control over how the data being sent appears on the wire and thus cannot construct a message that could be misinterpreted by an intermediary as an HTTP request.
— 번역: 클라이언트→서버의 모든 데이터를 마스킹함으로써, 공격자(원격 스크립트)가 와이어에 실제로 놓이는 바이트의 모양을 통제할 수 없게 만든다. 따라서 중간 장비가 HTTP 요청으로 오해할 만한 메시지를 구성하는 것 자체가 불가능해진다.
이 방어가 성립하려면 키가 프레임마다 새롭고, 예측 불가능해야 한다. 키가 예측되면 공격자는 "원하는 바이트 ⊕ 다음 키"를 미리 계산해 보내고, 클라이언트의 마스킹이 오히려 그것을 복원 해주는 역공이 가능하기 때문이다 (XOR의 자기 역원 성질이 여기서는 공격자의 무기가 된다):
Clients MUST choose a new masking key for each frame, using an algorithm that cannot be predicted by end applications ... If the same key is used or a decipherable pattern exists for how the next key is chosen, the attacker can send a message that, when masked, could appear to be an HTTP request.
— 번역: 클라이언트는 프레임마다 새 마스킹 키를, 애플리케이션이 예측할 수 없는 알고리즘(예: 암호학적 난수 생성기)으로 선택해야 한다. 같은 키를 재사용하거나 다음 키에 패턴이 있으면, 공격자는 마스킹된 결과가 HTTP 요청처럼 보이는 메시지를 만들 수 있다.
서버→클라이언트 방향이 마스킹 면제 인 이유도 §10.3이 직접 답한다: 위협 모델은 "클라이언트가 가짜 HTTP 요청 을 보내는 것"이고, 서버 응답만으로는 공격이 성립하지 않기 때문이다(가짜 응답이 캐시되려면 어차피 가짜 요청이 선행돼야 한다). 즉 보호가 필요한 채널은 클라→서버뿐이다.
레슨 1의 Sec-WebSocket-Key와 같은 주제의식이다: WebSocket 스펙은 "프로토콜을 모르는 중간 장비"를 일관되게 경계한다. 핸드셰이크에서는 우발적 수락을 막고, 프레임에서는 캐시 오염을 막는다.
직접 브라우저가 되어보기 — 프레임 인코더
브라우저 콘솔에서 실행하면 브라우저가 서버로 보낼 실제 프레임 바이트를 만들어볼 수 있다. 같은 메시지라도 실행할 때마다 마스킹 키가 새로 뽑혀 페이로드 바이트가 달라진다 — 이것이 "공격자가 와이어의 바이트를 선택할 수 없다"의 의미다. "안녕"을 넣으면 길이가 6으로 찍히는 것도 확인해보라.
function encodeFrame(msg) {
const payload = new TextEncoder().encode(msg);
const mask = crypto.getRandomValues(new Uint8Array(4));
const masked = payload.map((b, i) => b ^ mask[i % 4]);
const b0 = 0x80 | 0x1; // FIN=1, opcode=0x1 (Text)
const lenBytes =
payload.length < 126
? [0x80 | payload.length] // MASK=1
: [0x80 | 126, payload.length >> 8, payload.length & 0xff];
const hex = (bytes) =>
[...bytes].map((b) => b.toString(16).padStart(2, '0')).join(' ');
return `${hex([b0])} ${hex(lenBytes)} ${hex(mask)} ${hex(masked)}`;
}
encodeFrame('Hello'); // 실행할 때마다 뒤 9바이트(키+페이로드)가 달라진다
확인 퀴즈
Q1. 클라이언트가 서버로 보내는 프레임의 마스킹 규칙은? (항상 마스킹해야 한다 / TLS면 생략해도 된다 / 요청받을 때만 한다 / 바이너리만 마스킹하면 된다)
정답 보기
항상 마스킹해야 한다. MUST mask — TLS 여부와 무관하게 항상이다. 반대로 서버→클라이언트는 마스킹이 금지된다 (§5.3).
Q2. Payload len 필드(7비트) 값이 126일 때의 의미는? (다음 2바이트가 실제 길이다 / 다음 8바이트가 실제 길이다 / 페이로드가 정확히 126바이트다 / 확장 데이터가 이어진다는 뜻이다)
정답 보기
다음 2바이트가 실제 길이다. 126은 "다음 16비트를 길이로 읽어라"는 탈출 코드다. 127이면 다음 64비트. 0–125만 값 그대로다 (§5.2).
Q3. ws.send("안녕") 실행 시 프레임의 opcode는? (0x1 / 0x2 / 0x8 / 0x9)
정답 보기
0x1. 문자열은 Text 프레임(0x1)이고 페이로드는 반드시 유효한 UTF-8이어야 한다. ArrayBuffer였다면 0x2.
Q4. 컨트롤 프레임(Close·Ping·Pong)에 대한 스펙의 제약은? (페이로드 125바이트 이하·단편화 금지 / 크기 제한 없음·단편화 가능 / 텍스트 데이터만 가능 / 데이터 프레임보다 항상 먼저 전송)
정답 보기
페이로드 125바이트 이하, 단편화 금지 (§5.5). 이 제약 덕에 컨트롤 프레임은 단편화된 메시지 조각 사이에 끼어들 수 있다.
다음 단계
1차 자료 읽기. 오늘의 원문: RFC 6455 §5.2 (Base Framing Protocol) — 위에서 다이어그램을 이미 읽었으니 원문이 그대로 읽힐 것이다. 성능 관점의 해설은 HPBN Ch.17의 "Message Overhead" 절 추천.
시리즈 참고: 레슨 1 — WebSocket은 HTTP인가? 열림 핸드셰이크 · WebSocket 시리즈 용어집
