Blazor 성능 최적화: ShouldRender, Virtualize, 메모리 관리

성능 최적화
📚
핵심 요약

성능 최적화

ShouldRender, Virtualize 컴포넌트, 메모리 관리로 Blazor 앱의 렌더링 성능을 개선합니다.

Blazor 성능 최적화
ShouldRender, Virtualize 컴포넌트, 메모리 관리로 Blazor 앱의 렌더링 성능을 개선합니다.

📌 학습 목표

  • Blazor의 렌더링 사이클 구조를 이해하고 불필요한 리렌더링을 진단할 수 있습니다.
  • ShouldRender() 메서드를 재정의하여 컴포넌트 렌더링을 조건부로 제어할 수 있습니다.
  • Virtualize 컴포넌트로 수만 건의 목록을 효율적으로 렌더링할 수 있습니다.
  • IDisposable / IAsyncDisposable을 구현하여 타이머·이벤트 구독의 메모리 누수를 예방할 수 있습니다.
  • @keyInvokeAsync를 올바르게 사용하여 목록 최적화와 스레드 안전성을 확보할 수 있습니다.

📝 개념 설명

1. Blazor 렌더링 사이클 이해

Blazor는 컴포넌트 상태가 변경될 때마다 자동으로 UI를 다시 렌더링합니다. 이 사이클을 이해하는 것이 성능 최적화의 출발점입니다.

 

컴포넌트가 리렌더링되는 주요 상황은 다음과 같습니다.

  • StateHasChanged() 호출 시 — 컴포넌트가 상태 변경을 Blazor에 알림
  • 부모 컴포넌트가 리렌더링될 때 — 자식 컴포넌트도 기본적으로 함께 리렌더링
  • EventCallback 실행 완료 시 — 이벤트 처리 후 자동으로 StateHasChanged 호출
  • 비동기 작업 완료 시 — await 지점마다 중간 렌더링이 발생할 수 있음
🔄 Blazor 렌더링 사이클 흐름
상태 변경 / 이벤트 발생
StateHasChanged() 호출
ShouldRender() 판단
렌더 트리 재생성 (BuildRenderTree)
DOM 차분(diff) 패치 적용

2. ShouldRender로 불필요한 렌더링 방지

ShouldRender()는 컴포넌트가 실제로 렌더링을 수행할지 결정하는 가상 메서드입니다. 기본 반환값은 true이며, 이를 재정의하면 렌더링 조건을 직접 제어할 수 있습니다.

@code {
    private int _previousCount = -1;
    private int _count = 0;

    protected override bool ShouldRender()
    {
        // 값이 실제로 변경된 경우에만 렌더링 허용
        if (_count == _previousCount)
            return false;

        _previousCount = _count;
        return true;
    }

    private void IncrementCount() => _count++;
}

위 예제에서 버튼을 연속으로 클릭해도 카운트 값이 이전과 동일하면 렌더링이 생략되어 불필요한 DOM 연산을 피할 수 있습니다.

3. Virtualize 컴포넌트로 대용량 목록 최적화

수천~수만 개의 항목을 한꺼번에 DOM에 렌더링하면 초기 로딩이 수 초씩 지연될 수 있습니다. Virtualize 컴포넌트는 현재 화면(뷰포트)에 보이는 항목만 실제로 렌더링하는 UI 가상화(Virtualization) 기법을 제공합니다.

@* 고정 높이 컨테이너 필수 *@
<div style='height:500px; overflow-y:auto;'>
    <Virtualize Items='@_products' Context='product' OverscanCount='3'>
        <div class='item-row'>
            <span>@product.Id</span>
            <span>@product.Name</span>
            <span>@product.Price.ToString("N0")원</span>
        </div>
    </Virtualize>
</div>

@code {
    private List<Product> _products = new();

    protected override void OnInitialized()
    {
        _products = Enumerable.Range(1, 10000)
            .Select(i => new Product(i, $"상품 {i}", i * 1000m))
            .ToList();
    }

    public record Product(int Id, string Name, decimal Price);
}

OverscanCount는 화면 밖에 미리 렌더링할 항목 수를 지정합니다. 값을 3~5로 설정하면 스크롤 시 콘텐츠가 깜박이는 현상을 줄일 수 있습니다.

4. ItemsProvider로 서버 측 가상화 구현

전체 데이터를 메모리에 올리지 않고, 사용자가 스크롤하는 범위만 서버에서 조회하는 패턴입니다. 수십만 건 이상의 대용량 데이터베이스에 특히 효과적입니다.

