내용 보기

작성자

관리자 (IP : 106.247.248.10)

날짜

2023-02-14 13:57

제목

[ASP.NET Core] 속도 제한 설정(Rate limiting middleware)


오랜만에 ASP.NET Core 관련 내용 입니다.
이번 내용은 .NET 7 에서 새로 도입된 속도 제한(Rate Limiter) 처리 기법에 대해 알아보는 내용 입니다.
.NET 7에 새로 도입된 속도 제한 처리는 속도 제한 처리 방법 중 여러가지 알고리즘 방식을 선택해서 설정 및 사용할 수 있도록 제공되고 있습니다.
그럼 먼저 속도 제한 처리가 무엇인지 부터 간단하게 알아보겠습니다.

이 글에서 다루는 코드는 다음 Repository에서 확인할 수 있습니다.
code_check - Rate_limiting_Example

웹 서비스 Rate Limiter 란 ?

웹 서비스에서 속도 제한(Rate Limiter)이란 서버의 리소스를 클라이언트가 요청할 수 있는 횟수에 대해 제한(limit)하는 처리 입니다.
예를 들어 서버의 물리적 성능을 고려하여 분당 10,000개 이상의 요청 처리는 서버 부담이 크기 때문에 그 이상의 요청은 강제로 거부 하거나, 요청을 큐에 쌓아두고 대기 시켜 이전에 요청했던 작업이 완료 되면 순차적으로 처리하도록 제한(limit)하는 것 입니다.
이 글에서는 새롭게 .NET 7 에서 제공되는 속도 제한 처리 기법에 대해 살펴 보고 적용하는 방법에 대해 알아 보겠습니다.

Concurrency limit

Rate Limiter 알고리즘 기법중 하나인 동시성 제한(Concurrency limit)은 클라이언트의 동시 요청 수를 제한하는 방법 입니다.
예를 들어 동시에 요청 허용 횟수가 10개인 경우 11개의 클라이언트가 동시 요청시 11번째 요청에 대해서는 큐에 대기 하거나 요청을 거절 할 수 있습니다.

Token bucket limit

토큰 버킷 제한(Token bucket limit) 알고리즘은 클라이언트 요청 발생시 미리 정해진 만큼 생성된 토큰중 하나를 가져와서 요청 처리를 수행 합니다.
이때 사용된 토큰은 요청 처리 완료시 폐기 처분이 되고 특정 시간 이후에 정해진 갯수만큼 토큰은 자동 생성 됩니다.
사용할 토큰이 없는 경우 클라이언트 요청은 큐에 대기 하거나 요청을 거절 할 수 있습니다.
다음 그림은 Token bucket 알고리즘을 사용한 단순한 Rate Limiter 처리 과정 입니다.

만약 bucket에 최대 토큰이 3개 이고 10초 마다 토큰 1개씩 보충 된다고 했을때 다음과 같이 2개의 동시 요청이 왔을때 토큰 2개를 가져와 요청을 수행 합니다.
10초 이후 토큰 1개가 보충 되고 이때는 다시 최대 2개의 동시 요청을 수행 할 수 있습니다.
그림1

Fixed window limit

고정 창 제한(Fixed window limit) 알고리즘은 클라이언트 요청에 대해 제한 구역을 담당하는 공간을 정하는데 이를 창(window)이라고 부릅니다.
창(window)은 요청을 몇개만큼 허용 할 지에 대해 정해져 있고 특정 시간 동안만 유효 합니다.


특정 시간이 지나면 다음 창으로 이동되며 다음 창으로 이동 하면 클라이언트 요청 제한이 초기화 됩니다.
이런 방식은 극장 상영관에 비유해서 이해 하기 좋습니다.
총 100명의 인원을 수용 할 수 있는 상영관에 30분 짜리 영화가 상영 된다면 100명 까지만 입장 가능 하고, 다음 인원은 30분 동안 줄을 서서 대기 해야 하는 상황과 동일 합니다.

Sliding window limit

