Delphi와 .NET을 위한 Server-Sent Events 클라이언트

· 컴포넌트
Delphi와 .NET을 위한 Server-Sent Events 클라이언트

Server-Sent Events는 한때 WebSockets의 조용한 사촌 같은 존재였습니다. 서버가 작은 텍스트 이벤트를 하나씩 계속 기록하는, 끝나지 않는 평범한 HTTP 응답이었죠. 그런데 어느새 어디에나 있습니다. LLM API의 스트리밍 응답은 SSE로 도착하고, MCP 서버는 Streamable HTTP를 통해 이를 사용하며, 대시보드와 알림, 작업 진행률 피드도 HTTP를 허용하는 모든 프록시를 통과한다는 이유로 이를 사용합니다.

sgcWebSockets 2026.10은 Delphi, C++Builder, .NET용 EventSource 클라이언트인 TsgcSSEClient를 추가합니다. 백그라운드 스레드에서 스트림을 읽고, 모든 이벤트를 타입과 데이터, id와 함께 전달하며, 연결이 끊기면 스스로 재연결하고, Last-Event-ID로 재개하므로 그 사이에 아무것도 잃지 않습니다. 이 컴포넌트는 현재 다운로드 버전에는 포함되어 있지 않습니다. 2026.10 릴리스와 함께, 모든 에디션에 제공됩니다.

TsgcSSEClient가 타입이 지정된 이벤트를 수신하다가 연결을 잃고, Last-Event-ID로 재개하는 모습입니다. YouTube에서도 볼 수 있습니다.

지금 SSE가 중요한 이유

SSE 스트림은 짧은 블록들로 이루어진 text/event-stream 응답입니다. 타입을 담은 선택적인 event: 줄 하나, 하나 이상의 data: 줄, 선택적인 id:, 그리고 이벤트를 끝맺는 빈 줄로 구성됩니다. 서버는 재연결 전에 얼마나 기다려야 하는지 알려주는 retry:를 보낼 수도 있습니다. 형식은 단순하지만, 올바른 클라이언트라면 어느 위치에서나 나뉠 수 있는 청크, 여러 줄에 걸친 데이터, CR, LF, CRLF 줄바꿈, 그리고 멈춘 위치를 기억하는 재연결을 처리해야 합니다. 바로 이 부분을 TsgcSSEClient가 대신 처리해 줍니다.

빠른 시작

sgcHTTP_SSE_Client 유닛을 추가하고, URL을 설정하고, OnEvent를 처리한 다음 Open을 호출하세요. bearer 토큰 같은 요청 헤더는 Headers에 한 줄에 하나씩 Name: Value 형식으로 넣으며, 재연결을 포함해 모든 연결에서 전송됩니다.

procedure TForm1.FormCreate(Sender: TObject);
begin
  oSSE := TsgcSSEClient.Create(nil);
  oSSE.URL := 'https://www.example.com/events';
  oSSE.Headers.Add('Authorization: Bearer ' + FToken);
  oSSE.OnEvent := OnSSEEvent;
  oSSE.OnError := OnSSEError;
  oSSE.Open;
end;

procedure TForm1.OnSSEEvent(Sender: TObject; const aEvent: TsgcSSEEvent);
begin
  if aEvent.EventType = 'alert' then
    ShowMessage(aEvent.Data)
  else
    Memo1.Lines.Add(aEvent.EventType + ' #' + aEvent.Id + ': ' + aEvent.Data);
end;

procedure TForm1.OnSSEError(Sender: TObject; const aError: string);
begin
  Memo1.Lines.Add('error: ' + aError);
end;

모든 이벤트는 타입이 지정됩니다. EventType은 event:의 이름을 담고 있으므로, 하나의 핸들러로 alert, tick, 또는 서버가 만들어내는 어떤 타입이든 라우팅할 수 있습니다. VCL이나 FireMonkey 애플리케이션에서는 기본적으로 이벤트가 메인 스레드로 전달되므로 핸들러가 UI를 직접 건드릴 수 있습니다. NotifyEvents로 이를 바꿀 수 있습니다. OnOpen과 OnClose는 스트림이 시작되고 멈추는 시점을 알려주며, ReadyState는 sseConnecting, sseOpen, sseClosed 중 하나를 보고합니다.

클라이언트는 브라우저의 EventSource와 같은 방식으로 서버에 반응합니다.

Last-Event-ID로 재개하기

