다음을 통해 공유


ASP.NET Core 모범 사례

참고 항목

이 문서의 최신 버전은 아닙니다. 현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

Warning

이 버전의 ASP.NET Core는 더 이상 지원되지 않습니다. 자세한 내용은 .NET 및 .NET Core 지원 정책을 참조 하세요. 현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

Important

이 정보는 상업적으로 출시되기 전에 실질적으로 수정될 수 있는 시험판 제품과 관련이 있습니다. Microsoft는 여기에 제공된 정보에 대해 어떠한 명시적, 또는 묵시적인 보증을 하지 않습니다.

현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

Mike Rousos 작성

이 문서에서는 ASP.NET Core 앱의 성능 및 안정성을 극대화하기 위한 지침을 제공합니다.

적극적으로 캐시

캐싱은 이 문서의 여러 부분에서 설명합니다. 자세한 내용은 ASP.NET Core의 개요 캐싱을 참조하세요.

핫 코드 경로 이해

이 문서에서 핫 코드 경로 는 자주 호출되고 많은 실행 시간이 발생하는 코드 경로로 정의됩니다. 핫 코드 경로는 일반적으로 앱 스케일 아웃 및 성능을 제한하며 이 문서의 여러 부분에서 설명합니다.

차단되는 호출 방지

ASP.NET Core 앱은 여러 요청을 동시에 처리하도록 디자인해야 합니다. 비동기 API를 사용하면 차단 호출을 기다리지 않고 작은 스레드 풀에서 수천 개의 동시 요청을 처리할 수 있습니다. 스레드는 장기 실행 동기 작업이 완료되기를 기다리는 대신 다른 요청에서 작업할 수 있습니다.

ASP.NET Core 앱의 일반적인 성능 문제는 비동기적일 수 있는 호출을 차단하는 것입니다. 많은 동기 차단 호출은 스레드 풀 결핍 및 응답 시간 저하로 이어집니다.

Task.Wait 또는 Task<TResult>.Result를 호출하여 비동기 실행을 차단하지 마세요. 공통 코드 경로에서 잠금을 획득하지 마세요. ASP.NET Core 앱은 코드를 병렬로 실행하도록 설계할 때 가장 효율적입니다. Task.Run를 호출하지 말고 즉시 기다리세요. ASP.NET Core는 이미 일반 스레드 풀 스레드에서 앱 코드를 실행하므로 Task.Run을 호출하면 불필요한 스레드 풀 일정만 추가로 발생합니다. 예약된 코드가 스레드를 차단하더라도 Task.Run은 이를 방지하지 않습니다.

  • 핫 코드 경로를 비동기식으로 만듭니다.
  • 비동기 API를 사용할 수 있는 경우 데이터 액세스, I/O 및 장기 실행 작업 API를 비동기적으로 호출합니다.
  • 동기 API를 비동기식으로 만들려면 Task.Run을 사용하지 마세요.
  • 컨트롤러/Razor 페이지 작업을 비동기식으로 만듭니다. 전체 호출 스택은 비동기/대기 패턴을 활용하기 위해 비동기식입니다.
  • Azure Service Bus와 같은 메시지 브로커를 사용하여 장기 실행 호출 오프로드 고려

PerfView와 같은 프로파일러를 사용하여 스레드 풀에 자주 추가되는 스레드를 찾을 수 있습니다. Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start 이벤트는 스레드 풀에 추가된 스레드를 나타냅니다.

여러 개의 작은 페이지로 큰 컬렉션 반환

하나의 웹 페이지에서 한 번에 대량의 데이터를 로드하지 않는 것이 좋습니다. 개체의 컬렉션을 반환할 때 성능 문제가 발생할 수 있는지 여부를 고려합니다. 해당 디자인이 다음과 같은 잘못된 결과를 생성할 수 있는지 여부를 확인합니다.

이전 시나리오를 완화하려면 페이지 매김을 추가합니다. 개발자는 페이지 크기 및 페이지 인덱스 매개 변수를 사용하여 부분 결과를 반환하는 디자인을 선호합니다. 철저한 결과가 필요한 경우 서버 리소스 잠금을 방지하려면 페이지 매김을 사용하여 결과 일괄 처리를 비동기적으로 채워야 합니다.