슬라이딩 윈도우 제한(Sliding window limit) 알고리즘은 Fixed window limit 알고리즘의 단점에 대응하기 위한 속도 제한 알고리즘 입니다.
Fixed window limit 방식은 클라이언트의 요청 건수를 제한 하는 목적에 있어 구현은 간단하지만 창이 이동되는 시간 경계점에 요청이 몰리면 원래 예상했던 트래픽보다 두배의 트래픽 부하를 받게 되는 단점이 있습니다.
그림2
동시 요청 처리 예상 최대 수는 3 이지만 시간 경계점에 몰려 동시 요청 처리 6으로 처리)

그래서 Sliding window limit 방식은 기존 Fixed window limit 방식에서 세그먼트를 추가하여 처리 하게 됩니다.
예를 들어 Window 타임이 20분이고 세그먼트 수를 2로 설정 했을때 두개의 10분 Window가 설정 됩니다.


시간이 흘러 가면서 각 세그먼트들을 지나게 되는데 요청 클라이언트의 요청 되는 수는 현재의 세그먼트 Limit counter에서 차감 됩니다.
각 세그먼트가 시간이 지나 이동 되면 왼쪽 기준에 Window 시간에 벗어 나는 요청 수는 해제 되고 계속 Window 시간 영역에 있는 요청 수는 함께 이월 되서 요청 제한 카운터에 계산 됩니다.
다음 그림을 보면 좀 더 쉽게 이해하기 쉽습니다.
그림4

System.Threading.RateLimiting

System.Threading.RateLimiting 은 .NET 7에서 새로 도입된 Rate Limiter 처리 관련 추상 클래스 입니다.
System.Threading.RateLimiting 클래스는 위에서 설명한 Rate Limiter 처리 방법들의 알고리즘을 구현하고 있는 각각의 FixedWindowRateLimiter 클래스, SlidingWindowRateLimiter 클래스, TokenBucketRateLimiter 클래스, ConcurrencyLimiter 클래스에서 상속받고 구현 되어 있습니다.

다음은 Fixed window limit 방식을 사용해서 Rate Limiter 적용 처리에 대한 코드 입니다.

먼저 간단하게 클라이언트 요청 기능중 현재 서버의 시간을 반환하는 Web API를 구현해 보겠습니다.
그에 따라 현재 시간을 반환하는 서비스를 다음과 같이 구현합니다.

[Services/GetTimeService.cs]

namespace Rate_limiting_Example.Services
{
    public interface IGetTimeService
    {
        /// <summary>
        /// 현재 시간 반환
        /// </summary>
        /// <returns></returns>
        TimeOnly currentTime();
    }

    public class GetTimeService : IGetTimeService
    {
        public GetTimeService()
        {
        }

        public TimeOnly currentTime()
        {
            // TimeOnly 구조체 : 하루 시간 단위를 나타내는 구조체
            return TimeOnly.FromDateTime(DateTime.Now);
        }
    }
}

위 서비스를 의존성 주입으로 사용하기 위해 다음과 같이 서비스에 등록합니다.

[Program.cs]

..[생략]..

// GetTimeService 서비스 등록
builder.Services.AddScoped<IGetTimeService, GetTimeService>();

..[생략]..

그리고 컨트롤러를 추가 합니다.

[Controllers/TimeController.cs]

namespace Rate_limiting_Example.Controllers
{
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.RateLimiting;
    using System.Threading.RateLimiting;
    using Rate_limiting_Example.Services;

    [ApiController]
    public class TimeController : Controller
    {
        private readonly IGetTimeService _service;

        public TimeController(IGetTimeService service)
        {
            _service = service;
        }

        [HttpGet]
        [Route("[controller]/time-current")]
        public IActionResult Index()
        {
            return Ok(_service.currentTime());
        }
    }
}

이렇게 간단하게 서버의 현재 시간을 반환하는 Web API가 구성 되었습니다.
여기에 Rate Limiter 처리를 하기 위해 서비스에 RateLimiter를 추가 합니다.
예시 코드에서는 10초 동안 요청 허용 갯수를 1로 제한 하였습니다. 이를 적용한 전체 코드를 다음과 같습니다.

[Program.cs]

namespace Rate_limiting_Example
{
    using Microsoft.AspNetCore.RateLimiting;
    using Rate_limiting_Example.Services;
    using System.Globalization;
    using System.Net;
    using System.Threading.RateLimiting;

    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.

            builder.Services.AddControllers();
            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();
            
