ASP.NET Core Blazor 성능 모범 사례

참고 항목

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

Important

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

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

Blazor는 가장 현실적인 애플리케이션 UI 시나리오에서 고성능을 구현하도록 최적화되어 있습니다. 그러나 최상의 성능을 위해서는 개발자가 적합한 패턴과 기능을 채택해야 합니다.

참고 항목

이 문서의 코드 예제에서는 NRT(nullable 참조 형식) 및 .NET 컴파일러 null 상태 정적 분석을 채택합니다. 이 분석은 .NET 6 이상의 ASP.NET Core에서 지원됩니다.

렌더링 속도 최적화

렌더링 속도를 최적화하여 렌더링 워크로드를 최소화하고 UI 응답성을 향상합니다. 그러면 UI 렌더링 속도가 ‘10배 이상 향상’될 수 있습니다.

불필요한 구성 요소 하위 트리 렌더링 방지

이벤트가 발생할 때 자식 구성 요소 하위 트리의 렌더링을 건너뛰면 부모 구성 요소의 렌더링 비용을 대부분 제거할 수 있습니다. 렌더링 비용이 특히 많이 들고 UI 지연을 발생시키는 하위 트리의 다시 렌더링을 건너뛰는 부분만 신경쓰면 됩니다.

런타임에 구성 요소는 계층 구조에 있습니다. 루트 구성 요소(로드된 첫 번째 구성 요소)에는 자식 구성 요소가 있습니다. 차례대로 루트의 자식에는 자체 자식 구성 요소가 있습니다. 사용자의 단추 선택과 같은 이벤트가 발생하는 경우 다음 프로세스에 따라 다시 렌더링할 구성 요소를 결정합니다.

  1. 이벤트가 이벤트 처리기를 렌더링한 구성 요소에 디스패치됩니다. 이벤트 처리기를 실행한 후 구성 요소가 다시 렌더링됩니다.
  2. 구성 요소가 다시 렌더링되면 매개 변수 값의 새 복사본을 각 자식 구성 요소에 제공합니다.
  3. 새 매개 변수 값 세트를 수신한 후 각 구성 요소는 다시 렌더링할지 여부를 선택합니다. 기본적으로 매개 변수 값이 변경되었을 수 있는 경우(예: 변경 가능한 개체인 경우) 구성 요소는 다시 렌더링됩니다.

위 시퀀스의 마지막 두 단계는 구성 요소 계층 구조 아래까지 반복적으로 계속됩니다. 대부분의 경우 전체 하위 트리는 다시 렌더링됩니다. 상위 수준 구성 요소 아래의 모든 구성 요소는 다시 렌더링되어야 하므로 상위 수준 구성 요소를 대상으로 하는 이벤트로 인해 다시 렌더링 비용이 증가할 수 있습니다.

특정 하위 트리에 대한 렌더링 반복을 방지하려면 다음 방법 중 하나를 사용하세요.

  • 자식 구성 요소 매개 변수가 변경이 불가능한 기본 형식(예: string, int, bool, DateTime, 기타 유사한 형식)인지 확인합니다. 변경이 불가능한 기본 매개 변수 값이 변경된 경우 변경 내용을 검색하는 기본 제공 논리가 자동으로 다시 렌더링을 건너뜁니다. <Customer CustomerId="@item.CustomerId" />를 사용하여 자식 구성 요소를 렌더링하는 경우(여기서 CustomerIdint 형식임) Customer 구성 요소는 item.CustomerId가 변경되는 경우에만 다시 렌더링됩니다.
  • 재정 ShouldRender의:
    • 복잡한 사용자 지정 모델 형식, 이벤트 콜백, RenderFragment 값과 같은 기본이 아닌 매개 변수 값을 허용하려는 경우입니다.
    • 매개 변수 값 변경에 관계없이 초기 렌더링 이후 변경되지 않는 UI 전용 구성 요소를 작성하는 경우입니다.

다음 항공편 검색 도구 예제에서는 프라이빗 필드를 사용하여 변경 내용을 검색하는 데 필요한 정보를 추적합니다. 이전 인바운드 항공편 식별자(prevInboundFlightId)와 이전 아웃바운드 항공편 식별자(prevOutboundFlightId)는 잠재적인 다음 구성 요소 업데이트에 대한 정보를 추적합니다. OnParametersSet에서 구성 요소의 매개 변수를 설정할 때 항공편 식별자 중 하나가 변경되면 shouldRendertrue로 설정되어 있기 때문에 구성 요소가 다시 렌더링됩니다. 항공편 식별자를 확인한 후 shouldRenderfalse로 평가되면 비용이 많이 드는 다시 렌더링을 수행하지 않아도 됩니다.

@code {
    private int prevInboundFlightId = 0;
    private int prevOutboundFlightId = 0;
    private bool shouldRender;

    [Parameter]
    public FlightInfo? InboundFlight { get; set; }

    [Parameter]
    public FlightInfo? OutboundFlight { get; set; }

    protected override void OnParametersSet()
    {
        shouldRender = InboundFlight?.FlightId != prevInboundFlightId
            || OutboundFlight?.FlightId != prevOutboundFlightId;

        prevInboundFlightId = InboundFlight?.FlightId ?? 0;
        prevOutboundFlightId = OutboundFlight?.FlightId ?? 0;
    }

    protected override bool ShouldRender() => shouldRender;
}

이벤트 처리기도 shouldRendertrue로 설정할 수 있습니다. 일반적으로 대부분의 구성 요소에 대해 개별 이벤트 처리기 수준에서 다시 렌더링을 결정할 필요가 없습니다.

자세한 내용은 다음 리소스를 참조하세요.

가상화