반환된 레코드를 페이징하고 레코드 수를 제한하는 방법에 대한 자세한 내용은 다음을 참조하세요.

IEnumerable<T> 또는 IAsyncEnumerable<T> 반환

작업에서 IEnumerable<T>를 반환하면 Serializer에 의한 동기 컬렉션 반복이 발생합니다. 그 결과는 호출 차단이며 스레드 풀 고갈의 가능성도 있습니다. 동기식 열거를 방지하려면 열거 가능한 대상을 반환하기 전에 ToListAsync를 사용합니다.

ASP.NET Core 3.0부터는 비동기적으로 열거하는 IEnumerable<T> 대신 IAsyncEnumerable<T>을 사용할 수 있습니다. 자세한 내용은 컨트롤러 작업 반환 형식을 참조하세요.

대량 개체 할당 최소화

.NET Core 가비지 수집기는 ASP.NET Core 앱에서 메모리의 할당 및 해제를 자동으로 관리합니다. 자동 가비지 수집 기능이 있으면 일반적으로 개발자가 메모리 해제 방법 또는 시기에 대해 걱정할 필요가 없습니다. 그러나 참조되지 않은 개체를 정리하는 경우 CPU 시간이 걸리므로 개발자는 핫 코드 경로에서 개체 할당을 최소화해야 합니다. 가비지 수집은 큰 개체(>= 85,000바이트)에서 특히 비용이 많이 듭니다. 대형 개체는 대형 개체 힙에 저장되며 정리하려면 전체(2세대) 가비지 수집이 필요합니다. 0세대 및 1세대 수집과 달리 2세대 수집에서는 앱 실행을 임시로 일시 중단해야 합니다. 대형 개체를 자주 할당 및 할당 취소하면 성능이 일관되지 않을 수 있습니다.

권장 사항:

  • 자주 사용되는 대형 개체는 캐싱을 고려합니다. 대형 개체를 캐시하면 비용이 많이 드는 할당이 방지됩니다.
  • ArrayPool<T>을 사용하여 대량 배열을 저장함으로써 버퍼를 풀링합니다.
  • 핫 코드 경로에 수명이 짧은 많은 개체를 할당하지 마세요.

PerfView에서 GC(가비지 수집) 통계를 검토하고 다음을 검사하여 앞서 언급한 것과 같은 메모리 문제를 진단할 수 있습니다.

  • 가비지 수집 일시 중지 시간
  • 가비지 수집에 소요되는 프로세서 시간의 비율
  • 0, 1, 2세대의 가비지 수집 수

자세한 내용은 가비지 수집 및 성능을 참조하세요.

데이터 액세스 및 I/O 최적화

데이터 저장소 및 다른 원격 서비스와의 상호 작용은 종종 ASP.NET Core 앱의 가장 느린 부분입니다. 데이터를 효율적으로 읽고 쓰는 기능은 좋은 성능을 위해 중요합니다.

권장 사항:

  • 모든 데이터 액세스 API를 비동기식으로 호출합니다.
  • 필요한 것보다 더 많은 데이터를 검색하지 않습니다. 현재 HTTP 요청에 필요한 데이터만 반환하는 쿼리를 작성합니다.
  • 약간 오래된 데이터에 액세스할 수 있는 경우 데이터베이스 또는 원격 서비스에서 검색된 자주 액세스하는 데이터를 캐시하는 것이 좋습니다. 시나리오에 따라 MemoryCache 또는 DistributedCache를 사용합니다. 자세한 내용은 ASP.NET Core의 응답 캐싱을 참조하세요.
  • 네트워크 왕복을 최소화합니다. 목표는 여러 호출이 아닌 단일 호출에서 필요한 데이터를 검색하는 것입니다.
  • 읽기 전용 용도로 데이터에 액세스할 때 Entity Framework Core에서 비추적 쿼리사용합니다. EF Core는 비추적 쿼리의 결과를 보다 효율적으로 반환할 수 있습니다.
  • LINQ 쿼리(예: .Where, .Select 또는 .Sum 문 사용)를 필터링 및 집계합니다. 이 필터링은 데이터베이스에서 수행합니다.
  • EF Core에서 클라이언트의 일부 쿼리 연산자를 확인한다는 점을 고려합니다. 이 경우 쿼리가 비효율적으로 실행될 수 있습니다. 자세한 내용은 클라이언트 평가 성능 문제를 참조하세요.
  • 컬렉션에 대해 프로젝션 쿼리를 사용하지 않도록 합니다. 이 경우 “N + 1” SQL 쿼리가 실행될 수 있습니다. 자세한 내용은 상호 관련된 하위 쿼리 최적화를 참조하세요.

