재연결과 재시도: 지수 백오프, 지터, 그리고 Retry-After | eSeGeCe 블로그

재연결과 재시도: 지수 백오프, 지터, 그리고 Retry-After

· 컴포넌트

클라이언트 1만 개가 붙어 있는 WebSocket 서버를 재시작하면 불쾌한 일이 벌어집니다. 모든 클라이언트가 거의 같은 순간에 연결 해제를 감지하고, 모두 똑같이 고정된 초만큼 기다린 뒤, 모두 같은 틱에 다시 접속을 시도합니다. 서버가 올라오자마자 1만 개의 동시 TLS 핸드셰이크에 두들겨 맞습니다. 서버는 쓰러지거나 스로틀링을 걸고, 클라이언트들은 다시 일제히 재시도합니다. 이것이 썬더링 허드(thundering herd)이며, 고정 재연결 간격은 이를 보장합니다.

sgcWebSockets 2026.7은 이미 곤경에 빠진 서버를 클라이언트가 두들기는 두 지점을 다룹니다. 이제 WatchDog은 고정 간격 대신 지수 백오프와 지터로 재연결할 수 있고, HTTP 클라이언트는 서버의 Retry-After 힌트를 존중하면서 동일한 백오프 계산을 사용해 실패한 요청을 스스로 재시도할 수 있습니다. 둘 다 선택형이며 기본값은 꺼짐이므로, 여러분이 켜기 전까지 기존 애플리케이션은 이전과 정확히 동일하게 동작합니다.

고정 재연결 간격의 문제

WatchDog은 언제나 클라이언트를 연결된 상태로 유지하는 표준적인 방법이었습니다. Enabled를 설정하고 Interval을 초 단위로 지정하면, 연결이 끊어졌을 때 컴포넌트가 Interval초마다 다시 연결될 때까지 재시도합니다.

불안정한 Wi-Fi 링크에 붙은 클라이언트 하나라면 괜찮습니다. 하지만 대규모 클라이언트 군집에게는 잘못된 동작입니다. 고정 간격에서는 군집의 모든 클라이언트가 같은 박자로 째깍이는 메트로놈이 되고, 서버 재시작이 그들 전부를 동기화합니다. 모두 같은 순간에 끊겼으니 모두 같은 순간에 재연결하고, 그것이 영원히 반복됩니다. 더 나쁜 것은 간격이 적응하지 않는다는 점입니다. 서버가 10분간 다운되어 있다면 Interval = 1인 클라이언트는 600번의 무의미한 연결 시도를 하고, 다른 모든 클라이언트도 마찬가지입니다.

여러분이 원하는 것은 그 반대입니다. 첫 번째는 빨리 다시 시도해야 합니다. 대부분의 연결 해제는 일시적이고 빠른 재연결은 사용자 눈에 보이지 않기 때문입니다. 그다음에는 물러서야 합니다. 정말로 다운된 서버를 두들기지 않기 위해서입니다. 그리고 클라이언트를 흩뿌려야 합니다. 모두가 한꺼번에 도착하지 않도록 말입니다.

WatchDog의 지수 백오프

WatchDog 옵션 객체에 네 개의 속성이 추가되었습니다. 타입은 sgcTCP_Classes에 있습니다.

type
  TsgcWatchDogBackoff = (wdbFixed, wdbExponential);

모든 WebSocket 컴포넌트에서 옵션은 Client.WatchDog으로 접근하며, 백오프 설정은 다음과 같습니다.

uses
  sgcWebSocket, sgcTCP_Classes;

begin
  sgcWebSocketClient1.WatchDog.Enabled           := True;
  sgcWebSocketClient1.WatchDog.Attempts          := 0;     // 0 = keep trying forever
  sgcWebSocketClient1.WatchDog.Interval          := 1;     // seconds, the first delay
  sgcWebSocketClient1.WatchDog.Backoff           := wdbExponential;
  sgcWebSocketClient1.WatchDog.BackoffMultiplier := 2.0;   // double it every attempt
  sgcWebSocketClient1.WatchDog.MaxInterval       := 60;    // seconds, the ceiling
  sgcWebSocketClient1.WatchDog.Jitter            := 0.2;   // up to 20% random spread
end;

