성능 최적화
ShouldRender, Virtualize 컴포넌트, 메모리 관리로 Blazor 앱의 렌더링 성능을 개선합니다.
📌 학습 목표
- Blazor의 렌더링 사이클 구조를 이해하고 불필요한 리렌더링을 진단할 수 있습니다.
- ShouldRender() 메서드를 재정의하여 컴포넌트 렌더링을 조건부로 제어할 수 있습니다.
- Virtualize 컴포넌트로 수만 건의 목록을 효율적으로 렌더링할 수 있습니다.
- IDisposable / IAsyncDisposable을 구현하여 타이머·이벤트 구독의 메모리 누수를 예방할 수 있습니다.
@key와InvokeAsync를 올바르게 사용하여 목록 최적화와 스레드 안전성을 확보할 수 있습니다.
📝 개념 설명
1. Blazor 렌더링 사이클 이해
Blazor는 컴포넌트 상태가 변경될 때마다 자동으로 UI를 다시 렌더링합니다. 이 사이클을 이해하는 것이 성능 최적화의 출발점입니다.
컴포넌트가 리렌더링되는 주요 상황은 다음과 같습니다.
- StateHasChanged() 호출 시 — 컴포넌트가 상태 변경을 Blazor에 알림
- 부모 컴포넌트가 리렌더링될 때 — 자식 컴포넌트도 기본적으로 함께 리렌더링
- EventCallback 실행 완료 시 — 이벤트 처리 후 자동으로 StateHasChanged 호출
- 비동기 작업 완료 시 — await 지점마다 중간 렌더링이 발생할 수 있음
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을 사용합니다. 비동기 작업이 완료될 때까지 기다려야 하는 경우(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를 조합한 실전 패턴입니다.
@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);
}<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;
}
}⚠️ 자주 틀리는 것 / 주의사항
- ShouldRender 내부에서 상태 변경 금지: ShouldRender는 순수 비교 함수여야 합니다. 내부에서 서비스를 호출하거나 필드를 변경하는 부작용(side effect)은 예측 불가능한 동작을 유발합니다.
- EventCallback vs Action 혼동: 자식 컴포넌트 콜백에
Action을 사용하면 이벤트 처리 후 StateHasChanged가 자동 호출되지 않아 UI가 갱신되지 않습니다. 반드시EventCallback을 사용하세요. - 복잡한 객체를 파라미터로 직접 생성: 부모 렌더링 시
new Product()를 인라인으로 전달하면 매번 새 참조가 생성되어 자식도 항상 리렌더링됩니다. 가능한 한 기본 타입(int, string)을 파라미터로 사용하세요. - Placeholder 미작성: ItemsProvider를 사용할 때 Placeholder를 생략하면 데이터 로딩 중 빈 공간이 표시되어 사용자 경험이 떨어집니다.
🎯 마무리
Blazor 성능 최적화는 세 가지 핵심 축으로 정리할 수 있습니다. ShouldRender로 불필요한 렌더링 사이클을 차단하고, Virtualize로 대용량 목록을 뷰포트 기준으로만 렌더링하며, IDisposable / IAsyncDisposable로 컴포넌트 소멸 시 리소스를 반드시 반환합니다.
세 기법을 함께 적용하면 수만 건의 데이터를 다루는 Blazor 앱에서도 부드럽고 빠른 사용자 경험을 만들 수 있습니다. @key와 InvokeAsync는 작지만 누락 시 심각한 버그로 이어지므로 실전에서 반드시 체크리스트로 확인하세요.
- 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 자동 호출