Minimal API vs Controller 성능 비교 (1000배 차이)

asp.net core 8으로 테스트 했습니다. 아마 core 3.1에서도 현상이 비슷했던걸로…

테스트 방법

NBomber를 사용하여 10MBytes 요청을 초당 10번/30초 부하 테스트.

public record Dto
{
    public byte[] Data { get; init; }
}

minimal api

app.MapPost("minimal", (Dto dto) => Results.Ok(new
{
    Message = "Data received successfully.",
    DataLength = dto.Data.Length
}));


mvc controller

[Route("controller")]
public class PostController : ControllerBase
{

    [HttpPost]
    public IActionResult GetPosts([FromBody]Dto dto) => Ok(new
    {
        Message = "Data received successfully.",
        DataLength = dto.Data.Length
    });
}


결론

minimal api 의 응답 속도는 23ms, controller의 응답 속도는 21610ms 입니다.
정말 말도 안되는 성능 차이인데 controller 성능 문제에 대해 수년 동안 방치 하는게 이해하기 매우 어렵네요.

5개의 좋아요

미니멀 api 가 빠르다고는 알고 있었지만, 이 정도나 차이가 나는지는 몰랐네요.

보통 차이가 15% 정도로 알려져 있는데, 이렇게 차이가 나는 이유가 궁금해집니다.

2개의 좋아요

간단하게 벤치마크를 만들어 돌려봤습니다.

Minimal API vs MVC Controller Benchmark

Method Size Mean Ratio Gen0 Allocated Alloc Ratio
MinimalApi 1 KB 73.21 us 1.00 - 6.43 KB 1.00
Controller 1 KB 164.50 us 2.26 - 97.71 KB 15.20
MinimalApi 1 MB 7,813.06 us 1.01 - 1.0 MB 1.00
Controller 1 MB 67,046.98 us 8.69 333.3333 112.2 MB 109.97
MinimalApi 10 MB 22,834.96 us 1.00 - 10 MB 1.00
Controller 10 MB 627,384.67 us 27.59 3000.0000 1133 MB 111.15

Minimal API가 Controller 방식보다 훨씬 가볍고 빠르다는 것은 분명하지만, 이 결과는 일반적인 호출 성능 차이라기보다는, 테스트에 사용된 Dto 객체의 구조로 인해 발생한 엣지 케이스로 보입니다.

메모리 사용량을 보면 10 MB vs 1133 MB로 매우 큰 차이를 보이는데, 이는 아마도 JSON 역직렬화 과정에서 Minimal API는 네트워크 스트림으로부터 JSON 데이터를 Dto 객체로 직접 처리한 반면, Controller는 [FromBody] 처리에 의해 문자열 데이터를 버퍼링 한 뒤 처리 과정을 거쳤기 때문일 것으로 추측됩니다.

일반적인 호출 케이스에 대한 성능 비교는 아래의 글에서도 참고할 수 있습니다.

6개의 좋아요

controller는 현재 pitfall이 존재하는 상황인거지요.

“C# 성능 좋다며 응답 지연 시간 왜이래?”

식은땀이…

2개의 좋아요

문제를 좀 더 관찰해 보면 흥미로운 부분을 찾을 수 있습니다.
dotTrace를 사용하여 호출 스택을 관찰해 보았습니다.

Microsoft.AspNetCore.Mvc.ModelBinding.Validation.ValidationVisitor.VisitChildren(IValidationStrategy)

매우 이상해 보이는 부분이 쉽게 발견되었군요 :upside_down_face:

스택오버플로우의 도움을 받아 Model Validation을 비활성화 해 봅니다.

[ValidateNever]
public record Dto
{
    public byte[] Data { get; init; }
}


성능 문제가 개선되었습니다.

MVC의 Model Validation이 성능 저하의 주된 요인으로 작용하는군요.

8개의 좋아요

JSON 처리와 관련돼 있을거라 추측했었는데 다른 곳에 원인이 있었네요:sweat_smile:

원래 byte[] 형식의 Parameter는 자동 단락 규칙에 의해 유효성 검사에서 제외되어야 하지만 Dto 형식에 중첩되어 넘어왔기 때문에 Complex Type Validation 루틴을 타게 된 것 같습니다.

올려주신 ValidationVisitor.VisitChildren 부분의 소스코드를 살펴보면, Model 속성은 Parameter로 들어온 Dto 객체를 가리키며, 각 속성의 하위 요소들에 대해 재귀적으로 Validation을 수행합니다.