<Virtualize ItemsProvider='@LoadProducts' Context='product' OverscanCount='5'>
    <ItemContent>
        <div class='product-card'>@product.Name</div>
    </ItemContent>
    <Placeholder>
        <div class='loading-placeholder'>로딩 중...</div>
    </Placeholder>
</Virtualize>

@code {
    private async ValueTask<ItemsProviderResult<Product>> LoadProducts(
        ItemsProviderRequest request)
    {
        var result = await ProductService.GetPagedAsync(
            request.StartIndex,
            request.Count,
            request.CancellationToken);

        return new ItemsProviderResult<Product>(result.Items, result.TotalCount);
    }
}

Placeholder는 데이터가 로딩되는 동안 표시할 임시 UI를 정의합니다. 스켈레톤 UI를 넣으면 사용자 경험이 크게 향상됩니다.

5. 메모리 관리 — IDisposable과 IAsyncDisposable

Blazor 컴포넌트가 타이머, 이벤트 핸들러, 외부 서비스 구독을 등록한 후 컴포넌트가 DOM에서 제거될 때 이를 해제하지 않으면 메모리 누수좀비 이벤트가 발생합니다.

@page "/clock"
@implements IAsyncDisposable
@inject NavigationManager Nav

<h3>현재 시각: @_currentTime.ToString("HH:mm:ss")</h3>

@code {
    private DateTime _currentTime = DateTime.Now;
    private PeriodicTimer? _timer;
    private Task? _timerTask;

    protected override void OnInitialized()
    {
        // PeriodicTimer: .NET 6+, Dispose()로 안전하게 종료됨
        _timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
        _timerTask = RunTimerAsync();

        Nav.LocationChanged += OnLocationChanged;
    }

    private async Task RunTimerAsync()
    {
        try
        {
            while (await _timer!.WaitForNextTickAsync())
            {
                _currentTime = DateTime.Now;
                // 외부 스레드에서 UI 갱신 시 InvokeAsync 필수
                await InvokeAsync(StateHasChanged);
            }
        }
        catch (OperationCanceledException) { }
    }

    private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
    {
        InvokeAsync(StateHasChanged);
    }

    public async ValueTask DisposeAsync()
    {
        Nav.LocationChanged -= OnLocationChanged;
        _timer?.Dispose();
        if (_timerTask is not null)
            await _timerTask;
    }
}
💡 IDisposable vs IAsyncDisposable: 동기 정리 작업(이벤트 구독 해제, 일반 Dispose)에는 IDisposable을 사용합니다. 비동기 작업이 완료될 때까지 기다려야 하는 경우(Task 완료 대기 등)에는 IAsyncDisposable을 사용합니다. Blazor는 두 인터페이스를 모두 지원합니다.

6. @key로 목록 렌더링 최적화

목록 항목에 @key를 지정하면 Blazor가 항목 ID를 기준으로 어떤 항목이 추가·제거·이동되었는지 정확히 추적하여 변경된 항목만 다시 렌더링합니다.

@* ✅ @key 사용 — 항목 순서 변경 시에도 정확한 차분 업데이트 *@
@foreach (var item in _products)
{
    <ProductCard @key='item.Id' Product='@item' />
}

@* ❌ @key 없음 — 항목 추가/삭제/정렬 시 전체 재렌더링 발생 *@
@foreach (var item in _products)
{
    <ProductCard Product='@item' />
}

💡 예제 & 실습

실습: 고성능 상품 목록 컴포넌트 완전 구현

Virtualize와 ShouldRender를 조합한 실전 패턴입니다.

ProductList.razor
@page "/products"

<h2>상품 목록 (총 @_products.Count개)</h2>

<div style='height:600px; overflow-y:auto; border:1px solid #ddd; border-radius:4px;'>
    <Virtualize Items='@_products' Context='product' OverscanCount='5'>
        <ProductCard @key='product.Id'
                     Product='@product'
                     IsSelected='@(_selectedId == product.Id)'
                     OnSelect='@HandleSelect' />
    </Virtualize>
</div>

@if (_selectedId > 0)
{
    <p>선택된 ID: @_selectedId</p>
}

@code {
    private List<ProductItem> _products = new();
    private int _selectedId;

    protected override void OnInitialized()
    {
        _products = Enumerable.Range(1, 10000)
            .Select(i => new ProductItem(i, $"상품 {i}", i * 1000m))
            .ToList();
    }

    private void HandleSelect(int id) => _selectedId = id;

    public record ProductItem(int Id, string Name, decimal Price);
}
ProductCard.razor
<div class='@(_isSelected ? "card selected" : "card")'
     @onclick='@(() => OnSelect.InvokeAsync(Product.Id))'>
    <strong>#@Product.Id</strong> @Product.Name
    <span>@Product.Price.ToString("N0")원</span>