다음 방법은 대규모 앱의 성능을 향상시킬 수 있습니다.

코드베이스를 커밋하기 전에 이전 고성능 방법이 미치는 영향을 측정하는 것이 좋습니다. 컴파일된 쿼리는 복잡성을 추가하므로 성능 향상이 구현되지 못할 수 있습니다.

Application Insights 또는 프로파일링 도구로 데이터에 액세스하는 데 걸린 시간을 검토하여 쿼리 문제를 검색할 수 있습니다. 또한 대부분의 데이터베이스는 자주 실행되는 쿼리와 관련해서 통계를 사용할 수 있도록 합니다.

HttpClientFactory를 사용하여 HTTP 연결 풀링

HttpClient 인터페이스를 IDisposable 구현하지만 다시 사용하도록 설계되었습니다. 닫힌 HttpClient 인스턴스는 짧은 시간 동안 소켓을 TIME_WAIT 상태로 열어 둡니다. HttpClient 개체를 만들고 삭제하는 코드 경로가 자주 사용되는 경우 앱에서 사용 가능한 소켓이 고갈될 수 있습니다. HttpClientFactory 이 문제에 대한 해결 방법으로 ASP.NET Core 2.1에서 도입되었습니다. 성능 및 안정성을 최적화하기 위해 HTTP 연결 풀링을 처리합니다. 자세한 내용은 HttpClientFactory를 사용하여 복원력 있는 HTTP 요청 구현을 참조하세요.

권장 사항:

일반 코드 경로를 빠르게 유지

모든 코드를 빠르게 만들려고 할 것입니다. 자주 호출되는 코드 경로를 최적화하는 것이 가장 중요합니다. 여기에는 다음이 포함됩니다.

  • 앱의 요청 처리 파이프라인에 있는 미들웨어 구성 요소, 특히 미들웨어는 파이프라인 초기에 실행됩니다. 이러한 구성 요소는 성능에 큰 영향을 미칩니다.
  • 요청마다 또는 요청마다 여러 번 실행되는 코드. 예를 들어 사용자 지정 로깅, 권한 부여 처리기 또는 임시 서비스 초기화가 여기에 해당합니다.

권장 사항:

HTTP 요청 외부에서 장기 실행 작업 완료

ASP.NET Core 앱에 대한 대부분의 요청은 컨트롤러 또는 페이지 모델에서 필요한 서비스를 호출하고 HTTP 응답을 반환하여 처리할 수 있습니다. 장기 실행 작업을 포함하는 일부 요청의 경우 전체 요청-응답 프로세스를 비동기식으로 만드는 것이 좋습니다.

권장 사항:

  • 일반 HTTP 요청 처리의 일부로 장기 실행 작업이 완료될 때까지 기다리지 않도록 합니다.
  • 백그라운드 서비스를 사용하거나 Azure Function 및/또는 Azure Service Bus같은 메시지 브로커를 사용하여 장기 실행 요청을 처리하는 것이 좋습니다. out-of-process 작업을 완료하면 CPU 집약적 작업에 특히 유용합니다.
  • 클라이언트와 비동기식으로 통신하기 위해 SignalR와 같은 실시간 통신 옵션을 사용합니다.

클라이언트 자산 축소

복잡한 프런트 엔드가 있는 ASP.NET Core 앱은 종종 많은 JavaScript, CSS 또는 이미지 파일을 제공합니다. 초기 로드 요청의 성능은 다음을 통해 향상시킬 수 있습니다.

  • 번들: 여러 파일을 하나로 결합합니다.
  • 축소: 공백과 주석을 제거하여 파일 크기를 줄입니다.

