WebSocket vs HTTP — 소켓으로 몰면 잃는 것
알림을 바로 띄우거나 다른 사람이 바꾼 내용을 화면에 반영하는 것처럼, 서버가 먼저 클라이언트에게 뭔가를 보내야 하는 기능이 있었습니다.
HTTP 는 클라이언트가 물어보면 서버가 답하는 구조라 이런 걸 하려면 주기적으로 계속 물어보는 수밖에 없습니다(polling). 주기를 짧게 잡으면 바뀐 게 없어도 빈 응답만 오가고 길게 잡으면 그만큼 늦게 반영되니, 어느 쪽으로 맞춰도 손해가 남았습니다.
그래서 꺼낸 게 WebSocket 이고, 한 번 연결해 두면 양쪽이 아무 때나 메시지를 보낼 수 있으니 클라이언트가 주기적으로 물어볼 필요 자체가 없어집니다.
그러다 보면 그냥 다 WebSocket 으로 하면 되지 않나 싶어집니다. 양방향에 실시간이니 REST 가 하던 일도 다 될 것 같지만, 옮기고 나서 잃는 게 생각보다 컸습니다.
상태를 갖게 되어 수평 확장이 막힘
HTTP 요청은 무상태입니다. 어느 서버 인스턴스로 가든 상관없고, 인스턴스를 늘린 만큼 부하가 그대로 분산됩니다. 요청 하나가 끝나면 연결도 끝나니 다음 요청은 다른 서버가 받아도 그만입니다.
WebSocket 은 시작부터 다른데, 첫 요청은 HTTP 지만 거기에 프로토콜을 바꿔 달라는 헤더가 붙습니다.
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
101 이 오면 그 TCP 연결은 더 이상 HTTP 가 아니고, 끊길 때까지 그 서버와 그 클라이언트 사이에서
계속 살아 있어서, 연결이 특정 노드에 묶입니다.
그래서 A 노드에 붙은 사용자와 B 노드에 붙은 사용자가 메시지를 주고받으려면 노드 사이를 잇는 장치가 따로 필요해서, 보통 Redis pub/sub 을 중간에 둡니다.
사용자1 ──(소켓)── 서버A ──┐
├── Redis pub/sub
사용자2 ──(소켓)── 서버B ──┘
무상태였다면 그냥 됐을 수평 확장이 상태를 갖는 순간 따로 설계해야 할 일이 됩니다.
HTTP 인프라를 못 씀
REST 위에는 그동안 쌓인 인프라가 있지만 소켓으로 옮기면 이걸 대부분 못 씁니다. 응답을 캐시하고
ETag 로 재검증하는 CDN 과 캐시 층은 소켓 스트림에 안 붙습니다. ALB 의 Host·Path 라우팅은 처음
Upgrade 요청 한 번에만 걸리고, 연결이 맺어진 뒤로 오가는 메시지에는 경로가 없어서 그다음부터는
분기할 수단이 없습니다. WAF 나 rate limit 처럼 요청 단위로 거는 보호 장치도 지속 연결에는 그대로
안 걸립니다.
공짜로 주던 것 — 관측성·재접속·응답 짝짓기
HTTP 가 공짜로 주던 것들이 없어진 자리는 직접 채워야 합니다. 요청과 응답 로그가 자동으로 안 남으니 관측성을 직접 계측해야 하고, 끊겼을 때 다시 붙고 상태를 복구하는 것도 내 몫입니다. 아무것도 안 하는 사용자라도 연결이 살아 있는 동안 서버 쪽 커넥션과 메모리를 계속 잡고 있고, 끊기지 않게 ping 을 주고받는 비용도 그만큼 듭니다.
제일 손이 많이 가는 건 응답을 짝지어 주는 일이고, HTTP 는 요청 하나에 응답 하나라 어느 응답이 어느 요청의 것인지 프로토콜이 알아서 맞춰 줍니다. 소켓은 메시지가 양방향으로 따로 흐르니 이걸 직접 붙여야 합니다.
// 보낼 때 id 를 달아 두고
{ "id": "req-8f2a", "type": "getOrder", "orderId": 1024 }
// 받은 쪽이 그 id 를 그대로 돌려줘야
{ "id": "req-8f2a", "type": "order", "status": "PAID" }
// 서버가 먼저 보내는 것에는 id 가 없다
{ "type": "orderUpdated", "orderId": 1024, "status": "SHIPPED" }
id 를 안 붙이면 응답이 여러 개 올 때 어느 게 내가 기다리던 건지 알 수 없습니다. 클라이언트에서
보낸 요청을 id 로 들고 있다가 짝이 오면 꺼내 쓰는 코드를 직접 짜야 합니다.
무엇을 고르나 — 서버가 먼저 말해야 하는가
그래서 판단은 하나로 좁혀집니다. 채팅이나 협업 편집처럼 양쪽이 아무 때나 말해야 하는 구조면 WebSocket 이고, 클라이언트가 물을 때만 답하면 되는 캐시 가능한 요청-응답이면 REST 로 두는 게 맞습니다.
서버가 먼저 말하기만 하면 되고 클라이언트가 보낼 게 없다면 — 알림 배지나 진행률 같은 것 — SSE
라는 선택지가 있습니다. 연결이 노드에 묶이는 건 똑같지만 그냥 HTTP 응답이라 프록시나 인증
미들웨어를 그대로 통과하고, 재접속은 브라우저 EventSource 가 알아서 하고, 끊긴 지점부터
이어받는 것도 서버가 Last-Event-ID 만 처리해 주면 됩니다.
WebSocket 은 양방향과 실시간을 주는 대신 무상태와 HTTP 인프라, 그리고 공짜로 얻던 관측성을 잃습니다. 실시간처럼 보이는 기능이라면 먼저 클라이언트가 물어봐도 되는 일인지 따져 보고, 서버가 먼저 말을 걸어야 하는 자리에만 소켓을 씁니다.
댓글 0