수천 개 항목을 포함하는 목록 또는 그리드와 같은 많은 양의 UI를 루프 내에서 렌더링할 때 렌더링 작업량이 많으면 UI 렌더링이 지연될 수 있습니다. 사용자가 스크롤하지 않고 한 번에 적은 수의 요소만 볼 수 있다면 현재 표시되지 않는 요소를 렌더링하는 데 불필요하게 시간이 소요될 수 있습니다.

Blazor는 임의로 커질 수 있는 목록의 모양 및 스크롤 동작을 만들지만 현재 스크롤 뷰포트에 있는 목록 항목만 렌더링하는 Virtualize<TItem> 구성 요소를 제공합니다. 예를 들어 구성 요소는 100,000개 항목이 포함된 목록을 렌더링할 수 있지만 표시되는 20개 항목의 렌더링 비용만 지급합니다.

자세한 내용은 ASP.NET Core Razor 구성 요소 유효성 검사를 참조하세요.

간단하고 최적화된 구성 요소 만들기

대부분의 구성 요소가 UI에서 반복되지 않고 자주 다시 렌더링되지 않기 때문에 대부분의 Razor 구성 요소에 적극적인 최적화 작업이 필요하지 않습니다. 예를 들어 @page 지시문을 사용하는 라우팅 가능한 구성 요소와 상위 수준의 UI(예: 대화 상자 또는 양식)를 렌더링하는 데 사용되는 구성 요소는 한 번에 하나만 표시되고 사용자 제스처에 대한 응답으로만 다시 렌더링될 가능성이 많습니다. 일반적으로 이러한 구성 요소의 렌더링 워크로드는 많지 않으므로 렌더링 성능을 걱정하지 않고도 원하는 프레임워크 기능의 조합을 자유롭게 사용할 수 있습니다.

그러나 구성 요소가 대규모로 반복되고 종종 UI 성능이 저하되는 일반적인 시나리오가 있습니다.

  • 입력 또는 레이블과 같은 수백 개의 개별 요소가 포함된 대규모 중첩된 양식
  • 수백 개의 행 또는 수천 개의 셀이 있는 그리드
  • 수백만 개의 데이터 포인트가 있는 산점도

각 요소, 셀 또는 데이터 포인트를 개별 구성 요소 인스턴스로 모델링하는 경우 렌더링 성능이 중요한 경우가 많습니다. 이 섹션에서는 UI가 빠르고 뛰어난 응답성을 유지하도록 해당 구성 요소를 간단하게 만드는 방법에 관한 지침을 제공합니다.

수천 개의 구성 요소 인스턴스 방지

각 구성 요소는 부모 및 자식과 독립적으로 렌더링할 수 있는 분리된 섬입니다. UI를 구성 요소 계층 구조로 분할하는 방법을 선택하면 UI 렌더링의 세분성을 제어하게 됩니다. 그러면 성능이 향상되거나 저하될 수 있습니다.

UI를 별도의 구성 요소로 분할하면 이벤트가 발생할 때 다시 렌더링되는 UI 부분을 줄일 수 있습니다. 각 행에 단추가 있는 행이 많은 테이블에서 전체 페이지나 테이블 대신 자식 구성 요소를 사용하면 단일 행만 다시 렌더링되도록 할 수 있습니다. 그러나 각 구성 요소에서 독립적인 상태 및 렌더링 수명 주기를 처리하려면 추가 메모리 및 CPU 오버헤드가 필요합니다.

ASP.NET Core 제품 단위 엔지니어가 수행한 테스트에서는 구성 요소 인스턴스당 약 0.06ms의 렌더링 오버헤드가 Blazor WebAssembly 앱에서 확인되었습니다. 테스트 앱은 세 개의 매개 변수를 허용하는 간단한 구성 요소를 렌더링했습니다. 내부적으로 대부분 오버헤드는 사전에서 구성 요소별 상태를 검색하고 매개 변수를 전달 및 수신하기 때문에 발생합니다. 곱하기를 통해 2,000개 추가 구성 요소 인스턴스를 추가하면 렌더링 시간이 0.12초 늘어나고 사용자에게 UI가 느리게 느껴지는 것을 알 수 있습니다.

구성 요소를 더 간단하게 만들어 더 많이 포함하도록 할 수 있습니다. 그러나 너무 많은 구성 요소가 렌더링되지 않도록 하는 것이 보다 효과적인 기법입니다. 다음 섹션에서는 사용할 수 있는 두 가지 접근 방식을 설명합니다.

메모리 관리에 대한 자세한 내용은 ASP.NET Core 서버 쪽 Blazor 앱 호스트 및 배포를 참조하세요.

자식 구성 요소를 부모에 인라인

루프에서 자식 구성 요소를 렌더링하는 부모 구성 요소의 다음 부분을 고려합니다.

<div class="chat">
    @foreach (var message in messages)
    {
        <ChatMessageDisplay Message="message" />
    }
</div>

ChatMessageDisplay.razor:

<div class="chat-message">
    <span class="author">@Message.Author</span>
    <span class="text">@Message.Text</span>
</div>

@code {
    [Parameter]
    public ChatMessage? Message { get; set; }
}

위의 예제는 수천 개의 메시지가 한 번에 표시되지 않는 한 제대로 작동합니다. 한 번에 수천 개의 메시지를 표시하려면 별도의 ChatMessageDisplay 구성 요소를 분리하지 ‘않는’ 것이 좋습니다. 대신 자식 구성 요소를 부모에 인라인합니다. 다음 방법에서는 각 자식 구성 요소의 태그를 개별적으로 다시 렌더링할 수는 없지만 너무 많은 자식 구성 요소를 다시 렌더링하는 구성 요소별 오버헤드를 방지할 수 있습니다.

<div class="chat">
    @foreach (var message in messages)
    {
        <div class="chat-message">
            <span class="author">@message.Author</span>
            <span class="text">@message.Text</span>
        </div>
    }
</div>
코드에서 재사용 가능한 RenderFragments 정의

