LabHub
Get started
배우기 러닝패스 코스

Real-Time Communication — WebSocket, gRPC Streaming and WebRTC

Operating long-lived WebSockets

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

오래 붙어 있는 WebSocket 은 느린 구독자·유휴 타임아웃·말없는 상대·일괄 재접속을 견디도록 상한과 심장 박동과 재개 규칙을 갖춰야 합니다.

왜 이게 필요했나

프로토콜을 바르게 구현해도 WebSocket 서비스는 시간이 지나며 무너집니다. 연결이 몇 시간씩 살아 있는 동안 네 가지 일이 반드시 일어나기 때문입니다. 누군가는 느려지고, 중간 장비는 조용한 연결을 끊고, 휴대폰은 터널에 들어가 말없이 사라지고, 배포는 모든 연결을 한 번에 끊습니다. 요청과 응답이 짧게 끝나는 HTTP 에서는 이 넷이 거의 보이지 않았습니다.

어떻게 동작하나

느린 구독자. 발행 루프가 구독자마다 await send 를 차례로 부르면, 한 명의 송신 버퍼가 가득 찼을 때 그 뒤의 모두가 기다립니다. 구독자마다 큐와 그 큐를 비우는 루프를 따로 두면 기다림이 사람마다 떨어집니다. 그런데 큐에 상한이 없으면 느린 한 명 몫이 서버 메모리에 끝없이 쌓입니다. 상한에 닿았을 때 할 수 있는 일은 세 가지입니다 — 오래된 것을 버리거나, 최신 값 하나로 합치거나(시세·커서 위치), 연결을 끊습니다. 순서와 누락이 중요한 스트림이라면 구멍 난 채로 계속 보내느니 끊고 다시 붙게 하는 편이 정직하고, 그때 닫기 코드 1013(나중에 다시)으로 이유를 남깁니다. 브라우저의 WebSocket API 에는 배압이 없고 bufferedAmount 로 밀린 양을 볼 수 있을 뿐이라, 보내는 쪽이 이 숫자를 직접 봐야 합니다.

유휴 타임아웃. 로드밸런서와 프록시는 조용한 연결을 정해진 시간 뒤에 끊습니다. AWS Application Load Balancer 의 기본값은 60초이고, nginx 의 proxy_read_timeout 기본값도 60초입니다. 리눅스 TCP keepalive 는 기본적으로 두 시간 뒤에야 첫 탐침을 보내므로 이 목적에 쓸모가 없습니다. 그래서 애플리케이션이 가장 짧은 유휴 한도보다 자주 ping 을 보냅니다. ping 은 연결을 살려 두는 동시에, 답(pong)이 오는지로 상대가 살아 있는지를 확인합니다.

말없는 상대. 상대가 close 없이 사라지면 TCP 는 한동안 아무것도 모릅니다. ping 을 보내고 정해진 시간 안에 pong 이 없으면 끊는 것이 유일한 확인 방법입니다. 여기서 흔한 결함이 하나 있습니다. 라이브러리가 연결을 닫아도, 구독자 큐를 기다리던 코루틴은 새 메시지가 오기 전까지 깨지 않습니다. 목록에는 죽은 연결이 남아 연결 수 지표를 부풀리고 메시지를 받아 쌓습니다. 큐와 연결 닫힘을 함께 기다려야 합니다.

일괄 재접속. 서버 하나가 재시작하면 그 서버의 연결 수만 개가 동시에 끊기고, 같은 규칙으로 재시도하는 클라이언트들이 같은 순간에 다시 몰려옵니다. 지수 백오프에 무작위성을 섞는 것(AWS 아키텍처 블로그가 full jitter 라고 부른 방식: 0 부터 min(상한, 기준 × 2^시도) 사이에서 고르게 뽑기)이 그 파도를 넓게 펴 줍니다. 닫기 코드도 판단에 씁니다. 1001·1006·1011·1012·1013 처럼 상대 사정이 바뀔 수 있는 경우만 다시 붙고, 1008(정책 위반)이나 1002(프로토콜 오류)로 끊긴 연결은 다시 붙어도 같은 이유로 끊깁니다.

재개. SSE 는 Last-Event-ID 로 끊긴 자리를 알리는 규칙이 명세에 있지만 WebSocket 에는 없습니다. 메시지에 순번을 붙이고, 서버는 최근 메시지를 링 버퍼에 두었다가 클라이언트가 마지막으로 처리한 순번을 알려 오면 그 뒤를 다시 보냅니다. 버퍼에서 이미 밀려난 자리라면 조용히 구멍을 내지 말고 "처음부터 다시 받으라" 고 알립니다. 클라이언트가 알려야 하는 것은 받은 순번이 아니라 처리해서 적은 순번입니다.

연결 수의 한계. 연결 하나는 파일 서술자 하나와 커널 버퍼, 애플리케이션의 큐를 차지합니다. 파드의 서술자 한도(ulimit -n)와 메모리가 동시 연결 수의 천장을 정하므로, 부하 시험에서 연결 수를 늘려 가며 연결당 메모리를 재 두어야 합니다.

확장. 연결이 서버 한 대에 묶이는 것도 WebSocket 의 성질입니다. 발행자와 구독자가 다른 서버에 붙으면 서버끼리 메시지를 나눌 곳(Redis 의 pub/sub, 메시지 브로커)이 필요하고, 재개용 링 버퍼도 서버 한 대의 메모리가 아니라 그 공유 저장소에 두어야 다른 서버로 다시 붙어도 이어 받을 수 있습니다. 순번을 서버가 아니라 채널 단위로 매기는 것도 그 때문입니다.

현장에서 만나는 모습

배포할 때마다 몇 분 동안 서버 CPU 가 치솟고 연결 오류가 쏟아지는 서비스가 흔합니다. 들여다보면 재접속에 지터가 없고, 재접속 직후 전체 상태를 다시 내려받습니다. 지터와 순번 기반 재개 두 가지로 그 몇 분이 사라집니다. 서버를 내릴 때는 1001 이나 1012 로 닫아 클라이언트가 "다시 붙어도 된다" 는 것을 알게 하는 것도 도움이 됩니다.

다음 실습에서 할 것

websockets 로 발행·구독 허브와 재접속 클라이언트를 만듭니다. 읽지 않는 구독자 하나를 붙인 채 64MB 를 발행해 메모리와 1013 을 확인하고, 2.5초 유휴 한도의 중계기 뒤에서 6초를 버티고, pong 을 보내지 않는 구독자를 6초 안에 치우고, 발행 도중 연결을 두 번 끊어도 순번 1부터 끝까지 빠짐없이 한 번씩 적는지 봅니다.