목요일, 4월 25, 2024

.NET 6 애플리케이션의 성능 병목 현상 문제 해결

Must read

Ae Dong-Yul
Ae Dong-Yul
"트위터를 통해 다양한 주제에 대한 생각을 나누는 아 동율은 정신적으로 깊이 있습니다. 그는 맥주를 사랑하지만, 때로는 그의 무관심함이 돋보입니다. 그러나 그의 음악에 대한 열정은 누구보다도 진실합니다."

성능 문제는 예상치 못한 상황에서 발생할 수 있습니다. 이는 고객에게 부정적인 결과를 초래할 수 있습니다. 사용자 기반이 증가함에 따라 수요를 충족할 수 없기 때문에 애플리케이션이 지연될 수 있습니다. 다행히 적시에 이러한 문제를 해결할 수 있는 도구와 기술이 있습니다.

우리는 이 기사를 대지. SitePoint를 가능하게 하는 파트너를 지원해 주셔서 감사합니다.

이번에는 .NET 6 애플리케이션의 성능 병목 현상에 대해 알아보겠습니다. 프로덕션에서 개인적으로 본 성능 문제에 중점을 둘 것입니다. 목표는 로컬 개발 환경에서 문제를 재현하고 문제를 해결할 수 있도록 하는 것입니다.

에서 샘플 코드를 자유롭게 다운로드하십시오. 깃허브 또는 계속합니다. 솔루션에는 상상할 수 없는 이름의 두 가지 API가 있습니다. First.Api 그리고 Second.Api. 첫 번째 API는 두 번째 API를 호출하여 날씨 데이터를 가져옵니다. 이는 API가 다른 API를 호출할 수 있으므로 데이터 소스가 별도로 유지되고 개별적으로 확장될 수 있기 때문에 일반적인 사용 사례입니다.

먼저 다음이 있는지 확인하십시오. NET 6 SDK 장치에 설치되었습니다. 그런 다음 터미널 또는 콘솔 창을 엽니다.

> dotnet new webapi --name First.Api --use-minimal-apis --no-https --no-openapi
> dotnet new webapi --name Second.Api --use-minimal-apis --no-https --no-openapi

위는 다음과 같은 솔루션 폴더에 들어갈 수 있습니다. performance-bottleneck-net6. 이렇게 하면 최소한의 API, HTTPS, 블러스터 또는 개방형 API가 없는 두 개의 웹 프로젝트가 생성됩니다. 이 도구는 폴더 구조를 지원하므로 이 두 개의 새 프로젝트를 설정하는 데 도움이 필요한 경우 샘플 코드를 살펴보십시오.

솔루션 파일은 솔루션 폴더로 이동할 수 있습니다. 이를 통해 Rider 또는 Visual Studio와 같은 IDE를 통해 전체 솔루션을 열 수 있습니다.

dotnet new sln --name Performance.Bottleneck.Net6
dotnet sln add First.Api\First.Api.csproj
dotnet sln add Second.Api\Second.Api.csproj

다음으로 각 웹 프로젝트에 대한 포트 번호를 설정해야 합니다. 샘플 코드에서 첫 번째 API는 5060으로, 두 번째 API는 5176으로 설정했습니다. 정확한 숫자는 중요하지 않지만 샘플 코드를 통해 API를 참조하는 데 사용할 것입니다. 따라서 포트 번호를 변경하거나 스캐폴드가 생성하는 항목을 유지하고 일관성을 유지해야 합니다.

문제가 되는 애플리케이션

열기 Program.cs 두 번째 API를 파일로 만들고 날씨 데이터에 응답하는 코드를 배치합니다.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var summaries = new[]
{
 "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherForecast", async () =>
{
 await Task.Delay(10);
 return Enumerable
   .Range(0, 1000)
   .Select(index =>
     new WeatherForecast
     (
       DateTime.Now.AddDays(index),
       Random.Shared.Next(-20, 55),
       summaries[Random.Shared.Next(summaries.Length)]
     )
   )
   .ToArray()[..5];
});

app.Run();

public record WeatherForecast(
 DateTime Date,
 int TemperatureC,
 string? Summary)
{
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

.NET 6의 최소 API 기능은 코드를 작고 간결하게 유지하는 데 도움이 됩니다. 이것은 수천 개의 레코드를 거치며 비동기 데이터 처리를 시뮬레이트하기 위해 지연됩니다. 실제 프로젝트에서 이 코드는 데이터 입력과 관련된 작업인 분산 캐시 또는 데이터베이스에 연결할 수 있습니다.

지금, 이동 Program.cs 첫 번째 API를 파일로 만들고 이 날씨 데이터를 사용하는 코드를 작성합니다. 이것을 간단히 복사하여 붙여넣고 스캐폴드가 생성하는 모든 것을 대체할 수 있습니다.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => new HttpClient(
 new SocketsHttpHandler
 {
   PooledConnectionLifetime = TimeSpan.FromMinutes(5)
 })
{
 BaseAddress = new Uri("http://localhost:5176")
});