렌더링 논리를 다시 사용하는 방법으로 순수하게 자식 구성 요소를 분리할 수 있습니다. 이 경우 추가 구성 요소를 구현하지 않고도 재사용 가능한 렌더링 논리를 만들 수 있습니다. 구성 요소의 @code 블록에서 RenderFragment를 정의합니다. 필요할 때마다 모든 위치에서 조각을 렌더링합니다.

@RenderWelcomeInfo

<p>Render the welcome content a second time:</p>

@RenderWelcomeInfo

@code {
    private RenderFragment RenderWelcomeInfo = @<p>Welcome to your new app!</p>;
}

RenderTreeBuilder 코드를 여러 구성 요소에서 재사용 가능하도록 하려면 RenderFragmentpublicstatic를 선언합니다.

public static RenderFragment SayHello = @<h1>Hello!</h1>;

위의 예제에서 SayHello는 관련 없는 구성 요소에서 호출할 수 있습니다. 이 기법은 구성 요소별 오버헤드 없이 렌더링되는 재사용 가능한 태그 코드 조각의 라이브러리를 빌드하는 데 유용합니다.

RenderFragment 대리자는 매개 변수를 허용할 수 있습니다. 다음 구성 요소는 메시지(message)를 RenderFragment 대리자에 전달합니다.

<div class="chat">
    @foreach (var message in messages)
    {
        @ChatMessageDisplay(message)
    }
</div>

@code {
    private RenderFragment<ChatMessage> ChatMessageDisplay = message =>
        @<div class="chat-message">
            <span class="author">@message.Author</span>
            <span class="text">@message.Text</span>
        </div>;
}

위의 방법은 구성 요소별 오버헤드 없이 렌더링 논리를 다시 사용합니다. 그러나 이 방법은 UI의 하위 트리를 독립적으로 새로 고치는 것을 허용하지 않으며, 구성 요소 경계가 없기 때문에 부모가 렌더링할 때 UI의 하위 트리 렌더링을 건너뛰는 기능도 없습니다. RenderFragment 대리자에 대한 할당은 Razor 구성 요소 파일(.razor)에서만 지원되고 이벤트 콜백은 지원되지 않습니다.

다음 예제의 TitleTemplate과 같이 필드 이니셜라이저가 참조할 수 없는 비정적 필드, 메서드 또는 속성의 경우, RenderFragment의 필드 대신 속성을 사용합니다.

protected RenderFragment DisplayTitle =>
    @<div>
        @TitleTemplate
    </div>;

너무 많은 매개 변수를 수신하지 않음

구성 요소가 너무 자주(예: 수백 또는 수천 번) 반복되는 경우 각 매개 변수를 전달하고 받는 오버헤드가 누적될 수 있습니다.

너무 많은 매개 변수가 성능을 크게 제한하는 경우는 드물지만 한 가지 요인이 될 수 있습니다. TableCell 그리드 내에서 4,000번 렌더링하는 구성 요소의 경우 구성 요소에 전달된 각 매개 변수는 총 렌더링 비용에 약 15ms를 추가합니다. 10개의 매개 변수를 전달하려면 약 150ms가 필요하며 UI 렌더링 지연이 발생합니다.

매개 변수 로드를 줄이려면 여러 매개 변수를 사용자 지정 클래스에 번들로 묶습니다. 예를 들어 테이블 셀 구성 요소는 공용 개체를 허용할 수 있습니다. 다음 예제에서 Data는 모든 셀에서 서로 다르지만 Options는 모든 셀 인스턴스에서 공통적입니다.

@typeparam TItem

...

@code {
    [Parameter]
    public TItem? Data { get; set; }
    
    [Parameter]
    public GridOptions? Options { get; set; }
}

그러나 위의 예제와 같이 테이블 셀 구성 요소를 포함하지 않으면 성능이 향상될 수 있습니다. 대신 해당 논리를 부모 구성 요소에 인라인으로 지정합니다.

참고 항목

성능 향상을 위해 여러 가지 방법을 사용할 수 있는 경우 최상의 결과를 얻을 수 있는 방법을 결정하려면 일반적으로 벤치마킹이 필요할 수 있습니다.

제네릭 형식 매개 변수(@typeparam)에 대한 자세한 내용은 다음 리소스를 참조하세요.

연계 매개 변수가 고정되어 있는지 확인

CascadingValue 구성 요소에는 선택적 IsFixed 매개 변수가 있습니다.

  • IsFixedfalse(기본값)이면 연계된 값의 모든 수신자가 변경 알림을 받도록 구독을 설정합니다. 각 [CascadingParameter]는 구독 추적으로 인해 일반 [Parameter]보다 비용이 훨씬 더 많이 듭니다.
  • IsFixedtrue(예: <CascadingValue Value="someValue" IsFixed="true">)이면 수신자는 초기 값을 수신하지만 업데이트를 수신하도록 구독을 설정하지 않습니다. 각 [CascadingParameter]는 간단하며 일반 [Parameter] 보다 비용이 더 많이 들지 않습니다.

IsFixedtrue로 설정하면 연계된 값을 수신하는 다른 구성 요소가 많은 경우 성능이 향상됩니다. 가능하면 연계된 값에서 IsFixedtrue로 설정합니다. 제공된 값이 시간이 지남에 따라 변경되지 않는 경우 IsFixedtrue로 설정할 수 있습니다.

구성 요소가 this를 연계된 값으로 전달하면 IsFixedtrue로 설정할 수도 있습니다.

<CascadingValue Value="this" IsFixed="true">
    <SomeOtherComponents>
</CascadingValue>

자세한 내용은 ASP.NET Core Blazor 연계 값 및 매개 변수를 참조하세요.

CaptureUnmatchedValues를 사용하여 특성 스플래팅 방지

구성 요소는 CaptureUnmatchedValues 플래그를 사용하여 “일치하지 않는” 매개 변수 값을 수신하도록 선택할 수 있습니다.