            // GetTimeService 서비스 등록
            builder.Services.AddScoped<IGetTimeService, GetTimeService>();
            
            // Fixed window limit 알고리즘 방식으로 속도 제한 처리
            builder.Services.AddRateLimiter(_ => _
            .AddFixedWindowLimiter(policyName: "LimiterPolicy", options =>
            {
                // 요청 허용 갯수 : 1
                options.PermitLimit = 1;
                // 창 이동시간 10초 [10초 동안 최대 1개의 요청만 처리 가능]
                options.Window = TimeSpan.FromSeconds(10);
                options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
                // 제한시 3개의 요청만 대기열에 추가
                options.QueueLimit = 3;
            }));

            var app = builder.Build();
            // 속도 제한 사용
            app.UseRateLimiter();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();


            app.MapControllers();


            app.Run();
        }
    }
}

이렇게 RateLimiter를 등록하면 컨트롤러 에서 Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute 특성 사용으로 지정한 이름으로 RateLimiter 적용이 가능 합니다.

[Controllers/TimeController.cs]

[ApiController]
// LimiterPolicy 이름의 RateLimiter 적용
[EnableRateLimiting("LimiterPolicy")]
public class TimeController : Controller
{
  ..[생략]..
}

결과를 확인해 보면 처음 요청은 정상적으로 서버의 현재 시간이 응답 되었고 이후 다시 요청을 하게 되면 약 10초 동안 대기 되고 이후에 응답이 되는 걸 확인 해 볼 수 있습니다.
image
(Rate Limiter 설정으로 인해 10초 동안 대기 상태 - 이후 10초 경과 서버 시간 반환)

마찬가지로 Fixed window limit 방식 뿐 아니라 Sliding window limit, Token bucket limit, Concurrency limit 방식도 동일한 방법으로 적용 가능합니다.

[Sliding window limit 방식으로 Rate Limiter 적용]

// Sliding window limit 알고리즘 방식으로 속도 제한 처리
builder.Services.AddRateLimiter(_ => _
.AddSlidingWindowLimiter(policyName: "LimiterPolicy", options =>
{
    // 요청 허용 갯수 : 100
    options.PermitLimit = 100;
    // 창 이동시간 30초
    options.Window = TimeSpan.FromSeconds(30);
    // 창 분할 세그먼트 갯수
    options.SegmentsPerWindow = 3;  // 1개의 세그먼트 : 30s / 3
    options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    // 제한시 3개의 요청만 대기열에 추가
    options.QueueLimit = 3;
}));

GlobalLimiter

Rate Limiter 적용은 전체 웹 서비스 대상으로 설정 할 수 있습니다.
Microsoft.AspNetCore.RateLimiting.RateLimiterOptions 클래스에는 GlobalLimiter 속성이 있는데 해당 속성으로 전체 웹 서비스 대상으로 Rate Limiter 적용이 가능 합니다.
이러한 Global limiter 설정으로 특정 사용자 또는 IPAddress 기준으로 Rate Limiter 적용 처리가 가능 합니다.
다음은 IPAddress 기준으로 요청 클라이언트 IPAddress가 로컬 루프백이 아닐 경우 Rate Limiter 적용하는 방법 입니다.
그리고 여기서는 Rate Limiter 관련 옵션 값을 환경설정 파일(appsettings.json)에서 읽고 클래스로 값을 바인딩 하여 사용 하는 방식으로 처리 해보겠습니다.
먼저 Rate Limiter 옵션 정보에 대한 클래스를 다음과 같이 생성 합니다.

[MyRateLimitOptions.cs]

namespace Rate_limiting_Example
{
    public class MyRateLimitOptions
    {
        public const string MyRateLimit = "MyRateLimit";
        public int PermitLimit { get; set; } = 100;
        public int Window { get; set; } = 10;
        public int ReplenishmentPeriod { get; set; } = 2;
        public int QueueLimit { get; set; } = 2;
        public int SegmentsPerWindow { get; set; } = 8;
        public int TokenLimit { get; set; } = 10;
        public int TokensPerPeriod { get; set; } = 4;
        public bool AutoReplenishment { get; set; } = false;
    }
}

환경설정은 다음과 같이 설정하였습니다.

