Entity Framework Core: 2026년 성능 최적화와 모범 사례

EF Core 10의 성능 최적화를 다룹니다. AsNoTracking, 컴파일된 쿼리, 배치 작업, 분할 쿼리, LeftJoin 연산자 등 .NET 10 프로덕션 애플리케이션을 위한 실용적인 C# 코드 예제를 제공합니다.

Entity Framework Core 성능 최적화 다이어그램 - 데이터베이스 쿼리와 .NET 10

Entity Framework Core 10은 2025년 11월 .NET 10 LTS와 함께 출시되었으며, LeftJoin 연산자, 벡터 검색, 명명된 쿼리 필터, SQL 변환의 대폭적인 개선을 도입했습니다. 이 가이드에서는 프로덕션 환경에서 쿼리 속도, 메모리 할당, 확장성에 직접적으로 영향을 미치는 EF Core 모범 사례를 다룹니다.

EF Core 10은 .NET 10 전용입니다

EF Core 10은 장기 지원(LTS) 릴리스이며, 2028년 11월까지 지원됩니다. .NET 10 SDK와 런타임이 필요합니다. 아직 .NET 8을 사용하는 애플리케이션은 마이그레이션이 완료될 때까지 EF Core 8(LTS)을 대상으로 해야 합니다.

쿼리 트래킹: 비활성화 시점과 그 이유

DbSet<T>에 대한 모든 호출은 기본적으로 반환된 엔터티를 변경 추적기에 연결합니다. 추적기는 원본 속성 값의 스냅샷을 유지하고, SaveChanges 시 차이를 계산하며, 탐색 간 ID 충돌을 해결합니다. 데이터가 API 응답이나 읽기 전용 뷰 모델로 직접 전달되는 경우, 이러한 오버헤드는 불필요합니다.