계산은 의도적으로 단순합니다. 첫 재연결 시도는 Interval만큼 기다립니다. 이후의 각 시도는 이전 지연에 BackoffMultiplier를 곱하고, 그 결과는 MaxInterval로 잘립니다. 위의 값이라면 지연은 1초, 2초, 4초, 8초, 16초, 32초, 60초, 60초, 60초 하는 식으로 이어집니다. 클라이언트가 다시 연결되는 즉시 카운터가 초기화되므로, 짧은 네트워크 끊김은 1분이 아니라 1초의 비용만 듭니다.

IntervalMaxInterval은 WatchDog이 늘 그래왔듯 둘 다 초 단위로 표현됩니다. MaxInterval이 0이면 상한이 없다는 뜻이며, 이것이 기본값입니다. 지수 백오프를 켠다면 반드시 상한을 설정하십시오. 그러지 않으면 지연이 시간 단위로 측정될 때까지 계속 두 배로 늘어납니다.

기존 사용자에게 중요한 세부 사항이 있습니다. Backoff의 기본값은 wdbFixed입니다. 업그레이드하고 아무것도 바꾸지 않으면, 클라이언트는 늘 그래왔던 것과 동일한 고정 간격으로 재연결합니다. 지연 계산은 wdbExponential로 전환하거나 Jitter를 설정할 때만 사용되며, HTTP 재시도가 쓰는 것과 동일한 헬퍼인 sgcBase_HelperssgcNextBackoffMs를 사용합니다.

지터, 그리고 규모에서 그것이 중요한 이유

지수 백오프만으로는 군집을 흩뜨리지 못합니다. 군집이 도착하는 빈도를 낮출 뿐입니다. 1만 개의 클라이언트가 모두 1초, 그다음 2초, 그다음 4초를 기다린다면 여전히 1만 개가 함께 도착하는 것이며, 다만 파도가 점점 더 커질 뿐입니다.

실제로 그들을 흩뿌리는 것은 Jitter입니다. 이는 계산된 지연의 일정 비율로, 클라이언트마다 그리고 시도마다 무작위로 적용됩니다. Jitter = 0.2라면 명목상 8초의 지연이 8초 언저리 20% 범위 안의 무작위 값이 되므로, 클라이언트 A는 7.1초에, 클라이언트 B는 8.6초에, 클라이언트 C는 8.0초에 깨어납니다. 군집은 뾰족한 봉우리가 아니라 넓게 번진 얼룩처럼 도착하고, 서버의 accept 큐가 이를 따라잡을 수 있습니다.

// A conservative fleet profile: fast first retry, hard ceiling, generous spread.
sgcWebSocketClient1.WatchDog.Enabled           := True;
sgcWebSocketClient1.WatchDog.Interval          := 2;
sgcWebSocketClient1.WatchDog.Backoff           := wdbExponential;
sgcWebSocketClient1.WatchDog.BackoffMultiplier := 1.5;   // gentler than doubling
sgcWebSocketClient1.WatchDog.MaxInterval       := 120;
sgcWebSocketClient1.WatchDog.Jitter            := 0.5;   // spread over half the delay

지터는 백오프 모드와 서로 직교합니다. BackoffwdbFixed로 둔 채 Jitter만 설정할 수도 있으며, 그러면 클라이언트들이 비동기화된 고전적인 고정 간격 방식을 얻게 됩니다. 그것만으로 충분한 경우가 많고, 기존 애플리케이션에 가할 수 있는 가장 작은 변경이기도 합니다.

HTTP 요청 자동 재시도

이 기능의 나머지 절반은 HTTP 쪽에 있습니다. 서버가 재배포 중이라 503으로 실패한 단일 HTTP 요청, 또는 속도 제한을 넘겨 429로 실패한 요청은 사실 오류가 아닙니다. 잠시 후에 다시 보내야 하는 요청입니다. 그 루프를 손으로 작성하는 일은 지루하고, 모두가 조금씩 잘못 작성합니다.

TsgcIdHTTP에는 이제 sgcHTTP_ClientTsgcIdHTTPRetry_Options로 선언된 RetryOptions 객체가 있습니다. 이를 켜면 요청이 스스로 재시도합니다.

uses
  sgcHTTP_Client;

var
  oHTTP: TsgcIdHTTP;
