사전 웹소켓
프로토콜

웹소켓

gabury1고친 사람 github-actions[bot]

웹소켓은 웹 페이지와 서버가 한 번 열어 둔 연결로 양쪽이 아무 때나 메시지를 보내게 해 줍니다. 서버도 보낼 것이 생기면 곧바로 보냅니다. 클라이언트가 다시 물어보기를 기다리지 않습니다.

쉽고 빠른 이해

웹소켓은 웹 페이지와 서버 사이에 연결 하나를 열어 두고 양쪽이 아무 때나 메시지를 보내게 하는 규칙입니다. 채팅방에서 남이 보낸 말이 내 화면에 곧바로 뜨는 것이 이 규칙이 하는 일입니다.

이게 없으면 서버는 먼저 말을 걸 방법이 없습니다. 웹 요청은 클라이언트가 물어야 서버가 답하는 구조라, 클라이언트가 새 소식이 있는지 계속 되물어야 합니다. 되물을 때마다 요청 머리가 붙습니다. 연결도 여럿 듭니다.

어떻게 도는가:

  1. 클라이언트가 평범한 웹 요청에 「이 연결을 웹소켓으로 갈아타자」는 표시를 얹어 보냅니다
  2. 서버가 받아들인다고 답하면 같은 연결이 웹소켓 연결로 갈아탑니다
  3. 그다음부터는 양쪽이 메시지를 작은 덩어리에 실어 아무 때나 보냅니다. 끝낼 때는 서로 닫자는 신호를 주고받습니다

대가도 있습니다. 중간의 프록시나 로드 밸런서는 한동안 조용한 연결을 끊어 버려서, 살아 있다는 신호를 주기적으로 흘려야 합니다. 끊긴 뒤 다시 붙는 일은 클라이언트 코드가 직접 맡습니다.

그래서 양쪽이 다 말을 걸어야 할 때 고릅니다. 서버가 페이지로 밀어 주기만 하면 되는 화면은 Server-Sent Events 가 맡습니다. 소식이 늦게 닿아도 되면 폴링으로 됩니다.

상세

연결 하나로 오가는 양방향 메시지

웹소켓은 클라이언트와 서버가 TCP(Transmission Control Protocol, 전송 제어 프로토콜) 연결 하나를 열어 두고 그 위로 메시지를 양쪽으로 주고받는 프로토콜입니다. 연결이 열린 뒤로는 양쪽이 서로를 기다리지 않습니다. 각자 보내고 싶을 때 보냅니다.

쓰는 곳은 서버 쪽에서 먼저 소식이 생기는 화면입니다. 메신저, 게임, 시세판, 여럿이 동시에 고치는 애플리케이션, 서버 쪽 서비스를 실시간으로 보여 주는 화면이 그렇습니다.

프로토콜은 두 부분으로 되어 있습니다. 처음 한 번 주고받는 핸드셰이크와 그 뒤의 데이터 전송입니다. 핸드셰이크는 두 쪽이 본론에 들어가기 전에 조건을 맞추는 첫 왕복입니다.

데이터 전송에서 오가는 단위는 메시지입니다. 메시지 하나는 선 위에서 프레임 하나 이상으로 나뉘어 나갑니다. 프레임은 연결 위를 지나는 가장 작은 덩어리입니다. 머리에 「무엇이 실렸나」와 「몇 바이트인가」를 적습니다.

웹소켓이 TCP 위에 얹는 틀은 일부러 얇습니다. 프레임으로 나누는 것과 글자·바이너리를 가르는 것 정도만 합니다. 메시지에 무슨 뜻을 담을지는 그 위의 애플리케이션이 정합니다.

flowchart TD
    A["애플리케이션이 정하는 메시지 형식"] --> B["웹소켓 · 메시지와 프레임"]
    B --> C["TLS · wss 주소일 때만"]
    C --> D["TCP 연결 하나"]

위에서 아래로 얹힌 순서입니다. 암호를 씌우는 층인 TLS(Transport Layer Security, 전송 계층 보안)는 wss:// 주소로 열 때만 낍니다. 그 아래는 언제나 TCP 연결 하나입니다.

폴링으로 흉내 내던 양방향 통신

웹 요청에 쓰는 HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 프로토콜)는 클라이언트가 요청하면 서버가 응답하는 한 쌍으로 돕니다. 응답은 언제나 앞선 요청의 답입니다. 그래서 서버가 먼저 말을 꺼낼 길이 없습니다.

웹소켓 전에는 HTTP 로 양방향을 흉내 냈습니다. 클라이언트가 새 소식이 있는지 서버에 계속 물었습니다. 보낼 것은 따로 HTTP 요청으로 올렸습니다. 이렇게 되묻는 방식을 폴링이라고 합니다.

흉내 내는 쪽은 연결을 두 몫으로 갈라 씁니다. 한 몫은 서버가 클라이언트에게 내려보내는 데 씁니다. 올라가는 메시지는 그때마다 새 HTTP 요청으로 보냅니다. 여기에 세 가지 문제가 따랐습니다.

  • 서버가 클라이언트 하나에 TCP 연결을 여럿 씁니다. 내려보내는 연결 하나와, 올라오는 메시지마다 새 연결 하나입니다
  • 클라이언트가 서버로 보내는 메시지마다 HTTP 헤더가 붙습니다. 헤더는 요청 앞에 붙는 이름-값 줄들입니다. 실어 나르는 군더더기가 그만큼 늘어납니다
  • 클라이언트 쪽 스크립트가 나가는 연결과 들어오는 응답을 직접 짝지어야 어느 답이 어느 요청의 것인지 압니다

TCP 연결 하나로 두 방향을 다 나르면 셋이 함께 풀립니다. 웹소켓이 주는 것이 그 연결입니다.

폴링 · 롱 폴링 · Server-Sent Events 와의 차이

폴링은 클라이언트가 정해진 간격으로 요청을 보내는 방식입니다. 소식이 없으면 서버는 빈 응답을 돌려줍니다. 클라이언트는 잠시 뒤 다시 묻습니다. 소식을 몇 초 안에 받으려고 간격을 좁히면 서버와 네트워크가 못 견딜 만큼 요청이 늘 수 있습니다.

롱 폴링은 서버가 요청을 받고도 바로 답하지 않는 방식입니다. 새 소식이나 정해 둔 시간 초과가 생길 때까지 응답을 붙들고 있다가 그때 답합니다. 답을 받은 클라이언트는 대개 곧바로 새 요청을 겁니다.

롱 폴링에도 남는 문제가 있습니다. 요청과 응답이 매번 온전한 HTTP 메시지라 헤더가 매번 붙습니다. 응답 하나를 보낸 서버는 다음 요청이 올 때까지 다음 소식을 못 보냅니다. 그래서 가장 늦을 때는 네트워크를 세 번 넘게 건너야 소식이 닿습니다. 앞의 응답이 내려가고, 새 요청이 올라가고, 그 응답이 다시 내려가는 세 번입니다. 평균으로는 한 번 건너는 것에 가깝습니다.