<div @attributes="OtherAttributes">...</div>

@code {
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object>? OtherAttributes { get; set; }
}

이 접근 방식을 사용하면 임의 추가 특성을 요소에 전달할 수 있습니다. 그러나 이 접근 방식에서는 렌더러가 다음을 충족해야 하기 때문에 비용이 많이 듭니다.

  • 제공된 모든 매개 변수를 알려진 매개 변수 세트에 대조하여 사전을 빌드합니다.
  • 동일한 특성의 얼마나 많은 복사본이 서로 덮어쓰는지 추적합니다.

자주 반복되지 않는 구성 요소와 같이 구성 요소 렌더링 성능이 중요하지 않은 경우 CaptureUnmatchedValues를 사용합니다. 큰 목록의 각 항목이나 그리드의 셀과 같이 대규모로 렌더링되는 구성 요소의 경우 특성 스플래팅을 방지합니다.

자세한 내용은 ASP.NET Core Blazor 특성 스플래팅 및 임의 매개 변수를 참조하세요.

수동으로 SetParametersAsync 구현

구성 요소별 렌더링 오버헤드의 주요 측면 중 하나는 들어오는 매개 변수 값을 [Parameter] 속성에 기록하는 것입니다. 렌더러는 리플렉션을 사용하여 매개 변수 값을 작성하므로 대규모 성능이 저하됩니다.

일부 극단적인 경우에는 리플렉션을 피하고 자체 매개 변수 설정 논리를 수동으로 구현하려고 할 수 있습니다. 여기에 해당할 수 있는 경우는 다음과 같습니다.

  • 구성 요소가 너무 자주 렌더링됩니다(예: UI에 수백 또는 수천 개의 구성 요소 복사본이 있는 경우).
  • 구성 요소가 많은 매개 변수를 허용합니다.
  • 매개 변수를 수신하는 오버헤드가 UI 응답성에 관찰 가능한 영향을 미치는 것을 알게 된 경우.

극단적인 경우에는 구성 요소의 가상 SetParametersAsync 메서드를 재정의하고 자체 구성 요소별 논리를 구현할 수 있습니다. 다음 예제에서는 사전 조회를 의도적으로 방지합니다.

@code {
    [Parameter]
    public int MessageId { get; set; }

    [Parameter]
    public string? Text { get; set; }

    [Parameter]
    public EventCallback<string> TextChanged { get; set; }

    [Parameter]
    public Theme CurrentTheme { get; set; }

    public override Task SetParametersAsync(ParameterView parameters)
    {
        foreach (var parameter in parameters)
        {
            switch (parameter.Name)
            {
                case nameof(MessageId):
                    MessageId = (int)parameter.Value;
                    break;
                case nameof(Text):
                    Text = (string)parameter.Value;
                    break;
                case nameof(TextChanged):
                    TextChanged = (EventCallback<string>)parameter.Value;
                    break;
                case nameof(CurrentTheme):
                    CurrentTheme = (Theme)parameter.Value;
                    break;
                default:
                    throw new ArgumentException($"Unknown parameter: {parameter.Name}");
            }
        }

        return base.SetParametersAsync(ParameterView.Empty);
    }
}

이전 코드에서 기본 클래스 SetParametersAsync 를 반환하면 매개 변수를 다시 할당하지 않고 일반 수명 주기 메서드가 실행됩니다.

위의 코드에서 알 수 있듯이 SetParametersAsync를 재정의하고 사용자 지정 논리를 제공하는 것은 복잡하고 힘들기 때문에 일반적으로 이 접근 방식을 권장하지 않습니다. 극단적인 경우 렌더링 성능이 20~25%까지 향상될 수 있지만 이 접근 방식은 이 섹션의 앞부분에 나열된 극단적인 시나리오에서만 사용하는 것이 좋습니다.

이벤트를 너무 빨리 트리거하지 않음

일부 브라우저 이벤트는 너무 자주 발생합니다. 예를 들어 onmousemoveonscroll은 초당 수십 또는 수백 번 발생할 수 있습니다. 대부분의 경우 UI 업데이트는 이렇게 자주 수행할 필요가 없습니다. 이벤트가 너무 빨리 트리거되면 UI 응답성이 손상되거나 과도한 CPU 시간이 사용될 수 있습니다.

빠르게 발생되는 네이티브 이벤트를 사용하는 대신 JS interop을 사용하여 자주 발생되지 않는 콜백을 등록하는 것이 좋습니다. 예를 들어 다음 구성 요소는 마우스의 위치를 표시하지만 500ms마다 한 번만 업데이트됩니다.

@implements IDisposable
@inject IJSRuntime JS

<h1>@message</h1>

<div @ref="mouseMoveElement" style="border:1px dashed red;height:200px;">
    Move mouse here
</div>

@code {
    private ElementReference mouseMoveElement;
    private DotNetObjectReference<MyComponent>? selfReference;
    private string message = "Move the mouse in the box";

    [JSInvokable]
    public void HandleMouseMove(int x, int y)
    {
        message = $"Mouse move at {x}, {y}";
        StateHasChanged();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            selfReference = DotNetObjectReference.Create(this);
            var minInterval = 500;

            await JS.InvokeVoidAsync("onThrottledMouseMove", 
                mouseMoveElement, selfReference, minInterval);
        }
    }

    public void Dispose() => selfReference?.Dispose();
}

해당 JavaScript 코드는 마우스 이동에 대한 DOM 이벤트 수신기를 등록합니다. 이 예제에서 이벤트 수신기는 Lodash의 throttle 함수를 사용하여 호출 빈도를 제한합니다.

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js"></script>
<script>
  function onThrottledMouseMove(elem, component, interval) {
    elem.addEventListener('mousemove', _.throttle(e => {
      component.invokeMethodAsync('HandleMouseMove', e.offsetX, e.offsetY);
    }, interval));
  }
</script>

