빠른 피드를 브로드캐스트하는 서버는 가장 느린 클라이언트의 속도가 아니라 자기 속도로 메시지를 만들어 냅니다. 시세 데이터, 텔레메트리, 실시간 로그 추적처럼 서버가 밀어내면 연결된 모든 클라이언트는 따라올 것으로 기대됩니다. 대부분은 따라옵니다. 그중 하나는 언젠가 따라오지 못합니다.
클라이언트가 소켓을 비워 내지 않게 되면, 그 클라이언트에게 보낸 메시지는 사라지지 않습니다. 그 연결의 전송 큐에 쌓이고, 그 큐는 곧 메모리입니다. 기차 터널로 들어간 휴대폰, 스로틀링된 브라우저 탭, 읽기 호출을 멈춘 클라이언트 프로세스, 이들 모두가 조용히 서버 메모리를 불려서 결국 무언가가 쓰러지게 만듭니다. sgcWebSockets 2026.7은 그 큐에 상한을 두고, 가득 찼을 때 무엇을 할지 결정할 수 있게 해 줍니다.
느린 소비자
문제는 네트워크가 느리다는 것이 아니라 속도의 불일치입니다. 여러분의 피드는 초당 500개의 메시지를 게시합니다. 회선 상태가 나쁜 클라이언트는 초당 50개를 비워 냅니다. 나머지 450개는 어딘가로 가야 하고, 그 "어딘가"는 서버 프로세스 안의 연결별 목록입니다. 1분이 지나면 그 연결 하나가 수만 개의 메시지를 붙들고 있습니다. 10분이 지나면 여러분의 RAM을 붙들고 있습니다.
피해는 느린 클라이언트에만 국한되지도 않습니다. 그 클라이언트가 먹어 치우는 메모리는 정상적인 클라이언트들이 쓰지 못하는 메모리이며, 프로세스가 스와핑을 시작하거나 할당자가 허덕이기 시작하면 모두의 지연 시간이 함께 나빠집니다. 터널 속 휴대폰 한 대가 모두의 장애가 됩니다.
지금까지 sgcWebSockets의 전송 큐는 무제한이었습니다. 모든 클라이언트가 잘 따라오는 동안에는 괜찮지만, 처음으로 그렇지 못한 클라이언트가 나타나는 순간 곤란해집니다. 해결책은 상한을 두는 것입니다.
큐에 상한 두기
큐는 컴포넌트의 QueueOptions 아래, 기존의 Text, Binary, Ping 레벨 하위 옵션 옆에 있습니다. 두 개의 새 속성이 상한을 정합니다. MaxQueueSize는 연결 하나가 보유할 수 있는 대기 메시지 수를 제한하고, OverflowPolicy는 그 한도에 도달한 뒤 들어오는 다음 메시지를 어떻게 처리할지 정합니다.
uses
sgcWebSocket, sgcWebSocket_Classes, sgcWebSocket_Types;
begin
// messages are queued per connection and flushed by the queue thread
oServer.QueueOptions.Text.Level := qmLevel1;
oServer.QueueOptions.Binary.Level := qmLevel1;
// at most 1000 pending messages per connection
oServer.QueueOptions.MaxQueueSize := 1000;
// when the queue is full, throw away the oldest pending message
oServer.QueueOptions.OverflowPolicy := qopDropOldest;
end;
QueueOptions는 TsgcWebSocketServer, TsgcWebSocketHTTPServer, TsgcWebSocketClient에 published되어 있고, 컴포넌트는 연결이 수락될 때마다 이 설정을 각 연결에 복사합니다. 따라서 제한은 서버당이 아니라 연결당입니다. MaxQueueSize = 1000인 연결이 1000개라면 최악의 경우 연결마다 1000개의 대기 메시지가 쌓이며, 이제 그 최악의 경우는 배포하기 전에 종이 위에서 곱해 볼 수 있는 숫자가 됩니다.
오버플로 정책 고르기
여기에는 보편적으로 옳은 답이 없고, 그래서 세 가지가 있습니다. 어떤 정책이 맞는지는 메시지가 무엇을 의미하는지에 달려 있습니다.
qopDropOldest는 가장 최신 값만이 의미 있는 실시간 피드에 알맞습니다. 가격 틱, 센서 측정값, 대시보드 게이지에서는 4초 전의 메시지가 다음 메시지가 도착하는 순간 무가치해집니다. 큐의 앞쪽에서 버린다는 것은, 뒤처졌다가 회복한 클라이언트가 따라잡기 위해 씹어 삼켜야 할 오래된 메시지 더미가 아니라 현재의 그림을 받게 된다는 뜻입니다. 이것이 기본값입니다.
qopDropNewest는 앞선 메시지가 중요하고 새로 들어오는 부하를 덜어 내고 싶을 때 알맞습니다. 클라이언트가 어떤 순서를 따라 안내받는 중이고 그 순서의 앞부분이 의미 있는 부분이라면, 이미 큐에 있는 것을 지키고 새로 온 메시지를 거절하십시오.
qopDisconnect는 따라오지 못하는 클라이언트라면 아예 원하지 않을 때 알맞습니다. 가장 정직한 선택지입니다. 그 연결에 구멍투성이의, 망가진 스트림 뷰를 조용히 먹이는 대신 연결을 닫아 버립니다. 클라이언트의 WatchDog이 재연결하고, 다시 구독하고, 알려진 상태에서 새로 시작합니다. 자신이 끊겼다는 것을 아는 클라이언트는 복구할 수 있습니다. 메시지를 잃었다는 것을 모르는 클라이언트는 복구할 수 없습니다.
// A market data feed: only the newest tick is worth anything.
oServer.QueueOptions.MaxQueueSize := 500;
oServer.QueueOptions.OverflowPolicy := qopDropOldest;
// A sequence where the head matters: shed the new load instead.
oServer.QueueOptions.OverflowPolicy := qopDropNewest;
// A client that falls this far behind is a client we do not want.
oServer.QueueOptions.OverflowPolicy := qopDisconnect;
무엇을 고르든 의도를 갖고 고르십시오. 메시지를 버리는 것은 트레이드오프이며, 이 세 가지 값의 요점은 어떤 트레이드오프를 하고 있는지를 운영 환경에서 뒤늦게 발견하는 대신 여러분이 직접 선택할 수 있다는 데 있습니다.
버리기 전에 알기: OnSendBufferFull
조용히 메시지를 버리는 큐는 여러분에게 거짓말을 하는 큐입니다. OnSendBufferFull이 그 탈출구입니다. 이 이벤트는 무언가가 폐기되기 전에 발생하며, 연결과 현재 큐 크기를 넘겨주고, var DropMessage 매개변수를 통해 코드가 폐기를 거부할 수 있게 해 줍니다.
procedure TForm1.WSServerSendBufferFull(Connection: TsgcWSConnection;
const QueueSize: Integer; var DropMessage: Boolean);
begin
// fires BEFORE anything is dropped: log it, count it, alert on it
DoLog('slow consumer ' + Connection.IP + ' [' + Connection.Guid + '] queue=' +
IntToStr(QueueSize));
Inc(FSlowConsumerHits);
// this connection is a paying feed we must not truncate: keep the message
// and let the queue grow past the cap for this one send
if IsPriorityConnection(Connection) then
DropMessage := False;
end;
DropMessage := False로 설정하면 그 메시지에 대해서는 오버플로 정책이 건너뛰어지고, 상한을 넘겨서라도 큐에 들어갑니다. 이 방법은 아껴서 쓰십시오. 항상 거부권을 행사한다면 제한이 아예 없는 것과 같기 때문입니다. 이 이벤트의 평소 쓰임새는 지루한 쪽이자 값진 쪽입니다. 그런 일이 일어났다는 사실, 어느 연결에서 일어났는지, 그리고 얼마나 뒤처져 있었는지를 아는 것입니다. 그 숫자가 프로세스의 메모리가 바닥나기 훨씬 전에 울리는 조기 경보입니다.
OnSendBufferFull은 TsgcWebSocketClient, TsgcWebSocketServer, TsgcWebSocketHTTPServer에 published되어 있습니다.
아무것도 버릴 수 없을 때는 어떻게 할까
때로는 어떤 메시지도 절대 잃어서는 안 된다는 것이 정직한 답입니다. 그렇다면 위의 세 가지 정책 중 어느 것도 원하는 답이 아니고, 큐를 더 키우는 것도 답이 아닙니다. 큐를 키운다고 문제가 해결되지는 않습니다. 미룰 뿐이고, 그것도 더 나쁜 순간으로 미룹니다. 버스트의 더 깊은 지점, 메모리 압박이 더 심한 지점, 그리고 아직 손을 쓸 수 있었던 시점으로부터 더 멀어진 지점으로 말이죠.
답은 다른 설계입니다. 전달되지 않은 메시지를 소켓 큐에 붙들고 있지 말고 저장해 두었다가, 클라이언트가 가능해졌을 때 놓친 것을 가져가게 하십시오. 메시지 히스토리와 재연결 복구가 바로 그 일을 합니다. 서버는 채널별로 오프셋이 붙은 제한된 히스토리를 보관하고, 뒤처졌거나 연결이 끊겼던 클라이언트는 다시 구독할 때 놓친 메시지를 재생합니다. 이것을 qopDisconnect와 짝지으면 가능한 가장 강력한 조합이 됩니다. 따라오지 못하는 클라이언트는 빠르게 끊어 내고, 돌아왔을 때는 놓친 것이 없는 척하는 대신 놓친 것을 전부 복구합니다.
기본값은 여전히 무제한
MaxQueueSize의 기본값은 0이며, 이는 무제한을 뜻하고, 정확히 sgcWebSockets가 이전에 보이던 동작입니다. 업그레이드해도 달라지는 것은 없습니다. 양수 상한을 설정하지 않으면 오버플로 경로에는 아예 들어가지도 않으므로, 기존 서버는 여러분이 달리 결정하기 전까지 늘 하던 대로 큐에 쌓습니다.
그렇다고는 해도, 여러분이 통제하지 못하는 클라이언트에게 브로드캐스트하고 있다면, 그리고 공개 인터넷에서는 결코 통제할 수 없습니다, 연결별 무제한 큐란 언젠가 접속해 올 최악의 클라이언트를 대신해 내어 준 무한정의 메모리 약속입니다. 상한을 설정하는 데는 한 줄이면 됩니다.
제공 범위
전송 큐 제한, 세 가지 오버플로 정책, 그리고 OnSendBufferFull은 sgcWebSockets 2026.7에서 Delphi 7부터 13까지와 C++Builder, Win32/Win64, Linux64, macOS, Android, iOS에서 제공됩니다.
구독이 유효한 고객은 고객 영역에서 새 빌드를 내려받을 수 있습니다. 체험판 사용자는 esegece.com/products/websockets/download에서 업데이트된 설치 파일을 받을 수 있습니다.
질문이나 의견이 있거나, 여러분의 피드에 맞는 정책을 고르는 데 도움이 필요하신가요? 문의하기, 코드를 직접 작성한 사람들이 답변해 드립니다.