아래 그림은 그 가장 늦은 경우입니다. 소식이 응답을 보낸 바로 뒤에 생겼을 때입니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    서버-->>클라이언트: 롱 폴링 응답 · 첫 번째 건넘
    Note over 서버: 바로 뒤에 새 소식이 생긴다
    클라이언트->>서버: 새 롱 폴링 요청 · 두 번째 건넘
    서버-->>클라이언트: 롱 폴링 응답 · 세 번째 건넘

새 소식은 서버에 이미 있는데도 클라이언트의 새 요청을 기다려야 나갑니다. 그 기다림이 두 번째 건넘입니다.

Server-Sent Events 는 서버가 웹 페이지로 데이터를 밀어 주려고 만든 브라우저 인터페이스입니다. 페이지는 EventSource 객체를 만들어 서버 주소를 엽니다. 서버는 그 HTTP 응답에 data: 로 시작하는 줄을 이어 붙여 메시지를 흘려보냅니다. 연결이 닫히면 클라이언트가 알아서 다시 붙습니다.

네 방식을 누가 보내기를 시작하나와 무엇을 반복하나로 견주면 이렇습니다.

방식 누가 보내기를 시작하나 메시지마다 되풀이되는 것
폴링 클라이언트가 묻고 서버가 답한다 요청·응답 한 쌍과 그 헤더
롱 폴링 클라이언트가 묻고, 서버는 소식이 생길 때까지 답을 미룬다 답 하나마다 새 요청 하나와 그 헤더
Server-Sent Events 서버가 열린 HTTP 응답으로 민다 응답 하나에 줄을 이어 붙인다
웹소켓 양쪽이 각자 아무 때나 연결 하나에 프레임을 이어 붙인다

웹소켓은 마지막 줄입니다. 연결 하나를 열어 두고 양쪽이 대등하게 보냅니다.

HTTP 와 맞닿는 핸드셰이크

웹소켓은 HTTP 와 따로 서는, TCP 위의 프로토콜입니다. HTTP 와 닿는 곳은 하나뿐입니다. 핸드셰이크가 HTTP 서버에게는 같은 연결을 다른 프로토콜로 갈아타자는 요청으로 읽힌다는 점입니다.

이렇게 맞닿게 만든 것은 이미 깔린 웹 장비를 그대로 쓰기 위해서입니다. 웹소켓은 HTTP 포트인 80 과 443 에서 돕니다. HTTP 프록시와 중간 장비를 지나가도록 설계됐습니다. 프록시는 클라이언트와 서버 사이에 끼어 요청을 대신 날라 주는 장비입니다. 그래서 한 포트를 HTTP 클라이언트와 웹소켓 클라이언트가 같이 씁니다.

웹소켓 주소는 두 가지입니다. ws:// 는 기본 포트가 80 입니다. wss:// 는 기본 포트가 443 이고 TLS 로 암호를 씌운 연결입니다. 웹 주소가 http:// 와 https:// 로 갈리는 것과 같은 짝입니다.

웹소켓을 고르는 기준

클라이언트도 같은 연결로 수시로 보내야 하면 웹소켓이 맞습니다. 메신저와 게임처럼 양쪽이 다 말을 거는 화면이 그런 쪽입니다.

서버가 페이지로 밀어 주기만 하면 되면 Server-Sent Events 가 그 일을 하려고 만든 인터페이스입니다. 끊겼을 때 다시 붙는 일도 브라우저가 맡습니다.

소식이 늦게 닿아도 되면 폴링도 됩니다. 지연을 길게 잡을 수 있으면 요청 간격도 넓어져 폴링의 부담이 줄어듭니다.

세 경우를 가르는 물음을 순서대로 타면 이렇습니다.

flowchart TD
    A{"클라이언트도 같은 연결로 수시로 보내나"} -->|그렇다| W["웹소켓"]
    A -->|아니다| B{"서버 소식이 몇 초 안에 닿아야 하나"}
    B -->|그렇다| S["Server-Sent Events"]
    B -->|아니다| P["폴링"]

웹소켓은 첫 물음에서만 나옵니다. 양쪽이 다 보낼 일이 없으면 다른 길이 있습니다.

교환 순서

웹소켓 연결 하나에서 오가는 왕복은 셋입니다. 여는 핸드셰이크, 연결이 열린 동안의 핑과 퐁, 닫는 핸드셰이크입니다. 그 사이로 데이터 프레임이 양쪽에서 흐릅니다.

왕복이 하나 끝날 때마다 연결 상태가 옮겨갑니다. 상태 이름은 CONNECTING · OPEN · CLOSING · CLOSED 넷입니다. 이 절은 세 왕복을 메시지 이름 그대로 따라갑니다. 끝에서 상태의 흐름을 모아 봅니다.

여는 핸드셰이크

클라이언트가 먼저 서버로 TCP 연결을 엽니다. 프록시를 거치거나 TLS 로 감싼 연결이어도 됩니다. 이때 연결 상태는 CONNECTING 입니다.

그다음 클라이언트는 웹소켓으로 갈아타자는 HTTP 요청을 보냅니다. 메서드는 GET 이어야 하고 HTTP 판은 1.1 이상이어야 합니다(MUST).

괄호 안의 MUST·SHOULD·MAY 는 표준 문서가 요구의 세기를 적는 낱말입니다. MUST 는 반드시 그래야 한다, SHOULD 는 그렇게 하기를 권한다, MAY 는 해도 된다는 뜻입니다. 아래 표의 「강도」 칸도 같은 낱말입니다.

요청에 싣는 헤더는 이렇습니다.

헤더 값 강도 하는 일
Host 서버 호스트 이름 MUST 양쪽이 같은 호스트를 말하는지 맞춘다
Upgrade websocket 을 포함 MUST 무엇으로 바꾸자는지 말한다
Connection Upgrade 를 포함 MUST Upgrade 가 이 연결에만 쓰는 헤더임을 알린다
Sec-WebSocket-Key 무작위 16바이트를 base64 로 적은 값 MUST 서버가 이 요청을 받았다는 증거를 만들 재료
Sec-WebSocket-Version 13 MUST 프로토콜 판
Origin 요청을 연 페이지의 출처 브라우저면 MUST 남의 사이트 스크립트가 붙는 것을 서버가 가려낸다
Sec-WebSocket-Protocol 쓰고 싶은 하위 프로토콜 목록 MAY 웹소켓 위에서 쓸 애플리케이션 규약을 고른다
Sec-WebSocket-Extensions 쓰고 싶은 확장 목록 MAY 프로토콜 수준 확장을 청한다

base64 는 바이트를 영문자·숫자·+·/ 로 적는 표기법입니다. Sec-WebSocket-Key 의 무작위 값은 연결마다 새로 뽑아야 합니다(MUST). 쿠키나 Authorization 같은 다른 헤더도 실을 수 있습니다(MAY).