[appsettings.json]

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",

  "MyRateLimit": {
    "PermitLimit": 1,
    "Window": 10,
    "ReplenishmentPeriod": 1,
    "QueueLimit": 1,
    "SegmentsPerWindow": 4,
    "TokenLimit": 1,
    "TokensPerPeriod": 1,
    "AutoReplenishment": true
  }
}

환경설정을 클래스로 다음과 같이 바인딩 해서 해당 클래스로 사용 할 수 있습니다.

// 환경설정 'MyRateLimit' 섹션 내용 바인딩
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

그리고 다음과 같이 Global limiter 설정으로 IPAddress 기준으로 Global limiter 적용을 합니다.

[Program.cs]

// 환경설정 'MyRateLimit' 섹션 내용 바인딩
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(limiterOptions =>
{
    // 글로벌 속도 제한 설정
    limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(context =>
    {
        // 요청 IPAddress
        IPAddress? remoteIpAddress = context.Connection.RemoteIpAddress;

        // 요청된 IPAddress가 루프백이 아닌 경우
        if (IPAddress.IsLoopback(remoteIpAddress!) == false)
        {
            // IPAddress에 대해 속도 제한 설정 [Fixed window limit 알고리즘 적용]
            return RateLimitPartition.GetFixedWindowLimiter
            (remoteIpAddress!, _ =>
            new FixedWindowRateLimiterOptions
            {
                // 요청 허용 갯수 : 1
                PermitLimit = myOptions.PermitLimit,
                // 창 이동시간 10초 [10초 동안 최대 1개의 요청만 처리 가능]
                Window = TimeSpan.FromSeconds(myOptions.Window),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                // 제한시 3개의 요청만 대기열에 추가
                QueueLimit = myOptions.QueueLimit,
            });
        }

        // 루프백 IPAddress는 속도 제한 없음.
        return RateLimitPartition.GetNoLimiter(IPAddress.Loopback);
    });
});

var app = builder.Build();
// 속도 제한 사용
app.UseRateLimiter();

..[이하 생략]..

이렇게 Global limiter 적용이 가능 합니다.

OnRejected

클라이언트 요청이 허용 가능한 갯수 초과시 콜백 처리를 할 수 있는데 Microsoft.AspNetCore.RateLimiting.RateLimiterOptions 클래스에 속성중 Func<T> 타입의 OnRejected 으로 콜백을 받아 볼 수 있습니다.

RetryAfter

OnRejected 콜백 처리에서 Rejected된 요청이 언제 요청이 가능할지 시간을 계산해 알 수 있습니다.
Rejected된 요청 컨텍스트는 메타 데이터중 “RETRY_AFTER” 키를 가지고 있는데 해당 키 값에 이후 언제 요청이 가능한지 시간 정보가 포함 되어 있습니다.
하지만 RetryAfter 값은 FixedWindowRateLimiterSlidingWindowRateLimiterTokenBucketRateLimiter 방식의 OnRejected 콜백에서만 사용 할 수 있습니다.
그 이유는 Concurrency limit 방식은 이후 요청 가능 시간 계산이 불가능 하기 때문 입니다.
다음은 Rejected된 요청에 RetryAfter 값 을 알아내고 응답 헤더에 RetryAfter 값을 설정해서 TooManyRequests 상태로 응답 처리를 구현한 코드 입니다.

[Program.cs]

// 환경설정 'MyRateLimit' 섹션 내용 바인딩
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(limiterOptions =>
{
    // 요청이 속도 제한 초과시 호출되는 OnRejected 콜백
    limiterOptions.OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }
    
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        return ValueTask.CompletedTask;
    };
    
    ..[이하 생략]..
};

Rate limiter 정책 구현

Rate limiter 대상 처리를 직접 구현하여 처리 할 수 있습니다.
요청된 클라이언트의 Header 정보 판단으로 JWT나 쿠키 체크로 특정 사용자 에게만 속도 제한 적용을 할 수 있습니다.
이러한 정책을 직접 구현 하기 위해서 Microsoft.AspNetCore.RateLimiting.IRateLimiterPolicy<TPartitionKey> 인터페이스를 사용 할 수 있습니다.

