Share via


.NET에서 HTTP 처리기 속도 제한

이 문서에서는 보내는 요청 수를 제한하는 클라이언트 쪽 HTTP 처리기를 만드는 방법을 알아봅니다. "www.example.com" 리소스에 액세스하는 HttpClient가 있습니다. 리소스는 리소스에 의존하는 여러 앱에서 사용되는데 앱이 단일 리소스에 대해 너무 많은 요청을 하면 리소스 경합이 발생할 수 있습니다. 리소스 경합은 리소스가 너무 많은 앱에서 사용되어 리소스가 리소스를 요청하는 모든 앱에 제공될 수 없는 경우에 발생합니다. 이로 인해 사용자 환경이 저하되고 경우에 따라 DoS(서비스 거부) 공격으로 이어질 수도 있습니다. DoS에 대한 자세한 내용은 OWASP: 서비스 거부를 참조하세요.

속도 제한이란?

속도 제한은 리소스에 액세스할 수 있는 양을 제한하는 개념입니다. 예를 들어 앱이 액세스하는 데이터베이스가 분당 1,000개의 요청을 안전하게 처리할 수 있지만 이를 훨씬 상회하면 처리하지 못할 수 있습니다. 1분마다 1,000개의 요청만 허용하고 데이터베이스에 액세스하기 전에 더 이상 요청을 거부하는 속도 제한기를 앱에 배치할 수 있습니다. 따라서 데이터베이스 속도를 제한하여 앱이 안전한 수의 요청을 처리할 수 있도록 합니다. 이는 앱이 여러 인스턴스로 실행 중일 수 있는 분산 시스템의 일반적인 패턴으로, 이러한 인스턴스가 모두 동시에 데이터베이스에 액세스하지 않도록 해야 합니다. 요청 흐름을 제어하는 여러 가지 속도 제한 알고리즘이 있습니다.

.NET에서 속도 제한을 사용하려면 System.Threading.RateLimiting NuGet 패키지를 참조합니다.

DelegatingHandler 하위 클래스 구현

요청 흐름을 제어하기 위해 사용자 지정 DelegatingHandler 하위 클래스를 구현합니다. 이는 서버로 전송되기 전에 요청을 가로채고 처리할 수 있는 HttpMessageHandler의 한 형식입니다. 응답을 호출자에게 반환되기 전에 가로채고 처리할 수도 있습니다. 이 예제에서는 단일 리소스로 보낼 수 있는 요청 수를 제한하는 사용자 지정 DelegatingHandler 하위 클래스를 구현합니다. 다음 사용자 지정 ClientSideRateLimitedHandler 클래스를 생각해 보겠습니다.

internal sealed class ClientSideRateLimitedHandler(
    RateLimiter limiter)
    : DelegatingHandler(new HttpClientHandler()), IAsyncDisposable
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        using RateLimitLease lease = await limiter.AcquireAsync(
            permitCount: 1, cancellationToken);

        if (lease.IsAcquired)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
        if (lease.TryGetMetadata(
                MetadataName.RetryAfter, out TimeSpan retryAfter))
        {
            response.Headers.Add(
                "Retry-After",
                ((int)retryAfter.TotalSeconds).ToString(
                    NumberFormatInfo.InvariantInfo));
        }

        return response;
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    { 
        await limiter.DisposeAsync().ConfigureAwait(false);

        Dispose(disposing: false);
        GC.SuppressFinalize(this);
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);

        if (disposing)
        {
            limiter.Dispose();
        }
    }
}

위의 C# 코드에서:

  • DelegatingHandler 형식을 상속합니다.
  • IAsyncDisposable 인터페이스를 구현합니다.
  • 생성자에서 할당된 RateLimiter 필드를 정의합니다.
  • SendAsync 메서드를 재정의하여 서버로 전송되기 전에 요청을 가로채고 처리합니다.
  • DisposeAsync() 메서드를 재정의하여 RateLimiter 인스턴스를 삭제합니다.