서버가 이벤트에 id: 필드를 부여하면, 클라이언트는 마지막 값을 LastEventId에 보관하고, 재연결할 때마다 Last-Event-ID 헤더로 전송합니다. 서버는 이 헤더를 읽고 해당 이벤트 이후부터 이어가므로, 연결이 끊겨도 메시지가 손실되거나 중복되지 않습니다. 같은 세션 안에서는 이를 위해 코드를 작성할 필요가 전혀 없습니다.

애플리케이션이 재시작된 뒤에도 재개하려면, 중지할 때 LastEventId를 저장하고 Open 전에 복원하세요. 이 속성은 Close 이후에도 값을 유지하며, Open 이전에 설정한 값은 첫 번째 요청과 함께 전송됩니다.

procedure TForm1.StartStream;
begin
  if FileExists('sse_last_id.txt') then
    oSSE.LastEventId := Trim(TFile.ReadAllText('sse_last_id.txt'));
  oSSE.Open;
end;

procedure TForm1.StopStream;
begin
  oSSE.Close;
  TFile.WriteAllText('sse_last_id.txt', oSSE.LastEventId);
end;

재개 기능은 서버의 협조가 필요합니다. 서버는 이벤트에 id:를 보내야 하고 Last-Event-ID 헤더를 존중해야 합니다.

직접 제어하는 재연결 정책

재연결은 기본적으로 켜져 있으며 지연 시간은 3초입니다. ReconnectOptions를 사용하면 이를 서버에 걸맞은 어떤 정책으로도 바꿀 수 있습니다. 배수를 가진 지수 백오프, 지연 시간의 상한, 수천 개의 클라이언트가 같은 밀리초에 한꺼번에 돌아오지 않도록 하는 무작위 지터, 그리고 시도 횟수 제한까지 말입니다.

procedure TForm1.SetupReconnect;
begin
  oSSE.ReconnectOptions.Interval := 1000;
  oSSE.ReconnectOptions.Backoff := True;
  oSSE.ReconnectOptions.BackoffMultiplier := 2.0;
  oSSE.ReconnectOptions.MaxInterval := 30000;
  oSSE.ReconnectOptions.Jitter := 0.2;
  oSSE.ReconnectOptions.MaxAttempts := 10;
  oSSE.OnReconnect := OnSSEReconnect;
end;

MaxAttempts는 연속된 실패 횟수를 셉니다. 연결이 열릴 때마다 카운터가 다시 시작되므로, 가끔 네트워크 문제를 겪으며 몇 주씩 실행되는 클라이언트도 시도 횟수를 소진하는 일이 없습니다. 서버에도 발언권이 있습니다. retry: 필드는 남은 세션 동안 Interval을 대체합니다. 최종 결정권은 여러분에게 있으며, 매 시도 전에 계산된 지연 시간과 함께 실행되는 OnReconnect에서 내릴 수 있습니다:

procedure TForm1.OnSSEReconnect(Sender: TObject; aAttempt: Integer;
  var aDelay: Integer; var aCancel: Boolean);
begin
  // stop after the fifth failed attempt, otherwise wait at least 2 seconds
  if aAttempt > 5 then
    aCancel := True
  else if aDelay < 2000 then
    aDelay := 2000;
end;

TLS, 프록시, 인증

스트림은 TsgcHTTP1Client로 읽히며, OnBeforeConnect가 매 연결 시도 전에 이를 여러분에게 전달합니다. HTTP 클라이언트가 할 수 있는 일이라면 SSE 클라이언트도 할 수 있습니다. TLS 옵션, 프록시, 기본 인증, 더 긴 읽기 타임아웃까지 모두 가능합니다.

procedure TForm1.OnSSEBeforeConnect(Sender: TObject;
  const aHTTP: TsgcHTTP1Client);
begin
  aHTTP.TLSOptions.Version := tls1_2;
  aHTTP.Proxy.Enabled := True;
  aHTTP.Proxy.Host := '192.168.1.10';
  aHTTP.Proxy.Port := 8080;
  aHTTP.ReadTimeout := 60000;
end;

OnBeforeConnect와 OnReconnect는 항상 백그라운드 스레드에서 실행되므로, UI 작업은 그 안에 넣지 마세요.

LLM 스트림과 MCP를 위한 파서

