목차
채팅 화면을 만들었다. 메시지를 보내는 건 되는데, 상대가 보낸 메시지는 새로고침을 해야 보인다.
서버는 먼저 말을 걸 수 없다
이유는 요청과 응답 글의 첫 문장에 있다. 언제나 클라이언트가 먼저 묻고 서버가 답한다.
상대가 메시지를 보내면 서버는 그걸 알고 있다. 알려주고 싶다. 그런데 알려줄 방법이 없다. 브라우저가 물어보지 않으면 서버가 열어둔 입은 있어도 말을 걸 통로가 없다.
그래서 사람들은 이 제약 안에서 최대한 비슷한 것을 만들어왔다. 그 시도들을 순서대로 보면 WebSocket이 왜 그렇게 생겼는지가 저절로 설명된다.
계속 물어보기
가장 단순한 답이다. 새 소식이 있는지 주기적으로 물어본다. 폴링이라고 부른다.
setInterval(async () => {
const res = await fetch('/messages?since=' + lastId);
const messages = await res.json();
render(messages);
}, 3000);3초마다 묻는다. 만들기 쉽고, 서버는 평범한 API 하나만 있으면 되고, 중간의 어떤 장비도 이상하게 여기지 않는다. 지금까지 배운 HTTP 그대로다.
문제는 두 가지가 서로 반대 방향으로 당긴다는 것이다.
그리고 대부분의 요청은 헛걸음이다. 대화가 뜸한 시간에도 사용자 수만큼 3초마다 요청이 꽂힌다. 새 소식이 없다는 답을 받으려고 앞에서 아껴온 그 왕복을 계속 쓴다.
대답을 미룬다
폴링의 헛걸음을 줄이는 요령이 있다. 서버가 바로 답하지 않는 것.
요청이 오면 새 소식이 생길 때까지 응답을 붙들고 있는다. 소식이 생기면 그때 답한다. 클라이언트는 답을 받자마자 다시 요청을 건다. 롱 폴링이다.
헛걸음이 사라지고, 소식이 생기면 거의 즉시 전달된다. 여전히 평범한 HTTP라서 특별한 장비도 필요 없다.
대신 값이 붙는다. 응답을 붙들고 있는 동안 서버 자원이 잡혀 있다. 사용자 수만큼 대기 중인 요청이 쌓인다. 그리고 중간의 프록시나 로드밸런서에는 대개 응답 타임아웃이 있어서, 너무 오래 붙들면 중간에서 끊어버린다. 그래서 실제로는 일정 시간이 지나면 서버가 빈 응답으로 한 번 끊고 다시 받는 식으로 만든다.
무엇보다, 이건 여전히 요청 하나에 응답 하나다. 흉내일 뿐 방향이 뒤집힌 게 아니다.
서버가 밀어준다
방향을 진짜로 뒤집는 첫 번째 방법이 있다. 응답을 끝내지 않고 조금씩 계속 흘려보내는 것.
서버가 Content-Type: text/event-stream으로 답하고 연결을 열어둔 채 이벤트를 하나씩 써 내려간다.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
data: {"user":"kim","text":"안녕"}
data: {"user":"lee","text":"어서와"}
브라우저 쪽은 이걸 받는 전용 객체가 있다.
const es = new EventSource('/stream');
es.onmessage = (e) => render(JSON.parse(e.data));이게 **SSE(Server-Sent Events)**다. 응답 하나가 계속 이어지는 것이라 여전히 HTTP지만, 클라이언트가 다시 묻지 않아도 서버가 계속 보낸다.
편한 점이 있다. 끊기면 브라우저가 알아서 다시 붙는다. 그리고 서버가 이벤트에 번호를 붙여두면 재접속할 때 “이 번호 다음부터”를 자동으로 알려준다. 직접 만들면 성가신 재연결과 유실 처리를 규격이 챙겨준다.
한계도 분명하다. 한 방향뿐이다. 서버에서 클라이언트로만 흐른다. 클라이언트가 보낼 게 있으면 별도의 일반 요청을 써야 한다. 그리고 텍스트만 나른다.
HTTP/1.1에서 SSE를 쓰면 연결 하나를 계속 붙들게 되는데, 브라우저는 같은 호스트에 대한 동시 연결 수에 상한을 둔다. 그래서 탭을 여러 개 띄우면 상한에 걸려 다른 요청이 밀리는 일이 생길 수 있다. HTTP/2 이상에서는 연결 글에서 본 멀티플렉싱 덕에 이 압박이 줄어든다. 어느 버전으로 붙는지가 SSE의 실용성을 바꾼다.
HTTP로 시작해 프로토콜을 바꾼다
양방향이 필요하면 어떻게 하나. 여기서 발상이 한 번 더 뛴다. HTTP를 흉내 내지 말고, HTTP로 시작해서 다른 프로토콜로 갈아탄다.
WebSocket은 평범한 HTTP 요청으로 시작한다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13Upgrade: websocket이 “이 연결을 WebSocket으로 바꾸자”는 제안이다. 서버가 받아들이면 이렇게 답한다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=101 Switching Protocols. 상태 코드에서 거의 볼 일이 없던 1xx가 여기서 나온다. **“알겠다, 지금부터 다른 말로 하자”**는 뜻이다.
왜 굳이 HTTP로 시작하나. 이미 뚫려 있는 길을 쓰려고다. 새 프로토콜이 자기 포트를 쓰겠다고 하면 방화벽과 프록시가 막는다. 80·443 포트로 평범한 HTTP 요청처럼 시작하면 중간 장비들이 통과시킨다. 그리고 https로 시작하면 그 위에서 바뀐 연결도 TLS로 감싸진 상태를 그대로 이어받는다. 주소가 wss://로 표기되는 게 그 경우다.
바뀌고 나면 HTTP가 아니다
여기서 초중급이 자주 헷갈리는 지점을 짚는다. 101 이후로는 HTTP가 아니다.
지금까지 배운 것들이 대부분 사라진다.
| 지금까지 | WebSocket으로 바뀐 뒤 |
|---|---|
| 메서드·경로 | 없다. 무슨 메시지인지는 앱이 알아서 정한다 |
| 상태 코드 | 없다. 성공·실패도 앱이 정의한다 |
| 요청 헤더 | 없다. 핸드셰이크 때 한 번 오간 게 전부다 |
| 캐시 | 없다. 캐시 글의 헤더들이 안 통한다 |
| 요청-응답 짝 | 없다. 양쪽이 아무 때나 보낸다 |
무엇을 보낼지, 무엇이 응답인지, 오류를 어떻게 알릴지를 전부 직접 설계해야 한다. HTTP가 공짜로 주던 규약이 사라진 자리를 앱이 메운다. 이게 WebSocket의 진짜 비용이다.
그래서 실무에서는 그 위에 규약을 하나 더 얹는 경우가 많다. 메시지에 타입과 요청 식별자를 붙이는 자체 형식을 만들거나, 이미 있는 상위 프로토콜을 쓴다.
브라우저의 WebSocket은 쿠키를 핸드셰이크 요청에는 붙이지만, 그 뒤로는 요청 헤더를 붙일 자리가 없다. 그래서 인증을 언제 어떻게 확인할지를 따로 정해야 한다. 핸드셰이크에서 한 번만 확인하면, 연결이 몇 시간 이어지는 동안 권한이 회수돼도 반영되지 않는다(무효화 축과 같은 문제다). 연결 중에도 주기적으로 다시 확인할지가 설계 항목이 된다.
넷을 나란히 놓으면
지금까지 넷을 봤다. 한 표로 모은다.
| 폴링 | 롱 폴링 | SSE | WebSocket | |
|---|---|---|---|---|
| 방향 | 요청할 때만 | 요청할 때만 | 서버 → 클라이언트 | 양방향 |
| HTTP인가 | 그렇다 | 그렇다 | 그렇다 | 핸드셰이크만 |
| 지연 | 주기만큼 | 거의 즉시 | 거의 즉시 | 거의 즉시 |
| 재연결 | 필요 없음 | 직접 | 브라우저가 자동 | 직접 |
| 나르는 것 | 아무거나 | 아무거나 | 텍스트 | 텍스트·이진 |
| 만들기 | 가장 쉽다 | 중간 | 쉽다 | 서버 쪽 부담이 크다 |
| 중간 장비 | 문제없다 | 타임아웃 주의 | 타임아웃·연결 수 주의 | 지원 여부 확인 필요 |
고르는 기준을 한 줄로 줄이면 이렇다. 클라이언트도 자주 보내야 하면 WebSocket, 서버만 보내면 SSE, 실시간이 아니어도 되면 폴링.
밀리지 않는 게 하나 있다. 폴링이 촌스러운 선택이 아니라는 것. 몇 초쯤 늦어도 되는 알림이라면 폴링이 만들기도 운영하기도 제일 싸다. 실시간이 요구사항인지, 그냥 있어 보여서 원하는 건지를 먼저 가르는 게 순서다.
연결을 유지하면 상태가 생긴다
WebSocket에는 앞에서 다룬 것과 정면으로 부딪히는 성질이 있다. 무상태 글에서 서버가 요청 사이를 기억하지 않는 덕에 어느 서버가 받아도 같다고 했다. WebSocket은 그걸 깬다.
연결은 특정 서버 한 대에 붙어 있다. 그 사용자에게 메시지를 보내려면 그가 붙어 있는 그 서버가 보내야 한다.
lee가 서버 B에 보낸 메시지를 kim에게 전하려면, B가 A에게 부탁해야 한다. 그래서 실무 구성에는 서버들 사이를 잇는 중계가 따로 들어간다. Redis의 발행-구독 같은 것을 두고, 각 서버가 자기에게 붙은 연결에만 뿌린다.
여기서 오는 것들을 미리 알아두면 좋다.
- 서버를 마음대로 못 죽인다. 배포로 서버를 내리면 거기 붙은 연결이 전부 끊긴다. 클라이언트 쪽에 재연결과 그동안 놓친 것 복구를 반드시 만들어야 한다.
- 연결 수가 곧 부하다. 요청이 없어도 연결은 자원을 잡는다. 동시 접속자 수가 용량 계산의 단위가 된다.
- 끊긴 걸 모를 수 있다. 네트워크가 조용히 끊기면 양쪽 다 살아 있다고 믿는다. 그래서 주기적으로 신호를 주고받아 살아 있는지 확인한다.
- 앞단 설정이 필요하다. 로드밸런서나 프록시가 이 연결을 이해하고 통과시켜야 하고, 유휴 타임아웃도 넉넉해야 한다. 기본값으로 두면 조용히 끊기는 원인이 된다.
실무에서 무엇을 고르나
대부분의 화면은 실시간이 필요 없다. 주문 목록이 3초 늦게 갱신되는 게 문제인 경우는 생각보다 드물다. 요구사항이 “즉시”인지 “곧”인지부터 확인한다.
한 방향이면 SSE를 먼저 본다. 알림, 진행률, 로그 스트리밍, 대시보드 갱신처럼 서버만 보내는 경우가 실제로는 더 많다. 그런데 습관적으로 WebSocket을 고르는 일이 잦다. SSE는 HTTP 그대로라 앞단 설정도 덜 타고 재연결도 공짜다.
양방향이 진짜 필요하면 WebSocket. 채팅, 협업 편집, 게임처럼 클라이언트도 수시로 보내야 하는 것들이다.
직접 만들기 전에 라이브러리를 본다. 재연결, 심박 신호, 서버 간 중계, 브로드캐스트는 매번 같은 문제라 이미 잘 만들어진 것들이 있다. Spring의 STOMP 지원이나 Socket.IO 같은 것들이 그 자리에 있다. 다만 그런 라이브러리가 얹는 자체 규약 때문에 다른 스택의 클라이언트와 못 붙는 경우가 있으니 붙일 대상을 먼저 정해야 한다.
HTTP/2 위에서는 사정이 조금 다르다. WebSocket의 Upgrade 방식은 HTTP/1.1의 문법이라, HTTP/2 이상에서는 별도의 확장 규격을 통해 연결한다. 클라이언트와 서버, 중간 장비가 그걸 받쳐줘야 해서 환경에 따라 자동으로 HTTP/1.1로 내려가 연결되기도 한다. 어느 쪽으로 붙었는지는 개발자도구에서 확인할 수 있다.
정리
| 문제 | 클라이언트가 묻지 않으면 서버가 전할 방법이 없다 |
| 폴링 | 주기적으로 묻는다. 쉽지만 헛걸음이 많다 |
| 롱 폴링 | 답을 붙들고 기다린다. 헛걸음은 줄지만 여전히 요청-응답 짝 |
| SSE | 응답을 안 끝내고 계속 흘린다. 한 방향, 재연결이 공짜 |
| WebSocket | Upgrade + 101로 프로토콜을 바꾼다. 양방향 |
| 바뀐 뒤엔 | 메서드·상태 코드·헤더·캐시가 없다. 규약을 직접 설계해야 한다 |
| 대가 | 연결이 서버 한 대에 묶인다. 무상태가 주던 자유를 반납한다 |
| 고르는 법 | 양방향이면 WebSocket, 한 방향이면 SSE, 늦어도 되면 폴링 |
이 시리즈는 한 왕복에서 출발했다. 그 왕복에 무엇이 담기는지 보고, 요청들이 서로를 모른다는 사실과 그 대가를 보고, 연결을 아끼는 법과 아예 안 보내는 법을 보고, 감싸는 법과 감싸도 드러나는 것을 봤다. 그리고 마지막에 묻는 쪽과 답하는 쪽이 정해져 있다는 전제까지 뒤집었다.
전부 같은 자리에서 나왔다. HTTP는 단순하게 시작했고, 그 단순함이 남긴 불편을 하나씩 메워온 기록이 지금의 웹이다. 새로운 헤더나 버전을 만나면 이렇게 물어보면 된다. 이건 무엇이 불편해서 생겼나. 대부분 이 시리즈에서 본 불편 중 하나다.