begin
  oHTTP := TsgcIdHTTP.Create(nil);
  try
    oHTTP.RetryOptions.Enabled         := True;    // default False
    oHTTP.RetryOptions.MaxRetries      := 3;
    oHTTP.RetryOptions.InitialInterval := 500;     // milliseconds
    oHTTP.RetryOptions.MaxInterval     := 30000;   // milliseconds
    oHTTP.RetryOptions.Multiplier      := 2.0;
    oHTTP.RetryOptions.Jitter          := 0.2;
    oHTTP.RetryOptions.StatusCodes     := '429,502,503,504';

    ShowMessage(oHTTP.Get('https://api.example.com/v1/orders'));
  finally
    oHTTP.Free;
  end;
end;

여기서 간격은 초가 아니라 밀리초 단위입니다. HTTP 재시도는 재연결보다 훨씬 짧은 시간 척도에서 이루어지기 때문입니다. 지연은 WatchDog과 동일한 곡선을 따릅니다. 500ms, 1초, 2초, 4초로 늘어나며 MaxInterval로 제한되고 Jitter로 흩뿌려집니다.

StatusCodes는 재시도 대상으로 간주할 HTTP 상태 코드 목록이며, 쉼표로 구분된 문자열입니다. 기본값은 429,502,503,504로, "요청이 잘못되었다"가 아니라 "지금은 안 되니 다시 시도하라"를 뜻하는 코드들입니다. 400이나 401은 결코 재시도하지 않습니다. 같은 잘못된 요청을 네 번 더 보내봐야 아무에게도 도움이 되지 않기 때문입니다. 거부된 소켓이나 읽기 타임아웃 같은 연결 수준의 실패도 재시도됩니다.

같은 객체가 sgcHTTP_APITsgcHTTPAPI_client에도 있으며, 이는 라이브러리에 포함된 모든 즉시 사용 가능한 REST 클라이언트의 기반 클래스입니다. 따라서 클라이언트별 배선 작업 없이 모든 클라이언트에서 재시도 정책을 사용할 수 있습니다.

// Any API client that descends from TsgcHTTPAPI_client
sgcHTTPAPI_Client1.RetryOptions.Enabled     := True;
sgcHTTPAPI_Client1.RetryOptions.MaxRetries  := 5;
sgcHTTPAPI_Client1.RetryOptions.StatusCodes := '429,500,502,503,504';

Retry-After: 서버가 속도를 정하게 하라

자체 백오프를 계산하는 것은 추측입니다. 서버가 429나 503을 반환할 때는 얼마나 기다려야 하는지를 Retry-After 응답 헤더에 초 단위 숫자나 HTTP 날짜 형태로 정확히 알려주는 경우가 많습니다. 그 숫자는 제안이 아닙니다. 서버가 언제 다시 여러분을 받아줄지 알려주는 것이며, 이를 무시하고 자기 일정대로 재시도하는 클라이언트는 그저 다시 거절당할 뿐입니다.

HonorRetryAfter의 기본값은 True입니다. 응답에 Retry-After 헤더가 실려 있으면 그 값이 계산된 백오프보다 우선하며, 클라이언트는 서버가 요청한 만큼 기다립니다.

oHTTP.RetryOptions.Enabled         := True;
oHTTP.RetryOptions.HonorRetryAfter := True;   // default True
oHTTP.RetryOptions.InitialInterval := 500;
oHTTP.RetryOptions.MaxRetries      := 3;

// Server replies 429 with "Retry-After: 12".
// The computed backoff would have been 500ms, the client waits 12 seconds instead.

헤더의 두 가지 형식, 즉 델타 초 형식과 HTTP 날짜 형식이 모두 파싱됩니다. 이는 OpenAI와 Anthropic이 429를 반환할 때 클라이언트에게 정확히 요구하는 동작이며, 속도 제한에서 회복하는 클라이언트와 계속 속도 제한에 걸려 있는 클라이언트의 차이를 만듭니다.

AI 클라이언트의 재시도

AI 클라이언트야말로 재시도가 제 몫을 하는 곳입니다. 그곳에서는 속도 제한이 예외가 아니라 일상이기 때문입니다. 각 클라이언트는 자신의 *Options 속성에 고유한 재시도 옵션 객체를 노출하므로, 모델과 API 키 옆의 오브젝트 인스펙터에서 설정이 보입니다.

TsgcHTTP_API_OpenAI에서 경로는 OpenAIOptions.RetryOptions이며, 클래스는 TsgcHTTPOpenAIRetry_Options입니다.

uses
  sgcHTTP_API_OpenAI;

