시스템의 안정성과 보안을 보장하기 위해 타사 서비스를 호출할 때 재시도 및 회로 차단기를 추가할 수 있다.
재시도는 한 번의 호출이 실패한 후 다시 시도하여 다운스트림 서비스의 일시적인 단절로 인해 모든 프로세스가 종료되는 것을 방지한다.
회로 차단기는 과도한 무효 액세스를 방지하고 시스템에서 알 수 없는 예외가 발생하는 것을 방지하기 위한 것이다.
Polly는 독립적인 재시도 메커니즘의 서드파티 라이브러리이다.
아래 코드는 httpclient를 사용하여 하류 API에 대한 요청 시 재시도 및 회로 차단기에 대해서만 다룬다.
NuGet 패키지 Microsoft.Extensions.Http.Polly를 가져와야 한다.
using Polly;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddHttpClient("RetryClient", httpclient =>
{
httpclient.BaseAddress = new Uri("http://localhost:5258");
})
.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.RetryAsync(3));
var app = builder.Build();
// httpclient를 호출한다
app.MapGet("/test", async (IHttpClientFactory httpClientFactory) =>
{
try
{
var httpClient = httpClientFactory.CreateClient("RetryClient");
var content = await httpClient.GetStringAsync("other-api");
Console.WriteLine(content);
return "ok";
}
catch (Exception exc)
{
if (!Count.Time.HasValue)
{
Count.Time = DateTime.Now;
}
return $"{exc.Message} 【횟수:{Count.I++}】 【{Count.Time.Value.ToString("yyyy-MM-dd HH:mm:ss.fffffff")}】";
}
});
// 상태 코드 500을 돌려준다
app.MapGet("/other-api", (ILogger<Program> logger) =>
{
logger.LogInformation($"실패:{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fffffff")}");
return Results.StatusCode(500);
});
app.Run();
static class Count
{
public static int I = 1;
public static DateTime? Time;
}
.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.RetryAsync(3)) 에 의해 요청에 대해 3번의 재시도가 이루어지며, 첫 번째 재시도까지 총 4번의 재시도가 이루어진다.
다운스트림 서비스에 장애가 발생하면 이렇게 짧은 시간 내에 자동으로 복구되지 않을 수 있다. 더 좋은 방법은 재시도 횟수에 따라 요청 간 시간을 연장(무작위 또는 자체 지연 알고리즘을 구축)하는 것이다.
.AddTransientHttpErrorPolicy(policyBuilder =>
policyBuilder.WaitAndRetryAsync(3, retryNumber =>
{
switch (retryNumber)
{
case 1:
return TimeSpan.FromMilliseconds(500);
case 2:
return TimeSpan.FromMilliseconds(1000);
case 3:
return TimeSpan.FromMilliseconds(1500);
default:
return TimeSpan.FromMilliseconds(100);
}
}));
또 다른 재시도 전략을 소개한다.
// 뮤한으로 재시도 한다
.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.RetryForeverAsync());
// 2초 마다 재시도 한다
.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.WaitAndRetryForeverAsync(retryNumber =>
{
Console.WriteLine(retryNumber);
return TimeSpan.FromSeconds(2);
}));
// 5초간 4회 요청이 있고, 50%가 실패한 경우 10초간 서킷브레이커가 동작한다
.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.AdvancedCircuitBreakerAsync(0.5d, TimeSpan.FromSeconds(5), 4, TimeSpan.FromSeconds(10)));
서킷브레이커는 서비스를 보호하는 수단이다. 이 예에서 구체적인 사용 방법은 아래와 같다.
builder.Services
.AddHttpClient("RetryClient", httpclient =>
{
httpclient.BaseAddress = new Uri("http://localhost:5258");
})
.AddTransientHttpErrorPolicy(policyBuilder =>
policyBuilder.WaitAndRetryAsync(3, retryNumber =>
{
switch (retryNumber)
{
case 1:
return TimeSpan.FromMilliseconds(500);
case 2:
return TimeSpan.FromMilliseconds(1000);
case 3:
return TimeSpan.FromMilliseconds(1500);
default:
return TimeSpan.FromMilliseconds(100);
}
}))
// 서킷브레이커
.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.CircuitBreakerAsync(6, TimeSpan.FromSeconds(30)));
CircuitBreaker는 6번의 실패한 요청이 있을 경우, 30초 동안 일시 정지를 제어한다.
RateLimit은 네트워크의 기반 설비에서 설정하여 구현할 수도 있고, 게이트웨이에서 RateLimit을 할 수도 있다. 하지만 서비스 자체의 RateLimit도 빼놓을 수 없다.
복수의 레플리카가 있는 경우 하나의 레플리카가 장애가 발생하면 다른 레플리카에 대한 트래픽이 증가하게 되고, 이것이 감당할 수 있는 요청량을 초과하면 서비스가 연쇄적으로 크래시될 수 있기 때문이다. 따라서 개별 서비스 자체적으로도 RateLimit을 구현하는 것이 바람직하다.
ASP.NET Core 프로젝트에서는 AspNetCoreRateLimit을 도입하여 RateLimit 처리가 가능하다.
아래와 같은 방법으로 NuGet 패키지를 도입할 수 있다.
Install-Package AspNetCoreRateLimit
클라이언트 RateLimit 설정:
using AspNetCoreRateLimit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
// ClientRateLimiting 설정 파일을 읽는다
builder.Services.Configure<ClientRateLimitOptions>(builder.Configuration.GetSection("ClientRateLimiting"));
// ClientRateLimitPolicies 설정 파일을 읽는다
builder.Services.Configure<ClientRateLimitPolicies>(builder.Configuration.GetSection("ClientRateLimitPolicies"));
// RateLimit 메모리캐시 서비스를 도입
builder.Services.AddInMemoryRateLimiting();
// RateLimit 설정 파일 서비스를 도입
builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
var app = builder.Build();
// ClientRateLimitPolicies 를 유효화
var clientPolicyStore = app.Services.GetRequiredService<IClientPolicyStore>();
await clientPolicyStore.SeedAsync();
// 클라이언트 RateLimit 미들웨를 사용
app.UseClientRateLimiting();
app.MapGet("/test00", () => "get test00 ok");
app.MapGet("/test01", () => "get test01 ok");
app.MapGet("/test02", () => "get test02 ok");
app.MapPost("/test02", () => "post test02 ok");
app.Run();
appsetings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ClientRateLimiting": {
"EnableEndpointRateLimiting": false,
"StackBlockedRequests": false,
"ClientIdHeader": "X-ClientId",
"HttpStatusCode": 429,
"EndpointWhitelist": [ "get:/test00", "*:/test01" ],
"ClientWhitelist": [ "dev-id-1", "dev-id-2" ],
"GeneralRules": [
{
"Endpoint": "*",
"Period": "5s",
"Limit": 2
},
{
"Endpoint": "*",
"Period": "10s",
"Limit": 3
}
]
},
"ClientRateLimitPolicies": {
"ClientRules": [
{
"ClientId": "client-id-1",
"Rules": [
{
"Endpoint": "*",
"Period": "5s",
"Limit": 1
},
{
"Endpoint": "*",
"Period": "15m",
"Limit": 200
}
]
},
{
"ClientId": "client-id-2",
"Rules": [
{
"Endpoint": "*",
"Period": "1s",
"Limit": 5
},
{
"Endpoint": "*",
"Period": "15m",
"Limit": 150
},
{
"Endpoint": "*",
"Period": "12h",
"Limit": 500
}
]
}
]
}
}
설정 설명:
- EnableEndpointRateLimiting이 false인 경우 모든 요청의 총 수가 임계값을 초과하면 속도 제한을 적용하고, true인 경우 각 요청이 임계 값을 초과하면 속도 제한을 적용한다.
- StackBlockedRequests가 false인 경우 이전 5초 동안 2번 성공하고 1번 실패한 경우 6초 후에 한 번 더 성공할 수 있으며, true인 경우 6초 후의 요청은 성공하지 못한다.
- ClientIdHeader는 속도 제한의 블랙/화이트 리스트를 처리하기 위해 헤더 키 X-ClientId를 지정한다.
- ClientWhitelist는 dev-id-1, dev-id-2이며, 헤더 내 X-ClientId가 이 값이면 통과시킨다.
- EndpointWhitelist는 속도 제한에 포함되지 않는 엔드포인트이다.
- HttpStatusCode는 속도 제한 후 반환되는 상태 코드이다.
- GeneralRules는 일반적인 속도 제한 규칙이다.
- ClientRateLimitPolicies 설정은 서로 다른 X-ClientId에 대해 서로 다른 속도 제한을 설정하기 위한 것으로 클라이언트 ID의 그레이리스트를 의미한다.
또한, ClientID에 의한 속도 제한뿐만 아니라 클라이언트의 요청 IP에 대해서도 속도 제한을 할 수 있으며, 설정 방법은 동일하다.
IP Rate Limit 설정: IP Rate Limit 설정
using AspNetCoreRateLimit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
// IPRateLimiting 설정 파일을 읽는다
builder.Services.Configure<IpRateLimitOptions>(builder.Configuration.GetSection("IpRateLimiting"));
// IPRateLimitPolicies 설정 파일을 읽는다
builder.Services.Configure<IpRateLimitPolicies>(builder.Configuration.GetSection("IpRateLimitPolicies"));
// RateLimit 메모리캐시 서비스를 주입
builder.Services.AddInMemoryRateLimiting();
// RateLimit 설정 파일 서비스를 주입
builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
var app = builder.Build();
// IPRateLimitPolicies 를 유효화
var ipPolicyStore = app.Services.GetRequiredService<IIpPolicyStore>();
await ipPolicyStore.SeedAsync();
// IP RateLimit 미들웨어를 사용
app.UseIpRateLimiting();
app.MapGet("/test00", () => "get test00 ok");
app.MapGet("/test01", () => "get test01 ok");
app.MapGet("/test02", () => "get test02 ok");
app.MapPost("/test02", () => "post test02 ok");
app.MapGet("/test03", () => "get test01 ok");
app.Run();
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"IpRateLimiting": {
"EnableEndpointRateLimiting": false,
"StackBlockedRequests": false,
"RealIpHeader": "X-Real-IP",
"IpWhitelist": [ "127.0.0.1"],
"ClientIdHeader": "X-ClientId",
"HttpStatusCode": 429,
"EndpointWhitelist": [ "get:/test00", "*:/test01" ],
"ClientWhitelist": [ "dev-id-1", "dev-id-2" ],
"GeneralRules": [
{
"Endpoint": "*",
"Period": "5s",
"Limit": 2
},
{
"Endpoint": "*",
"Period": "10s",
"Limit": 3
}
]
},
"IpRateLimitPolicies": {
"IpRules": [
{
"Ip": "127.0.0.2",
"Rules": [
{
"Endpoint": "*",
"Period": "4s",
"Limit": 1
},
{
"Endpoint": "*",
"Period": "15m",
"Limit": 200
}
]
}
]
}
}
API POST로 전송되는 데이터의 유효성을 검증하기 위해 FluentValidation(자세한 내용은 공식 웹사이트 https://fluentvalidation.net 참조)을 도입할 수 있으며, asp.net mvc에서는 모델의 유효성 검사를 사용하여, 엔티티 클래스 상에 속성을 추가하여 검증 효과를 얻고 있다.
FluentValidation의 원리는 AbstractValidator의 구현을 통해 T 엔티티 클래스의 검증을 수행하는 것으로, T의 속성을 다양한 규칙을 통해 검증한다(더 많은 검증 규칙은 공식 웹사이트를 참조). 아래 구현을 참고한다:
public class Person{
public int Id { get; set; }
public string Name { get; set; }
public string Tel { get; set; }
public string Email { get; set; }
public DateTime Birthday { get; set; }
public string IDCard { get; set; }
public PersonAddress Address { get; set; }
}
public class PersonAddress{
public string Country { get; set; }
public string Province { get; set; }
public string City { get; set; }
public string County { get; set; }
public string Address { get; set; }
public string Postcode { get; set; }
}
/// <summary>
/// Person 검증
/// </summary>
public class PersonValidator : AbstractValidator<Person>{
public PersonValidator(IPersonService personService)
{
RuleFor(p => p.Name).NotNull().NotEmpty();
RuleFor(p => p.Email).NotNull().EmailAddress();
RuleFor(p => p.Birthday).NotNull();
RuleFor(p => p.IDCard)
.NotNull()
.NotEmpty()
.Length(18)
.When(p => (DateTime.Now > p.Birthday.AddYears(1)))
.WithMessage(p => $"出生日期为{p.Birthday},现在时间为{DateTime.Now},大于一岁,CardID值必填!");
RuleFor(p => p.Tel).NotNull().Matches(@"^(\d{3,4}-)?\d{6,8}$|^[1]+[3,4,5,8]+\d{9}$").WithMessage("电话格式为:0000-0000000或13000000000");
RuleFor(p => p.Address).NotNull();
RuleFor(p => p.Address).SetValidator(new PersonAddressValidator());
//외부 메소드를 호출하여 검증한다
RuleFor(p => p.Id).Must(id => personService.IsExist(id)).WithMessage(p => $"不存在id={p.Id}の用户");
}
}
/// <summary>
/// Person Address 검증
/// </summary>
public class PersonAddressValidator : AbstractValidator<PersonAddress>{
public PersonAddressValidator()
{
RuleFor(a => a.Country).NotNull().NotEmpty();
RuleFor(a => a.Province).NotNull().NotEmpty();
RuleFor(a => a.City).NotNull().NotEmpty();
RuleFor(a => a.County).NotNull().NotEmpty();
RuleFor(a => a.Address).NotNull().NotEmpty();
RuleFor(a => a.Postcode).NotNull().NotEmpty().Length(6);
}
}
FluentValidation을 도입하는 것도 쉬운데, IValidator를 주입하여 구현할 수도 있고, AddFluentValidation으로 주입한 후 IValidatorFactory를 사용하여 Validator를 가져와 검증을 할 수도 있다. 코드는 아래와 같다:
using FluentValidation;
using FluentValidation.AspNetCore;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFluentValidation();
builder.Services.AddScoped<IValidator<Person>, PersonValidator>();
builder.Services.AddScoped<IPersonService, PersonService>();
var app = builder.Build();
app.MapPost("/person", async (IValidator<Person> validator, Person person) => {
var result = await validator.ValidateAsync(person);
if (!result.IsValid)
{
var errors = new StringBuilder();
foreach (var valid in result.Errors)
{
errors.AppendLine(valid.ErrorMessage);
}
return errors.ToString();
}
return "OK";
});
app.MapPost("/person1", async (IValidatorFactory validatorFactory, Person person) => {
var result = await validatorFactory.GetValidator<Person>().ValidateAsync(person);
if (!result.IsValid)
{
var errors = new StringBuilder();
foreach (var valid in result.Errors)
{
errors.AppendLine(valid.ErrorMessage);
}
return errors.ToString();
}
return "OK";
});
app.MapPost("/person2", async (IValidatorFactory validatorFactory, Person person) => {
var result = await validatorFactory.GetValidator(typeof(Person)).ValidateAsync(new ValidationContext<Person>(person));
if (!result.IsValid)
{
var errors = new StringBuilder();
foreach (var valid in result.Errors)
{
errors.AppendLine(valid.ErrorMessage);
}
return errors.ToString();
}
return "OK";
});
app.Run();
public interface IPersonService{
public bool IsExist(int id);
}
public class PersonService : IPersonService{
public bool IsExist(int id)
{
if (DateTime.Now.Second % 2 == 0)
{
return false;
}
else
{
return true;
}
}
}