상태를 변경하지 않고 이벤트를 처리한 후 다시 렌더링하는 것을 방지합니다.

기본적으로 구성 요소는 ComponentBase에서 상속하며 StateHasChanged 구성 요소의 이벤트 처리기가 호출된 후에 자동으로 호출됩니다. 일부 경우에는 이벤트 처리기가 호출된 후 다시 렌더링을 트리거하는 것이 불필요하거나 바람직하지 않을 수 있습니다. 예를 들어 이벤트 처리기가 구성 요소 상태를 수정하지 못할 수 있습니다. 이러한 시나리오에서 앱은 IHandleEvent 인터페이스를 활용하여 Blazor의 이벤트 처리 동작을 제어할 수 있습니다.

모든 구성 요소의 이벤트 처리기가 다시 렌더링되는 것을 방지하려면 IHandleEvent를 구현하고 StateHasChanged를 호출하지 않고 이벤트 처리기를 호출하는 IHandleEvent.HandleEventAsync 작업을 제공하세요.

다음 예제에서는 구성 요소에 추가된 이벤트 처리기가 렌더링을 다시 트리거하지 않으므로 HandleSelect이 호출될 때 다시 렌더링되지 않습니다.

HandleSelect1.razor:

@page "/handle-select-1"
@using Microsoft.Extensions.Logging
@implements IHandleEvent
@inject ILogger<HandleSelect1> Logger

<p>
    Last render DateTime: @dt
</p>

<button @onclick="HandleSelect">
    Select me (Avoids Rerender)
</button>

@code {
    private DateTime dt = DateTime.Now;

    private void HandleSelect()
    {
        dt = DateTime.Now;

        Logger.LogInformation("This event handler doesn't trigger a rerender.");
    }

    Task IHandleEvent.HandleEventAsync(
        EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg);
}

이벤트 처리기가 글로벌 방식으로 구성 요소에서 발생된 후의 다시 렌더링을 방지할 뿐만 아니라 다음 유틸리티 메서드를 사용하여 단일 이벤트 처리기 후의 다시 렌더링도 방지할 수 있습니다.

Blazor 앱에 다음 EventUtil 클래스를 추가합니다. EventUtil 클래스의 가장 위에 있는 정적 작업과 함수는 Blazor가 이벤트를 처리할 때 사용하는 여러 인수 조합 및 반환 유형을 처리하는 처리기를 제공합니다.

EventUtil.cs:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;

public static class EventUtil
{
    public static Action AsNonRenderingEventHandler(Action callback)
        => new SyncReceiver(callback).Invoke;
    public static Action<TValue> AsNonRenderingEventHandler<TValue>(
            Action<TValue> callback)
        => new SyncReceiver<TValue>(callback).Invoke;
    public static Func<Task> AsNonRenderingEventHandler(Func<Task> callback)
        => new AsyncReceiver(callback).Invoke;
    public static Func<TValue, Task> AsNonRenderingEventHandler<TValue>(
            Func<TValue, Task> callback)
        => new AsyncReceiver<TValue>(callback).Invoke;

    private record SyncReceiver(Action callback) 
        : ReceiverBase { public void Invoke() => callback(); }
    private record SyncReceiver<T>(Action<T> callback) 
        : ReceiverBase { public void Invoke(T arg) => callback(arg); }
    private record AsyncReceiver(Func<Task> callback) 
        : ReceiverBase { public Task Invoke() => callback(); }
    private record AsyncReceiver<T>(Func<T, Task> callback) 
        : ReceiverBase { public Task Invoke(T arg) => callback(arg); }

    private record ReceiverBase : IHandleEvent
    {
        public Task HandleEventAsync(EventCallbackWorkItem item, object arg) => 
            item.InvokeAsync(arg);
    }
}

호출될 때 렌더링을 트리거하지 않는 이벤트 처리기를 호출하려면 EventUtil.AsNonRenderingEventHandler를 호출하세요.

다음 예제에서

  • HandleClick1를 호출하는 첫 번째 단추를 선택하면 렌더링이 다시 트리거됩니다.
  • HandleClick2를 호출하는 두 번째 단추를 선택하면 렌더링이 다시 트리거되지 않습니다.
  • HandleClick3를 호출하는 세 번째 단추를 선택하면 다시 렌더링이 트리거되지 않고 이벤트 인수(MouseEventArgs)가 사용됩니다.

HandleSelect2.razor:

@page "/handle-select-2"
@using Microsoft.Extensions.Logging
@inject ILogger<HandleSelect2> Logger

<p>
    Last render DateTime: @dt
</p>

<button @onclick="HandleClick1">
    Select me (Rerenders)
</button>

<button @onclick="EventUtil.AsNonRenderingEventHandler(HandleClick2)">
    Select me (Avoids Rerender)
</button>

<button @onclick="EventUtil.AsNonRenderingEventHandler<MouseEventArgs>(HandleClick3)">
    Select me (Avoids Rerender and uses <code>MouseEventArgs</code>)
</button>

@code {
    private DateTime dt = DateTime.Now;

    private void HandleClick1()
    {
        dt = DateTime.Now;

        Logger.LogInformation("This event handler triggers a rerender.");
    }

    private void HandleClick2()
    {
        dt = DateTime.Now;

        Logger.LogInformation("This event handler doesn't trigger a rerender.");
    }
    
    private void HandleClick3(MouseEventArgs args)
    {
        dt = DateTime.Now;

        Logger.LogInformation(
            "This event handler doesn't trigger a rerender. " +
            "Mouse coordinates: {ScreenX}:{ScreenY}", 
            args.ScreenX, args.ScreenY);
    }
}

IHandleEvent 인터페이스를 구현하는 것 외에 이 문서에 설명된 다른 모범 사례를 활용하면 이벤트가 처리된 후 원치 않는 렌더링을 줄일 수 있습니다. 예를 들어 대상 구성 요소의 하위 구성 요소에서 ShouldRender을 대체하여 다시 렌더링하는 것을 제어할 수 있습니다.