표의 마지막 두 줄에 나오는 하위 프로토콜과 확장은 둘 다 핸드셰이크에서 양쪽이 합의하는 것입니다. 하위 프로토콜은 웹소켓 위에서 주고받을 메시지의 뜻을 정하는 애플리케이션 규약입니다. 확장은 프레임 자신을 손대는 규격입니다. 확장은 프레임 내용 앞에 자기 데이터를 얼마나 붙이는지와, 프레임 머리의 예약 비트에 어떤 뜻을 주는지를 스스로 정해야 합니다(MUST). 합의한 확장이 없으면 그 예약 비트는 0 입니다. 붙는 확장 데이터도 0바이트입니다.

요청을 보낸 클라이언트는 서버 응답이 올 때까지 다른 데이터를 보내면 안 됩니다(MUST). 받아들이는 서버는 101 Switching Protocols 로 답합니다. 응답에는 Upgrade: websocket · Connection: Upgrade · Sec-WebSocket-Accept 가 들어갑니다. 하위 프로토콜을 골랐으면 Sec-WebSocket-Protocol 에 하나를 적습니다.

Sec-WebSocket-Accept 는 서버가 이 요청을 받았다는 증명입니다. 서버는 받은 키 뒤에 고정 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 을 붙입니다. 그 문자열의 SHA-1(Secure Hash Algorithm 1, 20바이트 해시를 내는 함수) 값을 base64 로 적어 돌려줍니다. 이 고정 문자열은 GUID(Globally Unique Identifier, 전역 고유 식별자)입니다. 웹소켓을 모르는 쪽이 쓸 가능성이 낮은 값입니다.

이 증명은 서버가 클라이언트에게 하는 것입니다. 클라이언트는 같은 계산을 해 보고 값이 다르면 연결을 실패시킵니다. 그래서 핸드셰이크를 제대로 받은 웹소켓 서버와만 연결이 이어집니다. 웹소켓이 아닌 연결은 웹소켓 연결로 쓰이지 않습니다.

이 왕복이 막는 것은 공격자가 XMLHttpRequest 나 폼 전송으로 교묘하게 짠 요청을 보내 웹소켓 서버를 속이는 일입니다. 그렇게 꾸민 요청이 어느 대목에서 이 왕복을 못 넘는지는 자료에 없습니다(미확인).

응답을 보낸 서버는 연결을 OPEN 으로 보고 바로 보내기 시작해도 됩니다. 클라이언트는 응답을 검사해 맞으면 OPEN 으로 넘어갑니다. 101 이 아닌 HTTP 상태 코드는 핸드셰이크가 안 끝났다는 뜻입니다. 그 연결에는 여전히 HTTP 의 의미가 적용됩니다.

아래 그림의 마스킹은 클라이언트가 보내는 프레임 내용을 무작위 열쇠로 뒤섞는 일입니다. 왜 한쪽만 뒤섞는지는 아래 「마스킹」 소절에서 봅니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    클라이언트->>서버: GET · Upgrade 헤더 · Sec-WebSocket-Key
    서버-->>클라이언트: 101 Switching Protocols · Sec-WebSocket-Accept
    Note over 클라이언트,서버: 양쪽 다 OPEN
    클라이언트->>서버: 텍스트 프레임 · 마스킹함
    서버->>클라이언트: 텍스트 프레임 · 마스킹 안 함

요청 한 번과 응답 한 번으로 갈아타기가 끝납니다. 같은 TCP 연결이 그대로 웹소켓 연결이 됩니다. 그 뒤로는 양쪽이 프레임을 각자 보냅니다.

데이터 프레임

데이터 프레임은 핸드셰이크가 끝난 뒤부터 자기가 닫기 프레임을 보내기 전까지 어느 쪽이든 아무 때나 보낼 수 있습니다(MAY). 프레임의 종류는 머리의 4비트 칸인 opcode 가 정합니다.

opcode 프레임 갈래
0x0 이어짐(continuation) 데이터
0x1 텍스트 데이터
0x2 바이너리 데이터
0x3~0x7 예약 데이터
0x8 닫기(Close) 제어
0x9 핑(Ping) 제어
0xA 퐁(Pong) 제어
0xB~0xF 예약 제어

텍스트 프레임의 내용은 UTF-8(Unicode Transformation Format 8-bit, 유니코드를 바이트로 적는 방식) 글자입니다. 메시지 전체가 올바른 UTF-8 이어야 합니다(MUST). 바이너리 프레임의 내용은 어떻게 읽을지를 애플리케이션이 전부 정합니다.

모르는 opcode 를 받은 쪽은 연결을 실패시켜야 합니다(MUST). 연결을 실패시킨다는 것은 연결을 닫고 상대가 보낸 데이터를 더는 처리하지 않는 절차입니다. 「실패」 절에서 자세히 봅니다.

메시지 하나를 여러 프레임으로 쪼갤 때는 머리의 FIN 비트를 씁니다. FIN 은 이 프레임이 메시지의 마지막 조각인지를 말하는 한 비트입니다. 첫 프레임은 opcode 가 텍스트나 바이너리이고 FIN 이 0 입니다. 가운데 프레임은 opcode 0x0 에 FIN 0 입니다. 마지막 프레임은 opcode 0x0 에 FIN 1 입니다. 쪼개지 않은 메시지는 FIN 이 1 인 프레임 하나입니다.

쪼개는 주된 까닭은 크기를 모르는 메시지를 보내기 위해서입니다. 쪼갤 수 없으면 길이를 세려고 메시지 전체를 버퍼에 담아 둔 뒤에야 첫 바이트를 보낼 수 있습니다.

제어 프레임(닫기·핑·퐁)은 내용이 125바이트 이하여야 하고 쪼개면 안 됩니다(MUST). 대신 쪼개진 메시지의 조각 사이에 끼어들 수 있습니다. 끼어들 수 없으면 큰 메시지 뒤에 선 핑이 한참 늦어집니다.

프레임 머리의 비트 배치

프레임 머리는 비트 단위로 칸이 정해져 있습니다. 아래는 내용 길이가 125바이트 이하이고 마스킹한 프레임의 앞 8바이트입니다.

packet-beta
0: "FIN"
1: "RSV1"
2: "RSV2"
3: "RSV3"
4-7: "opcode"
8: "MASK"
9-15: "Payload len"
16-47: "Masking-key"
48-63: "Payload data"

FIN 은 위에서 본 마지막 조각 표시입니다. RSV1~RSV3 는 합의한 확장이 뜻을 정하지 않는 한 0 이어야 하는 예약 비트입니다(MUST). MASK 는 내용을 마스킹했는지를, Payload len 은 내용이 몇 바이트인지를 적습니다.

Payload len 이 0~125 면 그 값이 곧 길이입니다. 126 이면 뒤따르는 2바이트가, 127 이면 뒤따르는 8바이트가 길이입니다. 그때는 길이 칸이 그만큼 늘어나 Masking-key 가 뒤로 밀립니다. Masking-key 는 MASK 가 1 일 때만 있는 4바이트입니다. 그 뒤로 내용이 이어집니다.

마스킹

클라이언트가 서버로 보내는 프레임은 전부 마스킹해야 합니다(MUST). TLS 위에서 돌아도 마찬가지입니다. 서버가 클라이언트로 보내는 프레임은 마스킹하면 안 됩니다(MUST NOT).

