Community코딩 & 개발github.com

efcore-patterns

Entity Framework Core 모범 사례: 기본 NoTracking, 탐색 컬렉션을 위한 쿼리 분할, 마이그레이션 관리, 전용 마이그레이션 서비스 및 피해야 할 일반적인 함정.

efcore-patterns란 무엇인가요?

이 스킬은 .NET 개발자가 Entity Framework Core를 사용할 때 따라야 할 모범 사례를 제공합니다. 핵심 원칙은 기본적으로 NoTracking을 설정하여 읽기 전용 쿼리의 성능을 최적화하고, 명시적인 업데이트 패턴을 통해 데이터 무결성을 유지하는 것입니다. 또한 마이그레이션을 수동으로 편집하지 않고 CLI 명령어를 사용하거나, 전용 MigrationService를 통해 Aspire 오케스트레이션에서 마이그레이션을 실행하고, ExecuteUpdate/ExecuteDelete를 사용한 대량 작업, ExecutionStrategy를 통한 일시적 오류 처리 등의 패턴을 다룹니다. 이 스킬은 Cursor, Claude Code, Codex와 같은 AI 코딩 에이전트에서 사용할 수 있도록 설계되었으며, 실제 프로덕션 환경에서 발생할 수 있는 N+1 쿼리, 추적 충돌, 루프 내 쿼리와 같은 일반적인 함정을 피하는 방법도 포함합니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Aaronontheweb/dotnet-skills/tree/main/skills/efcore-patterns

Installed? Explore more 코딩 & 개발 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Entity Framework Core 패턴

이 스킬을 사용해야 할 때

다음과 같은 경우에 이 스킬을 사용하세요:

  • 새 프로젝트에 EF Core 설정
  • 쿼리 성능 최적화
  • 데이터베이스 마이그레이션 관리
  • .NET Aspire와 EF Core 통합
  • 변경 추적 문제 디버깅
  • 여러 탐색 컬렉션을 효율적으로 로드 (쿼리 분할)

핵심 원칙

  1. 기본 NoTracking - 대부분의 쿼리는 읽기 전용입니다. 추적은 선택적으로 사용합니다.
  2. 마이그레이션을 수동으로 편집하지 않음 - 항상 CLI 명령어를 사용합니다.
  3. 전용 마이그레이션 서비스 - 애플리케이션 시작과 마이그레이션 실행을 분리합니다.
  4. 재시도를 위한 ExecutionStrategy - 일시적인 데이터베이스 오류를 처리합니다.
  5. 명시적 업데이트 - 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 수명 주기 사용

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

akka-hosting-actor-patterns

Patterns for building entity actors with Akka.Hosting - GenericChildPerEntityParent, message extractors, cluster sharding abstraction, akka-reminders, and ITimeProvider. Supports both local testing and clustered production modes.

akka-net-aspire-configuration

Configure Akka.NET with .NET Aspire for local development and production deployments. Covers actor system setup, clustering, persistence, Akka.Management integration, and Aspire orchestration patterns.

akka-net-best-practices

Critical Akka.NET best practices including EventStream vs DistributedPubSub, supervision strategies, error handling, Props vs DependencyResolver, work distribution patterns, and cluster/local mode abstractions for testability.

akka-net-management

Akka.Management for cluster bootstrapping, service discovery (Kubernetes, Azure, Config), health checks, and dynamic cluster formation without static seed nodes.

akka-net-testing-patterns

Write unit and integration tests for Akka.NET actors using modern Akka.Hosting.TestKit patterns. Covers dependency injection, TestProbes, persistence testing, and actor interaction verification. Includes guidance on when to use traditional TestKit.

api-design

Design stable, compatible public APIs using extend-only design principles. Manage API compatibility, wire compatibility, and versioning for NuGet packages and distributed systems.

aspire-configuration

Configure Aspire AppHost to emit explicit app config via environment variables; keep app code free of Aspire clients and service discovery.

aspire-integration-testing

Write integration tests using .NET Aspire

aspire-service-defaults

Create a shared ServiceDefaults project for Aspire applications. Centralizes OpenTelemetry, health checks, resilience, and service discovery configuration across all services.

crap-analysis

Analyze code coverage and CRAP (Change Risk Anti-Patterns) scores to identify high-risk code. Use OpenCover format with ReportGenerator for Risk Hotspots showing cyclomatic complexity and untested code paths.

csharp-concurrency-patterns

Choosing the right concurrency abstraction in .NET - from async/await for I/O to Channels for producer/consumer to Akka.NET for stateful entity management. Avoid locks and manual synchronization unless absolutely necessary.

database-performance

Database access patterns for performance. Separate read/write models, avoid N+1 queries, use AsNoTracking, apply row limits, and never do application-side joins. Works with EF Core and Dapper.

dependency-injection-patterns

Organize DI registrations using IServiceCollection extension methods. Group related services into composable Add* methods for clean Program.cs and reusable configuration in tests.

dotnet-devcert-trust

Diagnose and fix .NET HTTPS dev certificate trust issues on Linux. Covers the full certificate lifecycle from generation to system CA bundle inclusion, with distro-specific guidance for Ubuntu, Fedora, Arch, and WSL2.

dotnet-local-tools

Managing local .NET tools with dotnet-tools.json for consistent tooling across development environments and CI/CD pipelines.

dotnet-project-structure

Modern .NET project structure including .slnx solution format, Directory.Build.props, central package management, SourceLink, version management with RELEASE_NOTES.md, and SDK pinning with global.json.

dotnet-slopwatch

Use Slopwatch to detect LLM reward hacking in .NET code changes. Run after every code modification to catch disabled tests, suppressed warnings, empty catch blocks, and other shortcuts that mask real problems.

ilspy-decompile

Understand implementation details of .NET code by decompiling assemblies. Use when you want to see how a .NET API works internally, inspect NuGet package source, view framework implementation, or understand compiled .NET binaries.

mailpit-integration

Test email sending locally using Mailpit with .NET Aspire. Captures all outgoing emails without sending them. View rendered HTML, inspect headers, and verify delivery in integration tests.

marketplace-publishing

Workflow for publishing skills and agents to the dotnet-skills Claude Code marketplace. Covers adding new content, updating plugin.json, validation, and release tagging.

관련 스킬