반복되는 여러 요소 또는 구성 요소에 대한 대리자 다시 만들기 방지

반복되는 요소 또는 구성 요소에 대한 Blazor의 람다 식 대리자 다시 만들기는 성능을 저하할 수 있습니다.

이벤트 처리 문서에 나오는 다음 구성 요소는 단추 집합을 렌더링합니다. 각 단추는 해당 이벤트에 대리자를 @onclick 할당합니다. 렌더링할 단추가 많지 않으면 괜찮습니다.

EventHandlerExample5.razor:

@page "/event-handler-example-5"

<h1>@heading</h1>

@for (var i = 1; i < 4; i++)
{
    var buttonNumber = i;

    <p>
        <button @onclick="@(e => UpdateHeading(e, buttonNumber))">
            Button #@i
        </button>
    </p>
}

@code {
    private string heading = "Select a button to learn its position";

    private void UpdateHeading(MouseEventArgs e, int buttonNumber)
    {
        heading = $"Selected #{buttonNumber} at {e.ClientX}:{e.ClientY}";
    }
}
@page "/event-handler-example-5"

<h1>@heading</h1>

@for (var i = 1; i < 4; i++)
{
    var buttonNumber = i;

    <p>
        <button @onclick="@(e => UpdateHeading(e, buttonNumber))">
            Button #@i
        </button>
    </p>
}

@code {
    private string heading = "Select a button to learn its position";

    private void UpdateHeading(MouseEventArgs e, int buttonNumber)
    {
        heading = $"Selected #{buttonNumber} at {e.ClientX}:{e.ClientY}";
    }
}

위의 방법을 사용하여 많은 수의 단추를 렌더링하는 경우 렌더링 속도가 저하되고 사용자 환경에 문제가 발생합니다. 클릭 이벤트에 대한 콜백을 사용하여 많은 단추를 렌더링하기 위해, 다음 예제에서는 각 단추의 @onclick 대리자를 Action에 할당하는 단추 개체 컬렉션을 사용합니다. 다음 방법을 사용하면 Blazor에서는 단추가 렌더링될 때마다 모든 단추 대리자를 다시 빌드하지 않아도 됩니다.

LambdaEventPerformance.razor:

@page "/lambda-event-performance"

<h1>@heading</h1>

@foreach (var button in Buttons)
{
    <p>
        <button @key="button.Id" @onclick="button.Action">
            Button #@button.Id
        </button>
    </p>
}

@code {
    private string heading = "Select a button to learn its position";

    private List<Button> Buttons { get; set; } = new();

    protected override void OnInitialized()
    {
        for (var i = 0; i < 100; i++)
        {
            var button = new Button();

            button.Id = Guid.NewGuid().ToString();

            button.Action = (e) =>
            {
                UpdateHeading(button, e);
            };

            Buttons.Add(button);
        }
    }

    private void UpdateHeading(Button button, MouseEventArgs e)
    {
        heading = $"Selected #{button.Id} at {e.ClientX}:{e.ClientY}";
    }

    private class Button
    {
        public string? Id { get; set; }
        public Action<MouseEventArgs> Action { get; set; } = e => { };
    }
}

JavaScript interop 속도 최적화

.NET과 JavaScript 간 호출에는 다음과 같은 이유로 오버헤드가 추가됩니다.

  • 기본적으로 호출은 비동기적입니다.
  • 기본적으로 매개 변수 및 반환 값은 .NET과 JavaScript 형식 간에 이해하기 쉬운 변환 메커니즘을 제공하도록 JSON으로 직렬화됩니다.

또한 서버 쪽 Blazor 앱의 경우 이러한 호출은 네트워크를 통해 전달됩니다.

과도하게 세분화된 호출 방지

각 호출에는 약간의 오버헤드가 포함되므로 호출 수를 줄이는 것이 중요할 수 있습니다. 다음 코드를 사용하여 항목 컬렉션을 브라우저의 localStorage에 저장합니다.

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    foreach (var item in items)
    {
        await JS.InvokeVoidAsync("localStorage.setItem", item.Id, 
            JsonSerializer.Serialize(item));
    }
}

앞의 예제에서는 각 항목에 대해 별도의 JS interop 호출을 수행합니다. 대신, 다음 접근 방식은 단일 호출에 대한 JS interop을 줄입니다.

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    await JS.InvokeVoidAsync("storeAllInLocalStorage", items);
}

해당 JavaScript 함수는 항목의 전체 컬렉션을 클라이언트에 저장합니다.

function storeAllInLocalStorage(items) {
  items.forEach(item => {
    localStorage.setItem(item.id, JSON.stringify(item));
  });
}

Blazor WebAssembly 앱의 경우 개별 JS interop 호출을 단일 호출로 롤링하면 일반적으로 구성 요소가 JS interop을 많이 호출하는 경우에만 성능이 현저하게 향상됩니다.

동기 호출 사용

.NET에서 JavaScript 호출

이 섹션은 클라이언트 쪽 구성 요소에만 적용됩니다.

JS interop 호출은 호출된 코드가 동기인지 비동기인지와 관계없이 기본적으로 비동기적입니다. 기본적으로 호출은 비동기적이므로 구성 요소가 서버 쪽 및 클라이언트 쪽 렌더링 모드에서 호환되도록 합니다. 서버에서 모든 JS interop 호출은 네트워크 연결을 통해 전송되므로 비동기적이어야 합니다.

구성 요소가 WebAssembly에서만 실행된다는 것을 확실히 알고 있는 경우 동기 JS interop 호출을 하도록 선택할 수 있습니다. 이렇게 하면 비동기 호출을 수행하는 것보다 오버헤드가 약간 감소하며 결과를 기다리는 동안 중간 상태가 없기 때문에 렌더링 주기가 감소할 수 있습니다.