마스킹은 내용을 무작위 열쇠로 뒤섞는 일입니다. 뒤섞는 쪽은 페이지의 스크립트가 아니라 그 아래의 클라이언트 구현, 곧 브라우저입니다. 클라이언트는 프레임마다 새 32비트 열쇠를 뽑습니다. 열쇠는 데이터를 준 애플리케이션이 다음 값을 못 짐작하는 방법으로 뽑아야 합니다(MUST). 암호학적으로 강한 난수 생성기에서 뽑는 것이 그 한 예입니다. 내용의 i 번째 바이트를 열쇠의 (i mod 4) 번째 바이트와 XOR(exclusive OR, 배타적 논리합) 합니다. 같은 계산을 한 번 더 하면 원래 내용이 돌아옵니다. 길이는 바뀌지 않습니다.

한 프레임을 보내기 시작한 뒤에는 그 프레임의 내용을 애플리케이션이 고칠 수 없어야 합니다. 더 보낼 것이나 바뀐 것은 새 열쇠를 쓰는 새 프레임으로 나가야 합니다.

규칙을 어기면 연결이 끝납니다. 마스킹 안 한 프레임을 받은 서버는 연결을 닫아야 합니다(MUST). 마스킹한 프레임을 받은 클라이언트도 연결을 닫아야 합니다(MUST).

뒤섞는 까닭은 중간에 낀 프록시에 있습니다. 프록시 가운데 캐싱 프록시는 지나간 응답을 저장해 두었다가 같은 요청이 오면 다시 내줍니다.

이 프로토콜을 만들던 때에 캐싱 프록시를 겨냥한 공격이 실험으로 드러났습니다. 공격을 받는 사람이 공격자의 웹사이트를 열면 그 페이지의 스크립트가 다음 순서로 움직입니다.

sequenceDiagram
    participant 스크립트 as 공격자 스크립트
    participant 프록시 as 중간의 캐싱 프록시
    participant 공격자서버 as 공격자 서버
    participant 사용자 as 같은 캐시 뒤의 다른 사용자
    스크립트->>공격자서버: 연결을 열고 HTTP 를 갈아탄다
    Note over 프록시: 이 연결이 프록시를 지난다
    스크립트->>공격자서버: 「널리 쓰이는 스크립트를 달라」는 GET 요청처럼 보이는 데이터
    공격자서버-->>스크립트: 그 요청의 응답처럼 보이는 데이터
    Note over 프록시: 둘을 요청·응답 한 쌍으로 잘못 읽고 그 스크립트의 응답으로 저장한다
    사용자->>프록시: 그 스크립트를 달라
    프록시-->>사용자: 공격자가 심은 스크립트

이것을 캐시 오염이라고 합니다. 저장된 것은 공격자가 고른 내용이라, 같은 캐시 뒤에 있는 다른 사용자도 공격자가 심은 스크립트를 받게 될 수 있습니다.

클라이언트 쪽 데이터를 브라우저가 열쇠로 뒤섞으면 두 번째 걸음이 깨집니다. 공격자 스크립트는 자기가 준 바이트가 선 위에서 어떤 값이 될지 고를 수 없습니다. 그러면 중간 장비가 HTTP 요청으로 잘못 읽을 거리를 만들 수 없습니다.

막아야 할 것은 클라이언트가 HTTP 요청처럼 보이는 데이터를 보내는 것입니다. 그래서 뒤섞는 방향도 클라이언트 → 서버 한쪽입니다. 공격자 서버는 응답처럼 보이는 것을 만들 수 있지만, 캐시에 들어가려면 짝이 될 요청도 있어야 합니다. 그 요청은 클라이언트가 보내는 것입니다. 마스킹이 그것을 막습니다.

마스킹을 안 하는 클라이언트와 서버가 있으면, 규칙을 안 지키는 HTTP 프록시는 여전히 이 공격에 당할 수 있습니다.

핑과 퐁

핑(0x9)은 상대가 살아 있는지 묻는 제어 프레임입니다. 퐁(0xA)은 그 답입니다. 연결이 열린 뒤부터 닫히기 전까지 어느 쪽이든 핑을 보낼 수 있습니다(MAY).

핑을 받은 쪽은 이미 닫기 프레임을 받은 경우가 아니면 퐁으로 답해야 합니다(MUST). 가능한 한 빨리 답하기를 권합니다(SHOULD). 퐁의 내용은 받은 핑의 내용과 같아야 합니다.

핑은 연결을 살려 두는 신호로도, 상대가 아직 응답하는지 확인하는 수단으로도 쓸 수 있습니다. 핑이 여럿 밀렸으면 가장 최근 핑 하나에만 퐁으로 답해도 됩니다(MAY).

퐁은 핑 없이 먼저 보내도 됩니다(MAY). 그런 퐁은 한 방향 하트비트 노릇을 합니다. 하트비트는 살아 있음을 주기로 알리는 신호입니다. 받은 쪽은 이 퐁에 답하지 않습니다.

닫는 핸드셰이크

끝내려는 쪽이 닫기 프레임(0x8)을 보냅니다. 닫기 프레임에는 내용이 있어도 됩니다(MAY). 내용이 있으면 앞 2바이트가 상태 코드입니다. 그 뒤는 UTF-8 로 적은 이유 글입니다. 이유 글은 사람이 읽을 수 있다는 보장이 없어서 클라이언트는 이것을 최종 사용자에게 보여 주면 안 됩니다(MUST NOT).

낱말 둘을 갈라 둡니다. 닫기 프레임에 싣는 이 2바이트 값을 상태 코드라고 합니다. 여는 핸드셰이크에 나온 101 이나 400 같은 HTTP 상태 코드와는 다른 번호 체계입니다. 연결이 닫힌 뒤에 애플리케이션이 보게 되는 값은 닫힘 코드라고 합니다. 상대가 보낸 닫기 프레임의 상태 코드가 그대로 닫힘 코드가 됩니다. 닫기 프레임을 아예 못 받았으면 프레임에는 실을 수 없는 값이 닫힘 코드로 들어갑니다. 어느 값이 그런지는 「실패」 절의 표에 있습니다.

닫기 프레임을 보낸 쪽은 더는 데이터 프레임을 보내면 안 됩니다(MUST NOT). 닫기 프레임을 받은 쪽은 아직 자기가 안 보냈다면 닫기 프레임으로 답해야 합니다(MUST). 답할 때는 대개 받은 상태 코드를 돌려줍니다.

곧바로 답하기를 권합니다(SHOULD). 다만 보내던 메시지의 남은 조각을 마저 보낸 뒤에 답해도 됩니다(MAY). 양쪽이 동시에 닫기 프레임을 보내도 괜찮습니다. 둘 다 보내고 받은 셈이 되어 그대로 끝납니다.

닫기 프레임을 보내고 받은 쪽은 아래의 TCP 연결을 닫아야 합니다(MUST). 서버는 곧바로 닫아야 합니다(MUST). 클라이언트는 서버가 닫기를 기다리기를 권합니다(SHOULD). 서버에게서 TCP 닫기가 한참 안 오면 클라이언트가 먼저 닫아도 됩니다(MAY).