SendAsync 메서드를 좀 더 자세히 살펴보면 다음을 확인할 수 있습니다.

  • RateLimiter 인스턴스를 사용하여 AcquireAsync에서 RateLimitLease를 가져옵니다.
  • lease.IsAcquired 속성이 true인 경우 요청이 서버로 전송됩니다.
  • 그렇지 않으면 HttpResponseMessage429 상태 코드와 함께 반환되고, leaseRetryAfter 값이 포함된 경우 Retry-After 헤더가 해당 값으로 설정됩니다.

여러 동시 요청 에뮬레이션

이 사용자 지정 DelegatingHandler 하위 클래스를 테스트하려면 여러 동시 요청을 에뮬레이트하는 콘솔 앱을 만듭니다. 이 Program 클래스는 사용자 지정 ClientSideRateLimitedHandler를 사용하여 HttpClient를 만듭니다.

var options = new TokenBucketRateLimiterOptions
{ 
    TokenLimit = 8, 
    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
    QueueLimit = 3, 
    ReplenishmentPeriod = TimeSpan.FromMilliseconds(1), 
    TokensPerPeriod = 2, 
    AutoReplenishment = true
};

// Create an HTTP client with the client-side rate limited handler.
using HttpClient client = new(
    handler: new ClientSideRateLimitedHandler(
        limiter: new TokenBucketRateLimiter(options)));

// Create 100 urls with a unique query string.
var oneHundredUrls = Enumerable.Range(0, 100).Select(
    i => $"https://example.com?iteration={i:0#}");

// Flood the HTTP client with requests.
var floodOneThroughFortyNineTask = Parallel.ForEachAsync(
    source: oneHundredUrls.Take(0..49), 
    body: (url, cancellationToken) => GetAsync(client, url, cancellationToken));

var floodFiftyThroughOneHundredTask = Parallel.ForEachAsync(
    source: oneHundredUrls.Take(^50..),
    body: (url, cancellationToken) => GetAsync(client, url, cancellationToken));

await Task.WhenAll(
    floodOneThroughFortyNineTask,
    floodFiftyThroughOneHundredTask);

static async ValueTask GetAsync(
    HttpClient client, string url, CancellationToken cancellationToken)
{
    using var response =
        await client.GetAsync(url, cancellationToken);

    Console.WriteLine(
        $"URL: {url}, HTTP status code: {response.StatusCode} ({(int)response.StatusCode})");
}

이전 콘솔 앱에서 다음을 수행합니다.

  • TokenBucketRateLimiterOptions8의 토큰 제한, OldestFirst의 큐 처리 순서, 3의 큐 제한, 1밀리초의 보충 기간, 2의 기간 값당 토큰, true의 자동 보충 값으로 구성됩니다.
  • HttpClientTokenBucketRateLimiter로 구성된 ClientSideRateLimitedHandler를 사용하여 만들어집니다.
  • 100개의 요청을 에뮬레이트하기 위해 Enumerable.Range는 각각 고유한 쿼리 문자열 매개 변수를 사용하여 100개의 URL을 만듭니다.
  • 두 개의 Task 개체가 Parallel.ForEachAsync 메서드에서 할당되어 URL을 두 그룹으로 분할합니다.
  • HttpClient는 각 URL에 GET 요청을 보내는 데 사용되며 응답은 콘솔에 기록됩니다.
  • Task.WhenAll는 두 작업이 모두 완료될 때까지 기다립니다.

HttpClientClientSideRateLimitedHandler로 구성되므로 모든 요청이 서버 리소스에 도달하는 것은 아닙니다. 콘솔 앱을 실행하여 이 어설션을 테스트할 수 있습니다. 총 요청의 일부만 서버로 전송되고 나머지는 HTTP 상태 코드 429로 거부됩니다. TokenBucketRateLimiter를 만드는 데 사용되는 options 개체를 변경하여 서버로 전송되는 요청 수가 어떻게 변경되는지 확인해 봅니다.

다음 예제 출력을 생각해 보겠습니다.