클라이언트 쪽 구성 요소에서 .NET에서 JavaScript로 동기 호출을 수행하려면 interop 호출을 수행 JS 하도록 IJSInProcessRuntime 캐스팅 IJSRuntime 합니다.

@inject IJSRuntime JS

...

@code {
    protected override void HandleSomeEvent()
    {
        var jsInProcess = (IJSInProcessRuntime)JS;
        var value = jsInProcess.Invoke<string>("javascriptFunctionIdentifier");
    }
}

ASP.NET Core 5.0 이상 클라이언트 쪽 구성 요소에서 작업 IJSObjectReference 하는 경우 대신 동기적으로 사용할 IJSInProcessObjectReference 수 있습니다. IJSInProcessObjectReferenceIAsyncDisposable/IDisposable 는 다음 예제와 같이 메모리 누수 방지를 위해 가비지 수집을 구현하고 삭제해야 합니다.

@inject IJSRuntime JS
@implements IAsyncDisposable

...

@code {
    ...
    private IJSInProcessObjectReference? module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSInProcessObjectReference>("import", 
            "./scripts.js");
        }
    }

    ...

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is not null)
        {
            await module.DisposeAsync();
        }
    }
}

JavaScript에서 .NET 호출

이 섹션은 클라이언트 쪽 구성 요소에만 적용됩니다.

JS interop 호출은 호출된 코드가 동기인지 비동기인지와 관계없이 기본적으로 비동기적입니다. 기본적으로 호출은 비동기적이므로 구성 요소가 서버 쪽 및 클라이언트 쪽 렌더링 모드에서 호환되도록 합니다. 서버에서 모든 JS interop 호출은 네트워크 연결을 통해 전송되므로 비동기적이어야 합니다.

구성 요소가 WebAssembly에서만 실행된다는 것을 확실히 알고 있는 경우 동기 JS interop 호출을 하도록 선택할 수 있습니다. 이렇게 하면 비동기 호출을 수행하는 것보다 오버헤드가 약간 감소하며 결과를 기다리는 동안 중간 상태가 없기 때문에 렌더링 주기가 감소할 수 있습니다.

클라이언트 쪽 구성 요소에서 JavaScript에서 .NET으로 동기 호출을 하려면 대신 사용합니다 DotNet.invokeMethodDotNet.invokeMethodAsync.

동기 호출이 작동하는 경우는 다음과 같습니다.

  • 구성 요소는 WebAssembly에서 실행하기 위해 렌더링됩니다.
  • 호출된 함수는 값을 동기적으로 반환합니다. 함수는 async 메서드가 아니며 .NET Task 또는 JavaScript Promise를 반환하지 않습니다.

이 섹션은 클라이언트 쪽 구성 요소에만 적용됩니다.

JS interop 호출은 호출된 코드가 동기인지 비동기인지와 관계없이 기본적으로 비동기적입니다. 기본적으로 호출은 비동기적이므로 구성 요소가 서버 쪽 및 클라이언트 쪽 렌더링 모드에서 호환되도록 합니다. 서버에서 모든 JS interop 호출은 네트워크 연결을 통해 전송되므로 비동기적이어야 합니다.

구성 요소가 WebAssembly에서만 실행된다는 것을 확실히 알고 있는 경우 동기 JS interop 호출을 하도록 선택할 수 있습니다. 이렇게 하면 비동기 호출을 수행하는 것보다 오버헤드가 약간 감소하며 결과를 기다리는 동안 중간 상태가 없기 때문에 렌더링 주기가 감소할 수 있습니다.

클라이언트 쪽 구성 요소에서 .NET에서 JavaScript로 동기 호출을 수행하려면 interop 호출을 수행 JS 하도록 IJSInProcessRuntime 캐스팅 IJSRuntime 합니다.

@inject IJSRuntime JS

...

@code {
    protected override void HandleSomeEvent()
    {
        var jsInProcess = (IJSInProcessRuntime)JS;
        var value = jsInProcess.Invoke<string>("javascriptFunctionIdentifier");
    }
}

ASP.NET Core 5.0 이상 클라이언트 쪽 구성 요소에서 작업 IJSObjectReference 하는 경우 대신 동기적으로 사용할 IJSInProcessObjectReference 수 있습니다. IJSInProcessObjectReferenceIAsyncDisposable/IDisposable 는 다음 예제와 같이 메모리 누수 방지를 위해 가비지 수집을 구현하고 삭제해야 합니다.

@inject IJSRuntime JS
@implements IAsyncDisposable

...

@code {
    ...
    private IJSInProcessObjectReference? module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSInProcessObjectReference>("import", 
            "./scripts.js");
        }
    }

    ...

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is not null)
        {
            await module.DisposeAsync();
        }
    }
}

역마샬링된 호출 사용

‘이 섹션은 Blazor WebAssembly 앱에만 적용됩니다.’

Blazor WebAssembly에서 실행되는 경우 .NET에서 JavaScript로 역 마샬링된 호출을 수행할 수 있습니다. 해당 동기 호출은 인수 또는 반환 값의 JSON serialization을 수행하지 않습니다. .NET 표현과 JavaScript 표현 간 메모리 관리 및 변환의 모든 측면은 개발자가 결정합니다.

Warning

IJSUnmarshalledRuntime을 사용하는 것이 JS interop 접근 방식 중에서 오버헤드가 가장 덜 발생하지만, 이러한 API와 상호 작용하는 데 필요한 JavaScript API는 현재 문서화되지 않은 상태이며 이후 릴리스에서 호환성이 손상되는 변경이 적용될 수 있습니다.

function jsInteropCall() {
  return BINDING.js_to_mono_obj("Hello world");
}
@inject IJSRuntime JS

@code {
    protected override void OnInitialized()
    {
        var unmarshalledJs = (IJSUnmarshalledRuntime)JS;
        var value = unmarshalledJs.InvokeUnmarshalled<string>("jsInteropCall");
    }
}