권장 사항:

  • 호환되는 도구를 언급하고 ASP.NET Core의 environment 태그를 사용하여 DevelopmentProduction 환경을 모두 처리하는 묶음 및 축소 지침사용합니다.
  • 복합 클라이언트 자산 관리를 위해 다른 타사 도구(예: Webpack)의 사용을 고려합니다.

응답 압축

응답 크기를 줄이면 일반적으로 앱의 응답성이 크게 향상됩니다. 페이로드 크기를 줄이는 한 가지 방법은 앱의 응답을 압축하는 것입니다. 자세한 내용은 응답 압축을 참조하세요.

최신 ASP.NET Core 릴리스 사용

ASP.NET Core의 새 릴리스에는 성능 향상 기능이 포함되어 있습니다. .NET Core 및 ASP.NET Core의 최적화는 일반적으로 최신 버전이 이전 버전보다 나은 성능을 제공하게 됨을 의미합니다. 예를 들어 .NET Core 2.1은 컴파일된 정규식을 추가적으로 지원하며 범위<T>에 따른 이점을 제공합니다. ASP.NET Core 2.2에는 HTTP/2에 대한 지원이 추가되었습니다. ASP.NET Core 3.0에는 메모리 사용을 줄이고 처리량을 향상시키는 다음과 같은 여러 개선 사항이 추가되었습니다. 성능이 우선되는 경우 현재 버전의 ASP.NET Core로 업그레이드하는 것이 좋습니다.

예외 최소화

예외는 드물게 발생합니다. 예외 throw 및 catch는 다른 코드 흐름 패턴에 비해 속도가 느립니다. 따라서 일반적인 프로그램 흐름을 제어하는 데는 예외를 사용하지 않아야 합니다.

권장 사항:

  • 특히 핫 코드 경로에서 일반적인 프로그램 흐름의 수단으로 예외 throw 또는 catch를 사용하지 않도록 합니다.
  • 앱에 예외를 유발하는 조건을 감지하고 처리하는 논리를 포함합니다.
  • 비정상적이거나 예기치 않은 조건에 대한 예외를 throw하거나 catch합니다.

Application Insights와 같은 앱 진단 도구는 앱에서 성능에 영향을 줄 수 있는 일반적인 예외를 식별하는 데 유용할 수 있습니다.

HttpRequest/HttpResponse 본문에서 동기식 읽기 또는 쓰기 방지

ASP.NET Core의 모든 I/O는 비동기식입니다. 서버는 동기 및 비동기 오버로드를 모두 포함하는 Stream 인터페이스를 구현합니다. 스레드 풀 스레드를 차단하지 않도록 비동기식 오버로드를 사용하는 것이 좋습니다. 스레드를 차단하면 스레드 풀이 고갈될 수 있습니다.

수행하지 말아야 할 작업: 다음 예제에서는 ReadToEnd를 사용합니다. 이 문은 결과를 대기하는 현재 스레드를 차단합니다. 이것은 sync over async의 예입니다.

public class BadStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public ActionResult<ContosoData> Get()
    {
        var json = new StreamReader(Request.Body).ReadToEnd();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }
}

위의 코드에서 Get은 동기식으로 전체 HTTP 요청 본문을 메모리로 읽습니다. 클라이언트가 느리게 업로드하는 경우 앱이 sync over async를 수행하는 것입니다. Kestrel은 동기식 읽기를 지원하지 않으므로 앱은 sync over async를 수행합니다.

수행할 작업: 다음 예제에서는 ReadToEndAsync를 사용하고 읽는 동안 스레드를 차단하지 않습니다.

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        var json = await new StreamReader(Request.Body).ReadToEndAsync();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }

}

위의 코드는 비동기식으로 전체 HTTP 요청 본문을 메모리로 읽습니다.

Warning

요청이 클 경우 전체 HTTP 요청 본문을 메모리로 읽으면 OOM(메모리 부족) 상태가 발생할 수 있습니다. OOM으로 인해 서비스 거부가 발생할 수 있습니다. 자세한 내용은 이 문서의 대량 요청 본문 또는 응답 본문을 메모리로 읽지 않음을 참조하세요.