ProductRepository.cscsharp
public async Task<List<ProductDto>> GetActiveByCategoryAsync(
    int categoryId, CancellationToken ct)
{
    return await _context.Products
        .AsNoTracking()              // skip change tracker entirely
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .OrderBy(p => p.Name)
        .Select(p => new ProductDto   // project to DTO at the database level
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(ct);
}

AsNoTracking은 스냅샷 생성과 ID 확인에 따른 엔터티별 오버헤드를 제거합니다. Select와 결합하면 필요한 컬럼만 네트워크를 통해 전송됩니다. 50,000행 테이블에서 이 패턴은 추적이 활성화된 전체 엔터티 쿼리 대비 메모리 할당을 일반적으로 40~60% 줄입니다.

데이터를 변경하지 않는 컨텍스트의 경우, 등록 시 기본값을 설정할 수 있습니다:

Program.cscsharp
builder.Services.AddDbContext<CatalogContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

분할 쿼리로 데카르트 곱 폭발 방지

여러 컬렉션 탐색 속성이 있는 엔터티를 Include로 로드하면 JOIN이 포함된 단일 SQL 문이 생성됩니다. 두 개 이상의 컬렉션을 동시에 로드하면 결과 집합이 컬렉션의 데카르트 곱으로 증가하며, 부모 행 데이터가 모든 조합에 걸쳐 중복됩니다.

OrderRepository.cscsharp
public async Task<Order?> GetWithDetailsAsync(int orderId, CancellationToken ct)
{
    return await _context.Orders
        .AsSplitQuery()              // one SQL per Include
        .Include(o => o.Items)
            .ThenInclude(i => i.Product)
        .Include(o => o.Payments)
        .Include(o => o.ShippingEvents)
        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
}

AsSplitQuery는 탐색 속성마다 별도의 SQL 문으로 분할합니다. 단일 라운드트립 대신 여러 번의 라운드트립이 발생하지만, 각 결과 집합은 작게 유지되고 행 중복 문제를 방지할 수 있습니다. EF Core 10에서는 분할 쿼리의 오래된 정렬 불일치 문제도 수정되어, 하위 쿼리의 정렬이 기본 쿼리와 일치하도록 보장됩니다.

단일 쿼리가 적합한 경우

단일 쿼리는 하나의 컬렉션 탐색 속성을 로드하거나 라운드트립 지연 시간이 높은 경우(리전 간 데이터베이스 호출)에 여전히 적합합니다. 특정 액세스 패턴에 대해 두 모드를 모두 벤치마크한 후 결정하는 것이 중요합니다.

ExecuteUpdate와 ExecuteDelete를 활용한 배치 작업

기존 EF 워크플로에서는 엔터티를 로드하고, 속성을 수정한 다음 SaveChanges를 호출합니다. 수천 행에 영향을 미치는 대량 작업의 경우, 수천 개의 추적 인스턴스와 개별 UPDATE 문이 생성됩니다. EF Core 7에서 도입된 ExecuteUpdateAsyncExecuteDeleteAsync는 작업을 단일 SQL 명령으로 전송합니다. EF Core 10에서는 식 트리 대신 일반 람다를 허용하여 API가 더욱 간소화되었습니다.

PromotionService.cscsharp
public async Task ApplySeasonalDiscountAsync(
    int categoryId, decimal discountPercent, CancellationToken ct)
{
    var affected = await _context.Products
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .ExecuteUpdateAsync(s =>
        {
            s.SetProperty(p => p.Price, p => p.Price * (1 - discountPercent / 100));
            s.SetProperty(p => p.LastModified, DateTime.UtcNow);
        }, ct);

    // affected = number of rows updated
}

이것은 단일 UPDATE ... SET ... WHERE 문으로 변환됩니다. 메모리에 엔터티가 로드되지 않습니다. 10,000행 업데이트의 경우, 실행 시간이 추적 방식의 수 초에서 밀리초로 단축됩니다.

동일한 패턴을 삭제에도 적용할 수 있습니다:

CleanupService.cscsharp
public async Task PurgeExpiredSessionsAsync(CancellationToken ct)
{
    await _context.Sessions
        .Where(s => s.ExpiresAt < DateTime.UtcNow)
        .ExecuteDeleteAsync(ct);
}

.NET 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

EF Core 10의 LeftJoin 연산자

EF Core 10 이전에는 LEFT JOIN을 수행하려면 GroupJoin, SelectMany, DefaultIfEmpty를 특정 패턴으로 조합해야 했으며, 대부분의 개발자가 매번 검색해야 했습니다. EF Core 10에서는 LeftJoin이 일급 LINQ 연산자로 추가되었습니다.

ReportService.cscsharp
public async Task<List<EmployeeReportDto>> GetEmployeeDepartmentReportAsync(
    CancellationToken ct)
{
    return await _context.Employees
        .LeftJoin(
            _context.Departments,
            employee => employee.DepartmentId,
            department => department.Id,
            (employee, department) => new EmployeeReportDto
            {
                FullName = employee.FirstName + " " + employee.LastName,
                Department = department.Name ?? "Unassigned",
                HiredAt = employee.HiredAt
            })
        .OrderBy(r => r.FullName)
        .ToListAsync(ct);
}

생성된 SQL은 표준 LEFT JOIN 절을 사용합니다. RightJoin 연산자도 사용할 수 있습니다. 이러한 연산자들은 이전에 필요했던 장황한 3-메서드 체인을 제거합니다.

다중 관심사 필터링을 위한 명명된 쿼리 필터

글로벌 쿼리 필터는 EF Core 2.0 이후 엔터티 유형당 하나의 필터로 제한되어 왔습니다. 소프트 삭제와 멀티 테넌시를 모두 구현하는 애플리케이션에서는 조건을 하나의 식으로 결합해야 했으며, 선택적으로 비활성화할 수 없었습니다. EF Core 10에서는 명명된 쿼리 필터가 도입되었습니다.

AppDbContext.cscsharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Invoice>()
        .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
        .HasQueryFilter("Tenant", i => i.TenantId == _tenantId);
}