begin
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Enabled         := True;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Retries         := 3;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Wait            := 10000;  // ms, first delay
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.MaxInterval     := 30000;  // ms, ceiling
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Multiplier      := 2.0;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Jitter          := 0.2;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.HonorRetryAfter := True;
end;

Anthropic도 AnthropicOptions.RetryOptions 아래에서 같은 모양이고, Gemini는 GeminiOptions.RetryOptions 아래에 있습니다. 동일한 패턴이 Grok, Mistral, DeepSeek, Ollama 클라이언트에도, 그리고 Stripe와 Paddle 클라이언트에도 존재합니다. 결제 호출의 재시도는 전혀 다른 차원의 중요성을 갖습니다.

// Anthropic returns 429 and 529 under load, both are treated as retryable.
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.Enabled         := True;
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.Retries         := 3;
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.HonorRetryAfter := True;

AI 객체의 속성 이름은 MaxRetriesInitialInterval이 아니라 RetriesWait라는 점에 유의하십시오. 해당 컴포넌트의 다른 곳에서 이미 사용 중인 명명 방식과 일관성을 유지하기 위해서입니다. 동작은 동일하며, 내부적으로는 하위 HTTP 클라이언트에 동일한 TsgcIdHTTPRetry_Options를 설정합니다.

실패 이유 알기: Connect와 LastError

재시도 루프는 그 오류 처리만큼만 좋습니다. 어떤 실패가 재시도할 가치가 있는지 판단하려면 먼저 그 실패가 무엇이었는지 알아야 하는데, 지금까지 WebSocket 클라이언트에서 이를 알아내는 유일한 방법은 OnError 핸들러를 연결하고 메시지를 어딘가의 필드에 담아두는 것이었습니다.

2026.7은 오류를 직접 넘겨주는 Connect 오버로드를 sgcWebSocket_Client_Base에 추가합니다.

function Connect(const aTimeout: Integer = 10000): Boolean; overload;
function Connect(out AError: string; const aTimeout: Integer = 10000): Boolean; overload;

따라서 수동 재연결 루프가 이유를 살펴보고 스스로 판단할 수 있습니다.

var
  vError: string;
begin
  if not sgcWebSocketClient1.Connect(vError, 5000) then
  begin
    // A DNS failure or a refused socket is worth retrying.
    // A rejected certificate or a 401 on the handshake is not.
    Memo1.Lines.Add('connect failed: ' + vError);
    Exit;
  end;
end;

마지막 오류는 컴포넌트 자체에도 보관됩니다. sgcWebSocket_ClassesTsgcWSComponent_Base는 읽기 전용 LastError: string 속성과 ClearLastError 메서드를 게시하므로, 사후에 이유를 읽을 수 있고 측정하려는 시도 전에 초기화할 수 있습니다.

sgcWebSocketClient1.ClearLastError;
if not sgcWebSocketClient1.Connect(5000) then
  ShowMessage('connect failed: ' + sgcWebSocketClient1.LastError);

이것들이 OnError를 대체하지는 않습니다. 이는 늘 그래왔듯 여전히 발동합니다. 다만 단순하고 동기적인 코드가 단순한 질문에 답하기 위해 더 이상 이벤트 핸들러를 설정할 필요가 없다는 뜻입니다.

제공 범위

WatchDog 백오프, 지터, HTTP 재시도, 그리고 새 Connect 오버로드는 sgcWebSockets 2026.7부터 Delphi 7에서 13까지와 C++Builder에서, Win32/Win64, Linux64, macOS, Android, iOS 전반에 걸쳐 사용할 수 있습니다. 여기서 설명한 모든 것은 기본값이 꺼짐입니다. BackoffwdbFixed, Jitter는 0, RetryOptions.EnabledFalse이므로, 기존 애플리케이션은 동작 변화 없이 그대로 업그레이드되며 필요한 곳에서만 선택적으로 켜면 됩니다.

구독이 유효한 고객은 고객 영역에서 새 빌드를 내려받을 수 있습니다. 평가판 사용자는 esegece.com/products/websockets/download에서 최신 설치 파일을 받을 수 있습니다.

질문이나 피드백이 있으신가요? 아니면 여러분의 클라이언트 군집에 맞는 백오프 프로파일을 고르는 데 도움이 필요하신가요? 문의하기, 코드를 작성한 사람들에게서 직접 답변을 받으실 수 있습니다.