수행할 작업: 다음 예제는 버퍼링되지 않은 요청 본문을 사용하는 완전 비동기식입니다.

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
    }
}

위의 코드는 요청 본문을 C# 개체로 비동기식으로 deserialize합니다.

Request.Form보다 ReadFormAsync 선호

HttpContext.Request.ReadFormAsync 대신 HttpContext.Request.Form를 사용합니다. HttpContext.Request.Form은 다음과 같은 경우에만 안전하게 읽을 수 있습니다.

  • ReadFormAsync를 호출하여 양식을 읽은 경우 및
  • 캐시된 양식 값을 HttpContext.Request.Form을 사용하여 읽는 경우

수행하지 말아야 할 작업: 다음 예제에서는 HttpContext.Request.Form을 사용합니다. HttpContext.Request.Formsync over async를 사용하고 스레드 풀 고갈을 발생시킬 수 있습니다.

public class BadReadController : Controller
{
    [HttpPost("/form-body")]
    public IActionResult Post()
    {
        var form =  HttpContext.Request.Form;

        Process(form["id"], form["name"]);

        return Accepted();
    }

수행할 작업: 다음 예제에서는 HttpContext.Request.ReadFormAsync를 사용하여 양식 본문을 비동기식으로 읽습니다.

public class GoodReadController : Controller
{
    [HttpPost("/form-body")]
    public async Task<IActionResult> Post()
    {
       var form = await HttpContext.Request.ReadFormAsync();

        Process(form["id"], form["name"]);

        return Accepted();
    }

대량 요청 본문 또는 응답 본문을 메모리로 읽지 않음

.NET에서 85,000바이트보다 크거나 같은 모든 개체 할당은 LOH(큰 개체 힙)끝납니다. 대형 개체는 다음과 같은 두 가지 이유로 비용이 많이 듭니다.

  • 새로 할당된 대형 개체의 메모리를 지워야 하므로 할당 비용이 높습니다. CLR은 새로 할당된 모든 개체의 메모리를 지울 것을 보장합니다.
  • LOH는 힙과 rest 함께 수집됩니다. LOH에는 전체 가비지 수집 또는 2세대 수집이 필요합니다.

블로그 게시물에서 이 문제를 간략하게 설명합니다.

대형 개체가 할당되면 2세대 개체로 표시됩니다. 0세대는 소형 개체용이 아닙니다. LOH에서 메모리가 부족해지면 GC는 LOH 뿐만 아니라 관리되는 힙 전체를 정리합니다. 따라서 LOH를 포함하여 0세대, 1세대 및 2세대를 정리합니다. 이를 전체 가비지 수집이라고 하며 가장 시간이 많이 걸리는 가비지 수집입니다. 많은 애플리케이션에서는 이러한 수집을 사용해도 괜찮습니다. 그러나 평균 웹 요청을 처리하는 데 필요한 큰 메모리 버퍼가 거의 없는 고성능 웹 서버는 아닙니다(소켓에서 읽기, 압축 해제, JSON 디코딩 등).

단일 byte[] 또는 string에 대량 요청 또는 응답 본문을 저장하면 다음 결과가 나타납니다.

  • LOH의 공간이 빠르게 부족해질 수 있습니다.
  • 전체 GC가 실행되므로 앱의 성능 문제가 발생할 수 있습니다.

동기 데이터 처리 API 작업

동기 읽기 및 쓰기만 지원하는 serializer/deserializer(예: Json.NET)를 사용하는 경우:

  • 데이터를 serializer/deserializer로 전달하기 전에 비동기식으로 메모리에 버퍼링합니다.

Warning

요청이 크면 OOM(메모리 부족) 상태가 발생할 수 있습니다. OOM으로 인해 서비스 거부가 발생할 수 있습니다. 자세한 내용은 이 문서의 대량 요청 본문 또는 응답 본문을 메모리로 읽지 않음을 참조하세요.

ASP.NET Core 3.0은 JSON serialization을 위해 기본적으로 System.Text.Json을 사용합니다. System.Text.Json:

  • JSON을 비동기적으로 읽고 씁니다.
  • UTF-8 텍스트에 최적화되어 있습니다.
  • 일반적으로 Newtonsoft.Json보다 성능이 높습니다.

IHttpContextAccessor.HttpContext를 필드에 저장하지 않음

IHttpContextAccessor.HttpContext는 요청 스레드에서 액세스될 때 활성 요청의 HttpContext를 반환합니다. IHttpContextAccessor.HttpContext는 필드 또는 변수에 저장하지 않아야 합니다.

수행하지 말아야 할 작업: 다음 예제에서는 HttpContext를 필드에 저장한 다음, 나중에 사용하려고 시도합니다.

public class MyBadType
{
    private readonly HttpContext _context;
    public MyBadType(IHttpContextAccessor accessor)
    {
        _context = accessor.HttpContext;
    }

    public void CheckAdmin()
    {
        if (!_context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

위의 코드는 생성자에서 null 또는 잘못된 HttpContext를 캡처하는 경우가 많습니다.

수행할 작업: 다음 예제에서:

  • IHttpContextAccessor를 필드에 저장합니다.
  • 올바른 시간에 HttpContext 필드를 사용하고 null이 있는지 확인합니다.
public class MyGoodType
{
    private readonly IHttpContextAccessor _accessor;
    public MyGoodType(IHttpContextAccessor accessor)
    {
        _accessor = accessor;
    }

    public void CheckAdmin()
    {
        var context = _accessor.HttpContext;
        if (context != null && !context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

여러 스레드에서 HttpContext에 액세스하지 않음

HttpContext은 스레드로부터 안전하지 않습니다. 여러 스레드에서 병렬로 액세스하면 HttpContext 응답, 충돌 및 데이터 손상을 중지하는 서버와 같은 예기치 않은 동작이 발생할 수 있습니다.

수행하지 말아야 할 작업: 다음 예제에서는 세 개의 병렬 요청을 만들고 나가는 HTTP 요청 전후에 들어오는 요청 경로를 로깅합니다. 요청 경로는 여러 스레드에서 동시에 액세스할 수 있습니다.

public class AsyncBadSearchController : Controller
{       
    [HttpGet("/search")]
    public async Task<SearchResults> Get(string query)
    {
        var query1 = SearchAsync(SearchEngine.Google, query);
        var query2 = SearchAsync(SearchEngine.Bing, query);
        var query3 = SearchAsync(SearchEngine.DuckDuckGo, query);

        await Task.WhenAll(query1, query2, query3);

        var results1 = await query1;
        var results2 = await query2;
        var results3 = await query3;

        return SearchResults.Combine(results1, results2, results3);
    }       

    private async Task<SearchResults> SearchAsync(SearchEngine engine, string query)
    {
        var searchResults = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.", 
                                    HttpContext.Request.Path);
            searchResults = _searchService.Search(engine, query);
            _logger.LogInformation("Finishing search query from {path}.", 
                                    HttpContext.Request.Path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed query from {path}", 
                             HttpContext.Request.Path);
        }

        return await searchResults;
    }

수행할 작업: 다음 예제에서는 세 개의 병렬 요청을 만들기 전에 들어오는 요청에서 모든 데이터를 복사합니다.

public class AsyncGoodSearchController : Controller
{       
    [HttpGet("/search")]
    public async Task<SearchResults> Get(string query)
    {
        string path = HttpContext.Request.Path;
        var query1 = SearchAsync(SearchEngine.Google, query,
                                 path);
        var query2 = SearchAsync(SearchEngine.Bing, query, path);
        var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path);

        await Task.WhenAll(query1, query2, query3);

        var results1 = await query1;
        var results2 = await query2;
        var results3 = await query3;

        return SearchResults.Combine(results1, results2, results3);
    }

    private async Task<SearchResults> SearchAsync(SearchEngine engine, string query,
                                                  string path)
    {
        var searchResults = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.",
                                   path);
            searchResults = await _searchService.SearchAsync(engine, query);
            _logger.LogInformation("Finishing search query from {path}.", path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed query from {path}", path);
        }

        return await searchResults;
    }

요청이 완료된 후에 HttpContext를 사용하지 않음

HttpContext는 ASP.NET Core 파이프라인에 활성 HTTP 요청이 있는 경우에만 유효합니다. 전체 ASP.NET Core 파이프라인은 모든 요청을 실행하는 대리자의 비동기 체인입니다. 이 체인에서 반환된 Task가 완료되면 HttpContext가 재활용됩니다.

수행하지 말아야 할 작업: 다음 예제에서는 async void를 사용하여 첫 번째 await에 도달할 때 HTTP 요청이 완료되도록 합니다.

  • async void을 사용하는 것은 ASP.NET Core 앱에서 항상 잘못된 사례입니다.
  • 예제 코드는 HTTP 요청이 완료된 후 HttpResponse에 액세스합니다.
  • 늦은 액세스가 프로세스와 충돌합니다.
public class AsyncBadVoidController : Controller
{
    [HttpGet("/async")]
    public async void Get()
    {
        await Task.Delay(1000);

        // The following line will crash the process because of writing after the 
        // response has completed on a background thread. Notice async void Get()

        await Response.WriteAsync("Hello World");
    }
}

수행할 작업: 다음 예제에서는 프레임워크에 대해 Task를 반환하므로 작업이 완료될 때까지 HTTP 요청이 완료되지 않습니다.

public class AsyncGoodTaskController : Controller
{
    [HttpGet("/async")]
    public async Task Get()
    {
        await Task.Delay(1000);

        await Response.WriteAsync("Hello World");
    }
}

백그라운드 스레드에서 HttpContext를 캡처하지 않음

수행하지 말아야 할 작업: 다음 예제에서는 클로저가 Controller 속성에서 HttpContext를 캡처하는 방법을 보여 줍니다. 작업 항목이 다음과 같을 수 있으므로 이것은 잘못된 사례입니다.

  • 요청 범위 외부에서 실행됩니다.
  • 잘못된 HttpContext를 읽으려고 합니다.
[HttpGet("/fire-and-forget-1")]
public IActionResult BadFireAndForget()
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        var path = HttpContext.Request.Path;
        Log(path);
    });

    return Accepted();
}

수행할 작업: 다음 예제에서:

  • 요청하는 동안 백그라운드 작업으로 필요한 데이터를 복사합니다.
  • 컨트롤러에서 아무 것도 참조하지 않습니다.
[HttpGet("/fire-and-forget-3")]
public IActionResult GoodFireAndForget()
{
    string path = HttpContext.Request.Path;
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        Log(path);
    });

    return Accepted();
}

백그라운드 작업은 호스팅된 서비스로 구현되어야 합니다. 자세한 내용은 호스티드 서비스를 사용하는 백그라운드 작업을 참조하세요.

백그라운드 스레드에서 컨트롤러에 삽입된 서비스를 캡처하지 않도록 합니다.

수행하지 말아야 할 작업: 다음 예제에서는 클로저가 Controller 작업 매개 변수에서 DbContext를 캡처하는 방법을 보여 줍니다. 이것은 잘못된 방법입니다. 작업 항목은 요청 범위 외부에서 실행될 수 있습니다. ContosoDbContext가 요청으로 범위가 지정되고 ObjectDisposedException이 발생합니다.

[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1([FromServices]ContosoDbContext context)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        context.Contoso.Add(new Contoso());
        await context.SaveChangesAsync();
    });

    return Accepted();
}

수행할 작업: 다음 예제에서:

  • 백그라운드 작업 항목에서 범위를 만들기 위해 IServiceScopeFactory를 삽입합니다. IServiceScopeFactory는 싱글톤입니다.
  • 백그라운드 스레드에서 새 종속성 삽입 범위를 만듭니다.
  • 컨트롤러에서 아무 것도 참조하지 않습니다.
  • 들어오는 요청에서 ContosoDbContext를 캡처하지 않도록 합니다.
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using (var scope = serviceScopeFactory.CreateAsyncScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

강조 표시된 다음 코드:

  • 백그라운드 작업의 수명 범위를 만들고 해당 작업에서 서비스를 확인합니다.
  • 올바른 범위에서 ContosoDbContext를 사용합니다.
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using (var scope = serviceScopeFactory.CreateAsyncScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

응답 본문이 시작된 후 상태 코드 또는 헤더를 수정하지 않음

ASP.NET Core는 HTTP 응답 본문을 버퍼링하지 않습니다. 처음 응답을 쓸 때:

  • 헤더는 해당 본문의 청크와 함께 클라이언트에 전송됩니다.
  • 응답 헤더를 더 이상 변경할 수 없습니다.

수행하지 말아야 할 작업: 다음 코드는 응답이 이미 시작된 후 응답 헤더를 추가하려고 합니다.

app.Use(async (context, next) =>
{
    await next();

    context.Response.Headers["test"] = "test value";
});

위의 코드에서 context.Response.Headers["test"] = "test value";next()가 응답에 기록될 경우 예외를 throw합니다.

수행할 작업: 다음 예제에서는 헤더를 수정하기 전에 HTTP 응답이 시작되었는지를 확인합니다.

app.Use(async (context, next) =>
{
    await next();

    if (!context.Response.HasStarted)
    {
        context.Response.Headers["test"] = "test value";
    }
});

수행할 작업: 다음 예제에서는 HttpResponse.OnStarting을 사용하여 응답 헤더가 클라이언트에 플러시되기 전에 헤더를 설정합니다.

응답이 시작되지 않았는지 확인하면 응답 헤더를 쓰기 직전에 호출되는 콜백을 등록할 수 있습니다. 응답이 시작되지 않았는지 확인하면 다음 결과가 나타납니다.

  • 헤더를 적시에 추가하거나 재정의하는 기능을 제공합니다.
  • 파이프라인의 다음 미들웨어에 대한 정보를 요구하지 않습니다.
app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers["someheader"] = "somevalue";
        return Task.CompletedTask;
    });

    await next();
});

