Entity Framework Core 패턴
이 스킬을 사용해야 할 때
다음과 같은 경우에 이 스킬을 사용하세요:
- 새 프로젝트에 EF Core 설정
- 쿼리 성능 최적화
- 데이터베이스 마이그레이션 관리
- .NET Aspire와 EF Core 통합
- 변경 추적 문제 디버깅
- 여러 탐색 컬렉션을 효율적으로 로드 (쿼리 분할)
핵심 원칙
- 기본 NoTracking - 대부분의 쿼리는 읽기 전용입니다. 추적은 선택적으로 사용합니다.
- 마이그레이션을 수동으로 편집하지 않음 - 항상 CLI 명령어를 사용합니다.
- 전용 마이그레이션 서비스 - 애플리케이션 시작과 마이그레이션 실행을 분리합니다.
- 재시도를 위한 ExecutionStrategy - 일시적인 데이터베이스 오류를 처리합니다.
- 명시적 업데이트 - NoTracking일 때 엔터티를 명시적으로 업데이트로 표시합니다.
패턴 1: 기본 NoTracking
DbContext가 기본적으로 변경 추적을 비활성화하도록 구성합니다. 읽기 중심 워크로드의 성능이 향상됩니다.
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
// 읽기 전용 쿼리의 성능 향상을 위해 기본 변경 추적 비활성화
// 변경을 추적해야 하는 쿼리에는 .AsTracking()을 명시적으로 사용
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
}
NoTracking이 활성화된 경우
읽기 전용 쿼리는 정상적으로 작동합니다:
// ✅ 빠른 읽기 - 추적 오버헤드 없음
var orders = await dbContext.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
쓰기 작업은 명시적인 처리가 필요합니다:
// ❌ 잘못됨 - 엔터티가 추적되지 않아 SaveChanges가 아무것도 하지 않음
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // 아무 일도 일어나지 않음!
// ✅ 올바름 - 엔터티를 명시적으로 업데이트로 표시
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
dbContext.Orders.Update(order); // 전체 엔터티를 수정됨으로 표시
await dbContext.SaveChangesAsync();
// ✅ 또한 올바름 - 쿼리에 AsTracking() 사용
var order = await dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // 작동함!
추적을 사용해야 할 때
| 시나리오 | 추적 사용? | 이유 |
|---|---|---|
| UI에 데이터 표시 | 아니요 | 읽기 전용, 업데이트 없음 |
| API GET 엔드포인트 | 아니요 | 데이터 반환, 변경 없음 |
| 단일 엔터티 업데이트 | 예 또는 명시적 Update() | 변경 사항 저장 필요 |
| 탐색 속성을 포함한 복잡한 업데이트 | 예 | 추적이 관계 처리 |
| 일괄 작업 | 아니요 + ExecuteUpdate | 더 효율적 |
명시적 추가/업데이트 패턴
public class OrderService
{
private readonly ApplicationDbContext _db;
// CREATE - 항상 Add 사용 (추적 여부와 관계없이 작동)
public async Task<Order> CreateOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
// UPDATE - 명시적으로 수정됨으로 표시
public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId)
?? throw new NotFoundException($"Order {orderId} not found");
order.Status = newStatus;
order.UpdatedAt = DateTimeOffset.UtcNow;
// DbContext가 기본적으로 NoTracking을 사용하므로 명시적으로 수정됨으로 표시
_db.Orders.Update(order);
await _db.SaveChangesAsync();
}
// DELETE - 연결 후 제거
public async Task DeleteOrderAsync(Guid orderId)
{
var order = new Order { Id = orderId };
_db.Orders.Remove(order);
await _db.SaveChangesAsync();
}
}
패턴 2: 마이그레이션을 수동으로 편집하지 않음
중요: EF Core CLI 명령어를 사용하여 마이그레이션을 관리하세요. 절대:
- 마이그레이션 파일을 수동으로 편집하지 마세요(
Up()/Down()의 사용자 지정 SQL 제외). - 마이그레이션 파일을 직접 삭제하지 마세요.
- 마이그레이션 파일의 이름을 바꾸지 마세요.
- 마이그레이션을 프로젝트 간에 복사하지 마세요.
마이그레이션 생성
# 새 마이그레이션 생성
dotnet ef migrations add AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 여러 DbContext가 있는 경우 특정 DbContext 지정
dotnet ef migrations add AddCustomerTable \
--context ApplicationDbContext \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
마이그레이션 제거
# 마지막 마이그레이션 제거 (아직 적용되지 않은 경우)
dotnet ef migrations remove \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 절대 이렇게 하지 마세요:
# rm Migrations/20240101_AddCustomerTable.cs # ❌ 나쁨!
마이그레이션 적용
# 보류 중인 모든 마이그레이션 적용
dotnet ef database update \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 특정 마이그레이션으로 적용
dotnet ef database update AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 이전 마이그레이션으로 롤백
dotnet ef database update PreviousMigrationName \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
SQL 스크립트 생성
# 모든 마이그레이션에 대한 SQL 스크립트 생성
dotnet ef migrations script \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--output migrations.sql
# 멱등성 스크립트 생성 (여러 번 실행해도 안전)
dotnet ef migrations script \
--idempotent \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
패턴 3: Aspire를 사용한 전용 마이그레이션 서비스
주 애플리케이션과 마이그레이션 실행을 분리하는 전용 마이그레이션 서비스를 사용하세요. 이렇게 하면:
- 앱이 시작되기 전에 마이그레이션이 완료됩니다.
- 관심사의 명확한 분리가 가능합니다.
- 테스트 환경에서 제어된 시딩이 가능합니다.
프로젝트 구조
src/
├── MyApp.AppHost/ # Aspire 오케스트레이션
├── MyApp.Api/ # 주 애플리케이션
├── MyApp.Infrastructure/ # DbContext 및 마이그레이션
└── MyApp.MigrationService/ # 전용 마이그레이션 실행기
MigrationService Program.cs
using MyApp.Infrastructure.Data;
using MyApp.MigrationService;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
// Aspire 서비스 기본값 추가
builder.AddServiceDefaults();
// PostgreSQL DbContext 추가
var connectionString = builder.Configuration.GetConnectionString("appdb")
?? throw new InvalidOperationException("Connection string 'appdb' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
npgsqlOptions.MigrationsAssembly("MyApp.Infrastructure")));
// 마이그레이션 워커 추가
builder.Services.AddHostedService<MigrationWorker>();
var host = builder.Build();
host.Run();
MigrationWorker.cs
public class MigrationWorker : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<MigrationWorker> _logger;
public MigrationWorker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime,
ILogger<MigrationWorker> logger)
{
_serviceProvider = serviceProvider;
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Migration service starting...");
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await RunMigrationsAsync(dbContext, stoppingToken);
_logger.LogInformation("Migration service completed successfully.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Migration service failed: {Error}", ex.Message);
throw;
}
finally
{
// 마이그레이션 완료 후 애플리케이션 중지
_hostApplicationLifetime.StopApplication();
}
}
private async Task RunMigrationsAsync(ApplicationDbContext dbContext, CancellationToken ct)
{
// 일시적 오류 처리를 위한 실행 전략 사용
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(ct);
if (pendingMigrations.Any())
{
_logger.LogInformation("Applying {Count} pending migrations...",
pendingMigrations.Count());
await dbContext.Database.MigrateAsync(ct);
_logger.LogInformation("Migrations applied successfully.");
}
else
{
_logger.LogInformation("No pending migrations. Database is up to date.");
}
});
}
}
AppHost 구성
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres");
var db = postgres.AddDatabase("appdb");
// 마이그레이션이 먼저 실행된 후 종료
var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
.WaitFor(db)
.WithReference(db);
// API는 마이그레이션이 완료될 때까지 대기
var api = builder.AddProject<Projects.MyApp_Api>("api")
.WaitForCompletion(migrations) // 핵심: 마이그레이션이 완료될 때까지 대기
.WithReference(db);
패턴 4: 일시적 오류를 위한 ExecutionStrategy
일시적으로 실패할 수 있는 작업에는 항상 CreateExecutionStrategy()를 사용하세요:
public async Task UpdateWithRetryAsync(Guid id, Action<Order> update)
{
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var order = await _dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return;
update(order);
await _dbContext.SaveChangesAsync();
});
}
중요: 사용자 시작 트랜잭션과 함께 CreateExecutionStrategy()를 사용할 수 없습니다. 재시도와 함께 트랜잭션이 필요한 경우:
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// 트랜잭션은 전략 콜백 내부에 있어야 함
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
// ... 작업 ...
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
});
패턴 5: ExecuteUpdate/ExecuteDelete를 사용한 대량 작업
대량 작업의 경우 엔터티를 로드하는 대신 EF Core 7+ ExecuteUpdateAsync 및 ExecuteDeleteAsync를 사용하세요:
// ❌ 느림 - 모든 엔터티를 메모리에 로드
var expiredOrders = await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ToListAsync();
foreach (var order in expiredOrders)
{
order.Status = OrderStatus.Expired;
}
await _db.SaveChangesAsync();
// ✅ 빠름 - 단일 SQL UPDATE 문
await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));
// ✅ 빠름 - 단일 SQL DELETE 문
await _db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
일반적인 함정
1. NoTracking일 때 업데이트를 잊는 경우
// ❌ 자동 실패 - 엔터티가 추적되지 않음
var customer = await _db.Customers.FindAsync(id);
customer.Name = "New Name";
await _db.SaveChangesAsync(); // 아무 일도 일어나지 않음!
// ✅ 명시적 업데이트
var customer = await _db.Customers.FindAsync(id);
customer.Name = "New Name";
_db.Customers.Update(customer);
await _db.SaveChangesAsync();
2. N+1 쿼리 문제
// ❌ N+1 쿼리 - 주문당 하나의 쿼리
var customers = await _db.Customers.ToListAsync();
foreach (var customer in customers)
{
var orders = customer.Orders; // 지연 로드로 쿼리 발생
}
// ✅ 즉시 로드 - 단일 쿼리
var customers = await _db.Customers
.Include(c => c.Orders)
.ToListAsync();
3. 여러 DbContext 인스턴스와의 추적 충돌
// ❌ 추적 충돌 - 다른 컨텍스트에서 추적된 엔터티
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync(); // 예외를 던지거나 예상치 못하게 동작할 수 있음
// ✅ 단일 컨텍스트 사용 또는 먼저 분리
_db1.Entry(order1).State = EntityState.Detached;
4. Async를 일관되게 사용하지 않는 경우
// ❌ 비동기 컨텍스트에서 차단 호출
var orders = _db.Orders.ToList(); // 스레드 차단
// ✅ 모든 곳에서 Async 사용
var orders = await _db.Orders.ToListAsync();
5. 루프 내에서 쿼리
// ❌ 반복당 쿼리
foreach (var orderId in orderIds)
{
var order = await _db.Orders.FindAsync(orderId);
// 주문 처리
}
// ✅ 단일 쿼리
var orders = await _db.Orders
.Where(o => orderIds.Contains(o.Id))
.ToListAsync();
DI에서의 DbContext 수명 주기
ASP.NET Core (기본 범위 지정)
// Scoped = HTTP 요청당 하나의 인스턴스
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
.NET Aspire (명시적 수명 주기)
// Aspire에서 수명 주기 명시
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString),
ServiceLifetime.Scoped);
탐색 컬렉션 쿼리 분할
여러 탐색 컬렉션을 로드할 때 기본적으로 Cartesian 폭발 문제를 방지하기 위해 AsSplitQuery()를 사용하세요:
// ✅ 여러 탐색 컬렉션에 쿼리 분할 사용
var orders = await _db.Orders
.Include(o => o.Items)
.Include(o => o.Shipments)
.AsSplitQuery() // 각 컬렉션에 대해 별도의 SQL 쿼리 생성
.Where(o => o.CustomerId == customerId)
.ToListAsync();
쿼리 분할을 사용해야 할 때:
- 여러 개의
Include또는ThenInclude호출 - 각 엔터티에 많은 관련 엔터티가 있는 컬렉션 탐색
- 데이터 전송량이 문제가 되는 경우 (중복된 부모 데이터 방지)
쿼리 분할을 피해야 할 때:
- 단일 탐색 컬렉션 또는 소량의 데이터만 로드하는 경우
- 데이터베이스 왕복 횟수가 중요한 경우 (분할 쿼리로 인해 여러 번의 왕복이 발생할 수 있음)
- 데이터 일관성이 중요하고 단일 스냅샷이 필요한 경우
요약 체크리스트
-
DbContext에서 기본적으로QueryTrackingBehavior.NoTracking구성 - 쓰기 작업에
Update()또는AsTracking()사용 - 마이그레이션에 CLI 명령어 사용. 수동 편집 금지
- Aspire 워크플로우에 전용
MigrationService사용 - 일시적 오류 처리를 위해
CreateExecutionStrategy()사용 - 대량 작업에
ExecuteUpdateAsync/ExecuteDeleteAsync사용 - 탐색 컬렉션에
AsSplitQuery()사용 (필요한 경우) - 모든 데이터베이스 작업에
Async메서드 사용 - 느린
N+1쿼리 패턴 방지 (즉시 로드 사용) - 루프 내 쿼리 방지 (
Contains로 일괄 처리) - 데이터 문맥이 추적 중일 때
AsNoTracking()사용 안 함 - 모든 데이터베이스 컨텍스트에 올바른 DI 수명 주기 사용