protected virtual bool VisitChildren(IValidationStrategy strategy)
{
    Debug.Assert(Metadata is not null && Key is not null && Model is not null);

    var isValid = true;
    var enumerator = strategy.GetChildren(Metadata, Key, Model);
    var parentEntry = new ValidationEntry(Metadata, Key, Model);

    while (enumerator.MoveNext())
    {
        var entry = enumerator.Current;
        var metadata = entry.Metadata;
        var key = entry.Key;
        if (metadata.PropertyValidationFilter?.ShouldValidateEntry(entry, parentEntry) == false)
        {
            SuppressValidation(key);
            continue;
        }

        isValid &= Visit(metadata, key, entry.Model);
    }

    return isValid;
}

(aspnetcore/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidationVisitor.cs at d12915f18974ae45826ac7475c5c87aaef218615 · dotnet/aspnetcore · GitHub)

여기서 byte[] 형식인 Data 속성에 대한 처리를 수행할 때 엄청난 일이 일어납니다.

public static string CreateIndexModelName(string parentName, string index)
{
    return (parentName.Length == 0) ? "[" + index + "]" : parentName + "[" + index + "]";
}

(aspnetcore/src/Mvc/Mvc.Core/src/ModelBinding/ModelNames.cs at d12915f18974ae45826ac7475c5c87aaef218615 · dotnet/aspnetcore · GitHub)

위의 코드에서는 10 MB에 이르는 byte[] 배열의 모든 요소에 대한 ValidationEntryKey속성 값을 생성하기 위해 "Data[0]", "Data[1]", "Data[2]",…,"Data[10485759]" 이렇게 모든 시퀀스의 이름을 문자열로 생성하게 되는데요, 이 부분이 dotTrace에 보이는 string.Concat(string,string,string,string) (parentName + "[" + index + "]")의 동작입니다. (10 MB 크기 배열이라면 이 문자열에 대한 힙 할당 크기만 약 400 MB​:astonished:)

Complex Type 객체의 하위 요소에 대해서도 자동 단락 규칙이 적용되어야 할 것 같은데, 이 부분을 사용자에게 맡긴 것은 MS 나름의 이유가 있지 않나 싶습니다.

public record Dto
{
	[ValidateNever]
	public required byte[] Data { get; init; }
}

아래는 벤치마크 입니다.

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3775)
12th Gen Intel Core i7-12700H, 1 CPU, 20 logical and 14 physical cores
.NET SDK 9.0.203
  [Host]     : .NET 8.0.15 (8.0.1525.16413), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.15 (8.0.1525.16413), X64 RyuJIT AVX2
Method Size Mean Ratio Allocated Alloc Ratio
MinimalApi 1 KB 67.71 us 1.00 6.42 KB 1.00
Controller 1 KB 77.92 us 1.15 10.5 KB 1.63
MinimalApi 1 MB 7.84 ms 1.02 1.05 MB 1.00
Controller 1 MB 8.20 ms 1.07 1.28 MB 0.98
MinimalApi 10 MB 22.25 ms 1.01 10.07 MB 1.00
Controller 10 MB 21.91 ms 0.99 10.07 MB 1.00

이러한 사례들을 공유해주셔서 프레임워크에 대한 이해가 조금씩 쌓이는 것 같습니다.

이렇게 또 한 가지 배워갑니다. :blush:

10개의 좋아요

.net 10 부터 미니멀 api 도 Model validation 이 지원된다고 하니, 이점은 주의해야겠습니다.

7개의 좋아요

source generator 기반으로 MVC Model Validation과는 또 다른 물건 같군요.
.net 10 preview 3에서도 테스트 해 볼 수 있습니다. interceptor를 사용합니다.

우선 발견한 불편사항은… record 타입의 dto를 지원하지 않는군요. :flushed:

3개의 좋아요

이 제한은 문제가 되지 않을 수 있습니다.

Restful Api 에서, 요청 바디에 포함된 모델에 대한 유효성 검증이 필요한 경우는 Post, Put 요청인데, 클라이언트 입장에서 이 모델은 mutable 한 편이 좋습니다.

예를 들어, Post 요청 바디에 포함될 모델이 클래스로 선언된 경우,

class CreatePersonRequest
{
   public string Name { get; set; } = "";
}

클라이언트에서는, 별도의 Input 모델을 선언하지 않고, 이 모델을 사용하여 사용자 입력과 바인딩하고, api 로 바로 보낼 수 있습니다.

만약, 이 모델이 record 로 선언되어 있다면, record 는 사용자 입력과 바인딩할 수 없기에, 바인딩 용 class 를 별도로 선언하고, 이를 다시 record 로 변환해야 하는 추가적인 코드가 필요합니다.

저는 프론트 엔드와 백엔드에 동일한 C# 언어를 사용하는데, Post 와 Put 용 모델은 항상 class 로 선언해서 공용으로 사용할 수 있도록 합니다.

3개의 좋아요