📚 시작하기 전에
c# .NET Core Blazor 핵심 포인트 정리
지금까지 배운 모든 기술을 핵심 정리하여 실전 Blazor 웹 애플리케이션을 개발할 수 있도록 꼭 알아야 하는 핵심 포인트만 정리합니다.
📌 현재 모델: claude-sonnet-4-6 — 이 글은 C# .NET Core Blazor 강의 커리큘럼의 최종편입니다. 지금까지 학습한 모든 핵심 개념을 체계적으로 정리하여 실전 Blazor 웹 애플리케이션 개발에 바로 활용할 수 있도록 구성하였습니다.
🎯 학습 목표
- Blazor 호스팅 모델(Server / WebAssembly / Hybrid)의 차이와 선택 기준을 명확히 이해합니다.
- 컴포넌트 생명주기의 각 단계와 올바른 활용 패턴을 정리합니다.
- 데이터 바인딩, 이벤트 처리, 라우팅, 의존성 주입의 핵심 패턴을 종합적으로 확립합니다.
- 상태 관리, 폼 유효성 검사, HTTP 통신, JavaScript Interop을 실전에 적용하는 방법을 숙지합니다.
- 자주 발생하는 실수를 파악하고 실전 Best Practice를 습득합니다.
1. Blazor 호스팅 모델 비교
Blazor는 C#으로 웹 UI를 구성하는 .NET 프레임워크입니다. 프로젝트 성격에 따라 세 가지 호스팅 모델 중 선택합니다.
| 항목 | Blazor Server | Blazor WebAssembly | Blazor Hybrid |
|---|---|---|---|
| 실행 위치 | 서버 (.NET 런타임) | 브라우저 (WebAssembly) | 네이티브 앱 (MAUI 등) |
| 초기 로드 | 빠름 | 느림 (런타임 다운로드) | 빠름 |
| 오프라인 지원 | ❌ | ✅ | ✅ |
| 서버 상시 연결 | SignalR 필수 | 불필요 (API만) | 불필요 |
| 서버 부하 | 높음 (세션 유지) | 낮음 | 낮음 |
| 적합한 사례 | 기업 내부 시스템, 실시간 앱 | 공개 SPA, PWA | 데스크톱·모바일 앱 |
2. 컴포넌트 구조와 생명주기
2.1 .razor 파일 기본 구조
Blazor의 기본 단위는 .razor 파일입니다. 디렉티브(지시문), HTML 마크업, @code 블록이 하나의 파일에 결합됩니다.
@page "/products"
@using MyApp.Models
@inject IProductService ProductService
<h2>@Title</h2>
@if (isLoading)
{
<p>데이터를 불러오는 중...</p>
}
else
{
<ul>
@foreach (var item in products)
{
<li>@item.Name — @item.Price.ToString("N0")원</li>
}
</ul>
}
@code {
[Parameter] public string Title { get; set; } = "상품 목록";
private List<Product> products = new();
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
products = await ProductService.GetAllAsync();
isLoading = false;
}
}2.2 컴포넌트 생명주기
🔄 Blazor 컴포넌트 생명주기 흐름
① 컴포넌트 인스턴스 생성
↓
② SetParametersAsync — 매개변수 수신 및 설정
↓
③ OnInitialized / OnInitializedAsync — 최초 1회 초기화
↓
④ OnParametersSet / OnParametersSetAsync — 매개변수 변경마다 실행
↓
⑤ 렌더링 (BuildRenderTree)
↓
⑥ OnAfterRender / OnAfterRenderAsync — DOM 완성 후 (firstRender 구분)
↓
⑦ Dispose — 컴포넌트 소멸 (IDisposable 구현 시)
@implements IDisposable
@code {
// ③ 최초 1회 — API 호출, 데이터 로드에 사용
protected override async Task OnInitializedAsync()
{
data = await MyService.GetDataAsync();
}
// ④ 매개변수 변경마다 — 부모로부터 새 값을 받을 때
protected override void OnParametersSet()
{
filteredData = data.Where(x => x.Category == Category).ToList();
}
// ⑥ DOM 완성 후 — JS Interop, 차트 초기화 등
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("initChart", "chart-container");
}
}
// ⑦ 이벤트 구독 해제, 리소스 정리
public void Dispose()
{
AppState.OnChange -= StateHasChanged;
}
}3. 데이터 바인딩과 이벤트 처리
3.1 바인딩 종류
- 단방향 바인딩:
@PropertyName— C# 값을 HTML에 출력 - 양방향 바인딩:
@bind="PropertyName"— 입력 요소와 값 동기화 (기본: onchange) - 실시간 바인딩:
@bind="name" @bind:event="oninput"— 키 입력마다 반영 - 컴포넌트 바인딩:
@bind-Value="value"— 자식 컴포넌트 ValueChanged 패턴
<!-- 단방향: 표시만 -->
<p>검색어: @keyword</p>
<!-- 양방향: 포커스 이탈 시 업데이트 -->
<input @bind="keyword" />
<!-- 실시간: 타이핑마다 업데이트 -->
<input @bind="keyword" @bind:event="oninput" />
@code {
private string keyword = "";
}3.2 이벤트 처리
<button @onclick="HandleClick">클릭</button>
<input @onchange="HandleChange" />
<input @oninput="e => liveText = e.Value?.ToString()" />
@code {
private void HandleClick(MouseEventArgs e)
{
Console.WriteLine($"클릭: ({e.ClientX}, {e.ClientY})");
}
private void HandleChange(ChangeEventArgs e)
{
inputValue = e.Value?.ToString() ?? "";
}
}3.3 부모 ↔ 자식 컴포넌트 통신
<!-- 자식 컴포넌트: SearchBox.razor -->
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public EventCallback OnSearch { get; set; }
private async Task OnInput(ChangeEventArgs e)
{
// 부모에게 값 전달 (@bind-Value 패턴 지원)
await ValueChanged.InvokeAsync(e.Value?.ToString());
}
}
<!-- 부모 컴포넌트 -->
<SearchBox @bind-Value="searchKeyword" OnSearch="DoSearch" />
<p>현재 검색어: @searchKeyword</p>4. 라우팅과 내비게이션
<!-- 복수 라우트 등록 가능 -->
@page "/products"
@page "/products/{Category}"
@page "/products/{Id:int}"
<!-- NavLink: 활성 경로 자동 CSS 처리 -->
<NavLink href="/products" Match="NavLinkMatch.All" class="nav-item">
전체 상품
</NavLink>
@inject NavigationManager Nav
@code {
[Parameter] public string? Category { get; set; }
[Parameter] public int Id { get; set; }
private void GoToDetail(int id) => Nav.NavigateTo($"/products/{id}");
// 쿼리스트링 읽기 (.NET 7+)
[SupplyParameterFromQuery] public string? Sort { get; set; }
}💡 라우트 제약 조건 종류
{Id:int}— 정수{Name:alpha}— 알파벳{Guid:guid}— GUID{Price:decimal}— 소수{*catchAll}— 나머지 전체 경로
5. 의존성 주입 (의존성 주입(DI))
// Program.cs — 서비스 등록
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddSingleton<IAppCache, AppCache>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddHttpClient<IApiService, ApiService>(
c => c.BaseAddress = new Uri("https://api.example.com/"));
// 컴포넌트에서 @inject 디렉티브로 주입
@inject IProductService ProductService
@inject NavigationManager Nav
@inject IJSRuntime JS
// code-behind (.razor.cs) 에서는 [Inject] 속성 사용
public partial class ProductList : ComponentBase
{
[Inject] private IProductService ProductService { get; set; } = default!;
}| 수명 | 인스턴스 생성 시점 | 적합한 사용 예 |
|---|---|---|
| Singleton | 앱 전체 단 1개 | 전역 캐시, 설정, 로거 |
| Scoped | 연결(Circuit)당 1개 | DB 컨텍스트, 장바구니 상태 |
| Transient | 요청마다 새 인스턴스 | 경량 유틸, 이메일 발송 |
6. 상태 관리
6.1 CascadingValue / CascadingParameter
<!-- 상위 레이아웃에서 공급 -->
<CascadingValue Value="currentTheme">
<CascadingValue Value="currentUser" Name="User">
@Body
</CascadingValue>
</CascadingValue>
<!-- 깊은 하위 컴포넌트에서 수신 -->
@code {
[CascadingParameter] private string CurrentTheme { get; set; } = "light";
[CascadingParameter(Name = "User")] private UserInfo? User { get; set; }
}6.2 상태 컨테이너 패턴 (Scoped Service)
// AppState.cs — Scoped 서비스로 등록
public class AppState
{
public int CartCount { get; private set; }
public event Action? OnChange;
public void AddToCart(int quantity = 1)
{
CartCount += quantity;
NotifyStateChanged();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}
// 컴포넌트에서 구독 및 해제
@inject AppState AppState
@implements IDisposable
<span>장바구니: @AppState.CartCount</span>
@code {
protected override void OnInitialized()
{
AppState.OnChange += StateHasChanged; // 상태 변경 시 리렌더링
}
public void Dispose()
{
AppState.OnChange -= StateHasChanged; // 메모리 누수 방지
}
}7. 폼 처리와 유효성 검사
// 모델 클래스 — DataAnnotations 활용
public class RegisterModel
{
[Required(ErrorMessage = "이름은 필수입니다")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "2~50자 사이로 입력하세요")]
public string Name { get; set; } = "";
[Required, EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다")]
public string Email { get; set; } = "";
[Required, Range(0, 150)]
public int Age { get; set; }
}
<!-- EditForm: OnValidSubmit = 검증 통과 시만 실행 -->
<EditForm Model="model" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div>
<label>이름</label>
<InputText @bind-Value="model.Name" />
<ValidationMessage For="() => model.Name" />
</div>
<div>
<label>이메일</label>
<InputText @bind-Value="model.Email" />
<ValidationMessage For="() => model.Email" />
</div>
<div>
<label>나이</label>
<InputNumber @bind-Value="model.Age" />
<ValidationMessage For="() => model.Age" />
</div>
<button type="submit">가입</button>
</EditForm>
@code {
private RegisterModel model = new();
private async Task HandleSubmit()
{
await UserService.RegisterAsync(model);
Nav.NavigateTo("/welcome");
}
}8. HTTP 통신과 API 연동
// Program.cs
builder.Services.AddHttpClient<IProductApiService, ProductApiService>(
c => c.BaseAddress = new Uri(builder.Configuration["ApiBase"]!));
// ProductApiService.cs
public class ProductApiService : IProductApiService
{
private readonly HttpClient _http;
public ProductApiService(HttpClient http) => _http = http;
public Task<List<Product>> GetAllAsync()
=> _http.GetFromJsonAsync<List<Product>>("/api/products") ?? Task.FromResult(new List<Product>());
public Task<Product?> GetByIdAsync(int id)
=> _http.GetFromJsonAsync<Product>($"/api/products/{id}");
public Task CreateAsync(Product product)
=> _http.PostAsJsonAsync("/api/products", product);
public Task UpdateAsync(Product product)
=> _http.PutAsJsonAsync($"/api/products/{product.Id}", product);
public Task DeleteAsync(int id)
=> _http.DeleteAsync($"/api/products/{id}");
}
// 컴포넌트에서 사용 — 로딩 상태 처리 포함
@inject IProductApiService ApiService
@code {
private List<Product> products = new();
private bool isLoading = true;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
try
{
products = await ApiService.GetAllAsync();
}
catch (HttpRequestException ex)
{
errorMessage = $"데이터 로드 실패: {ex.Message}";
}
finally
{
isLoading = false;
}
}
}9. JavaScript Interop
@inject IJSRuntime JS
@code {
// C# → JS 단방향 호출 (반환값 없음)
private async Task ShowAlert()
=> await JS.InvokeVoidAsync("alert", "Blazor에서 호출!");
// C# → JS 값 반환 호출
private async Task<string> GetLocalStorageItem(string key)
=> await JS.InvokeAsync<string>("localStorage.getItem", key);
// DOM 조작은 반드시 OnAfterRenderAsync(firstRender) 에서
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await JS.InvokeVoidAsync("initializeMap", "map-container");
}
}
// wwwroot/js/app.js — JS → C# 역방향 호출
// window.callDotNet = async (data) => {
// await DotNet.invokeMethodAsync("MyApp", "ReceiveFromJS", data);
// };
// C# 수신 메서드 — [JSInvokable] 필수
[JSInvokable]
public static Task ReceiveFromJS(string payload)
{
Console.WriteLine($"JS에서 수신: {payload}");
return Task.CompletedTask;
}10. 인증과 인가
// Program.cs — ASP.NET Core Identity + Blazor 인증
builder.Services.AddAuthentication();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
});
// 컴포넌트 수준 인가 — @attribute 어트리뷰트
@attribute [Authorize]
@attribute [Authorize(Roles = "Admin,Manager")]
@attribute [Authorize(Policy = "AdminOnly")]
// 템플릿 수준 조건부 렌더링
<AuthorizeView>
<Authorized>
<p>안녕하세요, @context.User.Identity?.Name 님!</p>
<button @onclick="Logout">로그아웃</button>
</Authorized>
<NotAuthorized>
<a href="/login">로그인이 필요합니다</a>
</NotAuthorized>
</AuthorizeView>
<AuthorizeView Roles="Admin">
<Authorized><AdminPanel /></Authorized>
</AuthorizeView>
// 코드에서 인증 상태 확인
@inject AuthenticationStateProvider AuthProvider
@code {
protected override async Task OnInitializedAsync()
{
var state = await AuthProvider.GetAuthenticationStateAsync();
var user = state.User;
if (user.Identity?.IsAuthenticated == true)
userName = user.Identity.Name;
}
}📌 Blazor 실전 개발 핵심 포인트
컴포넌트
.razor 단위. @page·@inject·@code 세 영역으로 구성
생명주기
Init(1회)→ParametersSet(매번)→AfterRender→Dispose 순서
바인딩
@bind 양방향, EventCallback으로 자식→부모 전달
라우팅
@page + 타입 제약, NavigationManager 프로그래매틱 이동
의존성 주입
@inject로 주입. Singleton/Scoped/Transient 수명 선택
상태 관리
CascadingValue 또는 Scoped AppState + OnChange 이벤트
폼 처리
EditForm + DataAnnotations + InputXxx 컴포넌트
HTTP 통신
HttpClient + GetFromJsonAsync / PostAsJsonAsync
JS Interop
IJSRuntime.InvokeAsync + [JSInvokable] 양방향
인증/인가
[Authorize] + AuthorizeView로 UI 수준 접근 제어
실습: 완성형 할 일(Todo) 관리 앱
지금까지 배운 핵심 개념을 하나의 완성형 앱으로 통합합니다. 의존성 주입(DI), 생명주기, 바인딩, 이벤트, 폼 처리가 모두 포함되어 있습니다.
① 모델 및 서비스 정의
// Models/TodoItem.cs
public class TodoItem
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool IsCompleted { get; set; }
}
// Models/NewTodoModel.cs
public class NewTodoModel
{
[Required(ErrorMessage = "할 일 내용을 입력하세요")]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; } = "";
}
// Services/ITodoService.cs
public interface ITodoService
{
Task<List<TodoItem>> GetAllAsync();
Task<TodoItem> AddAsync(string title);
Task ToggleAsync(int id);
Task DeleteAsync(int id);
}
// Services/TodoService.cs (인메모리 구현)
public class TodoService : ITodoService
{
private readonly List<TodoItem> _store = new();
private int _nextId = 1;
public Task<List<TodoItem>> GetAllAsync()
=> Task.FromResult(_store.ToList());
public Task<TodoItem> AddAsync(string title)
{
var item = new TodoItem { Id = _nextId++, Title = title };
_store.Add(item);
return Task.FromResult(item);
}
public Task ToggleAsync(int id)
{
var item = _store.FirstOrDefault(x => x.Id == id);
if (item is not null) item.IsCompleted = !item.IsCompleted;
return Task.CompletedTask;
}
public Task DeleteAsync(int id)
{
_store.RemoveAll(x => x.Id == id);
return Task.CompletedTask;
}
}
// Program.cs
builder.Services.AddScoped<ITodoService, TodoService>();② 할 일 목록 컴포넌트 (Pages/TodoList.razor)
@page "/todo"
@inject ITodoService TodoService
<h2>할 일 관리</h2>
<EditForm Model="newTodo" OnValidSubmit="AddTodo">
<DataAnnotationsValidator />
<div style="display:flex; gap:8px;">
<InputText @bind-Value="newTodo.Title" placeholder="새 할 일을 입력하세요" />
<button type="submit">추가</button>
</div>
<ValidationMessage For="() => newTodo.Title" />
</EditForm>
@if (isLoading)
{
<p>불러오는 중...</p>
}
else if (!todos.Any())
{
<p>등록된 할 일이 없습니다.</p>
}
else
{
<p>전체 @todos.Count건 / 완료 @todos.Count(x => x.IsCompleted)건</p>
<ul>
@foreach (var item in todos)
{
<li>
<input type="checkbox" checked="@item.IsCompleted"
@onchange="() => ToggleTodo(item.Id)" />
<span style="text-decoration:@(item.IsCompleted ? "line-through" : "none")">
@item.Title
</span>
<button @onclick="() => DeleteTodo(item.Id)">삭제</button>
</li>
}
</ul>
}
@code {
private List<TodoItem> todos = new();
private NewTodoModel newTodo = new();
private bool isLoading = true;
// 생명주기: 최초 1회 데이터 로드
protected override async Task OnInitializedAsync()
{
todos = await TodoService.GetAllAsync();
isLoading = false;
}
private async Task AddTodo()
{
var item = await TodoService.AddAsync(newTodo.Title);
todos.Add(item);
newTodo = new(); // 폼 초기화
}
private async Task ToggleTodo(int id)
{
await TodoService.ToggleAsync(id);
var item = todos.FirstOrDefault(x => x.Id == id);
if (item is not null) item.IsCompleted = !item.IsCompleted;
}
private async Task DeleteTodo(int id)
{
await TodoService.DeleteAsync(id);
todos.RemoveAll(x => x.Id == id);
}
}자주 발생하는 실수와 주의사항
- StateHasChanged() 빠뜨리기 — 외부 이벤트(타이머, 웹소켓 콜백 등)로 상태가 바뀔 때 UI가 갱신되지 않습니다. 비동기 콜백 안에서는 반드시
await InvokeAsync(StateHasChanged)를 호출하십시오. - JavaScript Interop을 OnInitializedAsync에서 호출 — 이 시점에는 DOM이 아직 렌더링되지 않아 JS에서 요소를 찾지 못합니다. JS 초기화는 반드시
OnAfterRenderAsync(firstRender)안에서 수행하십시오. - IDisposable 구현 생략 —
AppState.OnChange등 이벤트를 구독하고 Dispose에서 해제하지 않으면 컴포넌트가 소멸된 뒤에도 핸들러가 호출되어 메모리 누수와 예외가 발생합니다. - @bind와 단방향 바인딩 혼용 오류 —
@bind="Value"를 사용하면서 동시에value="@Value"를 쓰면 컴파일 에러가 발생합니다. 양방향은@bind만, 단방향은@Value만 사용하십시오. - Blazor WebAssembly에서 직접 DB 접근 시도 — WASM은 브라우저 안에서 실행되므로 EF Core나 직접 SQL 연결이 불가능합니다. 반드시 Web API를 통해 서버와 통신하십시오.
- Scoped 서비스를 Singleton에서 참조 — Singleton 서비스 생성자에 Scoped 서비스를 주입하면 수명 불일치(Captive Dependency)가 발생합니다. IServiceScopeFactory를 사용하여 명시적으로 스코프를 생성하십시오.
- 매개변수 변경 감지 누락 — 부모에서 같은 참조 타입 객체의 속성만 변경하면 Blazor가 변경을 감지하지 못합니다. 새 객체로 교체하거나 자식에서
StateHasChanged()를 직접 호출하십시오.
마무리·실전 활용 팁
프로젝트 구조 권장 패턴
MyBlazorApp/
├── Pages/ ← @page 라우트 컴포넌트
├── Components/ ← 재사용 UI 컴포넌트 (라우트 없음)
├── Layouts/ ← MainLayout.razor 등
├── Services/ ← 비즈니스 로직 인터페이스·구현
├── Models/ ← 데이터 모델, DTO, 폼 모델
├── Shared/ ← 전역 공유 컴포넌트
└── wwwroot/
├── css/
└── js/ ← JS Interop용 스크립트성능 최적화 포인트
- ShouldRender() 오버라이드: 불필요한 리렌더링을 차단합니다. 변경이 없을 때
return false를 반환하십시오. - Virtualize 컴포넌트: 대량 목록을 렌더링할 때
<Virtualize Items="items" Context="item">를 사용하면 가시 영역만 렌더링하여 성능이 크게 향상됩니다. - lazy loading: 큰 어셈블리는 필요한 시점에만 로드하여 WASM 초기 로드 시간을 줄입니다.
- ErrorBoundary 컴포넌트: 자식 컴포넌트 예외가 전체 앱 크래시로 이어지지 않도록 오류 경계를 설정합니다.
<!-- 성능: 대량 목록 -->
<Virtualize Items="products" Context="p">
<ProductCard Product="p" />
</Virtualize>
<!-- 안정성: 오류 경계 -->
<ErrorBoundary>
<ChildContent><RiskyComponent /></ChildContent>
<ErrorContent Context="ex">
<p>일시적인 오류가 발생했습니다: @ex.Message</p>
</ErrorContent>
</ErrorBoundary>
<!-- 성능: 조건부 리렌더링 -->
@code {
private string lastValue = "";
protected override bool ShouldRender()
=> currentValue != lastValue; // 값이 바뀔 때만 렌더링
}배포 체크리스트
- Blazor Server: IIS / Nginx 리버스 프록시 + SignalR WebSocket 포트 허용
- Blazor WASM: 정적 파일 서버 배포 가능.
_framework/폴더 포함 확인 - 환경별 설정:
appsettings.Production.json에 API 주소·연결 문자열 분리 - HTTPS: 반드시 활성화. Blazor WASM은 HTTPS 없이 일부 브라우저 API 사용 불가
✅ 핵심 정리
- 호스팅 모델: 실시간·내부 시스템 → Blazor Server, 공개 SPA → WebAssembly, 네이티브 앱 → Hybrid
- 컴포넌트 생명주기 순서: OnInitializedAsync(1회) → OnParametersSet(매번) → OnAfterRender → Dispose
- 데이터 바인딩: @Property(단방향) / @bind(양방향) / EventCallback(자식→부모 전달)
- 라우팅: @page 디렉티브 + 타입 제약, 프로그래매틱 이동은 NavigationManager
- 의존성 주입: Program.cs에서 서비스 등록 → @inject 또는 [Inject]로 주입
- 상태 관리: CascadingValue(계층 전달) 또는 Scoped AppState + OnChange 이벤트
- 폼 처리: EditForm + DataAnnotationsValidator + InputXxx 컴포넌트 조합
- HTTP 통신: HttpClient + System.Net.Http.Json 확장 메서드(GetFromJsonAsync 등)
- JavaScript Interop: C#→JS는 InvokeAsync, JS→C#은 [JSInvokable] + DotNet.invokeMethodAsync
- 인증/인가: [Authorize] 어트리뷰트로 페이지 보호, AuthorizeView로 UI 조건부 렌더링
- 핵심 주의사항: JS Interop은 OnAfterRenderAsync에서 / 이벤트 구독은 Dispose에서 반드시 해제