JavaScript [JSImport]/[JSExport] interop 사용

앱용 Blazor WebAssembly JavaScript[JSExport]/[JSImport] interop는 .NET 7에서 Core를 ASP.NET 전에 프레임워크 릴리스에서 interop API보다 향상된 성능과 안정성 JS 을 제공합니다.

자세한 내용은 JavaScript JSImport/JSExport interop 및 ASP.NET Core Blazor를 참조하세요.

AOT(Ahead-Of-Time) 컴파일

AOT(Ahead-of-Time) 컴파일은 브라우저에서 직접 실행하기 위해 앱의 .NET 코드를 네이티브 WebAssembly로 직접 컴파일 Blazor 합니다. AOT 컴파일 앱을 사용하면 다운로드하는 데 더 많은 시간이 소요되지만 AOT 컴파일된 앱, 특히 CPU 집약적 작업을 실행하는 앱의 경우, 일반적으로 더 나은 런타임 성능을 제공합니다. 자세한 내용은 ASP.NET Core Blazor WebAssembly 빌드 도구 및 AOT(Ahead-Of-Time) 컴파일을 참조하세요.

앱 다운로드 크기 최소화

런타임 다시 링크

런타임 다시 연결로 앱의 다운로드 크기를 최소화하는 방법에 대한 자세한 내용은 ASP.NET Core Blazor WebAssembly 빌드 도구 및 AOT(Ahead-Of-Time) 컴파일을 참조하세요.

System.Text.Json 사용

Blazor의 JS interop 구현은 메모리 할당이 작은 고성능 JSON serialization 라이브러리인 System.Text.Json에 의존합니다. System.Text.Json을 사용해도 하나 이상의 대체 JSON 라이브러리를 추가하는 것에 비해 추가 앱 페이로드 크기가 발생하지 않습니다.

마이그레이션 지침은 Newtonsoft.Json에서 System.Text.Json으로 마이그레이션하는 방법을 참조하세요.

IL(중간 언어) 트리밍

‘이 섹션은 Blazor WebAssembly 앱에만 적용됩니다.’

앱에서 사용되지 않는 어셈블리를 Blazor WebAssembly 트리밍하면 앱의 이진 파일에서 사용되지 않는 코드를 제거하여 앱의 크기가 줄어듭니다. 자세한 내용은 ASP.NET Core Blazor용 트리머 구성을 참조하세요.

Blazor WebAssembly 앱을 연결하면 앱의 이진 파일에서 사용되지 않는 코드를 잘라내어 앱 크기를 줄일 수 있습니다. 기본적으로 IL(중간 언어) 링커는 Release 구성으로 빌드할 때만 사용할 수 있습니다. 이 기능의 이점을 활용하려면 dotnet publish 명령에서 -c|--configuration 옵션을 Release로 설정하여 배포를 위해 앱을 게시합니다.

dotnet publish -c Release

어셈블리 지연 로드

‘이 섹션은 Blazor WebAssembly 앱에만 적용됩니다.’

어셈블리가 경로에 필요한 경우 런타임에 어셈블리를 로드합니다. 자세한 내용은 ASP.NET Core Blazor WebAssembly에서 어셈블리 지연 로드를 참조하세요.

압축

‘이 섹션은 Blazor WebAssembly 앱에만 적용됩니다.’

Blazor WebAssembly 앱이 게시될 때 게시하는 도중에 출력을 정적으로 압축하여 앱의 크기를 줄이고 런타임 압축의 오버헤드를 제거합니다. Blazor는 콘텐츠 협상을 수행하고 정적으로 압축된 파일을 처리하기 위해 서버에 의존합니다.

앱이 배포된 후에는 앱이 압축된 파일을 처리하는지 확인합니다. 브라우저의 개발자 도구에서 네트워크 탭을 조사하여 파일이 Content-Encoding: br(Brotli 압축) 또는 Content-Encoding: gz(Gzip 압축)로 제공되는지 확인합니다. 호스트가 압축된 파일을 처리하지 않는 경우 ASP.NET Core Blazor WebAssembly 호스트 및 배포의 지침을 따르세요.

사용되지 않는 기능을 사용하지 않도록 설정

‘이 섹션은 Blazor WebAssembly 앱에만 적용됩니다.’

Blazor WebAssembly의 런타임에는 더 작은 페이로드 크기를 위해 사용하지 않도록 설정할 수 있는 다음과 같은 .NET 기능이 포함되어 있습니다.

  • 정확한 표준 시간대 정보를 위해 데이터 파일이 포함되어 있습니다. 앱에서 이 기능이 필요하지 않은 경우 앱의 프로젝트 파일에서 BlazorEnableTimeZoneSupport MSBuild 속성을 false로 설정하여 사용하지 않도록 설정하는 것이 좋습니다.

    <PropertyGroup>
      <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
    </PropertyGroup>
    
  • StringComparison.InvariantCultureIgnoreCase와 같은 API의 올바른 작동을 위해 데이터 정렬 정보가 포함되어 있습니다. 앱에서 데이터 정렬 데이터가 필요하지 않은 경우 앱의 프로젝트 파일에서 BlazorWebAssemblyPreserveCollationData MSBuild 속성을 false로 설정하여 사용하지 않도록 설정하는 것이 좋습니다.

    <PropertyGroup>
      <BlazorWebAssemblyPreserveCollationData>false</BlazorWebAssemblyPreserveCollationData>
    </PropertyGroup>
    
  • 기본적으로 Blazor WebAssembly는 날짜 및 통화와 같은 값을 사용자의 문화권에 표시하는 데 필요한 세계화 리소스를 전달합니다. 앱에 지역화가 필요하지 않은 경우 en-US 문화권을 기반으로 하는 고정 문화권을 지원하도록 앱을 구성할 수 있습니다.