var app = builder.Build();

app.MapGet("https://news.google.com/", async (HttpClient client) =>
{
 var result = new List<List<WeatherForecast>?>();

 for (var i = 0; i < 100; i++)
 {
   result.Add(
     await client.GetFromJsonAsync<List<WeatherForecast>>(
       "/weatherForecast"));
 }

 return result[Random.Shared.Next(0, 100)];
});

app.Run();

public record WeatherForecast(
 DateTime Date,
 int TemperatureC,
 string? Summary)
{
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

그만큼 HttpClient 이것은 클라이언트를 확장 가능하게 만들기 때문에 싱글톤으로 주입됩니다. .NET에서 새 클라이언트는 기본 운영 체제에서 소켓을 생성하므로 클래스 재사용을 통해 이러한 연결을 재사용하는 것이 좋습니다. 여기서 HTTP 클라이언트는 연결 풀의 수명을 설정합니다. 이를 통해 클라이언트는 필요한 만큼 소켓을 유지할 수 있습니다.

기본 주소는 단순히 클라이언트에게 어디로 가야 하는지 알려주므로 이것이 두 번째 API에 지정된 올바른 포트 번호를 가리키는지 확인하십시오.

요청이 들어오면 코드는 100번 반복한 다음 두 번째 API를 호출합니다. 예를 들어 다른 API에서 호출하는 데 필요한 여러 레지스터를 시뮬레이션하기 위한 것입니다. 중복 항목이 내장되어 있지만 실제 프로젝트에서는 사용자 목록이 될 수 있으며 비즈니스가 성장함에 따라 무한히 증가할 수 있습니다.

이제 성능 이론에 영향을 미치기 때문에 중복성에 주의를 집중하십시오. 전산 분석에서 단일 루프는 Big-O 선형 복잡성 또는 O(n)을 갖습니다. 그러나 두 번째 API도 반복되어 알고리즘을 2차 복잡도 또는 O(n^2)로 증가시킵니다. 또한 중복성은 I/O 경계를 통과하여 부팅되므로 성능이 저하됩니다.

이는 승수 효과가 있습니다. 첫 번째 API가 반복될 때마다 두 번째 API가 천 번 반복되기 때문입니다. 100 * 1000 반복이 있습니다. 이러한 목록은 바인딩되지 않았으므로 데이터 세트가 커짐에 따라 성능이 크게 떨어집니다.

화가 난 고객이 더 나은 사용자 경험을 요구하는 스팸 메일을 콜센터에 보내면 이러한 도구를 사용하여 무슨 일이 일어나고 있는지 알아내십시오.

CURL 및 NBomber

첫 번째 도구는 집중할 API를 결정하는 데 도움이 됩니다. 코드를 최적화할 때 끝없이 모든 것을 최적화할 수 있으므로 조기 최적화를 피하십시오. 목표는 성능을 ‘충분히 좋게’ 만드는 것이며 이는 주관적이고 비즈니스 요구 사항에 따라 결정되는 경향이 있습니다.

먼저 예를 들어 CURL을 사용하여 각 API를 개별적으로 호출하여 응답 시간을 파악합니다.

> curl -i -o /dev/null -s -w %{time_total} http://localhost:5060
> curl -i -o /dev/null -s -w %{time_total} http://localhost:5176

포트 번호 5060은 첫 번째 API에 속하고 5176은 두 번째 API에 속합니다. 장치의 올바른 포트인지 확인하십시오.

두 번째 API는 밀리초 단위로 응답하는데, 이는 충분히 양호하며 원인이 아닐 가능성이 높습니다. 그러나 첫 번째 API는 응답하는 데 약 2초가 걸립니다. 웹 서버가 오랜 시간이 걸리는 요청을 완료할 수 있기 때문에 이는 허용되지 않습니다. 또한 2초의 대기 시간은 깨진 지연이기 때문에 클라이언트의 관점에서 매우 느립니다.

그러면 NBomber와 같은 도구가 문제가 있는 API를 측정하는 데 도움이 됩니다.

콘솔로 돌아가서 루트 폴더 안에 테스트 프로젝트를 만듭니다.

dotnet new console -n NBomber.Tests
cd NBomber.Tests
dotnet add package NBomber
dotnet add package NBomber.Http
cd ..
dotnet sln add NBomber.Tests\NBomber.Tests.csproj

에서 Program.cs 파일, 유형 기준:

using NBomber.Contracts;
using NBomber.CSharp;
using NBomber.Plugins.Http.CSharp;

var step = Step.Create(
 "fetch_first_api",
 clientFactory: HttpClientFactory.Create(),
 execute: async context =>
 {
   var request = Http
     .CreateRequest("GET", "http://localhost:5060/")
     .WithHeader("Accept", "application/json");
   var response = await Http.Send(request, context);

   return response.StatusCode == 200
     ? Response.Ok(
       statusCode: response.StatusCode,
       sizeBytes: response.SizeBytes)
     : Response.Fail();
 });

var scenario = ScenarioBuilder
 .CreateScenario("first_http", step)
 .WithWarmUpDuration(TimeSpan.FromSeconds(5))
 .WithLoadSimulations(
   Simulation.InjectPerSec(rate: 1, during: TimeSpan.FromSeconds(5)),
   Simulation.InjectPerSec(rate: 2, during: TimeSpan.FromSeconds(10)),
   Simulation.InjectPerSec(rate: 3, during: TimeSpan.FromSeconds(15))
 );

NBomberRunner
.RegisterScenarios(scenario)
.Run();

NBomber는 초당 1개의 요청 속도로만 API를 스팸 처리합니다. 그런 다음 간헐적으로 다음 10초 동안 1초에 두 번. 마지막으로, 15초 동안 초당 세 번. 이렇게 하면 로컬 개발 시스템이 너무 많은 요청으로 과부하되는 것을 방지할 수 있습니다. NBomber는 또한 네트워크 소켓을 사용하므로 대상 API와 벤치마킹 도구가 모두 동일한 시스템에서 실행될 때 주의하십시오.

테스트 단계는 응답 코드를 추적하여 반환 값에 넣습니다. 이것은 API 실패를 추적합니다. .NET에서 Kestrel 서버가 너무 많은 요청을 받으면 실패 응답이 있는 요청을 거부합니다.

이제 결과를 검토하고 응답 시간, 동시 요청 및 처리량을 확인합니다.

P95의 대기 시간은 대부분의 고객이 경험하는 1.5초를 나타냅니다. 도구가 초당 최대 3개의 요청만 처리하도록 보정되었기 때문에 처리량은 여전히 ​​낮습니다. 로컬 개발 머신에서는 벤치마킹 도구를 실행하는 동일한 리소스가 서비스 요청에도 필요하기 때문에 동시성을 알기 어렵습니다.

dotTrace 분석

다음으로 dotTrace와 같은 알고리즘 분석을 수행할 수 있는 도구를 선택합니다. 이렇게 하면 성능 문제가 있을 수 있는 위치를 격리하는 데 도움이 됩니다.

분석을 위해 dotTrace를 실행하고 NBomber가 API에 최대한 스팸을 보낸 후 스냅샷을 찍습니다. 목표는 과부하를 시뮬레이트하여 속도 저하의 원인을 식별하는 것입니다. 이미 제시된 표준으로도 충분하므로 NBomber로 dotTrace를 실행해야 합니다.

dotTrace 분석

이 분석에 따르면 약 85%의 시간이 GetFromJsonAsync 의사소통하다. 도구를 찌르면 이것이 HTTP 클라이언트에서 온 것임을 알 수 있습니다. 이것은 O(n^2) 복잡성을 가진 비동기 루프가 문제가 될 수 있음을 보여주기 때문에 성능 이론과 관련이 있습니다.

로컬에서 실행되는 벤치마킹 도구는 병목 현상을 식별하는 데 도움이 됩니다. 다음 단계는 실제 생산 환경에서 주문을 추적할 수 있는 모니터링 도구를 사용하는 것입니다.

성능 조사는 정보를 수집하고 각 도구가 최소한 일관된 이야기를 전달하는지 확인하는 것입니다.

현장 연중무휴 모니터링

같은 도구 대지 성능 문제를 해결하는 데 도움이 될 수 있습니다.

이 애플리케이션의 경우 두 API의 P95 대기 시간에 집중하려고 합니다. 이는 API가 분산 아키텍처에서 상호 연결된 서비스 체인의 일부이기 때문에 파급 효과입니다. 한 API에서 성능 문제가 발생하기 시작하면 다른 API에서도 문제가 발생할 수 있습니다.

확장성은 또 다른 중요한 요소입니다. 사용자 기반이 성장함에 따라 애플리케이션이 지연되기 시작할 수 있습니다. 자연스러운 동작을 추적하고 시간 경과에 따라 앱이 어떻게 확장될지 예측하는 데 도움이 됩니다. 이 구현의 중첩된 비동기 루프는 N명의 사용자에 대해 제대로 작동할 수 있지만 숫자가 바인딩되지 않았기 때문에 확장되지 않을 수 있습니다.

마지막으로 개선 및 최적화를 배포할 때 버전 종속성을 추적하는 것이 중요합니다. 반복할 때마다 어떤 버전이 최고 또는 최악의 성능을 보이는지 알 수 있어야 합니다.

로컬 개발 환경에서 문제를 감지하는 것이 항상 쉬운 것은 아니므로 적절한 모니터링 도구가 필수적입니다. 현지에서 만든 가정은 고객이 다른 의견을 가질 수 있기 때문에 프로덕션에서는 유효하지 않을 수 있습니다. Site24x7 무료 30일 평가판 시작.

보다 효과적인 솔루션

도구가 최신 상태이므로 더 나은 접근 방식을 탐색할 때입니다.

CURL은 성능 문제가 있는 첫 번째 API라고 말했습니다. 즉, 두 번째 API에 대한 개선 사항은 무시할 수 있습니다. 여기에 파급 효과가 있지만 두 번째 API에서 몇 밀리초를 제거해도 큰 차이는 없습니다.

NBomber는 P95가 첫 번째 API에서 약 2초간 다운되었음을 보여줌으로써 이 이야기를 확인했습니다. 다음으로 dotTrace는 알고리즘이 대부분의 시간을 보내는 곳이기 때문에 비동기 루프를 정의합니다. Site24x7과 같은 모니터링 도구는 P95 대기 시간, 시간 경과에 따른 확장성 및 버전 관리를 보여줌으로써 지원 정보를 제공했을 것입니다. 중첩 루프를 도입한 특정 버전은 대기 시간이 길었을 가능성이 높습니다.

성능 이론에 따르면 성능이 기하급수적으로 저하되기 때문에 2차 복잡도가 주요 관심사입니다. 좋은 접근 방식은 루프 내부의 반복 횟수를 줄여 복잡성을 줄이는 것입니다.

.NET의 한 가지 제한 사항은 대기가 표시될 때마다 논리가 한 번에 하나의 요청만 전송한다는 것입니다. 이렇게 하면 루프가 중지되고 두 번째 API가 응답을 반환할 때까지 기다립니다. 공연에 대한 안타까운 소식입니다.

순진한 방법은 모든 HTTP 요청을 동시에 전송하여 루프를 간단히 분쇄하는 것입니다.

app.MapGet("https://news.google.com/", async (HttpClient client) =>
 (await Task.WhenAll( 
   Enumerable
     .Range(0, 100)
     .Select(_ =>
       client.GetFromJsonAsync<List<WeatherForecast>>( 
         "/weatherForecast")
     )
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

이렇게 하면 링과 블록 내부에서 대기 중인 미사일이 한 번만 발사됩니다. 그만큼 Task.WhenAll 모든 것을 병렬로 전송하여 루프를 끊습니다.

이 방법은 작동할 수 있지만 한 번에 너무 많은 요청으로 두 번째 API에 스팸을 보낼 위험이 있습니다. 웹 서버는 DoS 공격일 수 있다고 생각하기 때문에 요청을 거부할 수 있습니다. 훨씬 더 지속 가능한 접근 방식은 한 번에 몇 개만 전송하여 중복을 줄이는 것입니다.

var sem = new SemaphoreSlim(10); 

app.MapGet("https://news.google.com/", async (HttpClient client) =>
 (await Task.WhenAll(
   Enumerable
     .Range(0, 100)
     .Select(async _ =>
     {
       try
       {
         await sem.WaitAsync(); 
         return await client.GetFromJsonAsync<List<WeatherForecast>>(
           "/weatherForecast");
       }
       finally
       {
         sem.Release();
       }
     })
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

이것은 클럽의 경비원과 매우 흡사합니다. 최대 수용 인원은 10명입니다. 주문이 풀에 들어갈 때 한 번에 10개만 들어갈 수 있습니다. 이것은 또한 동시 요청을 허용하므로 하나의 요청이 풀에서 나가면 다른 요청이 10개의 요청을 기다리지 않고 즉시 들어갈 수 있습니다.

이것은 계산 복잡성을 10배로 줄이고 모든 미친 루핑에서 스트레스를 제거합니다.

이 코드를 사용하여 NBomber를 실행하고 결과를 확인합니다.

보다 효율적인 솔루션

P95의 이동 시간은 이제 이전의 1/3입니다. 0.5초의 응답은 1초 이상 걸리는 어떤 것보다 훨씬 더 의미가 있습니다. 물론 이를 계속해서 개선할 수 있지만 고객이 이에 매우 만족할 것이라고 생각합니다.

결론

성능 향상은 끝없는 이야기입니다. 비즈니스가 성장함에 따라 코드에서 만들어진 가정은 시간이 지남에 따라 무효화될 수 있습니다. 따라서 성능 문제를 원활하게 해결하기 위해 애플리케이션을 지속적으로 분석, 구성 및 모니터링할 수 있는 도구가 필요합니다.

Latest article