서버가 먼저 닫는 것은 TIME_WAIT 때문입니다. TCP 는 먼저 닫은 쪽이 한동안 TIME_WAIT 이라는 대기 상태를 쥡니다. 그 상태를 쥔 쪽은 같은 연결을 최대 세그먼트 수명의 두 배(2MSL) 동안 다시 열 수 없습니다. 최대 세그먼트 수명은 보낸 조각이 네트워크에 살아 있을 수 있는 최대 시간입니다. 그래서 클라이언트가 TIME_WAIT 을 쥐면 그만큼 다시 붙지 못합니다.

서버 쪽은 같은 제약을 받지 않습니다. 대기 상태에 있는 연결이라도 일련번호가 더 큰 새 SYN 이 오면 곧바로 다시 열리기 때문입니다.

TCP 에도 닫는 절차가 있습니다. 그래도 웹소켓이 닫기 왕복을 따로 두는 까닭은 둘입니다. 첫째, 가로채는 프록시 같은 중간 장비가 끼면 TCP 의 닫기가 끝에서 끝까지 늘 믿을 만하지는 않습니다. 둘째, 받을 데이터가 남은 소켓을 그냥 닫으면 연결을 초기화하는 신호가 나가는 플랫폼이 있습니다. 그 신호를 받은 쪽은 읽을 데이터가 기다리고 있어도 읽기가 실패합니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    서버->>클라이언트: Ping
    클라이언트->>서버: Pong · 핑과 같은 내용
    클라이언트->>서버: Close · 상태 코드 1000
    서버-->>클라이언트: Close · 대개 받은 코드를 돌려줌
    Note over 서버: TCP 연결을 먼저 닫는다

핑과 퐁은 연결이 열린 동안 몇 번이고 오갑니다. 닫기는 한 번입니다. 닫기 프레임이 양쪽에서 한 번씩 오간 뒤 서버가 TCP 를 닫으면 끝납니다.

연결 상태의 흐름

닫기 프레임을 보내거나 받는 순간 연결은 CLOSING 이 됩니다. 아래의 TCP 연결이 닫히면 CLOSED 입니다. 닫는 핸드셰이크를 마친 뒤에 TCP 가 닫혔으면 깨끗하게 닫혔다고 합니다. 연결을 끝내 못 연 경우도 CLOSED 지만 깨끗하게 닫힌 것은 아닙니다.

stateDiagram-v2
    [*] --> CONNECTING
    CONNECTING --> OPEN: 101 응답을 검사해 맞았다
    CONNECTING --> CLOSED: 핸드셰이크가 실패했다
    OPEN --> CLOSING: 닫기 프레임을 보내거나 받았다
    OPEN --> CLOSED: TCP 연결이 뜻밖에 끊겼다
    CLOSING --> CLOSED: TCP 연결이 닫혔다
    CLOSED --> [*]

브라우저의 WebSocket 객체도 같은 이름을 0~3 의 숫자로 readyState 에 보여 줍니다. CONNECTING 이 0, OPEN 이 1, CLOSING 이 2, CLOSED 가 3 입니다.

예시

핸드셰이크 한 왕복

클라이언트가 보내는 요청입니다.

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

클라이언트는 하위 프로토콜로 chat 과 superchat 을 원하는 순서대로 적었습니다. Origin 은 이 요청을 연 페이지가 http://example.com 에서 왔다고 말합니다.

서버가 돌려주는 응답입니다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

서버는 둘 중 chat 하나를 골라 돌려줬습니다. 이 응답 뒤로 같은 연결에서 프레임이 오갑니다.

Sec-WebSocket-Accept 계산

위 응답의 Sec-WebSocket-Accept 는 요청의 키에서 이렇게 나옵니다.

키                dGhlIHNhbXBsZSBub25jZQ==
고정 문자열을 붙임  dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
SHA-1 (20바이트)   b3 7a 4f 2c c0 62 4f 16 90 f6 46 06 cf 38 59 45 b2 be c4 ea
base64 로 적음     s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

키는 base64 로 풀지 않고 받은 글자 그대로 붙입니다. 클라이언트는 같은 계산을 해 보고 값이 다르면 연결을 실패시킵니다.

「Hello」 를 실은 텍스트 프레임

마스킹 안 한 프레임입니다. 서버가 클라이언트로 보내는 쪽이 이 꼴입니다.

0x81 0x05 0x48 0x65 0x6c 0x6c 0x6f
바이트 비트로 읽으면 뜻
0x81 FIN 1 · RSV 0 0 0 · opcode 0001 마지막 조각인 텍스트 프레임
0x05 MASK 0 · Payload len 5 마스킹 안 함 · 내용 5바이트
0x48~0x6f Hello 의 UTF-8 바이트

같은 메시지를 마스킹한 프레임입니다. 클라이언트가 서버로 보내는 쪽이 이 꼴입니다.

0x81 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58

둘째 바이트 0x85 는 MASK 1 에 Payload len 5 입니다. 그 뒤 4바이트 37 fa 21 3d 가 열쇠입니다. 마지막 5바이트가 뒤섞인 내용입니다. 열쇠를 바이트마다 XOR 하면 Hello 가 돌아옵니다.

i 원래 바이트 열쇠 바이트 (i mod 4) XOR 결과
0 0x48 H 0x37 0x7f
1 0x65 e 0xfa 0x9f
2 0x6c l 0x21 0x4d
3 0x6c l 0x3d 0x51
4 0x6f o 0x37 0x58

다섯째 바이트에서 열쇠가 첫 바이트 0x37 로 돌아갑니다. 길이 5 는 마스킹 앞뒤로 같습니다.

조각난 메시지와 핑·퐁 프레임

Hello 를 두 프레임으로 쪼개 보내면 이렇습니다.

0x01 0x03 0x48 0x65 0x6c          "Hel"
0x80 0x02 0x6c 0x6f               "lo"

첫 프레임 0x01 은 FIN 0 에 opcode 텍스트입니다. 둘째 프레임 0x80 은 FIN 1 에 opcode 이어짐입니다. 받는 쪽은 두 내용을 이어 Hello 한 메시지로 넘깁니다.

마스킹 안 한 핑과, 그 핑에 답하는 마스킹한 퐁입니다.

0x89 0x05 0x48 0x65 0x6c 0x6c 0x6f                       Ping · 내용 "Hello"
0x8a 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58   Pong · 같은 "Hello" 를 마스킹

핑은 마스킹이 없으니 서버가 보낸 것입니다. 퐁은 마스킹했으니 클라이언트가 보낸 것입니다. 핑의 내용 Hello 는 아무 값이어도 됩니다. 퐁은 그 내용을 똑같이 돌려줍니다.

길이 칸이 늘어난 프레임

내용이 125바이트를 넘으면 길이 칸이 늘어납니다. 둘 다 마스킹 안 한 바이너리 프레임입니다.

0x82 0x7E 0x0100 [256바이트]
0x82 0x7F 0x0000000000010000 [65536바이트]

