웹소켓
고친 사람 github-actions[bot]
웹소켓은 브라우저와 서버가 한 번 이어 놓은 연결 위에서 양쪽이 먼저 말을 걸게 해 줍니다. 서버는 보낼 것이 생긴 때에 곧바로 보냅니다. 클라이언트가 다시 물어보기를 기다리지 않습니다.
쉽고 빠른 이해
웹소켓은 브라우저와 서버 사이에 전화선 하나를 깔아 두고 양쪽이 아무 때나 말을 걸게 해 주는 규칙입니다. 채팅방에서 남이 보낸 말이 내 화면에 곧바로 뜨는 것이 이 규칙이 하는 일입니다.
이게 없으면 서버는 먼저 말을 걸 방법이 없습니다. 웹 통신은 클라이언트가 물어야 서버가 답하는 구조라, 새 소식이 있는지 클라이언트가 몇 초마다 되물어야 합니다. 되묻는 사이에 소식이 늦어집니다. 아무 일도 없어도 오간 요청이 서버를 갉아먹습니다.
어떻게 도는가:
- 클라이언트가 웹 요청을 보내면서 이 연결을 전화선으로 바꾸자고 제안합니다
- 서버가 받아들이면 그 연결은 웹 요청용에서 전화선으로 바뀝니다
- 그다음부터는 양쪽이 아무 때나 메시지를 보냅니다. 한쪽이 끊을 때까지 연결은 살아 있습니다
대가도 있습니다. 서버는 붙어 있는 클라이언트 수만큼 연결을 계속 들고 있어야 해서 메모리와 연결 개수 상한을 먼저 만납니다. 캐시나 재시도처럼 웹 통신이 공짜로 주던 것도 직접 만들어야 합니다.
상세
전화와 편지를 견줘 보면 차이가 잡힙니다. 편지는 내가 보내야 답장이 옵니다. 상대에게 할 말이 생겨도 내가 먼저 편지를 부치기 전에는 소식을 받을 수 없습니다. 전화는 선을 한 번 이어 두면 누구든 먼저 입을 열 수 있습니다.
웹소켓은 그 전화선에 해당합니다. 브라우저와 서버가 연결을 한 번 이어 놓고, 그 연결이 닫힐 때까지 양쪽이 아무 때나 메시지를 보냅니다.
서버가 먼저 말을 거는 문제
HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 프로토콜)는 클라이언트가 요청하면 서버가 응답하는 한 쌍으로 돌아갑니다. 이 구조에서 서버는 자기가 먼저 말을 꺼낼 방법이 없습니다. 응답은 언제나 앞선 요청의 답이기 때문입니다.
그런데 채팅·알림·시세 화면처럼 소식이 서버 쪽에서 먼저 생기는 화면이 있습니다. 서버가 못 부르면 클라이언트가 대신 계속 물어보는 수밖에 없습니다. 이 되묻기를 폴링이라고 합니다.
되묻기를 조금 낫게 만든 것이 롱 폴링입니다. 서버가 요청을 받고도 바로 답하지 않습니다. 보낼 소식이 생길 때까지 응답을 붙들고 있다가 그때 답합니다. 소식은 덜 늦지만 답을 한 번 받을 때마다 클라이언트가 다시 요청을 걸어야 하는 것은 그대로입니다.
네 방식을 누가 먼저 말을 걸 수 있나와 연결이 한 번에 얼마나 오래 사나로 견주면 이렇습니다.
| 방식 | 누가 말을 거나 | 연결이 사는 동안 |
|---|---|---|
| 폴링 | 클라이언트만 | 요청 한 번마다 새로 |
| 롱 폴링 | 클라이언트만 | 답이 나올 때까지 |
| Server-Sent Events | 서버만 | 닫을 때까지 |
| 웹소켓 | 양쪽 다 | 닫을 때까지 |
표의 셋째 줄에 있는 Server-Sent Events 는 서버가 웹 응답을 끊지 않고 흘려보내, 서버에서 클라이언트로 한 방향으로만 소식을 미는 방식입니다.
웹소켓은 그중 마지막 줄입니다. 연결 하나를 열어 두고 양쪽이 대등하게 말을 겁니다.
HTTP 연결을 갈아타는 핸드셰이크
핸드셰이크는 두 쪽이 본론에 들어가기 전에 조건을 맞추는 첫 왕복입니다. 웹소켓의 핸드셰이크는 새 연결을 따로 여는 것이 아니라, 이미 열려 있는 HTTP 연결을 웹소켓용으로 갈아타는 일입니다.
클라이언트가 먼저 평범한 HTTP 요청을 보냅니다. 다만 헤더에 갈아타자는 표시를 얹습니다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: (클라이언트가 만든 무작위 값)
Upgrade 헤더는 어느 규칙으로 갈아타고 싶은지를 말합니다. Sec-WebSocket-Key 는 이 요청이
웹소켓을 아는 쪽에서 왔다는 것을 보이는 무작위 값입니다.
서버가 받아들이면 갈아탔다는 상태 코드 101 을 돌려줍니다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: (받은 키에서 계산한 값)
서버는 받은 키를 정해진 방법으로 계산해 Sec-WebSocket-Accept 에 실어 보냅니다. 클라이언트는
그 값을 확인해서, 답한 쪽이 웹소켓을 실제로 이해하는 서버인지 우연히 101 을 뱉은 서버인지를
가릅니다.
이 왕복이 끝나면 같은 연결이 그대로 웹소켓 연결이 됩니다. 아래를 받치던 TCP(Transmission Control Protocol, 전송 제어 프로토콜) 연결은 끊기지 않고 이어집니다. 연결을 새로 맺느라 왕복을 더 쓰는 일이 없고, 웹 요청이 지나가던 통로를 그대로 물려받습니다.
주소는 ws:// 로 시작합니다. TLS(Transport Layer Security, 전송 계층 보안)로 암호를 씌운
연결은 wss:// 입니다. 앞의 핸드셰이크가 HTTP 위에서 벌어지는 일이라, 웹 주소가 http:// 와
https:// 로 갈리는 것과 같은 방식으로 갈립니다.
갈아탄 뒤 이 연결로 오가는 덩어리를 프레임이라고 합니다. 살아 있는지 묻는 핑과 그 물음에 답하는 퐁도 프레임입니다. 갈아타기부터 거기까지의 흐름은 이렇습니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 웹 요청 + 갈아타자는 헤더
서버-->>클라이언트: 101 · 갈아탔다
Note over 클라이언트,서버: 여기부터 같은 연결로 프레임이 오간다
클라이언트->>서버: 텍스트 프레임
서버->>클라이언트: 텍스트 프레임
서버->>클라이언트: 핑
클라이언트->>서버: 퐁
여기까지가 한 연결에서 벌어지는 일입니다. 요청 한 번에 응답 한 번이 아니라, 한 번 갈아탄 뒤로는 양쪽이 각자 보내고 싶을 때 보냅니다.
메시지를 싣는 프레임
갈아탄 뒤로는 요청도 응답도 없습니다. 오가는 것은 프레임뿐입니다. 프레임은 연결 위를 지나는 가장 작은 덩어리이고, 메시지 하나가 프레임 하나에 들어가기도 하고 여럿으로 쪼개지기도 합니다.
프레임 머리에는 몇 가지가 붙습니다. 이 프레임이 메시지의 마지막 조각인지, 안에 든 것이 글자인지 바이트 덩어리인지, 실린 내용이 몇 바이트인지가 거기 적힙니다. 이 표시들은 앞의 두 바이트 안에 비트 몇 개씩을 차지하고 앉습니다.
packet-beta 0: "끝" 1-3: "예약" 4-7: "무엇이 실렸나" 8: "뒤섞었나" 9-15: "실린 길이" 16-47: "뒤섞는 열쇠" 48-63: "실린 내용"
받는 쪽은 이 앞머리만 읽고 어디까지가 한 메시지인지 압니다. 예약 칸은 쓰지 않고 비워 둔 세 비트입니다. 뒤섞는 열쇠는 뒤섞어 보낸 프레임에만 붙습니다. 실린 내용이 길면 길이를 적는 칸이 뒤로 더 붙습니다.
글자와 바이트 덩어리 말고 살림용 프레임이 셋 더 있습니다. 연결을 닫자는 닫기 프레임, 살아 있느냐고 묻는 핑, 그 물음에 답하는 퐁입니다.
클라이언트가 서버로 보내는 프레임은 무작위 키로 뒤섞어서 보냅니다. 이것을 마스킹이라고 합니다. 서버가 클라이언트로 보내는 프레임은 뒤섞지 않습니다.
뒤섞는 까닭은 중간에 낀 장비에 있습니다. 프록시는 클라이언트와 서버 사이에 끼어 요청을 대신 날라 주는 장비입니다. 낡은 프록시는 프레임 안의 글자를 웹 요청으로 잘못 읽습니다.
그러면 프록시의 캐시에 엉뚱한 것이 들어갑니다. 뒤섞인 바이트는 웹 요청처럼 안 보이므로 잘못 읽을 거리가 없습니다.
살아 있음을 확인하고 연결을 닫는 프레임
오래 열어 두는 연결에는 조용한 구간이 생깁니다. 사람이 채팅을 안 치는 몇 분 동안은 아무 프레임도 안 지나갑니다. 문제는 중간에 놓인 프록시나 방화벽이 그런 연결을 죽은 것으로 보고 말없이 끊어 버린다는 것입니다.
핑과 퐁이 그 구간을 메웁니다. 한쪽이 핑을 보내면 상대는 퐁으로 답합니다. 이렇게 살아 있음을 주기로 확인하는 신호를 하트비트라고 부릅니다.
연결에 무언가 계속 흐르니 중간 장비가 연결을 죽은 것으로 보지 않습니다. 답이 안 오면 상대가 사라졌다는 것도 알게 됩니다.
끝낼 때는 닫기 프레임을 보냅니다. 받은 쪽도 닫기 프레임으로 답하고 나서 연결을 내립니다. 서로 닫겠다고 말한 뒤에 끊는 것이라, 보내던 메시지가 중간에 잘리지 않습니다.
그래서 연결은 핸드셰이크 중 · 열림 · 닫는 중의 세 단계를 지납니다. 서버가 갈아타기를 거절하면 열림까지 못 가고 웹 응답 한 번으로 끝납니다.
stateDiagram-v2
[*] --> 핸드셰이크중
핸드셰이크중 --> 열림: 서버가 갈아타기를 받아들였다
핸드셰이크중 --> [*]: 서버가 거절했다 · 웹 응답으로 끝난다
열림 --> 닫는중: 한쪽이 닫기 프레임을 보냈다
닫는중 --> [*]: 상대도 닫기 프레임으로 답했다
오래 열린 연결이 치르는 대가
가장 먼저 만나는 벽은 연결을 들고 있는 비용입니다. 클라이언트 만 개가 붙어 있으면 서버는 만 개의 연결을 동시에 들고 있어야 합니다. 요청이 끝나면 연결을 놓아 주던 방식과는 셈이 다릅니다.
연결 하나는 운영체제가 열린 연결마다 하나씩 내주는 번호표인 파일 디스크립터 하나와 받아 둘 버퍼를 차지합니다. 그래서 붙어 있는 클라이언트 수가 곧 서버의 메모리와 연결 개수 상한이 됩니다.
앞단 장비도 셈이 달라집니다. 앞에 서서 들어온 연결을 서버 여럿에 나눠 주는 로드 밸런서는 보통 연결 단위로 서버를 고릅니다. 웹소켓 연결은 한 번 고른 서버에 몇 시간씩 붙어 있습니다. 그래서 서버를 새로 붙여도 이미 열린 연결은 옮겨 가지 않아 부하가 고르게 안 나뉩니다.
배포도 조심스러워집니다. 서버를 내리면 거기 붙어 있던 클라이언트가 한꺼번에 떨어져 나가 동시에 다시 붙습니다.
클라이언트가 어느 서버에 붙어 있는지가 서버마다 갈린다는 점도 일감이 됩니다. 어떤 클라이언트에게 알림을 보내려면 그 클라이언트가 붙은 서버가 어디인지 알아야 합니다. 서버가 여럿이면 서버끼리 메시지를 옮겨 줄 중계가 따로 필요합니다. 붙는 데까지와 옮기는 데까지를 그리면 이렇습니다.
flowchart TD
C1["클라이언트 1"] --> LB["로드 밸런서"]
C2["클라이언트 2"] --> LB
LB -->|한 번 고르면 계속 붙어 있다| S1["서버 1"]
LB -->|한 번 고르면 계속 붙어 있다| S2["서버 2"]
S1 <--> R["중계"]
S2 <--> R
중계가 없으면 서버 1 에 붙은 클라이언트는 서버 2 가 만든 알림을 영영 못 받습니다.
HTTP 가 공짜로 주던 것도 대부분 못 씁니다. 캐시·재시도·상태 코드는 요청과 응답 한 쌍을 전제로 만들어진 장치라, 프레임만 오가는 연결에는 쓸 수 없습니다. 연결이 끊겼을 때 다시 붙는 절차도, 끊긴 사이에 놓친 메시지를 채우는 절차도 직접 짜야 합니다.
웹소켓을 고르는 기준
양쪽이 다 말을 걸어야 하고 늦으면 안 되는 화면에 맞습니다. 채팅, 여럿이 같이 고치는 문서, 게임, 시세판이 그런 쪽입니다. 이런 화면은 사람이 입력하는 순간과 남에게 보이는 순간 사이가 짧아야 쓸모가 생깁니다.
서버에서 클라이언트로 한 방향으로만 흐르면 Server-Sent Events 가 더 간단합니다. 알림 목록이나 진행률 표시가 그렇습니다. 앞에서 본 대로 웹 응답을 끊지 않고 흘려보내는 방식이라 앞단 장비가 이미 다룰 줄 압니다. 끊기면 브라우저가 알아서 다시 붙습니다.
몇 분에 한 번 갱신하면 되는 화면이라면 폴링으로 충분합니다. 연결을 계속 들고 있는 대가가 얻는 것보다 큽니다.
세 화면을 가르는 물음을 순서대로 타면 이렇습니다.
flowchart TD
A{"양쪽이 다 말을 거나"} -->|그렇다| W["웹소켓"]
A -->|아니다| B{"서버에서 오는 소식이 늦으면 안 되나"}
B -->|그렇다| S["Server-Sent Events"]
B -->|아니다| P["폴링"]
웹소켓은 첫 물음에서만 나옵니다. 양쪽이 다 말을 걸 일이 없으면 더 싼 쪽이 있습니다.
관련 항목
웹소켓이 대신하려 한 실시간 갱신 방식
폴링 · 롱 폴링 · Server-Sent Events · Comet · 푸시 · 스트리밍
웹소켓이 올라타는 아래쪽 프로토콜
TCP · TLS · HTTP · HTTPS · HTTP/1.1 · 전송 계층 · 소켓
웹소켓을 정의하고 이름을 등록하는 문서와 단체
RFC 6455 · RFC · IETF · IANA · WHATWG · W3C
웹소켓 연결이 나르는 데이터 단위
프레임 · 메시지 · 페이로드 · 옥텟 · 바이트 스트림 · 마스킹
핸드셰이크가 빌려 쓰는 HTTP 요소
헤더 · GET · 상태 코드 · Upgrade 헤더 · Origin 헤더 · 동일 출처 정책 · CORS
오래 열린 연결을 앞에서 다루는 장비
리버스 프록시 · 프록시 · 로드 밸런서 · 부하 분산 · 게이트웨이 · 방화벽 · NAT
웹소켓 서버가 연결마다 쓰는 자원
파일 디스크립터 · 논블로킹 입출력 · 동시성 · 수평 확장 · 세션 · 커넥션 풀
웹소켓 위에 얹어 쓰는 메시지 규약
STOMP · MQTT · Socket.IO · SignalR · JSON · 직렬화
오래 열린 연결에서 터지는 문제
다른 이름: WebSocket · 웹 소켓 · websocket