그 방법은 Microsoft.AspNetCore.RateLimiting.IRateLimiterPolicy<TPartitionKey> 인터페이스의 GetPartition(HttpContext httpContext) 메서드에서 직접 정책을 구현하고 조건에 맞다면 속도 제한을 적용 하면 됩니다.
다음은 Microsoft.AspNetCore.RateLimiting.IRateLimiterPolicy<TPartitionKey> 인터페이스를 사용해서 요청된 클라이언트가 인증된 유저 인지 판단하고 속도 제한 처리 하는 방법에 대한 구현 입니다.

[CustomRateLimiterPolicy.cs]

namespace Rate_limiting_Example
{
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.RateLimiting;
    using Microsoft.Extensions.Options;
    using System;
    using System.Threading;
    using System.Threading.RateLimiting;
    using System.Threading.Tasks;

    public class CustomRateLimiterPolicy : IRateLimiterPolicy<string>
    {
        private readonly MyRateLimitOptions _options;
        private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;

        public CustomRateLimiterPolicy(IOptions<MyRateLimitOptions> options)
        {
            // 요청이 속도 제한 초과시 호출되는 OnRejected 콜백
            _onRejected = (context, cancellationToken) =>
            {
                context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
                return ValueTask.CompletedTask;
            };
            _options = options.Value;
        }

        public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => _onRejected;

        public RateLimitPartition<string> GetPartition(HttpContext httpContext)
        {
            // 특정 유저 정보 (Header, JWT 등)를 읽어서 속도 제한 설정 처리

            var username = "anonymous user";
            if (httpContext.User.Identity?.IsAuthenticated is true)
            {
                username = httpContext.User.ToString()!;
            }

            return RateLimitPartition.GetFixedWindowLimiter(string.Empty,
                _ => new FixedWindowRateLimiterOptions
                {
                    // 요청 허용 갯수 : 1
                    PermitLimit = _options.PermitLimit,
                    //    // 창 이동시간 10초 [10초 동안 최대 1개의 요청만 처리 가능]
                    Window = TimeSpan.FromSeconds(_options.Window),
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    //    // 제한시 3개의 요청만 대기열에 추가
                    QueueLimit = _options.QueueLimit,
                });
        }
    }
}

이렇게 만든 속도 제한 정책 CustomRateLimiterPolicy를 Microsoft.AspNetCore.RateLimiting.RateLimiterOptions 에 등록 합니다.

[Program.cs]

// 환경설정 'MyRateLimit' 섹션 내용 의존성 주입 서비스 등록
builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));
    
// 환경설정 'MyRateLimit' 섹션 내용 바인딩
var myOptions = new MyRateLimitOptions();
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
    
builder.Services.AddRateLimiter(limiterOptions =>
{
    // 속도 제한 정책 등록
    limiterOptions.AddPolicy<string, CustomRateLimiterPolicy>("CustomLimiter");
});



var app = builder.Build();
// 속도 제한 사용
app.UseRateLimiter();

그리고 컨트롤러에서 Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute 특성 “CustomLimiter” 속도 제한 정책을 적용 하면 됩니다.

[Controllers/TimeController.cs]

[ApiController]
[EnableRateLimiting("CustomLimiter")]
public class TimeController : Controller

이렇게 적용 후 확인해 보면 클라이언트에서 요청시 마다 CustomRateLimiterPolicy 클래스의 GetPartition(HttpContext httpContext) 메서드가 호출되는 미들웨어 처리 된 상태를 확인해 볼 수 있습니다.
image
(클라이언트 요청시 GetPartition(HttpContext httpContext) 메서드 호출)

그리고 위 에서 설명했던 OnRejected 콜백 처리도 정상 처리 되는 걸 확인해 볼 수 있습니다.
image (클라이언트 요청이 Rejected 되고 콜백 호출 상태)

image
(429 Too Many Requests 상태 반환)

이렇게 지금까지 속도 제한 방식에 대한 각 알고리즘과 .NET 7 에서 기본 제공 되는 System.Threading.RateLimiting 사용으로 Rate limiting middleware 처리 방법에 대해 알아 보았습니다.


위 코드는 다음 Repository에서 확인할 수 있습니다.

code_check - Rate_limiting_Example

출처1

출처2