관리 엔드포인트에서는 테넌트 격리를 유지하면서 소프트 삭제 필터를 비활성화할 수 있습니다:

InvoiceRepository.cscsharp
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
    return await _context.Invoices
        .IgnoreQueryFilters(["SoftDelete"])  // tenant filter still applied
        .ToListAsync(ct);
}

이를 통해 필터 조건을 수동으로 추가하던 커스텀 IQueryable 확장 메서드가 불필요해집니다.

연결 복원력과 풀링 구성

일시적인 데이터베이스 장애(네트워크 순단, Azure SQL 장애 조치, 연결 풀 고갈)는 요청 파이프라인을 중단시키는 예외를 발생시킵니다. EF Core에는 재시도 로직이 내장되어 있지만, 기본적으로 명시적인 구성이 필요합니다.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null);    // retry on all transient errors
        sqlOptions.CommandTimeout(30);   // 30-second command timeout
    }));

연결 문자열의 풀링 매개변수도 중요합니다:

text
Server=db.example.com;Database=AppDb;Min Pool Size=5;Max Pool Size=100;Connection Timeout=15;

Min Pool Size는 버스트 트래픽에 대비하여 웜 연결을 유지합니다. Max Pool Size는 데이터베이스 과부하를 방지하기 위해 전체 열린 연결 수를 제한합니다. 기본값 100은 대부분의 웹 애플리케이션에 적합하지만, 고처리량 서비스에서는 실제 동시 쿼리 볼륨에 따라 조정이 필요할 수 있습니다.

재시도 로직과 트랜잭션

자동 재시도는 사용자가 시작한 트랜잭션 내에서는 작동하지 않습니다. 여러 작업에 걸친 일시적 장애를 처리하려면 context.Database.CreateExecutionStrategy().ExecuteAsync(...)를 사용하여 수동 재시도 전략으로 전체 트랜잭션을 래핑해야 합니다.

인덱싱 전략과 쿼리 분석

EF Core 마이그레이션에서는 인덱스를 선언적으로 정의할 수 있지만, 어떤 컬럼에 인덱스를 생성할지는 쿼리 패턴의 이해가 필요합니다.

AppDbContext.cs - OnModelCreatingcsharp
modelBuilder.Entity<Order>(entity =>
{
    // composite index for frequent query pattern
    entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
          .HasDatabaseName("IX_Order_Customer_Status_Date");

    // filtered index for active orders only
    entity.HasIndex(o => o.Status)
          .HasFilter("[Status] <> 'Cancelled'")
          .HasDatabaseName("IX_Order_ActiveStatus");
});

필터링된 인덱스는 쿼리가 대상으로 하지 않는 행을 제외하여 인덱스 크기를 줄입니다. 취소된 주문이 80%를 차지하는 테이블에서, 활성 상태에 대한 필터링된 인덱스는 크기가 5배 작고 스캔도 더 빠릅니다.

생성된 SQL을 분석하려면 개발 환경에서 로깅을 활성화합니다:

Program.cs (development only)csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());

로그에 기록된 SQL을 SQL Server Management Studio에 복사하고 SET STATISTICS IO ON으로 실행하여 논리적 읽기를 확인하거나, PostgreSQL에서는 EXPLAIN ANALYZE를 사용합니다. 쿼리 플랜의 누락된 인덱스 제안은 가장 높은 영향을 미치는 최적화 기회를 드러내는 경우가 많습니다.

핫 패스를 위한 컴파일된 쿼리

LINQ 식 트리는 실행할 때마다 SQL로 파싱 및 변환됩니다. 분당 수천 번 실행되는 쿼리(인증 조회, 세션 유효성 검사 등)에서는 이 변환 비용이 누적됩니다. 컴파일된 쿼리는 애플리케이션 시작 시 변환을 캐시합니다.