장기간 유지되는 EventSource가 전혀 필요하지 않을 때도 많습니다. stream: true를 사용하는 LLM 채팅 완성 요청은 응답이 SSE인 POST이며, MCP Streamable HTTP 응답 역시 SSE일 수 있습니다. 이런 경우를 위해 클라이언트 내부의 파서가 공개되어 있습니다. 바로 TsgcSSEParser입니다. 네트워크가 실제로 전달하는 그대로, 어떤 크기의 청크로든 원시 바이트나 텍스트를 넣어주면, 완전한 이벤트마다 OnEvent를, 유효한 retry: 필드마다 OnRetry를 실행합니다. 청크가 한 줄의 중간이나 UTF-8 문자의 중간에서 끝날 수도 있는데, 파서는 다음 호출까지 나머지 부분을 보관합니다.

procedure TForm1.ParseStream;
begin
  FParser := TsgcSSEParser.Create;
  FParser.OnEvent := OnParserEvent;
  // chunks arrive as the network delivers them, split anywhere
  FParser.Feed('event: content_block_delta'#10'data: {"delta":{"text":"Hel');
  FParser.Feed('lo"}}'#10#10'event: message_stop'#10);
  FParser.Feed(TEncoding.UTF8.GetBytes('data: {}'#10#10));
  // start again for the next response
  FParser.Reset;
end;

procedure TForm1.OnParserEvent(Sender: TObject; const aEvent: TsgcSSEEvent);
begin
  Memo1.Lines.Add(aEvent.EventType + ' ' + aEvent.Data);
end;

이 세 개의 청크는 두 개의 이벤트를 만들어 냅니다. 완전한 JSON {"delta":{"text":"Hello"}}를 담은 content_block_delta, 그리고 message_stop입니다. Reset은 다음 응답 전에 절반만 읽힌 이벤트를 모두 버리며, LastEventId는 파서가 마지막으로 본 id를 알려줍니다.

.NET용 C#

sgcWebSockets의 .NET 에디션에는 같은 이름의 동일한 컴포넌트가 esegece.sgcWebSockets 네임스페이스에 있습니다. Headers는 Name: Value 문자열의 목록이며, 이벤트는 일반적인 .NET 이벤트입니다.

using esegece.sgcWebSockets;

string token = args.Length > 0 ? args[0] : "";

var sse = new TsgcSSEClient();
sse.URL = "https://www.example.com/events";
sse.Headers.Add("Authorization: Bearer " + token);
if (File.Exists("sse_last_id.txt"))
    sse.LastEventId = File.ReadAllText("sse_last_id.txt").Trim();
sse.ReconnectOptions.Backoff = true;
sse.ReconnectOptions.MaxInterval = 30000;
sse.OnEvent += (sender, e) => Console.WriteLine($"{e.EventType} #{e.Id}: {e.Data}");
sse.OnError += (sender, error) => Console.WriteLine("error: " + error);
sse.Open();

Console.ReadLine();
sse.Close();
File.WriteAllText("sse_last_id.txt", sse.LastEventId);

TsgcSSEParser도 마찬가지로 제공되며, byte[]와 그 일부, 문자열을 위한 Feed 오버로드를 갖추고 있습니다.

데모 사용해보기

두 에디션 모두 완전히 오프라인으로 실행되는 데모를 제공합니다. 같은 애플리케이션 안에서 127.0.0.1, 포트 5580에 작은 SSE 서버를 띄워, id:가 있는 번호 매겨진 이벤트를 스트리밍하고, message, tick, alert 타입을 순환시키며, retry: 필드를 보내고, 받은 Last-Event-ID 이후로 재개합니다. 연결을 끊어보고 클라이언트가 재연결해 다음 id부터, 빠짐도 중복도 없이 이어가는 모습을 지켜보세요.

제공 현황

TsgcSSEClient와 TsgcSSEParser는 sgcWebSockets 2026.10에서 Delphi, C++Builder, .NET용으로, 모든 에디션에 제공됩니다. 오늘 다운로드할 수 있는 버전에는 포함되어 있지 않습니다. 2026.10이 출시되면 다운로드 페이지에 올라오며, 이 컴포넌트는 SGC HTTP 팔레트 탭에 등록됩니다.

모든 속성과 이벤트, 재개 가이드를 담은 전체 레퍼런스는 TsgcSSEClient 도움말에 있습니다. sgcWebSockets가 Server-Sent Events와 관련해 제공하는 다른 모든 기능은 SSE 제품 페이지를 참고하세요.

다음 읽을거리

질문이 있으신가요? 피드백이나 마이그레이션 도움이 필요하신가요? 문의하기를 이용해 주시면, 실제로 코드를 작성한 사람에게서 답변을 받으실 수 있습니다.