비동기 프로그래밍
async/await 패턴과 OnInitializedAsync로 비동기 데이터 로딩을 구현합니다.
📌 학습 목표
- C#의 async/await 패턴이 무엇인지, 왜 필요한지 설명할 수 있다.
- OnInitializedAsync 수명 주기 메서드를 사용해 컴포넌트 초기화 시 데이터를 비동기로 로딩할 수 있다.
- 데이터 로딩 중 로딩 스피너를 표시하고 완료 후 UI를 갱신할 수 있다.
- 비동기 메서드에서 예외 처리를 올바르게 구현할 수 있다.
- StateHasChanged()를 언제 호출해야 하는지 이해할 수 있다.
📝 개념 설명
1. 왜 비동기 프로그래밍이 필요한가?
웹 애플리케이션은 데이터베이스 조회, 외부 API 호출, 파일 읽기 등 시간이 걸리는 작업을 빈번하게 수행합니다. 이 작업들을 동기(Synchronous) 방식으로 처리하면 작업이 끝날 때까지 브라우저 UI 전체가 멈춥니다. 사용자는 화면이 얼어붙은 것처럼 느끼게 됩니다.
비동기(Asynchronous) 방식을 사용하면 시간이 걸리는 작업을 기다리는 동안 UI 스레드가 다른 작업(화면 렌더링, 사용자 입력 처리 등)을 계속 수행할 수 있습니다. 결과적으로 응답성 좋은 UI를 만들 수 있습니다.
| 항목 | 동기 방식 | 비동기 방식 |
|---|---|---|
| 처리 흐름 | 작업 완료까지 대기 | 작업 완료를 기다리지 않고 다음 코드 실행 |
| UI 반응성 | 작업 중 UI 멈춤 | 작업 중에도 UI 정상 동작 |
| 코드 표현 | var data = GetData(); | var data = await GetDataAsync(); |
| 반환 타입 | void, T | Task, Task<T> |
| Blazor 적합성 | 단순 계산에만 적합 | 데이터 로딩·API 호출 필수 |
2. async/await 패턴 기본 원리
C#에서 비동기 메서드는 async 키워드로 선언하고, 시간이 걸리는 작업 앞에 await 키워드를 붙입니다.
- async: 이 메서드는 비동기 작업을 포함한다고 컴파일러에게 알립니다.
- await: 해당 비동기 작업이 완료될 때까지 이 지점에서 실행을 일시 중단하되, 스레드는 해제하여 다른 작업을 처리할 수 있게 합니다.
- Task: 비동기 작업의 결과를 나타내는 객체입니다. 반환값이 없으면
Task, 있으면Task<T>를 사용합니다.
// 동기 메서드
string GetUserName(int id)
{
return database.FindUser(id).Name; // 완료될 때까지 블로킹
}
// 비동기 메서드
async Task<string> GetUserNameAsync(int id)
{
var user = await database.FindUserAsync(id); // 완료될 때까지 대기하되 스레드 해제
return user.Name;
}
3. Blazor의 수명 주기 메서드와 비동기
Blazor 컴포넌트는 여러 수명 주기(Lifecycle) 메서드를 제공합니다. 이 중 데이터를 초기 로딩할 때 사용하는 핵심 메서드가 OnInitializedAsync입니다.
생성자 실행
동기 초기화
비동기 초기화
데이터 로딩
UI 표시
매개변수 변경
UI 재렌더링
Blazor는 OnInitializedAsync를 특별하게 처리합니다. 이 메서드가 실행되면 두 번의 렌더링이 발생합니다.
- 첫 번째 렌더링:
await를 만나는 순간 즉시 렌더링 (로딩 상태 표시) - 두 번째 렌더링: 비동기 작업 완료 후 자동 재렌더링 (실제 데이터 표시)
💡 예제 & 실습
예제 1 — 기본 비동기 데이터 로딩
가장 기본적인 형태입니다. 컴포넌트가 초기화될 때 데이터를 비동기로 가져와 표시합니다.
@page "/users"
@inject HttpClient Http
<h2>사용자 목록</h2>
@if (users == null)
{
<p>데이터를 불러오는 중...</p>
}
else
{
<ul>
@foreach (var user in users)
{
<li>@user.Name (@user.Email)</li>
}
</ul>
}
@code {
private List<User>? users;
protected override async Task OnInitializedAsync()
{
// await를 만나는 순간 첫 번째 렌더링 발생 (users == null → 로딩 메시지 표시)
users = await Http.GetFromJsonAsync<List<User>>("api/users");
// 이 줄 이후 두 번째 렌더링 발생 (users에 데이터 → 목록 표시)
}
public class User
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
}
users를 null로 초기화하고 @if (users == null)로 분기하는 패턴은 Blazor에서 로딩 상태를 표시하는 가장 표준적인 방법입니다. List<User>?의 ?는 null을 허용한다는 의미입니다.
예제 2 — 로딩 스피너와 오류 처리
실전에서는 로딩 중 스피너를 표시하고, 오류가 발생했을 때 사용자에게 알려주는 처리가 필요합니다.
@page "/products"
@inject HttpClient Http
<h2>상품 목록</h2>
@if (isLoading)
{
<div class="spinner">⏳ 상품을 불러오는 중입니다...</div>
}
else if (errorMessage != null)
{
<div class="alert alert-danger">
❌ 오류 발생: @errorMessage
<button @onclick="LoadProductsAsync">다시 시도</button>
</div>
}
else
{
<ul>
@foreach (var product in products!)
{
<li>@product.Name — @product.Price.ToString("C")</li>
}
</ul>
}
@code {
private List<Product>? products;
private bool isLoading = false;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
await LoadProductsAsync();
}
private async Task LoadProductsAsync()
{
isLoading = true;
errorMessage = null;
try
{
products = await Http.GetFromJsonAsync<List<Product>>("api/products");
}
catch (HttpRequestException ex)
{
errorMessage = $"네트워크 오류: {ex.Message}";
}
catch (Exception ex)
{
errorMessage = $"알 수 없는 오류: {ex.Message}";
}
finally
{
isLoading = false;
}
}
public class Product
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
}
위 코드의 핵심 구조를 살펴봅니다.
isLoading플래그: 로딩 시작 시true, 완료 또는 오류 시false로 설정합니다.try/catch/finally:await는 예외를 일반 동기 코드처럼try/catch로 처리할 수 있습니다.finally는 성공·실패 모두 실행됩니다.- 재시도 버튼: 로딩 로직을 별도 메서드(
LoadProductsAsync)로 분리하면 버튼 클릭으로 재시도할 수 있습니다.
예제 3 — 매개변수 변경에 반응하는 비동기 로딩
URL 매개변수가 바뀔 때마다 새로운 데이터를 로딩해야 하는 경우, OnParametersSetAsync를 사용합니다.
@page "/product/{Id:int}"
@inject HttpClient Http
<h2>상품 상세</h2>
@if (product == null)
{
<p>로딩 중...</p>
}
else
{
<h3>@product.Name</h3>
<p>가격: @product.Price.ToString("C")</p>
<p>설명: @product.Description</p>
}
@code {
[Parameter] public int Id { get; set; }
private Product? product;
private int previousId = -1;
protected override async Task OnParametersSetAsync()
{
// Id가 실제로 변경된 경우에만 데이터 로딩
if (Id != previousId)
{
previousId = Id;
product = null; // 이전 데이터 초기화 (로딩 표시)
product = await Http.GetFromJsonAsync<Product>($"api/products/{Id}");
}
}
public class Product
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Description { get; set; } = string.Empty;
}
}
OnParametersSetAsync는 매개변수가 변경될 때마다 호출됩니다. 그런데 부모 컴포넌트가 재렌더링될 때도 자식의 매개변수가 같은 값으로 전달되면서 이 메서드가 호출될 수 있습니다. previousId와 비교해서 실제로 값이 변경된 경우에만 데이터를 로딩하면 불필요한 API 호출을 방지할 수 있습니다.
예제 4 — StateHasChanged()를 수동으로 호출해야 하는 경우
Blazor는 대부분의 경우 자동으로 UI를 갱신합니다. 그러나 외부 이벤트(타이머, 외부 서비스 콜백)에서 상태를 변경하는 경우에는 수동으로 StateHasChanged()를 호출해야 합니다.
@page "/clock"
@implements IDisposable
<h2>현재 시간: @currentTime.ToString("HH:mm:ss")</h2>
@code {
private DateTime currentTime = DateTime.Now;
private Timer? timer;
protected override void OnInitialized()
{
// 1초마다 시간 업데이트
timer = new Timer(async _ =>
{
currentTime = DateTime.Now;
// 타이머 콜백은 Blazor의 렌더링 루프 밖에서 실행되므로
// InvokeAsync로 UI 스레드에 작업을 전달하고 StateHasChanged 호출
await InvokeAsync(StateHasChanged);
}, null, 0, 1000);
}
public void Dispose()
{
timer?.Dispose(); // 컴포넌트 제거 시 타이머 해제
}
}
- InvokeAsync: Blazor의 동기화 컨텍스트로 작업을 전달합니다. 외부 스레드에서 UI를 업데이트할 때 반드시 사용합니다.
- StateHasChanged(): Blazor에게 “상태가 변경되었으니 다시 렌더링하라”고 알립니다.
- IDisposable: 타이머나 이벤트 구독 같은 자원은 컴포넌트가 제거될 때 반드시 해제해야 메모리 누수를 방지할 수 있습니다.
⚠️ 자주 틀리는 것 / 주의사항
❌ 실수 1: async void 사용
Blazor에서 async void는 거의 항상 잘못된 선택입니다. 예외가 발생해도 잡을 수 없으며, 완료를 기다릴 수 없습니다.
// ❌ 잘못된 방법
protected override async void OnInitialized() { ... }
// ✅ 올바른 방법
protected override async Task OnInitializedAsync() { ... }
단, 이벤트 핸들러(@onclick에 직접 연결되는 메서드)는 Blazor가 내부적으로 Task를 처리하므로 async Task로 선언하면 됩니다.
❌ 실수 2: await 없이 Task 반환
비동기 메서드를 호출할 때 await를 빠뜨리면, 메서드 호출이 즉시 반환되어 데이터가 로딩되기 전에 UI가 렌더링됩니다.
// ❌ 잘못된 방법 — await 빠짐
protected override async Task OnInitializedAsync()
{
LoadDataAsync(); // await 없음! 데이터가 아직 안 왔는데 메서드가 끝남
}
// ✅ 올바른 방법
protected override async Task OnInitializedAsync()
{
await LoadDataAsync(); // 완료될 때까지 기다림
}
❌ 실수 3: null 체크 없이 데이터 사용
비동기 로딩이 완료되기 전 첫 번째 렌더링에서 데이터가 null인 상태로 foreach를 돌리면 NullReferenceException이 발생합니다.
// ❌ 잘못된 방법 — null 체크 없음
@foreach (var item in items) // items가 null이면 런타임 오류!
{
<li>@item.Name</li>
}
// ✅ 올바른 방법 1 — null 조건부 연산자
@foreach (var item in items ?? Enumerable.Empty<Item>())
// ✅ 올바른 방법 2 — if 분기
@if (items != null)
{
@foreach (var item in items) { ... }
}
❌ 실수 4: 불필요한 StateHasChanged() 호출
이벤트 핸들러(@onclick 등)와 수명 주기 메서드(OnInitializedAsync 등) 안에서 상태를 변경하면 Blazor가 자동으로 재렌더링합니다. 이 경우 StateHasChanged()를 명시적으로 호출하면 불필요한 이중 렌더링이 발생합니다.
StateHasChanged()가 필요한 경우: 타이머 콜백, 외부 이벤트 구독자, Task.Run() 내부처럼 Blazor의 렌더링 컨텍스트 밖에서 상태를 변경할 때입니다.
❌ 실수 5: HttpClient를 생성자에서 직접 new
Blazor에서 HttpClient는 반드시 의존성 주입(DI)(의존성 주입)으로 받아야 합니다. 직접 new HttpClient()를 만들면 포트 고갈, 설정 누락 등의 문제가 발생합니다.
// ❌ 잘못된 방법
private HttpClient http = new HttpClient();
// ✅ 올바른 방법 — @inject 또는 [Inject]
@inject HttpClient Http
🎯 마무리
비동기 프로그래밍은 Blazor 애플리케이션에서 반응성 좋은 UI를 만드는 핵심 기법입니다. OnInitializedAsync를 활용하면 컴포넌트 초기화 시점에 자연스럽게 데이터를 로딩할 수 있으며, Blazor의 이중 렌더링 특성을 이해하면 로딩 상태를 손쉽게 표현할 수 있습니다.
실전에서는 다음 순서로 접근하면 안정적인 비동기 컴포넌트를 만들 수 있습니다.
- 상태 변수를
null로 초기화 (로딩 구분용) isLoading플래그로 스피너 표시try/catch/finally로 오류·완료 처리- 로딩 로직을 별도
async Task메서드로 분리 (재시도 지원) - 외부 자원(타이머, 구독)은
IDisposable로 정리
- async/await는 시간이 걸리는 작업 중에도 UI 스레드를 블로킹하지 않아 응답성을 유지합니다.
- Blazor의 OnInitializedAsync는 컴포넌트 초기화 시 비동기 데이터 로딩에 사용하는 표준 수명 주기 메서드입니다.
await를 만나는 순간 첫 번째 렌더링이 발생하고(로딩 상태), 작업 완료 후 두 번째 렌더링이 자동 발생합니다(데이터 표시).- 비동기 메서드는 async void 대신 async Task를 반환 타입으로 사용해야 예외 처리가 가능합니다.
- 외부 이벤트(타이머 등)에서 UI를 갱신할 때는 InvokeAsync(StateHasChanged)를 사용합니다.
- 타이머·이벤트 구독 같은 외부 자원은 IDisposable.Dispose()에서 반드시 정리합니다.
- 데이터가
null일 수 있는 첫 렌더링에서는 반드시 null 체크(@if)를 추가해 런타임 오류를 방지합니다.