첫 줄의 0x7E 는 126 이라 뒤 2바이트 0x0100(256)이 길이입니다. 둘째 줄의 0x7F 는 127 이라 뒤 8바이트가 길이입니다. 그 값은 65536 입니다.

실패

연결을 실패시킨다는 것

웹소켓에서 「연결을 실패시킨다」는 정해진 절차의 이름입니다. 클라이언트는 연결을 닫아야 합니다(MUST). 문제를 사용자에게 알려도 됩니다(MAY). 서버도 연결을 닫아야 합니다(MUST). 문제를 로그에 남기기를 권합니다(SHOULD).

연결이 이미 열린 뒤라면 알맞은 상태 코드를 담은 닫기 프레임을 먼저 보내기를 권합니다(SHOULD). 상대가 그 프레임을 처리하지 못할 것 같으면 생략해도 됩니다(MAY). 실패시키기로 한 뒤에는 상대가 보낸 데이터를 더 처리하면 안 됩니다(MUST NOT). 상대의 닫기 프레임도 마찬가지입니다.

핸드셰이크 단계의 실패

핸드셰이크에서 깨지는 조건과 그때 돌아오는 것입니다.

조건 누가 결과
클라이언트 요청이 형식에 안 맞는다 서버 처리를 멈추고 400 Bad Request 같은 HTTP 오류로 답한다(MUST)
클라이언트가 청한 Sec-WebSocket-Version 을 모른다 서버 426 Upgrade Required 같은 HTTP 오류에 아는 판을 Sec-WebSocket-Version 으로 적어 답한다(MUST)
요청의 Origin 에서 오는 연결을 안 받고 싶다 서버 알맞은 HTTP 오류 코드로 거절할 수 있다
응답의 HTTP 상태 코드가 101 이 아니다 클라이언트 HTTP 절차대로 처리한다. 401 이면 인증을 할 수 있고, 3xx 리다이렉트는 따르지 않아도 된다
응답에 Upgrade: websocket 이 없다 클라이언트 연결을 실패시킨다(MUST)
응답에 Connection: Upgrade 가 없다 클라이언트 연결을 실패시킨다(MUST)
Sec-WebSocket-Accept 가 없거나 계산값과 다르다 클라이언트 연결을 실패시킨다(MUST)
서버가 클라이언트가 안 청한 확장이나 하위 프로토콜을 적었다 클라이언트 연결을 실패시킨다(MUST)

판이 안 맞을 때는 다시 시도하는 길이 있습니다. 서버가 Sec-WebSocket-Version: 13, 8, 7 처럼 아는 판을 적어 보내면, 클라이언트는 그중 자기도 아는 판으로 핸드셰이크를 다시 보낼 수 있습니다. 판 목록을 싣는 응답의 HTTP 상태 코드는 426 이 아니라 400 Bad Request 일 수도 있습니다.

브라우저는 조금 더 엄격합니다. 응답이 네트워크 오류이거나 101 이 아니면 곧바로 연결을 실패시킵니다. 리다이렉트도 따르지 않습니다.

리다이렉트를 안 따르는 것은 열린 리다이렉터 때문입니다. 열린 리다이렉터는 주소에 적어 준 아무 호스트로나 방문자를 돌려보내 주는 경로입니다. 한 호스트의 어느 경로에는 웹소켓 서버가 있고 다른 경로에는 그런 리다이렉터가 있을 수 있습니다. 그러면 웹소켓 주소를 받은 스크립트가 호스트 이름이 맞는지 검사해도 인터넷의 아무 호스트와 통신하도록, 그 호스트에 비밀을 흘리도록 속을 수 있습니다.

하위 프로토콜을 청한 요청에 서버가 하나도 골라 돌려주지 않으면 브라우저는 그것도 실패로 봅니다.

열린 연결에서의 실패

연결이 열린 뒤에 깨지는 조건입니다.

조건 결과
서버가 마스킹 안 한 프레임을 받았다 서버가 연결을 닫는다(MUST). 프로토콜 오류를 뜻하는 상태 코드 1002 를 보낼 수 있다(MAY)
클라이언트가 마스킹한 프레임을 받았다 클라이언트가 연결을 닫는다(MUST). 1002 를 쓸 수 있다(MAY)
협상한 확장이 없는데 RSV 비트가 0 이 아니다 받은 쪽이 연결을 실패시킨다(MUST)
모르는 opcode 를 받았다 받은 쪽이 연결을 실패시킨다(MUST)
아래 TCP 연결이 뜻밖에 끊겼다 클라이언트가 연결을 실패시킨다(MUST). 닫기 프레임을 못 받았으므로 닫힘 코드는 1006 이 된다

닫힘 상태 코드

닫기 프레임에 싣는 상태 코드는 이렇게 정해져 있습니다.

코드 뜻
1000 정상 종료. 연결을 연 목적을 다 이뤘다
1001 떠난다. 서버가 내려가거나 브라우저가 페이지를 벗어났다
1002 프로토콜 오류로 끝낸다
1003 받을 수 없는 종류의 데이터를 받았다. 텍스트만 아는 쪽이 바이너리를 받은 경우
1004 예약. 뜻은 나중에 정해질 수 있다
1005 예약. 닫기 프레임에 상태 코드가 없었다는 표시. 프레임에 실으면 안 된다(MUST NOT)
1006 예약. 닫기 프레임을 주고받지 못하고 비정상으로 닫혔다는 표시. 프레임에 실으면 안 된다(MUST NOT)
1007 메시지 종류와 안 맞는 데이터를 받았다. 텍스트 메시지 안의 UTF-8 이 아닌 바이트
1008 정책을 어긴 메시지를 받았다. 더 맞는 코드가 없거나 정책을 숨겨야 할 때 쓴다
1009 처리하기에 너무 큰 메시지를 받았다
1010 클라이언트가 기대한 확장을 서버가 협상해 주지 않았다
1011 서버가 요청을 처리하지 못할 뜻밖의 상황을 만났다
1015 예약. TLS 핸드셰이크가 실패해 닫혔다는 표시. 프레임에 실으면 안 된다(MUST NOT)

1005·1006·1015 는 닫기 프레임에 싣는 값이 아니라 애플리케이션이 받는 닫힘 코드로만 쓰이는 값입니다. 받은 닫기 프레임에 상태 코드가 없으면 닫힘 코드는 1005 입니다. 닫기 프레임을 하나도 못 받고 연결이 닫히면 1006 입니다. TLS 핸드셰이크를 못 해서 닫히면 1015 입니다.

양쪽이 거의 동시에 각자 닫기를 시작하면 두 쪽이 서로 다른 닫힘 코드를 볼 수 있습니다. 각자 상대가 보낸 코드를 자기 닫힘 코드로 삼기 때문입니다.

브라우저에 보이는 실패 정보

브라우저의 WebSocket 객체는 연결이 닫히면 close 이벤트를 쏩니다. 이벤트의 wasClean 은 깨끗하게 닫혔는지, code 는 닫힘 코드, reason 은 이유 글입니다. 연결을 실패시킨 경우와 보낼 데이터가 쌓인 버퍼가 가득 차 닫힌 경우에는 close 앞에 error 이벤트가 먼저 갑니다. 그 버퍼는 아래 「보냈는데 아직 안 나간 데이터」에서 봅니다.

