재시도 패턴
개요
주문 화면이 재고 서비스에 남은 수량을 묻는다고 하자. 재고 서비스가 배포 중이거나 순간적으로 몰린 요청을 처리하는 동안에는 첫 호출이 503 Service Unavailable로 실패하고, 잠시 뒤 보낸 같은 요청은 200 OK로 성공한다. 첫 실패를 그대로 화면에 보여 주면 잠깐의 흔들림이 주문 실패가 된다. 반대로 성공할 때까지 곧바로 다시 부르는 루프는 이미 바쁜 서비스에 요청을 더 얹고, 상품 코드가 잘못된 400 Bad Request처럼 몇 번을 보내도 결과가 같은 요청에서도 멈추지 않는다.
재시도(Retry) 패턴은 실패한 호출을 정해 둔 규칙에 따라 다시 실행한다. Microsoft Azure 아키텍처 센터는 이 패턴을, 서비스나 네트워크 자원에 연결할 때 생기는 일시적 실패를 실패한 작업의 재시도로 처리하는 방법으로 설명한다.1 규칙은 어떤 실패를 다시 시도할지, 몇 번까지 시도할지, 시도 사이에 얼마나 기다릴지를 정하며, 이 글은 이 세 가지를 재시도 정책(RetryPolicy)이라는 값 하나에 모은다.
다시 보내면 성공할 수 있는 실패를 일시적 실패(transient failure)라고 한다. 네트워크 연결이 잠깐 끊기거나, 서비스를 잠시 쓸 수 없거나, 바쁜 서비스에서 시간 초과가 나는 경우다. 입력이 잘못됐거나 권한이 없어서 난 실패는 다시 보내도 결과가 같은데, 이 글은 이런 실패를 영구적 실패(permanent failure)라고 부른다. 정책의 RetryOn은 실패 종류를 받아 다시 시도할지를 돌려주는 함수다. 예제는 503을 일시적 실패로, 400을 영구적 실패로 나눈다.
MaxAttempts는 첫 호출을 포함한 최대 시도 횟수이고, 이 상한이 없으면 오래가는 장애에서 호출자가 끝나지 않는다. Backoff는 n번째 실패 뒤에 기다릴 시간을 돌려주는 함수다. 실패할 때마다 대기 시간을 일정 배수로 늘리는 방식을 지수 백오프(exponential backoff)라고 하며, 대기 시간이 끝없이 늘지 않도록 상한(cap)을 둔다. 대기 시간에 무작위 값을 섞는 지터(jitter)는 재시도 시점을 흩어 놓아, 같은 순간에 실패한 여러 클라이언트가 같은 순간에 다시 몰려오지 않게 한다.
정책에 따라 작업을 반복 실행하는 쪽을 이 글은 재시도 실행기(Retrier)라고 부른다. 실행기는 시도마다 결과와 판단(재시도 또는 중단)을 기록해 돌려주므로, 호출자는 최종 값과 함께 몇 번째 시도에서 왜 멈췄는지도 확인할 수 있다. 기다리는 일은 delay 함수로 받아서, 운영 코드는 실제 타이머를 넘기고 테스트는 요청된 시간만 기록하는 함수를 넘긴다.
구조
Retrier는 작업이 무슨 일을 하는지 모른다. 실패를 받으면 정책의 RetryOn으로 다시 시도할지 정하고, Backoff가 돌려준 시간만큼 delay에 대기를 맡긴 뒤 같은 작업을 다시 호출한다.
역사
실패한 전송을 무작위 간격을 두고 다시 보내고, 실패가 거듭될수록 그 간격을 늘리는 방식은 Metcalfe와 Boggs의 1976년 Ethernet 논문에 이미 나온다. 충돌한 스테이션의 컨트롤러는 각자 무작위 재전송 간격을 만들어 다시 충돌하지 않도록 한다. 새 패킷은 평균 재전송 간격 한 슬롯으로 시작하고, 충돌할 때마다 컨트롤러는 평균이 이전의 두 배인 무작위 간격만큼 기다린다. 저자들은 이 방식이 자신들이 Binary Exponential Backoff라고 부른 알고리즘을 근사한다고 썼다.2
HTTP는 언제 재시도해도 되는지를 프로토콜 수준에서 정한다. RFC 9110은 같은 요청을 여러 번 보내도 서버에 의도한 효과가 한 번 보낸 것과 같은 메서드를 멱등(idempotent)이라 정의하며, PUT, DELETE와 안전한 메서드(GET, HEAD, OPTIONS, TRACE)가 여기에 속한다. 응답을 읽기 전에 통신이 끊기면 멱등 요청은 자동으로 다시 보낼 수 있다. 멱등이 아닌 요청은 실제로 멱등임을 알거나 원래 요청이 적용되지 않았음을 확인할 방법이 없으면 자동으로 재시도하지 않아야 하고(SHOULD NOT), 실패한 자동 재시도를 다시 자동으로 재시도하지도 않아야 한다.3 이 규칙은 HTTP 클라이언트가 스스로 하는 자동 재시도에 관한 것이다. 이 글의 정책은 재시도할 대상과 횟수를 호출자가 명시하고, 대상을 멱등 작업으로 제한한다. 서버는 Retry-After 헤더에 HTTP 날짜나 초 수를 담아 다음 요청까지 기다릴 시간을 알릴 수 있다. 503 응답과 함께 보내면 서비스를 쓸 수 없을 것으로 예상되는 기간을 뜻한다.4 429 Too Many Requests 응답도 Retry-After를 담을 수 있다.5
Marc Brooker는 2015년 AWS Architecture Blog 글에서 여러 클라이언트가 한 자원을 두고 경합하는 상황을 시뮬레이션했다. 상한 있는 지수 백오프만 쓰면 호출이 여전히 몰려 있었고, 지터를 더하자 클라이언트 100개가 경합하는 경우 호출 수가 절반 넘게 줄었다. 그는 지터를 섞은 백오프를 원격 클라이언트의 표준 방식으로 보아야 한다고 썼다.6 AWS가 2026년 5월에 발표한 새 SDK 재시도 동작은 0과 1 사이의 균등 난수에 min(20,000 ms, 기본 지연 × 2^재시도)를 곱하는 full jitter 식을 쓴다. 발표에 따르면 이 동작은 2026년 11월에 기본값이 되며, 그 전까지는 환경 변수 AWS_NEW_RETRIES_2026=true로 켜야 한다.7
Google SRE 책의 연쇄 장애 장은 재시도가 장애를 키우는 경로를 다룬다. 여러 계층에서 재시도하면 한 요청이 만드는 시도 수가 계층별 시도 수의 곱만큼 커질 수 있다고 경고하고, 요청당 재시도 횟수를 제한하고, 서버 전체의 재시도 예산을 두고, 과부하일 때는 클라이언트가 재시도하지 않도록 별도 상태를 돌려주라고 권한다.8 2019년 Amazon Builders' Library 글은 다섯 계층이 각자 독립적으로 재시도하면 데이터베이스 부하가 243배가 된다는 예를 들고, 스택의 한 지점에서만 재시도하라고 권한다. 같은 글에 따르면 AWS는 2016년 AWS SDK에 토큰 버킷으로 재시도를 제한하는 동작을 넣었다.9
Microsoft Azure 아키텍처 센터는 Retry를 클라우드 설계 패턴의 하나로 문서화하고, 실패의 성격에 따라 취소, 즉시 재시도, 지연 후 재시도 가운데 하나를 고르라고 설명한다.10
왜 사용하는가?
재시도는 일시적 실패를 호출자에게 드러내지 않고 흡수한다. 정책이 값이라서 의존성마다 다른 정책을 줄 수 있다. 대화형 화면은 짧은 대기로 몇 번만 시도하고 실패를 알리며, 배치 작업은 시도 횟수를 늘리고 대기를 지수적으로 늘리는 식이다.11 대기 함수를 주입하면 실제로 기다리지 않고 시도 순서와 대기 시간을 테스트할 수 있다.
.NET에서는 직접 구현하는 대신 Polly의 재시도 전략을 쓰는 경우가 많다. ShouldHandle은 어떤 결과나 예외를 재시도할지 정하는 조건이고, MaxRetryAttempts는 첫 호출을 뺀 재시도 횟수이며, UseJitter를 켜면 대기 시간에 무작위 값을 더한다.12 HttpClient에는 Polly 위에 만든 Microsoft.Extensions.Http.Resilience 패키지가 있다. 이 패키지의 표준 핸들러는 지수 백오프와 지터로 최대 3회 재시도하고, 500 이상, 408, 429 응답을 재시도 대상으로 본다.13 재시도 옵션의 ShouldRetryAfterHeader는 기본값이 true이며, 이때 대기 시간은 Retry-After 헤더 규칙으로 정해진다.14
재시도는 일시적 실패에만 효과가 있다. 오래가는 장애에서는 응답을 늦추고 자원을 붙잡을 뿐이고, 비즈니스 로직 오류 같은 실패는 다시 실행해도 고쳐지지 않는다. 서비스가 자주 바쁘다면 재시도 대신 용량을 늘려야 한다.15
재시도는 이미 힘든 서버에 일을 더 얹는다. Builders' Library는 재시도를 이기적이라고 표현하는데, 재시도하는 클라이언트가 서버의 시간을 더 써서 자기 성공 가능성을 높인다는 뜻이다.9 앞에서 본 것처럼 계층마다 재시도하면 시도 수가 곱으로 늘기 때문에, 재시도는 한 계층에만 두고 아래 계층은 실패 이유를 빨리 위로 돌려주게 한다.16
한 시도가 끝나지 않으면 다음 시도도 시작되지 않으므로, 시도마다 시간 상한을 두는 타임아웃 패턴과 함께 쓰고 재시도 전체에도 시간 상한을 둔다. Microsoft의 표준 핸들러도 시도별 타임아웃(10초)과 재시도를 포함한 전체 타임아웃(30초)을 따로 둔다.17 장애가 길어지면 재시도할수록 비용만 늘어나므로, 실패가 이어질 때 호출 자체를 잠시 막는 서킷 브레이커 패턴과 짝을 이룬다.11
서버가 Retry-After로 기다릴 시간을 알려 주면 그 값을 따른다. AWS SDK도 서비스가 x-amz-retry-after 헤더로 보낸 대기 시간을 쓰며, 이는 서비스가 언제 처리 용량이 생길지 알릴 수 있게 하려는 것이다. 다만 SDK는 그 값을 계산한 백오프 이상, 백오프에 5,000 ms를 더한 값 이하로 제한한다.18 알려 준 시간이 호출자의 전체 시간 상한보다 길면 그 안에 다음 시도를 할 수 없으므로 호출은 실패로 끝난다.
멱등성
재시도는 같은 작업을 두 번 이상 실행할 수 있다. 서비스가 처리를 마쳤어도 응답이 끊기면 호출자는 그 결과를 모른다. 이때 같은 요청을 다시 보내면 이미 끝난 일이 한 번 더 실행될 수 있다.19 조회처럼 몇 번을 실행해도 효과가 같은 멱등(idempotent) 작업은 문제가 없지만, 주문 생성이나 결제처럼 실행할 때마다 새 효과를 내는 작업은 중복된다.
Builders' Library는 부작용이 있는 API는 멱등성을 제공하지 않는 한 재시도하기에 안전하지 않다고 적고, 명시적 토큰으로 멱등성을 제공하는 예로 Amazon EC2의 RunInstances API를 든다.9 범위 넓힌 예제는 이를 멱등성 키(idempotency key)로 구현한다. 클라이언트는 논리적 요청 하나마다 고유한 키를 만들어 모든 시도에 같은 키를 싣고, 서버는 키별로 첫 결과를 저장했다가 같은 키로 온 요청에는 그 결과를 돌려준다.
Microsoft.Extensions.Http.Resilience의 표준 핸들러는 기본으로 모든 HTTP 메서드를 재시도한다. 문서는 레코드를 삽입하는 POST를 재시도하면 데이터가 중복될 수 있다고 경고하고, POST, PATCH, PUT, DELETE, CONNECT의 재시도를 끄는 DisableForUnsafeHttpMethods를 안내한다.20 PUT과 DELETE는 RFC 9110에서 멱등이지만 안전한 메서드는 아니어서 함께 꺼진다.
예제
처음 두 번은 일시적 오류를 내고 세 번째에 성공하는 가짜 재고 서비스를 정책과 함께 실행한다. delay는 기다리지 않고 요청받은 시간만 기록한다.
using System;
using System.Collections.Generic;
// 앞의 두 호출은 일시적 오류를 내고 세 번째 호출은 성공하는 가짜 재고 서비스
var calls = 0;
Outcome GetStock()
{
calls++;
return calls <= 2 ? new Outcome(null, ErrorKind.Transient) : new Outcome("stock=7", null);
}
// 어떤 실패를, 몇 번까지, 얼마나 기다렸다가 다시 시도할지를 데이터로 둔다.
var policy = new RetryPolicy(
MaxAttempts: 4,
RetryOn: kind => kind == ErrorKind.Transient,
Backoff: failedAttempt => TimeSpan.FromMilliseconds(Math.Min(1000, 100 * Math.Pow(2, failedAttempt - 1))));
// 실제로 기다리지 않고 요청받은 대기 시간만 기록한다.
var waited = new List<double>();
var result = Retrier.Run(policy, GetStock, delay => waited.Add(delay.TotalMilliseconds));
foreach (var line in result.Log) Console.WriteLine(line);
Console.WriteLine($"value={result.Value}, attempts={result.Attempts}, delays=[{string.Join(", ", waited)}] ms");
enum ErrorKind { Transient, Permanent }
sealed record Outcome(string? Value, ErrorKind? Error);
sealed record RetryPolicy(int MaxAttempts, Func<ErrorKind, bool> RetryOn, Func<int, TimeSpan> Backoff);
sealed record RetryResult(string? Value, ErrorKind? Error, int Attempts, List<string> Log);
static class Retrier
{
// 시도마다 결과와 판단을 기록하고, 성공·재시도 불가·횟수 소진 중 하나에서 멈춘다.
public static RetryResult Run(RetryPolicy policy, Func<Outcome> operation, Action<TimeSpan> delay)
{
var log = new List<string>();
for (var attempt = 1; ; attempt++)
{
var outcome = operation();
if (outcome.Error is not { } error)
{
log.Add($"attempt {attempt}: ok");
return new RetryResult(outcome.Value, null, attempt, log);
}
if (!policy.RetryOn(error))
{
log.Add($"attempt {attempt}: {error}, not retryable");
return new RetryResult(null, error, attempt, log);
}
if (attempt >= policy.MaxAttempts)
{
log.Add($"attempt {attempt}: {error}, attempts exhausted");
return new RetryResult(null, error, attempt, log);
}
var wait = policy.Backoff(attempt);
log.Add($"attempt {attempt}: {error}, retry in {wait.TotalMilliseconds} ms");
delay(wait);
}
}
}attempt 1: Transient, retry in 100 ms
attempt 2: Transient, retry in 200 ms
attempt 3: ok
value=stock=7, attempts=3, delays=[100, 200] ms두 번의 실패 뒤 Backoff가 각각 100 ms와 200 ms를 정했고, 세 번째 시도에서 값을 받아 멈췄다.
범위 넓힌 예제
비동기 작업과 CancellationToken, 시드를 고정한 full jitter, Retry-After, 멱등성 검사를 더한다. 정책은 일시적 실패(Transient)와 요청 제한(Throttled)만 다시 시도한다. 실행기는 매 시도 전에 취소 여부를 확인하고, 취소됐으면 stopped: cancelled를 기록하고 멈춘다. Operation은 작업이 멱등인지를 함께 들고 다니고, 실행기는 여러 번 시도하는 정책으로 멱등이 아닌 작업을 실행하려 하면 실행 전에 거절한다. OrderServer는 첫 주문 요청을 저장한 뒤 응답이 끊긴 것처럼 실패를 돌려준다. cancelled while waiting은 첫 대기 중에 호출자가 취소한 경우를 흉내 낸다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
// 시드를 고정한 난수로 지터를 만들어 실행할 때마다 같은 대기 시간이 나오게 한다.
const int Seed = 42;
var rng = new Random(Seed);
var policy = new RetryPolicy(
MaxAttempts: 4,
RetryOn: f => f.Kind is ErrorKind.Transient or ErrorKind.Throttled,
Backoff: failedAttempt => FullJitter(rng, failedAttempt, baseMs: 100, capMs: 2000));
// 운영 코드라면 Task.Delay(delay, ct)를 넘긴다. 그때 대기 중 취소는 예외로 나온다. 여기서는 대기 시간만 받고 바로 끝낸다.
Task NoWait(TimeSpan delay, CancellationToken ct) => Task.CompletedTask;
Console.WriteLine($"jitter seed {Seed}");
await Show("transient twice", Scripted("GetStock",
Outcome.Fail(ErrorKind.Transient, "503"), Outcome.Fail(ErrorKind.Transient, "503"), Outcome.Ok("stock=7")));
await Show("permanent error", Scripted("GetStock", Outcome.Fail(ErrorKind.Permanent, "400")));
await Show("transient every time", Scripted("GetStock", Outcome.Fail(ErrorKind.Transient, "503")));
await Show("throttled with Retry-After", Scripted("GetStock",
Outcome.Fail(ErrorKind.Throttled, "429", TimeSpan.FromMilliseconds(2000)), Outcome.Ok("stock=7")));
// 첫 대기 중에 호출자가 취소한 것처럼 만든다.
using var cts = new CancellationTokenSource();
Task CancelDuringWait(TimeSpan delay, CancellationToken ct) { cts.Cancel(); return Task.CompletedTask; }
await Show("cancelled while waiting", Scripted("GetStock", Outcome.Fail(ErrorKind.Transient, "503")), CancelDuringWait, cts.Token);
var server = new OrderServer();
await Show("order without key", new Operation("CreateOrder", Idempotent: false, _ => Task.FromResult(server.Create(null))));
await Show("order with key k-1", new Operation("CreateOrder", Idempotent: true, _ => Task.FromResult(server.Create("k-1"))));
Console.WriteLine($"orders stored: {server.Stored}");
async Task Show(string title, Operation operation,
Func<TimeSpan, CancellationToken, Task>? delay = null, CancellationToken ct = default)
{
var result = await Retrier.RunAsync(policy, operation, delay ?? NoWait, ct);
Console.WriteLine($"== {title}");
foreach (var a in result.Attempts) Console.WriteLine($" #{a.Number} {a.Summary}");
Console.WriteLine($" -> {result.Stop}{(result.Value is null ? "" : " " + result.Value)}");
}
// 정해 둔 결과를 차례로 돌려주고, 끝나면 마지막 결과를 반복하는 가짜 의존성. 조회라서 멱등이다.
static Operation Scripted(string name, params Outcome[] script)
{
var next = 0;
return new Operation(name, Idempotent: true, _ => Task.FromResult(script[Math.Min(next++, script.Length - 1)]));
}
// full jitter: 0 이상 min(cap, base * 2^(n-1)) 미만에서 고르게 고른다.
static TimeSpan FullJitter(Random rng, int failedAttempt, int baseMs, int capMs)
{
var ceiling = Math.Min(capMs, baseMs * Math.Pow(2, failedAttempt - 1));
return TimeSpan.FromMilliseconds((int)(rng.NextDouble() * ceiling));
}
enum ErrorKind { Transient, Throttled, Permanent }
sealed record Failure(ErrorKind Kind, string Code, TimeSpan? RetryAfter);
sealed record Outcome(string? Value, Failure? Error)
{
public static Outcome Ok(string value) => new(value, null);
public static Outcome Fail(ErrorKind kind, string code, TimeSpan? retryAfter = null) => new(null, new Failure(kind, code, retryAfter));
}
sealed record Operation(string Name, bool Idempotent, Func<CancellationToken, Task<Outcome>> Run);
sealed record RetryPolicy(int MaxAttempts, Func<Failure, bool> RetryOn, Func<int, TimeSpan> Backoff);
sealed record AttemptRecord(int Number, string Summary);
sealed record RetryResult(string Stop, string? Value, Failure? Error, IReadOnlyList<AttemptRecord> Attempts);
static class Retrier
{
public static async Task<RetryResult> RunAsync(
RetryPolicy policy, Operation operation,
Func<TimeSpan, CancellationToken, Task> delay, CancellationToken ct)
{
var attempts = new List<AttemptRecord>();
// 멱등이 아닌 작업은 한 번도 실행하지 않고 거절한다. 다시 보내면 효과가 겹칠 수 있다.
if (!operation.Idempotent && policy.MaxAttempts > 1)
return new RetryResult($"refused: {operation.Name} is not idempotent", null, null, attempts);
for (var n = 1; ; n++)
{
// 다음 시도 전에 취소를 확인하고, 취소되면 기록을 남기고 멈춘다.
if (ct.IsCancellationRequested)
return new RetryResult("stopped: cancelled", null, null, attempts);
var outcome = await operation.Run(ct);
if (outcome.Error is not { } error)
{
attempts.Add(new AttemptRecord(n, "ok"));
return new RetryResult("succeeded", outcome.Value, null, attempts);
}
var failed = $"{error.Kind} {error.Code}";
if (!policy.RetryOn(error))
{
attempts.Add(new AttemptRecord(n, failed + ", not retryable"));
return new RetryResult("stopped: not retryable", null, error, attempts);
}
if (n >= policy.MaxAttempts)
{
attempts.Add(new AttemptRecord(n, failed + ", attempts exhausted"));
return new RetryResult("stopped: attempts exhausted", null, error, attempts);
}
// 서버가 Retry-After로 대기 시간을 알려 주면 그 값을 따른다.
var (wait, source) = error.RetryAfter is { } hint ? (hint, "Retry-After") : (policy.Backoff(n), "backoff");
attempts.Add(new AttemptRecord(n, $"{failed}, wait {wait.TotalMilliseconds} ms ({source})"));
await delay(wait, ct);
}
}
}
// 같은 멱등성 키로 다시 온 요청에는 처음 만든 주문을 돌려준다.
sealed class OrderServer
{
private readonly Dictionary<string, string> byKey = new();
private bool dropNextResponse = true;
public int Stored { get; private set; }
public Outcome Create(string? idempotencyKey)
{
if (idempotencyKey is not null && byKey.TryGetValue(idempotencyKey, out var existing))
return Outcome.Ok(existing);
Stored++;
var id = $"order-{Stored}";
if (idempotencyKey is not null) byKey[idempotencyKey] = id;
// 첫 요청은 저장까지 마친 뒤 응답이 끊긴 것처럼 실패를 돌려준다.
if (!dropNextResponse) return Outcome.Ok(id);
dropNextResponse = false;
return Outcome.Fail(ErrorKind.Transient, "connection reset");
}
}jitter seed 42
== transient twice
#1 Transient 503, wait 66 ms (backoff)
#2 Transient 503, wait 28 ms (backoff)
#3 ok
-> succeeded stock=7
== permanent error
#1 Permanent 400, not retryable
-> stopped: not retryable
== transient every time
#1 Transient 503, wait 12 ms (backoff)
#2 Transient 503, wait 104 ms (backoff)
#3 Transient 503, wait 67 ms (backoff)
#4 Transient 503, attempts exhausted
-> stopped: attempts exhausted
== throttled with Retry-After
#1 Throttled 429, wait 2000 ms (Retry-After)
#2 ok
-> succeeded stock=7
== cancelled while waiting
#1 Transient 503, wait 26 ms (backoff)
-> stopped: cancelled
== order without key
-> refused: CreateOrder is not idempotent
== order with key k-1
#1 Transient connection reset, wait 72 ms (backoff)
#2 ok
-> succeeded order-1
orders stored: 1full jitter는 0과 상한 사이에서 고르므로 두 번째 대기(28 ms)가 첫 번째(66 ms)보다 짧을 수 있다. 시드를 고정한 것은 출력을 재현하기 위해서다. 여러 인스턴스가 같은 시드를 쓰면 다시 같은 시점에 재시도하게 되므로 운영 코드는 시드를 고정하지 않는다. 여러 스레드가 정책을 함께 쓴다면 시드를 준 Random 인스턴스를 공유하지 말고, 여러 스레드에서 동시에 쓸 수 있는 Random.Shared를 쓴다.21 키가 없는 주문은 한 번도 실행되지 않았고, 키 k-1을 단 주문은 응답이 끊긴 뒤 다시 보냈는데도 한 번만 저장됐다(orders stored: 1). OrderServer의 키 저장소는 최소한으로만 만들어서, 같은 키로 동시에 들어온 요청을 막는 원자적 삽입이나 처리 중 상태, 키의 보존 기간, 같은 키에 다른 내용을 실은 요청의 거절이 없다. 취소는 시도 사이에서만 확인한다. 운영 코드가 넘기는 Task.Delay(delay, ct)는 대기 중 취소되면 예외를 던지므로 그 예외도 취소 결과로 바꿔야 한다. 이 실행기에는 시도별 타임아웃, 전체 시간 상한, 재시도 예산, 서킷 브레이커가 없다. Retry-After 값에도 상한이 없어 서버가 요구하는 만큼 기다리고, 작업의 멱등 여부는 호출자가 선언한 값을 검증 없이 믿는다.
TypeScript 예제
같은 정책과 실행기를 TypeScript로 옮긴다. Math.random은 시드를 정할 수 없어서 작은 시드 난수 생성기(mulberry32)를 직접 둔다. 취소는 AbortSignal로 넘기고, 실행기는 매 시도 전에 signal.aborted를 확인한다.
type ErrorKind = "Transient" | "Throttled" | "Permanent";
type Failure = { kind: ErrorKind; code: string; retryAfterMs?: number };
type Outcome = { ok: true; value: string } | { ok: false; error: Failure };
type Operation = { name: string; idempotent: boolean; run: (signal: AbortSignal) => Promise<Outcome> };
type RetryPolicy = {
maxAttempts: number;
retryOn: (failure: Failure) => boolean;
backoffMs: (failedAttempt: number) => number;
};
type RetryResult = { stop: string; value?: string; attempts: string[] };
const ok = (value: string): Outcome => ({ ok: true, value });
const fail = (kind: ErrorKind, code: string, retryAfterMs?: number): Outcome =>
({ ok: false, error: { kind, code, retryAfterMs } });
// 시드를 고정한 작은 난수 생성기(mulberry32). Math.random은 시드를 정할 수 없다.
function seeded(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// full jitter: 0 이상 min(cap, base * 2^(n-1)) 미만에서 고르게 고른다.
function fullJitter(random: () => number, failedAttempt: number, baseMs: number, capMs: number): number {
return Math.floor(random() * Math.min(capMs, baseMs * 2 ** (failedAttempt - 1)));
}
async function runWithRetry(
policy: RetryPolicy, op: Operation,
delay: (ms: number, signal: AbortSignal) => Promise<void>, signal: AbortSignal,
): Promise<RetryResult> {
const attempts: string[] = [];
// 멱등이 아닌 작업은 한 번도 실행하지 않고 거절한다.
if (!op.idempotent && policy.maxAttempts > 1) return { stop: `refused: ${op.name} is not idempotent`, attempts };
for (let n = 1; ; n++) {
// 다음 시도 전에 취소를 확인하고, 취소되면 기록을 남기고 멈춘다.
if (signal.aborted) return { stop: "stopped: cancelled", attempts };
const outcome = await op.run(signal);
if (outcome.ok) {
attempts.push(`#${n} ok`);
return { stop: "succeeded", value: outcome.value, attempts };
}
const failed = `#${n} ${outcome.error.kind} ${outcome.error.code}`;
if (!policy.retryOn(outcome.error)) {
attempts.push(`${failed}, not retryable`);
return { stop: "stopped: not retryable", attempts };
}
if (n >= policy.maxAttempts) {
attempts.push(`${failed}, attempts exhausted`);
return { stop: "stopped: attempts exhausted", attempts };
}
// 서버가 Retry-After로 대기 시간을 알려 주면 그 값을 따른다.
const hint = outcome.error.retryAfterMs;
const [waitMs, source] = hint !== undefined ? [hint, "Retry-After"] : [policy.backoffMs(n), "backoff"];
attempts.push(`${failed}, wait ${waitMs} ms (${source})`);
await delay(waitMs, signal);
}
}
// 정해 둔 결과를 차례로 돌려주고, 끝나면 마지막 결과를 반복하는 가짜 의존성. 조회라서 멱등이다.
function scripted(name: string, ...script: Outcome[]): Operation {
let next = 0;
return { name, idempotent: true, run: async () => script[Math.min(next++, script.length - 1)] };
}
const seed = 42;
const random = seeded(seed);
const policy: RetryPolicy = {
maxAttempts: 4,
retryOn: (f) => f.kind === "Transient" || f.kind === "Throttled",
backoffMs: (n) => fullJitter(random, n, 100, 2000),
};
// 운영 코드라면 signal을 존중하는 실제 타이머를 넘긴다. 여기서는 기다리지 않는다.
const noWait = async (_ms: number, _signal: AbortSignal): Promise<void> => {};
async function show(
title: string, op: Operation,
delay: (ms: number, signal: AbortSignal) => Promise<void> = noWait, signal = new AbortController().signal,
): Promise<void> {
const result = await runWithRetry(policy, op, delay, signal);
console.log(`== ${title}`);
for (const line of result.attempts) console.log(` ${line}`);
console.log(` -> ${result.stop}${result.value === undefined ? "" : " " + result.value}`);
}
async function main(): Promise<void> {
console.log(`jitter seed ${seed}`);
await show("transient twice", scripted("GetStock", fail("Transient", "503"), fail("Transient", "503"), ok("stock=7")));
await show("permanent error", scripted("GetStock", fail("Permanent", "400")));
await show("transient every time", scripted("GetStock", fail("Transient", "503")));
await show("throttled with Retry-After", scripted("GetStock", fail("Throttled", "429", 2000), ok("stock=7")));
// 첫 대기 중에 호출자가 취소한 것처럼 만든다.
const controller = new AbortController();
const cancelDuringWait = async (_ms: number, _signal: AbortSignal): Promise<void> => controller.abort();
await show("cancelled while waiting", scripted("GetStock", fail("Transient", "503")), cancelDuringWait, controller.signal);
await show("order without key", { name: "CreateOrder", idempotent: false, run: async () => ok("order-1") });
}
void main();jitter seed 42
== transient twice
#1 Transient 503, wait 60 ms (backoff)
#2 Transient 503, wait 89 ms (backoff)
#3 ok
-> succeeded stock=7
== permanent error
#1 Permanent 400, not retryable
-> stopped: not retryable
== transient every time
#1 Transient 503, wait 85 ms (backoff)
#2 Transient 503, wait 133 ms (backoff)
#3 Transient 503, wait 69 ms (backoff)
#4 Transient 503, attempts exhausted
-> stopped: attempts exhausted
== throttled with Retry-After
#1 Throttled 429, wait 2000 ms (Retry-After)
#2 ok
-> succeeded stock=7
== cancelled while waiting
#1 Transient 503, wait 52 ms (backoff)
-> stopped: cancelled
== order without key
-> refused: CreateOrder is not idempotent난수 생성기가 달라서 지터 값은 C# 예제와 다르다. 판단 순서(취소, 성공, 재시도 불가, 횟수 소진, Retry-After)와 멱등이 아닌 작업의 거절은 같다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Microsoft Learn, Azure Architecture Center. Retry pattern. 도입부, Context and problem 절. ↩
- Robert M. Metcalfe, David R. Boggs. "Ethernet: Distributed Packet Switching for Local Computer Networks". Communications of the ACM 19(5): 395-404 (1976). DOI, 본문 사본. 2절 System Summary, 4.4절 Controller. ↩
- R. Fielding, M. Nottingham, J. Reschke (편). RFC 9110: HTTP Semantics (2022). 9.2.1절 Safe Methods, 9.2.2절 Idempotent Methods. ↩
- R. Fielding, M. Nottingham, J. Reschke (편). RFC 9110: HTTP Semantics (2022). 10.2.3절 Retry-After, 15.6.4절 503 Service Unavailable. ↩
- M. Nottingham, R. Fielding. RFC 6585: Additional HTTP Status Codes (2012). 4절 429 Too Many Requests. ↩
- Marc Brooker. Exponential Backoff And Jitter. AWS Architecture Blog (2015). ↩
- AWS SDKs and Tools Reference Guide. Retry behavior. 문서 첫머리 Important 상자, How long does the SDK wait 절. Matthew Miller. Announcing updated retry behavior for AWS SDKs and tools. AWS Developer Tools Blog (2026-05-20). ↩
- Mike Ulrich. "Addressing Cascading Failures". Google SRE 책 22장. Copyright 2017 Google, Inc., O'Reilly Media 발행. Retries 절. ↩
- Amazon Builders' Library. Timeouts, retries, and backoff with jitter (2019). Retries and backoff 절. ↩
- Microsoft Learn, Azure Architecture Center. Retry pattern. Retry strategies 절. ↩
- Microsoft Learn, Azure Architecture Center. Retry pattern. Impact on performance 절. ↩
- Polly 문서. Retry resilience strategy. Defaults 표. ↩
- Microsoft Learn. Build resilient HTTP apps: Key development patterns. Standard resilience handler defaults 절, Retries and circuit breakers 절. ↩
- Microsoft Learn. HttpRetryStrategyOptions.ShouldRetryAfterHeader. Property Value, Remarks. ↩
- Microsoft Learn, Azure Architecture Center. Retry pattern. When to use this pattern 절. ↩
- Microsoft Learn, Azure Architecture Center. Retry pattern. General guidance 절. ↩
- Microsoft Learn. Build resilient HTTP apps: Key development patterns. Standard resilience handler defaults 절. ↩
- AWS SDKs and Tools Reference Guide. Retry behavior. Server-directed retry timing 절. ↩
- Microsoft Learn, Azure Architecture Center. Retry pattern. Idempotency 절. ↩
- Microsoft Learn. Build resilient HTTP apps: Key development patterns. Disable retries for a given list of HTTP methods 절. ↩
- Microsoft Learn. Random.Shared Property. ↩