콘텐츠로 이동

Blazor WebAssembly 성능 최적화

Blazor WebAssembly는 C#을 브라우저에서 실행하는 강력한 프레임워크지만, 초기 로딩 크기와 렌더링 성능이 주요 최적화 대상입니다. .NET 8에서는 AOT 컴파일, Static SSR, Streaming 렌더링이 추가되어 성능이 크게 개선되었습니다.


.csproj
<PropertyGroup>
<!-- IL Trimming: 사용하지 않는 코드 제거 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<!-- AOT 컴파일: WASM 네이티브 코드로 변환 -->
<RunAOTCompilation>true</RunAOTCompilation>
<!-- 압축 -->
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
Terminal window
# 게시 빌드 (최적화 최대)
dotnet publish -c Release
# AOT 포함 (빌드 오래 걸리나 런타임 빠름)
dotnet publish -c Release -p:RunAOTCompilation=true

2. Lazy Loading — 어셈블리 지연 로드

섹션 제목: “2. Lazy Loading — 어셈블리 지연 로드”
Program.cs
builder.Services.AddScoped<LazyAssemblyLoader>();
// 라우팅에서 Lazy 어셈블리 지정
// App.razor
<Router AppAssembly="@typeof(App).Assembly"
AdditionalAssemblies="@lazyLoadedAssemblies">
...
</Router>
@code {
private List<Assembly> lazyLoadedAssemblies = new();
protected override async Task OnInitializedAsync()
{
// 특정 경로 진입 시에만 어셈블리 로드
Router.OnNavigateAsync = NavigationHandler;
}
private async Task NavigationHandler(NavigationContext ctx)
{
if (ctx.Path.StartsWith("/admin"))
{
var assemblies = await LazyLoader.LoadAssembliesAsync(
new[] { "AdminModule.dll" });
lazyLoadedAssemblies.AddRange(assemblies);
}
}
}

@* CounterDisplay.razor *@
@inherits ComponentBase
<p>Count: @Count</p>
@code {
[Parameter] public int Count { get; set; }
private int _lastRenderedCount = -1;
// 실제로 변경된 경우에만 다시 렌더링
protected override bool ShouldRender()
{
if (_lastRenderedCount == Count) return false;
_lastRenderedCount = Count;
return true;
}
}

@* 10만 건도 부드럽게 렌더링 *@
<div style="height: 500px; overflow-y: auto;">
<Virtualize Items="@AllItems"
Context="item"
OverscanCount="3">
<ItemContent>
<div class="item-row">
<span>@item.Id</span>
<span>@item.Name</span>
</div>
</ItemContent>
<Placeholder>
<div class="loading-placeholder">로딩 중...</div>
</Placeholder>
</Virtualize>
</div>
@* 서버 페이지네이션과 결합 *@
<Virtualize Context="item"
ItemsProvider="@LoadItems"
ItemSize="50">
<div>@item.Name</div>
</Virtualize>
@code {
private async ValueTask<ItemsProviderResult<Item>> LoadItems(
ItemsProviderRequest req)
{
var result = await Api.GetPageAsync(req.StartIndex, req.Count);
return new(result.Items, result.TotalCount);
}
}

// ❌ 느림: 매 호출마다 JS 평가
await JSRuntime.InvokeVoidAsync("console.log", message);
// ✅ 빠름: 모듈 사전 로드 + IJSObjectReference 캐시
public class JsInteropService : IAsyncDisposable
{
private readonly Lazy<Task<IJSObjectReference>> _module;
public JsInteropService(IJSRuntime js)
{
_module = new(() => js.InvokeAsync<IJSObjectReference>(
"import", "./js/myModule.js").AsTask());
}
public async ValueTask LogAsync(string msg)
{
var m = await _module.Value;
await m.InvokeVoidAsync("log", msg);
}
public async ValueTask DisposeAsync()
{
if (_module.IsValueCreated)
await (await _module.Value).DisposeAsync();
}
}

6. .NET 8 Static SSR + Enhanced Navigation

섹션 제목: “6. .NET 8 Static SSR + Enhanced Navigation”
// Program.cs (.NET 8)
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
// App.razor
<Routes @rendermode="InteractiveWebAssembly" />
// 개별 페이지 렌더 모드 지정
@page "/counter"
@rendermode InteractiveWebAssembly
// 정적 페이지 (서버 렌더, JS 없음)
@page "/about"
// rendermode 없음 → Static SSR

// HttpClient 응답 캐싱
builder.Services.AddScoped(sp =>
{
var http = new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
};
return http;
});
// 컴포넌트 수준 캐싱
@code {
private static readonly Dictionary<int, Product> _cache = new();
protected override async Task OnInitializedAsync()
{
if (!_cache.TryGetValue(Id, out var product))
{
product = await Http.GetFromJsonAsync<Product>($"api/products/{Id}");
_cache[Id] = product!;
}
Product = product;
}
}

// wwwroot/service-worker-assets.js (자동 생성)
// wwwroot/service-worker.js (커스터마이징 가능)
// manifest.webmanifest
{
"name": "My Blazor App",
"short_name": "BlazorApp",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#03173d",
"icons": [
{ "src": "icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "icon-512.png", "type": "image/png", "sizes": "512x512" }
]
}
// .csproj에 PWA 활성화
// <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
// 오프라인 감지
@inject NavigationManager Navigation
@inject IJSRuntime JS
@code {
private bool _isOnline = true;
protected override async Task OnInitializedAsync()
{
_isOnline = await JS.InvokeAsync<bool>("navigator.onLine.toString") != "false";
}
}

Terminal window
# 게시 후 번들 크기 확인
dotnet publish -c Release -o out
# 압축 전후 크기 (대략적인 기준)
# 최소 Blazor WASM: ~2MB (Brotli 압축 후 ~600KB)
# AOT 포함: ~8MB (압축 후 ~2MB)
# Trimming 효과 확인
dotnet publish -c Release -p:PublishTrimmed=true -p:TrimMode=full
# 출력: Linking: size reduced by X% (Y → Z bytes)

Blazor WASM 최적화의 핵심은 번들 크기(Trimming + AOT), 렌더링 횟수(ShouldRender), 목록 성능(Virtualize), JS Interop 비용(모듈 캐시) 네 가지입니다. .NET 8에서는 Static SSR과 WebAssembly 렌더링을 페이지별로 혼용해 초기 로딩은 서버 렌더로 빠르게 하고, 인터랙티브 부분만 WASM으로 처리하세요. PWA로 오프라인 지원과 설치 가능한 앱 경험을 추가하고, dotnet publish -p:PublishTrimmed=true로 번들 크기를 주기적으로 측정하세요.