error 이벤트는 왜 실패했는지 알려 주지 않습니다. 아래 경우를 스크립트가 서로 구별할 수 있게 알려 주면 안 됩니다(MUST NOT).

  • 호스트 이름을 찾지 못한 서버
  • 패킷이 가 닿지 못한 서버
  • 그 포트에서 연결을 거절한 서버
  • TLS 핸드셰이크를 제대로 못 한 서버. 서버 인증서를 검증할 수 없는 경우가 그렇다
  • 여는 핸드셰이크를 끝내지 못한 서버. 웹소켓 서버가 아닌 경우가 그렇다
  • 핸드셰이크는 맞게 했지만 클라이언트가 연결을 버리게 만드는 옵션을 고른 웹소켓 서버
  • 핸드셰이크를 마친 뒤 갑자기 연결을 끊은 웹소켓 서버

이 경우 모두 닫힘 코드는 1006 입니다. 구별할 수 있게 하면 스크립트가 사용자의 내부 네트워크를 미리 더듬어 공격을 준비할 수 있기 때문입니다. 같은 까닭으로 브라우저는 1015 를 쓰지 않습니다.

스크립트가 부르는 메서드에도 실패가 있습니다.

호출 조건 결과
send(data) readyState 가 CONNECTING InvalidStateError 예외
close(code) code 가 1000 도 아니고 3000~4999 도 아니다 InvalidAccessError 예외
close(code, reason) reason 을 UTF-8 로 적은 길이가 123바이트를 넘는다 SyntaxError 예외

보냈는데 아직 안 나간 데이터

send() 가 예외 없이 돌아왔다고 상대가 받은 것은 아닙니다. send() 는 데이터를 보낼 줄에 세울 뿐입니다.

bufferedAmount 는 send() 로 줄을 세웠지만 아직 네트워크로 안 나간 애플리케이션 데이터의 바이트 수입니다. 프로토콜이 붙이는 프레임 머리는 세지 않습니다. 운영체제나 네트워크 하드웨어가 쥔 버퍼도 세지 않습니다. 그래서 이 값이 0 이어도 상대가 받았다는 뜻은 아닙니다.

버퍼가 가득 차 더 담을 수 없으면 브라우저는 연결을 닫습니다. 연결이 닫힌 뒤에도 send() 를 부를 때마다 bufferedAmount 는 늘기만 하고 0 으로 돌아가지 않습니다.

운영

프록시가 떨어뜨리는 Upgrade 헤더

웹소켓 핸드셰이크를 리버스 프록시 뒤의 서버로 넘기려면 프록시를 따로 설정해야 합니다. 리버스 프록시는 서버들 앞에 서서 클라이언트 요청을 받아 뒤의 서버로 넘기는 프록시입니다.

따로 설정해야 하는 것은 Upgrade 와 Connection 이 홉 바이 홉 헤더이기 때문입니다. 홉 바이 홉 헤더는 바로 다음 수신자에게만 쓰고 그 너머로는 안 넘기는 헤더입니다. 중간 장비는 Connection 에 적힌 이름의 헤더와 Connection 자신을 지운 뒤에 메시지를 넘겨야 합니다(MUST). 지우는 대신 자기가 쓸 값으로 바꿔 넣어도 됩니다. Upgrade 도 넘기기 전에 지우거나 바꾸기를 권합니다(SHOULD).

정방향 프록시에서는 클라이언트 쪽에 피해 가는 길이 있습니다. 정방향 프록시는 클라이언트가 자기가 그 프록시를 쓰는 줄 알고 설정해 둔 프록시입니다. 클라이언트는 CONNECT 메서드로 프록시에게 목적 호스트까지 빈 통로를 뚫어 달라고 청할 수 있습니다. 그 통로 안의 바이트는 프록시가 HTTP 메시지로 읽지 않으니 Upgrade 가 지워지지 않습니다.

리버스 프록시에서는 그 길이 없습니다. 클라이언트가 프록시가 있는 줄 모르니 CONNECT 를 보낼 상대를 모릅니다. 그래서 프록시 쪽에서 따로 처리해야 합니다.

nginx 는 1.3.13 판부터 뒤 서버가 101 로 답하고 클라이언트가 Upgrade 로 갈아타기를 청한 경우 둘 사이에 터널을 놓습니다. 두 헤더는 직접 실어 넘겨야 합니다.