URL: https://example.com?iteration=06, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=60, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=55, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=59, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=57, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=11, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=63, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=13, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=62, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=65, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=64, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=67, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=14, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=68, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=16, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=69, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=70, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=71, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=17, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=18, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=72, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=73, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=74, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=19, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=75, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=76, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=79, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=77, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=21, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=78, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=81, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=22, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=80, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=20, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=82, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=83, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=23, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=84, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=24, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=85, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=86, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=25, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=87, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=26, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=88, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=89, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=27, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=90, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=28, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=91, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=94, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=29, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=93, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=96, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=92, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=95, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=31, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=30, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=97, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=98, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=99, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=32, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=33, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=34, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=35, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=36, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=37, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=38, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=39, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=40, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=41, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=42, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=43, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=44, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=45, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=46, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=47, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=48, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=15, HTTP status code: OK (200)
URL: https://example.com?iteration=04, HTTP status code: OK (200)
URL: https://example.com?iteration=54, HTTP status code: OK (200)
URL: https://example.com?iteration=08, HTTP status code: OK (200)
URL: https://example.com?iteration=00, HTTP status code: OK (200)
URL: https://example.com?iteration=51, HTTP status code: OK (200)
URL: https://example.com?iteration=10, HTTP status code: OK (200)
URL: https://example.com?iteration=66, HTTP status code: OK (200)
URL: https://example.com?iteration=56, HTTP status code: OK (200)
URL: https://example.com?iteration=52, HTTP status code: OK (200)
URL: https://example.com?iteration=12, HTTP status code: OK (200)
URL: https://example.com?iteration=53, HTTP status code: OK (200)
URL: https://example.com?iteration=07, HTTP status code: OK (200)
URL: https://example.com?iteration=02, HTTP status code: OK (200)
URL: https://example.com?iteration=01, HTTP status code: OK (200)
URL: https://example.com?iteration=61, HTTP status code: OK (200)
URL: https://example.com?iteration=05, HTTP status code: OK (200)
URL: https://example.com?iteration=09, HTTP status code: OK (200)
URL: https://example.com?iteration=03, HTTP status code: OK (200)
URL: https://example.com?iteration=58, HTTP status code: OK (200)
URL: https://example.com?iteration=50, HTTP status code: OK (200)

기록된 첫 번째 항목은 항상 즉시 반환되는 429개 응답이며, 마지막 항목은 항상 200개 응답입니다. 속도 제한이 클라이언트 쪽에서 발생하고 서버에 대한 HTTP 호출을 방지하기 때문입니다. 이는 서버가 요청으로 넘치지 않는다는 것을 의미하기 때문에 좋은 일입니다. 또한 속도 제한이 모든 클라이언트에서 일관되게 적용됨을 의미합니다.

각 URL의 쿼리 문자열은 고유합니다. iteration 매개 변수를 검사하여 각 요청에 대해 하나씩 증가했는지 확인합니다. 이 매개 변수는 429개 응답이 첫 번째 요청의 응답이 아니라 속도 제한에 도달한 후 수행되는 요청의 응답임을 보여 줍니다. 200개 응답은 나중에 도착하지만 이러한 요청은 제한에 도달하기 전에 먼저 이루어졌습니다.

다양한 속도 제한 알고리즘을 더 잘 이해하려면 이 코드를 다시 작성하여 다른 RateLimiter 구현을 수락해 보세요. TokenBucketRateLimiter 이외에 다음을 시도할 수 있습니다.

  • ConcurrencyLimiter
  • FixedWindowRateLimiter
  • PartitionedRateLimiter
  • SlidingWindowRateLimiter

요약

이 문서에서는 사용자 지정 ClientSideRateLimitedHandler를 구현하는 방법을 알아보았습니다. 이 패턴은 API 제한이 있는 리소스에 대해 속도 제한 HTTP 클라이언트를 구현하는 데 사용할 수 있습니다. 이러한 방식으로 클라이언트 앱이 서버에 불필요한 요청을 하지 못하도록 방지하고 앱이 서버에 의해 차단되지 않도록 합니다. 또한 메타데이터를 사용하여 재시도 타이밍 값을 저장하면 자동 재시도 논리를 구현할 수도 있습니다.

추가 정보