</div>

@code {
    [Parameter] public ProductItem Product { get; set; } = default!;
    [Parameter] public bool IsSelected { get; set; }
    [Parameter] public EventCallback<int> OnSelect { get; set; }

    private bool _isSelected;
    private int _previousId;
    private bool _previousSelected;

    protected override bool ShouldRender()
    {
        if (Product.Id == _previousId && IsSelected == _previousSelected)
            return false;

        _previousId = Product.Id;
        _previousSelected = IsSelected;
        return true;
    }
}

⚠️ 자주 틀리는 것 / 주의사항

📌 성능 최적화 핵심 포인트 4가지
ShouldRender 오용
항상 false를 반환하면 UI가 영구적으로 갱신되지 않습니다. 반드시 이전 값과 비교하는 조건부 로직으로 구현하세요.
📏
Virtualize 컨테이너 미설정
부모 요소에 고정 height와 overflow-y: auto가 없으면 Virtualize가 전체 항목을 한꺼번에 렌더링합니다.
🗑️
IDisposable 누락
타이머·이벤트 구독을 Dispose에서 해제하지 않으면 컴포넌트 제거 후에도 이벤트가 계속 발화하는 좀비 상태가 됩니다.
🔒
InvokeAsync 누락
타이머·백그라운드 스레드에서 StateHasChanged를 직접 호출하면 스레드 안전 문제가 생깁니다. 반드시 InvokeAsync로 감싸세요.
  • ShouldRender 내부에서 상태 변경 금지: ShouldRender는 순수 비교 함수여야 합니다. 내부에서 서비스를 호출하거나 필드를 변경하는 부작용(side effect)은 예측 불가능한 동작을 유발합니다.
  • EventCallback vs Action 혼동: 자식 컴포넌트 콜백에 Action을 사용하면 이벤트 처리 후 StateHasChanged가 자동 호출되지 않아 UI가 갱신되지 않습니다. 반드시 EventCallback을 사용하세요.
  • 복잡한 객체를 파라미터로 직접 생성: 부모 렌더링 시 new Product()를 인라인으로 전달하면 매번 새 참조가 생성되어 자식도 항상 리렌더링됩니다. 가능한 한 기본 타입(int, string)을 파라미터로 사용하세요.
  • Placeholder 미작성: ItemsProvider를 사용할 때 Placeholder를 생략하면 데이터 로딩 중 빈 공간이 표시되어 사용자 경험이 떨어집니다.

🎯 마무리

Blazor 성능 최적화는 세 가지 핵심 축으로 정리할 수 있습니다. ShouldRender로 불필요한 렌더링 사이클을 차단하고, Virtualize로 대용량 목록을 뷰포트 기준으로만 렌더링하며, IDisposable / IAsyncDisposable로 컴포넌트 소멸 시 리소스를 반드시 반환합니다.

 

세 기법을 함께 적용하면 수만 건의 데이터를 다루는 Blazor 앱에서도 부드럽고 빠른 사용자 경험을 만들 수 있습니다. @keyInvokeAsync는 작지만 누락 시 심각한 버그로 이어지므로 실전에서 반드시 체크리스트로 확인하세요.

✅ 핵심 정리
  • ShouldRender() — 렌더링 전 호출, false 반환 시 DOM 갱신 건너뜀. 이전 값과 비교하는 조건부 로직으로 구현
  • Virtualize Items — 메모리 내 컬렉션 가상화. 고정 height + overflow-y:auto 컨테이너 필수
  • Virtualize ItemsProvider — 서버 측 페이징 가상화. 스크롤 범위만 DB에서 조회
  • OverscanCount — 뷰포트 밖 미리 렌더링 수. 기본값 3, 스크롤 깜박임 방지에 5 권장
  • IDisposable.Dispose() — 타이머·이벤트 구독 해제. @implements IDisposable 선언 필요
  • IAsyncDisposable.DisposeAsync() — 비동기 정리 작업(Task 완료 대기 등)에 사용
  • InvokeAsync(StateHasChanged) — 외부 스레드·타이머에서 UI 갱신 시 필수. 직접 호출 금지
  • @key — 목록 항목에 고유 ID 지정. 추가·삭제·정렬 시 정확한 차분 업데이트 보장
  • EventCallback — 자식 이벤트 콜백에 Action 대신 사용. StateHasChanged 자동 호출

댓글 남기기

Wordpress Social Share Plugin powered by Ultimatelysocial
Copy link
URL has been copied successfully!
THREADS
RSS
error: 저작권 콘텐츠보호를 부탁드립니다.