nginx
location /chat/ {
    proxy_pass http://backend;
    # proxy_http_version 1.1; # before version 1.29.7
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

proxy_set_header 두 줄이 클라이언트의 Upgrade 값을 다시 싣고 Connection 을 upgrade 로 채웁니다. Connection 쪽이 위에서 말한 「자기가 쓸 값으로 바꿔 넣기」입니다. proxy_http_version 1.1 줄은 1.29.7 이전 판에서만 필요해 주석으로 남아 있습니다.

조용한 연결을 끊는 유휴 제한 시간

웹소켓 연결은 오래 열려 있습니다. 그 사이에 아무것도 안 오가는 구간이 생깁니다. 앞단 장비는 그런 연결을 일정 시간 뒤에 끊습니다. 앞단 장비는 리버스 프록시나 로드 밸런서처럼 서버 앞에 선 장비입니다. 로드 밸런서는 들어온 연결을 뒤의 여러 서버에 나눠 주는 장비입니다.

두 장비를 예로 들면 기본값은 이렇습니다.

장비 설정 기본값 재는 것
nginx proxy_read_timeout 60s 뒤 서버에서 연이어 두 번 읽는 사이의 시간
AWS(Amazon Web Services) Application Load Balancer idle_timeout.timeout_seconds 60초 클라이언트·대상 연결에 데이터가 하나도 안 오간 시간

AWS 문서가 「대상」이라고 부르는 것이 로드 밸런서 뒤의 서버입니다.

nginx 는 기본으로 뒤 서버가 60초 동안 아무것도 안 보내면 연결을 닫습니다. 늘리려면 proxy_read_timeout 을 키웁니다. 아니면 뒤 서버가 주기적으로 핑 프레임을 보내 시간을 되돌리게 합니다. 그 핑으로 연결이 살아 있는지도 확인합니다.

sequenceDiagram
    participant 클라이언트
    participant 프록시 as 리버스 프록시
    participant 서버 as 뒤 서버
    Note over 프록시: 뒤 서버에서 60초 동안 아무것도 안 오면 닫는다
    서버->>프록시: Ping
    프록시->>클라이언트: Ping
    Note over 프록시: 방금 읽은 것이 있으니 시간이 되돌아간다
    클라이언트->>프록시: Pong
    프록시->>서버: Pong

핑이 뒤 서버에서 나가는 것이 이 그림의 요점입니다. 브라우저 스크립트는 핑을 보낼 수 없습니다. 브라우저의 WebSocket 인터페이스에 지금은 핑과 퐁이 없기 때문입니다.

브라우저 자신은 핑이나 청하지 않은 퐁을 보낼 수 있습니다(MAY). 보내는 것은 브라우저 자기 목적일 때입니다. 자기 쪽 네트워크의 NAT(Network Address Translation, 네트워크 주소 변환) 매핑을 유지하거나, 끊긴 연결을 알아내거나, 지연을 사용자에게 보여 주는 것이 그런 목적입니다. 그러나 서버를 거들려고 보내서는 안 됩니다(MUST NOT). 서버가 필요하면 자기가 퐁을 청한다고 보기 때문입니다. 그래서 유휴 제한 시간을 되돌리는 몫은 서버가 보내는 핑이나 애플리케이션이 흘리는 자기 메시지가 맡습니다.

Application Load Balancer 앞에서는 시간이 다 차기 전에 적어도 1바이트를 보내야 연결이 안 끊깁니다. 필요하면 시간을 늘립니다. 애플리케이션 쪽 유휴 제한 시간은 로드 밸런서의 것보다 길게 잡기를 권합니다.

한 서버에 오래 붙는 연결

Application Load Balancer 에서는 갈아타는 순간, 요청에 쓰던 TCP 연결이 로드 밸런서를 거쳐 클라이언트와 대상 서버 사이의 지속되는 웹소켓 연결이 됩니다. 그래서 연결 하나는 처음 닿은 서버 하나와 닫힐 때까지 같이 갑니다.

앞단 장비 하나가 웹소켓 연결마다 세 가지를 함께 집니다. Upgrade 와 Connection 을 다시 실어 넘겨야 합니다. 연결이 조용하면 유휴 제한 시간에 끊습니다. 그리고 그 연결이 닫힐 때까지 처음 닿은 대상 서버를 붙들고 있습니다.

로드 밸런서와 서버가 여럿인 구성에서는 웹소켓 연결용 호스트를 HTTP 서버와 따로 두는 쪽이 아마 관리하기 쉽습니다. IP(Internet Protocol, 인터넷 프로토콜) 주소 하나와 서버 하나로 한 호스트 이름의 모든 트래픽을 받는 단순한 구성에서는 HTTP 서버와 포트를 같이 쓰는 것도 쓸 만한 배치가 될 수 있습니다.

포트 선택도 같은 대목에 걸립니다. 이 프로토콜이 나올 무렵에는 포트에 따라 성공률이 갈렸습니다. 2011년 무렵 443 포트 연결이 80 포트보다 훨씬 잘 성공했습니다. 이 차이는 시간이 지나며 바뀔 수 있습니다.

서버를 내릴 때의 재접속

서버가 내려갈 때 붙어 있던 연결을 닫는 코드가 1001 입니다. 비정상으로 끊긴 연결이 다시 붙는 일은 클라이언트가 맡습니다.

끊긴 까닭이 잠깐의 오류였다면 다시 붙어 정상으로 돌아갈 수 있습니다. 까닭이 오래 가는 문제일 때 모든 클라이언트가 곧바로, 끈질기게 다시 붙으려 들면 서버는 서비스 거부 공격과 다를 바 없는 부하를 받을 수 있습니다. 그러면 서비스가 제때 회복하지 못하거나 회복이 훨씬 어려워집니다.

그래서 클라이언트는 비정상으로 끊긴 뒤 다시 붙을 때 물러서며 기다리는 백오프를 쓰기를 권합니다(SHOULD). 첫 재시도는 무작위 시간만큼 늦춥니다(SHOULD). 0~5초 사이의 무작위 값이 무난한 시작값입니다. 첫 재시도가 실패하면 뒤의 재시도는 점점 더 길게 늦춥니다(SHOULD). 상한을 둔 지수 백오프가 그 방법의 하나입니다.

메시지 크기 상한

프레임 크기나 조각을 다시 이은 메시지 크기에 한계가 있는 구현은 그 한계를 넘지 않게 스스로를 지켜야 합니다(MUST). 악의적인 상대는 2의 60제곱 바이트짜리 프레임 하나를 보내거나, 쪼갠 메시지의 작은 조각을 끝없이 이어 보내 메모리를 바닥내려 들 수 있습니다.

그래서 프레임 크기와 다시 이은 메시지 전체 크기에 상한을 두기를 권합니다(SHOULD). 처리하기에 너무 큰 메시지를 받은 쪽이 연결을 끝낼 때 쓰는 닫힘 코드가 1009 입니다.

관련 항목

웹소켓이 대신하려 한 실시간 갱신 방식

폴링 · 롱 폴링 · Server-Sent Events · EventSource · Comet · 푸시 · HTTP 스트리밍

웹소켓이 올라타는 아래쪽 프로토콜

TCP · TLS · HTTP · HTTPS · HTTP/1.1 · 전송 계층 · 소켓

웹소켓을 정의하고 이름을 등록하는 문서와 단체

RFC 6455 · RFC 9110 · RFC 6202 · RFC · IETF · IANA · WHATWG · W3C

웹소켓을 HTTP/2·HTTP/3 위로 옮기거나 넓히는 규격

RFC 8441 · RFC 9220 · RFC 7692 · permessage-deflate · 웹소켓 확장 · 하위 프로토콜

웹소켓 연결이 나르는 데이터 단위

프레임 · 메시지 · 페이로드 · 옥텟 · opcode · 제어 프레임 · 바이트 스트림

핸드셰이크가 빌려 쓰는 HTTP 요소

헤더 · GET · 상태 코드 · 101 Switching Protocols · 426 Upgrade Required · 400 Bad Request · Upgrade 헤더 · Connection 헤더 · Origin 헤더 · 동일 출처 정책 · CORS

핸드셰이크와 마스킹이 쓰는 계산

SHA-1 · Base64 · GUID · XOR · 난수 생성기 · UTF-8

마스킹이 막으려는 공격

캐시 오염 · 캐싱 프록시 · XMLHttpRequest · 서비스 거부 공격

브라우저가 웹소켓을 여는 인터페이스

WebSocket API · readyState · bufferedAmount · CloseEvent · MessageEvent

오래 열린 연결을 앞에서 다루는 장비

리버스 프록시 · 프록시 · 로드 밸런서 · nginx · AWS Application Load Balancer · 게이트웨이 · 방화벽 · NAT

웹소켓 연결을 끊고 다시 잇는 장치

하트비트 · 킵얼라이브 · 유휴 타임아웃 · 타임아웃 · 백오프 · 지수 백오프 · TIME_WAIT · 홉 바이 홉 헤더

웹소켓 서버가 연결마다 쥐는 자원

파일 디스크립터 · 논블로킹 입출력 · 동시성 · 수평 확장 · 세션 · 커넥션 풀

웹소켓 위에 얹어 쓰는 메시지 규약

STOMP · MQTT · Socket.IO · SignalR · JSON · 직렬화

오래 열린 연결에서 터지는 문제

헤드 오브 라인 블로킹 · 백프레셔 · 패킷 손실 · 속도 제한 · 썬더링 허드

다른 이름: WebSocket · 웹 소켓 · websocket