CRUD 기능 구현
EF Core와 서비스 레이어를 결합해 완전한 생성·조회·수정·삭제 기능을 만듭니다.
📌 학습 목표
- EF Core와 서비스 레이어를 결합한 CRUD 아키텍처를 직접 설계할 수 있습니다.
- 서비스 인터페이스(IBookService)와 구현 클래스를 분리해 유지보수성을 높일 수 있습니다.
- Blazor 컴포넌트에서 Create, Read, Update, Delete 기능을 완전히 구현할 수 있습니다.
- 등록과 수정을 하나의 폼 컴포넌트로 재사용하는 패턴을 적용할 수 있습니다.
- 비동기 데이터 작업 후 UI를 올바르게 갱신하는 방법을 이해하고 적용할 수 있습니다.
(UI 레이어)
(IBookService)
AppDbContext
(SQLite)
📝 개념 설명
1. CRUD란 무엇인가
CRUD는 Create(생성), Read(조회), Update(수정), Delete(삭제)의 첫 글자를 모은 약어입니다. 게시판, 쇼핑몰 상품 관리, 회원 시스템 등 데이터를 다루는 모든 웹 애플리케이션의 핵심 작업이 이 네 가지로 구성됩니다. Blazor와 EF Core를 함께 사용하면 이 네 작업을 체계적이고 안정적으로 구현할 수 있습니다.
2. 서비스 레이어를 분리하는 이유
Blazor 컴포넌트가 EF Core DbContext를 직접 호출하면 UI 코드와 데이터 처리 코드가 뒤섞여 유지보수가 어려워집니다. 서비스 레이어를 중간에 두면 다음과 같은 이점이 있습니다.
- 관심사 분리: 컴포넌트는 UI 표현에만 집중하고, 서비스는 데이터 처리에만 집중합니다.
- 재사용성: 동일한 서비스를 여러 컴포넌트에서 재사용할 수 있습니다.
- 테스트 용이성: 인터페이스를 Mock으로 대체해 단위 테스트를 쉽게 작성할 수 있습니다.
- 유연한 교체: EF Core를 다른 ORM으로 교체해도 컴포넌트 코드는 수정할 필요가 없습니다.
3. Scoped 생명주기와 DbContext
Blazor Server에서 DbContext는 반드시 Scoped 생명주기로 등록해야 합니다. DbContext는 내부에서 연결 상태와 변경 추적 정보를 유지하기 때문에 Singleton으로 등록하면 여러 사용자 간에 상태가 공유되어 심각한 동시성 버그가 발생합니다. 서비스 클래스도 DbContext와 동일하게 Scoped로 등록해야 충돌이 없습니다.
builder.Services.AddDbContext<T>()는 내부적으로 DbContext를 Scoped로 등록합니다. 서비스 클래스를 실수로 AddSingleton으로 등록하면, Singleton이 Scoped 객체인 DbContext를 캡처하면서 런타임 오류가 발생합니다. 반드시 AddScoped를 사용하세요.
💡 예제 & 실습 — 도서 관리 시스템 완전 구현
Step 1: 모델 및 DbContext 설정
데이터 유효성 검사를 위해 DataAnnotations를 함께 적용합니다. EF Core는 이 어노테이션을 기반으로 DB 스키마(NOT NULL, 길이 제한 등)도 자동 생성합니다.
// Models/Book.cs
using System.ComponentModel.DataAnnotations;
public class Book
{
public int Id { get; set; }
[Required(ErrorMessage = "제목을 입력해 주세요.")]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "저자를 입력해 주세요.")]
[MaxLength(100)]
public string Author { get; set; } = string.Empty;
[Range(1000, 2100, ErrorMessage = "유효한 출판연도를 입력해 주세요.")]
public int PublishedYear { get; set; }
public bool IsAvailable { get; set; } = true;
}
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Book> Books { get; set; } = null!;
}
Step 2: 서비스 인터페이스 정의
인터페이스를 먼저 정의하면 컴포넌트가 구현 세부사항에 의존하지 않아 나중에 교체·테스트가 용이합니다.
// Services/IBookService.cs
public interface IBookService
{
Task<List<Book>> GetAllBooksAsync();
Task<Book?> GetBookByIdAsync(int id);
Task<Book> CreateBookAsync(Book book);
Task<Book> UpdateBookAsync(Book book);
Task DeleteBookAsync(int id);
}
Step 3: 서비스 구현 클래스 작성
EF Core의 비동기 메서드(ToListAsync, FindAsync, SaveChangesAsync)를 사용해 데이터베이스 작업을 처리합니다. 모든 IO 작업은 await로 비동기 처리해야 Blazor UI 스레드를 차단하지 않습니다.
// Services/BookService.cs
using Microsoft.EntityFrameworkCore;
public class BookService : IBookService
{
private readonly AppDbContext _context;
public BookService(AppDbContext context)
{
_context = context;
}
// Read — 전체 목록 조회 (제목 오름차순)
public async Task<List<Book>> GetAllBooksAsync()
=> await _context.Books.OrderBy(b => b.Title).ToListAsync();
// Read — 단건 조회
public async Task<Book?> GetBookByIdAsync(int id)
=> await _context.Books.FindAsync(id);
// Create — 새 도서 등록
public async Task<Book> CreateBookAsync(Book book)
{
_context.Books.Add(book);
await _context.SaveChangesAsync();
return book; // 저장 후 DB가 할당한 Id가 채워진 상태로 반환
}
// Update — 도서 정보 수정
public async Task<Book> UpdateBookAsync(Book book)
{
var existing = await _context.Books.FindAsync(book.Id);
if (existing is null) throw new KeyNotFoundException($"Id {book.Id} 도서를 찾을 수 없습니다.");
existing.Title = book.Title;
existing.Author = book.Author;
existing.PublishedYear = book.PublishedYear;
existing.IsAvailable = book.IsAvailable;
await _context.SaveChangesAsync();
return existing;
}
// Delete — 도서 삭제
public async Task DeleteBookAsync(int id)
{
var book = await _context.Books.FindAsync(id);
if (book is not null)
{
_context.Books.Remove(book);
await _context.SaveChangesAsync();
}
}
}
_context.Books.Update(book)을 사용할 경우, 같은 Id의 엔티티가 이미 DbContext에 추적 중이면 InvalidOperationException이 발생합니다. 위 예제처럼 FindAsync로 추적 중인 엔티티를 가져와 속성을 직접 수정하면 이 문제를 안전하게 피할 수 있습니다.
Step 4: 의존성 주입 등록 (Program.cs)
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=library.db"));
builder.Services.AddScoped<IBookService, BookService>();
var app = builder.Build();
// 앱 시작 시 DB 자동 생성/마이그레이션
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
Step 5: 도서 목록 컴포넌트 — Read + Delete
전체 도서를 테이블로 표시하고, 각 행에서 수정·삭제를 바로 처리합니다. 삭제 후에는 목록을 다시 로드해 UI를 갱신합니다.
@* Pages/Books/BookList.razor *@
@page "/books"
@inject IBookService BookService
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
<h3>도서 목록</h3>
<a href="/books/create" class="btn btn-primary mb-3">새 도서 추가</a>
@if (books is null)
{
<p>데이터를 불러오는 중입니다...</p>
}
else if (!books.Any())
{
<p>등록된 도서가 없습니다.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>제목</th>
<th>저자</th>
<th>출판연도</th>
<th>대출 가능</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@foreach (var book in books)
{
<tr>
<td>@book.Title</td>
<td>@book.Author</td>
<td>@book.PublishedYear</td>
<td>@(book.IsAvailable ? "대출 가능" : "대출 중")</td>
<td>
<a href="/books/edit/@book.Id"
class="btn btn-sm btn-outline-primary me-1">수정</a>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteBook(book.Id)">삭제</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private List<Book>? books;
protected override async Task OnInitializedAsync()
=> await LoadBooks();
private async Task LoadBooks()
=> books = await BookService.GetAllBooksAsync();
private async Task DeleteBook(int id)
{
var confirmed = await JSRuntime.InvokeAsync<bool>(
"confirm", "정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.");
if (!confirmed) return;
await BookService.DeleteBookAsync(id);
await LoadBooks(); // 삭제 후 목록 갱신
}
}
Step 6: 도서 등록·수정 폼 컴포넌트 — Create + Update
하나의 폼 컴포넌트를 등록과 수정에 모두 재사용합니다. [Parameter]로 전달된 Id값 유무로 등록/수정 모드를 구분하며, Blazor의 EditForm과 DataAnnotationsValidator로 입력 유효성 검사도 함께 처리합니다.
@* Pages/Books/BookForm.razor *@
@page "/books/create"
@page "/books/edit/{Id:int}"
@inject IBookService BookService
@inject NavigationManager Navigation
<h3>@(isEditMode ? "도서 정보 수정" : "새 도서 등록")</h3>
<EditForm Model="book" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label class="form-label">제목</label>
<InputText class="form-control" @bind-Value="book.Title" />
<ValidationMessage For="() => book.Title" />
</div>
<div class="mb-3">
<label class="form-label">저자</label>
<InputText class="form-control" @bind-Value="book.Author" />
<ValidationMessage For="() => book.Author" />
</div>
<div class="mb-3">
<label class="form-label">출판연도</label>
<InputNumber class="form-control" @bind-Value="book.PublishedYear" />
<ValidationMessage For="() => book.PublishedYear" />
</div>
<div class="mb-3 form-check">
<InputCheckbox class="form-check-input" @bind-Value="book.IsAvailable" id="isAvailable" />
<label class="form-check-label" for="isAvailable">대출 가능</label>
</div>
<button type="submit" class="btn btn-primary">
@(isEditMode ? "수정 완료" : "등록하기")
</button>
<a href="/books" class="btn btn-secondary ms-2">취소</a>
</EditForm>
@code {
[Parameter] public int? Id { get; set; }
private Book book = new();
private bool isEditMode => Id.HasValue;
protected override async Task OnInitializedAsync()
{
if (!isEditMode) return;
var original = await BookService.GetBookByIdAsync(Id!.Value);
if (original is null)
{
Navigation.NavigateTo("/books"); // 존재하지 않는 도서면 목록으로 이동
return;
}
// 원본 엔티티를 복사해 편집용 객체 생성
// (취소 시 원본 DbContext 상태가 오염되지 않도록)
book = new Book
{
Id = original.Id,
Title = original.Title,
Author = original.Author,
PublishedYear = original.PublishedYear,
IsAvailable = original.IsAvailable
};
}
private async Task HandleSubmit()
{
if (isEditMode)
await BookService.UpdateBookAsync(book);
else
await BookService.CreateBookAsync(book);
Navigation.NavigateTo("/books");
}
}
Add(entity) → SaveChangesAsync(). 저장 후 DB가 Id를 자동 할당합니다.FindAsync(id)로 단건, ToListAsync()로 목록 조회. LINQ로 필터·정렬·페이징 적용 가능.SaveChangesAsync(). 충돌 없이 안전합니다.FindAsync로 엔티티 로드 → Remove() → SaveChangesAsync(). null 체크 필수.⚠️ 자주 틀리는 것 / 주의사항
1. SaveChangesAsync() 호출을 빠뜨리는 실수
Add(), Update(), Remove()는 DbContext의 변경 추적기에 등록만 할 뿐, 실제 DB에는 반영하지 않습니다. 반드시 await _context.SaveChangesAsync()를 호출해야 SQL이 실행됩니다.
// ❌ SaveChangesAsync 없이 Add만 하면 DB에 저장되지 않음
_context.Books.Add(book);
// 여기서 메서드가 끝나면 데이터는 사라진다!
// ✅ 올바른 패턴
_context.Books.Add(book);
await _context.SaveChangesAsync(); // 이 줄이 반드시 있어야 함
2. Update 시 추적 충돌(Tracking Conflict)
같은 Id의 엔티티가 이미 DbContext에 추적 중인 상태에서 _context.Books.Update(anotherInstance)를 호출하면 InvalidOperationException: The instance of entity type 'Book' cannot be tracked because another instance... 오류가 발생합니다. 위 Step 3의 서비스처럼 FindAsync로 추적 엔티티를 가져온 뒤 속성을 직접 수정하는 방법이 가장 안전합니다.
3. 폼 모델에 DB 엔티티를 직접 연결하는 위험
EditForm의 Model에 DbContext가 추적 중인 엔티티를 직접 바인딩하면, 사용자가 폼을 취소해도 객체 상태가 변경된 채로 남아 있습니다. Step 6의 예제처럼 편집 전에 새 Book 인스턴스로 복사해 사용하는 것이 안전합니다.
4. async void 이벤트 핸들러의 예외 무시
Blazor 이벤트 핸들러에 async void를 사용하면 내부에서 발생한 예외가 조용히 무시됩니다. 이벤트 핸들러는 반드시 async Task로 선언해야 예외가 올바르게 전파됩니다.
// ❌ async void — 예외 무시됨
private async void DeleteBook(int id)
{
await BookService.DeleteBookAsync(id); // 예외가 발생해도 아무 일도 안 일어남
}
// ✅ async Task — 예외가 Blazor 오류 경계로 전달됨
private async Task DeleteBook(int id)
{
await BookService.DeleteBookAsync(id);
await LoadBooks();
}
5. 라우트 매개변수 타입 강제(Type Constraint) 미사용
@page "/books/edit/{Id}"로 선언하면 Id가 문자열로 전달되어 정수 변환 시 오류가 생길 수 있습니다. {Id:int}처럼 타입 제약을 명시하면 숫자가 아닌 URL은 자동으로 404 처리됩니다.
// ❌ 타입 제약 없음 — Id가 문자열로 전달됨
@page "/books/edit/{Id}"
// ✅ int 타입 제약 명시
@page "/books/edit/{Id:int}"
🎯 마무리
이번 학습에서는 EF Core와 서비스 레이어를 결합해 Blazor 애플리케이션에서 완전한 CRUD 기능을 구현하는 방법을 도서 관리 시스템 예제를 통해 단계별로 살펴봤습니다. 서비스 인터페이스 기반 설계, 비동기 데이터 처리, 폼 컴포넌트 재사용 패턴은 규모가 큰 실제 프로젝트에서도 그대로 적용할 수 있는 핵심 아키텍처 패턴입니다.
이 패턴이 익숙해지면 페이징(Pagination), 검색 필터, 정렬 기능을 서비스 레이어에 추가해 사용자 경험을 향상시키거나, try-catch와 사용자 친화적인 오류 메시지를 추가해 완성도를 한층 높일 수 있습니다. CRUD 구현 능력은 Blazor 개발의 가장 중요한 토대이므로, 예제를 직접 타이핑하고 실행해 보며 손에 익히는 것을 권장합니다.
- CRUD는 Create·Read·Update·Delete의 약어로, 데이터 관리 애플리케이션의 필수 기능 집합입니다.
- 서비스 레이어(인터페이스 + 구현 클래스)를 분리하면 유지보수성, 재사용성, 테스트 용이성이 높아집니다.
- DbContext와 서비스는 반드시 Scoped로 등록해야 사용자 간 상태 공유 버그를 방지할 수 있습니다.
Add/Remove후 반드시await SaveChangesAsync()를 호출해야 변경사항이 DB에 반영됩니다.- Update 시 추적 충돌을 방지하려면
FindAsync로 추적 엔티티를 얻어 속성을 직접 수정합니다. - 등록·수정 폼을 하나의 컴포넌트로 재사용하려면
Id파라미터 유무로 모드를 구분합니다. - 이벤트 핸들러는
async void대신async Task로 선언해야 예외가 올바르게 처리됩니다.