응답 본문에 대한 쓰기를 이미 시작한 경우 next()를 호출하지 마세요.

구성 요소는 응답을 처리하고 조작할 수 있는 경우에만 호출될 것으로 예상됩니다.

IIS에서 In-process 호스팅 사용

In-Process 호스팅을 사용하면 ASP.NET Core 앱은 IIS 작업자 프로세스와 동일한 프로세스에서 실행됩니다. In-process 호스트는 요청이 루프백 어댑터를 통해 프록시되지 않으므로 out-of-process 호스팅보다 향상된 성능을 제공합니다. 루프백 어댑터는 나가는 네트워크 트래픽을 동일한 머신으로 다시 반환하는 네트워크 인터페이스입니다. IIS는 Windows Process Activation Service(WAS)를 사용하여 프로세스 관리를 처리합니다.

프로젝트는 ASP.NET Core 3.0 이상에서 기본적으로 In Process 호스팅 모델입니다.

자세한 내용은 IIS가 있는 Windows에서 ASP.NET Core 호스팅을 참조하세요.

HttpRequest.ContentLength가 null이 아니라고 가정하지 마세요.

Content-Length 헤더가 수신되지 않은 경우 HttpRequest.ContentLength가 null입니다. 이 경우 Null은 요청 본문의 길이를 알 수 없음을 의미합니다. 길이가 0인 것은 아닙니다. null을 사용하는 모든 비교(== 제외)는 false를 반환하므로, 예를 들어 요청 본문 크기가 1024보다 큰 경우 비교Request.ContentLength > 1024, false가 반환될 수 있습니다. 이를 알지 못하면 앱의 보안 허점이 발생할 수 있습니다. 그렇지 않은 경우 너무 큰 요청으로부터 보호하고 있다고 생각할 수 있습니다.

자세한 내용은 StackOverflow 답변을 참조하세요.

신뢰할 수 있는 웹앱 패턴

신뢰할 수 있는 웹앱 패턴 for.NET YouTube 비디오문서를 참조하여 처음부터 또는 기존 앱을 리팩터링하든 관계없이 최신의 안정적이고 성능이 뛰어나고 테스트 가능하고 비용 효율적이며 확장 가능한 ASP.NET Core 앱을 만드는 방법에 대한 지침을 참조하세요.