CompiledQueries.cscsharp
public static class UserQueries
{
    // compiled once, reused on every call
    public static readonly Func<AppDbContext, string, CancellationToken, Task<UserSession?>>
        GetActiveSession = EF.CompileAsyncQuery(
            (AppDbContext ctx, string token, CancellationToken ct) =>
                ctx.UserSessions
                    .AsNoTracking()
                    .FirstOrDefault(s => s.Token == token && s.ExpiresAt > DateTime.UtcNow));
}

// Usage in middleware
var session = await UserQueries.GetActiveSession(dbContext, bearerToken, ct);

컴파일된 쿼리는 식 트리 파싱 단계를 완전히 건너뜁니다. 성능 차이는 고빈도 경로(분당 1,000회 이상 호출)에서만 측정 가능합니다. 표준 CRUD 엔드포인트의 경우, EF Core의 내장 쿼리 캐시가 이미 변환 재사용을 처리합니다.

EF Core 10의 매개변수화된 컬렉션 변환

ID 목록을 기준으로 필터링하는 쿼리는 데이터 액세스에서 가장 일반적인 패턴 중 하나입니다. EF Core 10에서는 매개변수화된 컬렉션의 기본 변환 전략이 변경되었습니다. 목록을 JSON 배열로 인코딩하는 방식(EF Core 8-9)이나 상수를 인라인하는 방식(EF Core 7 이전) 대신, EF Core 10에서는 각 값을 별도의 SQL 매개변수로 변환합니다.

csharp
// Before (EF Core 8-9): JSON array parameter
// @__ids_0='[1,2,3]'
// SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0))

// EF Core 10: individual parameters with padding
// SELECT ... WHERE Id IN (@ids1, @ids2, @ids3)

var orderIds = new[] { 101, 205, 389 };
var orders = await _context.Orders
    .Where(o => orderIds.Contains(o.Id))
    .ToListAsync(ct);

새로운 접근 방식은 매개변수 패딩을 통해 플랜 캐시 비대화를 방지하면서 데이터베이스 쿼리 플래너에 카디널리티 정보를 제공합니다. JSON 접근 방식의 성능이 더 우수한 경우(매우 큰 컬렉션)에는 쿼리별로 동작을 재정의할 수 있습니다:

csharp
var orders = await _context.Orders
    .Where(o => EF.Parameter(orderIds).Contains(o.Id))  // force JSON mode
    .ToListAsync(ct);

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

결론

  • 모든 읽기 전용 쿼리에서 AsNoTracking()Select 프로젝션을 사용하여 변경 추적기 오버헤드를 제거한다
  • 여러 컬렉션 탐색 속성을 로드할 때 AsSplitQuery()를 적용하여 데카르트 곱 폭발을 방지한다
  • 대량 작업에서는 추적 기반의 로드-수정-저장 패턴을 ExecuteUpdateAsyncExecuteDeleteAsync로 대체한다
  • EF Core 10의 LeftJoin 연산자를 채택하여 장황한 GroupJoin/SelectMany/DefaultIfEmpty 체인을 대체한다
  • 명명된 쿼리 필터를 구성하여 소프트 삭제와 멀티 테넌시를 독립적으로 관리한다
  • 프로덕션 복원력을 위해 EnableRetryOnFailure를 설정하고 연결 풀 크기를 조정한다
  • 추측이 아닌 실제 쿼리 패턴을 기반으로 복합 인덱스와 필터링된 인덱스를 정의한다
  • 분당 1,000회 실행을 초과하는 진정한 핫 패스에만 컴파일된 쿼리를 사용한다
  • EF Core 10이 매개변수화된 컬렉션 변환을 기본적으로 처리하도록 하고, 벤치마크로 정당화되는 경우에만 재정의한다

참고 자료: EF Core Advanced 모듈에서는 이러한 패턴을 면접 맥락에서 다루고 있으며, .NET 클린 아키텍처 가이드에서는 이러한 쿼리를 래핑하는 리포지토리와 서비스 레이어의 구성 방법을 설명합니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#dotnet
#entity framework core
#csharp
#performance
#ef core 10

공유

관련 기사