Database

[EF Core] 대용량 트래픽 대비 쿼리 최적화: AsNoTracking, Split Query, 실행 계획 분석

전자기린 2026. 11. 11. 12:12

 

EF Core 쿼리는 기본 설정만으로도 조용히 느려집니다. 읽기 전용 조회의 추적 비용, Include 중복 행, 매 요청 재컴파일이 대표적입니다. 이 글은 대용량 트래픽을 가정하고 조회 경로에서 먼저 손댈 지점을 순서대로 정리한 것입니다.

 

절차

 

  1. 현재 쿼리 확인
  2. AsNoTracking 적용
  3. 프로젝션으로 필요한 컬럼만
  4. Split Query로 중복 행 제거
  5. 컴파일된 쿼리와 DbContext 풀링
  6. 벌크 수정·삭제
  7. 실행 계획과 인덱스

 

1. 현재 쿼리 확인

 

먼저 실제로 나가는 SQL을 봅니다. 코드만 보고 추측하면 대부분 틀립니다.

 

var query = db.Orders
    .Where(o => o.CustomerId == customerId)
    .OrderByDescending(o => o.CreatedAt);

Console.WriteLine(query.ToQueryString());

 

개발 환경에서는 로그로 확인합니다.

 

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging()
           .EnableDetailedErrors());

 

EnableSensitiveDataLogging은 파라미터 값을 그대로 남기므로 개발 환경에서만 켭니다.

 

2. AsNoTracking

 

조회 결과를 수정하지 않는다면 추적은 순수한 비용입니다. 변경 감지를 위한 스냅샷과 상태 관리가 사라집니다.

 

var orders = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .ToListAsync();

 

  1. 같은 엔티티가 여러 경로로 조회되어 인스턴스를 하나로 합쳐야 하면 AsNoTrackingWithIdentityResolution()을 씁니다.
  2. 읽기 위주 컨텍스트라면 기본값을 바꿉니다.

 

options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

 

  1. 수정이 필요한 조회에서만 AsTracking()을 명시합니다. 기본값을 NoTracking으로 두고 예외를 명시하는 쪽이 사고가 적습니다.

 

3. 프로젝션으로 필요한 컬럼만

 

엔티티 전체를 가져오면 쓰지 않는 컬럼까지 전송·역직렬화합니다. DTO로 바로 투영합니다.

 

var rows = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .Select(o => new OrderSummary(
        o.Id,
        o.CreatedAt,
        o.Items.Count,
        o.Items.Sum(i => i.Quantity * i.UnitPrice)))
    .ToListAsync();

 

  1. Select 뒤에는 추적이 자동으로 해제됩니다. AsNoTracking은 그래도 명시합니다.
  2. 집계를 SQL로 내리면 메모리에서 합산하지 않습니다.
  3. 익명 타입 대신 record DTO를 쓰면 캐시·직렬화가 단순해집니다.

 

4. Split Query

 

Include를 여러 개 붙이면 조인 결과가 곱으로 늘어납니다. 주문 1건에 항목 10개, 결제 5개가 붙으면 행이 50개가 됩니다.

 

var order = await db.Orders
    .AsNoTracking()
    .Include(o => o.Items)
    .Include(o => o.Payments)
    .AsSplitQuery()
    .FirstAsync(o => o.Id == orderId);

 

  1. AsSplitQuery()는 컬렉션 Include마다 별도 쿼리를 실행하고 메모리에서 합칩니다.
  2. 기본값을 바꾸려면 전역 설정을 씁니다.

 

options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);

 

  1. 단건 상세 조회처럼 컬렉션이 작고 조인 비용이 낮으면 단일 쿼리가 유리합니다. 목록 조회처럼 컬렉션이 여러 개 붙는 경로부터 Split을 적용합니다.
  2. 트랜잭션 격리 수준이 낮으면 분리 쿼리 사이에 데이터가 바뀔 수 있습니다. 정합성이 중요한 조회는 단일 쿼리나 스냅샷 격리를 씁니다.

 

5. 컴파일된 쿼리와 DbContext 풀링

 

같은 모양의 쿼리를 초당 수천 번 실행하면 쿼리 컴파일 비용이 누적됩니다.

 

private static readonly Func<AppDbContext, int, Task<Order?>> GetOrderById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));

var order = await GetOrderById(db, orderId);

 

  1. 컴파일된 쿼리는 정적 필드에 두고 재사용합니다.
  2. 식이 복잡하거나 동적으로 조립되는 쿼리에는 적용하지 않습니다.
  3. 컨텍스트 생성 비용은 풀링으로 줄입니다.

 

builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseSqlServer(connectionString), poolSize: 128);

 

풀링을 쓰면 OnConfiguring에서 상태를 바꾸는 코드가 재사용 시 누적될 수 있으므로, 옵션은 등록 시점에만 설정합니다.

 

6. 벌크 수정·삭제

 

엔티티를 읽어 하나씩 바꾸면 행 수만큼 SQL이 나갑니다. ExecuteUpdate·ExecuteDelete는 한 문장으로 처리합니다.

 

var affected = await db.Orders
    .Where(o => o.Status == OrderStatus.Expired && o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Status, OrderStatus.Canceled)
        .SetProperty(o => o.UpdatedAt, DateTime.UtcNow));

 

await db.Sessions
    .Where(s => s.ExpiresAt < DateTime.UtcNow)
    .ExecuteDeleteAsync();

 

  1. 두 메서드는 변경 추적을 거치지 않으므로 이미 로드된 엔티티와 상태가 어긋납니다. 벌크 작업 후에는 컨텍스트를 새로 쓰거나 ChangeTracker.Clear()를 호출합니다.
  2. 대량 삭제는 트랜잭션 로그가 커집니다. 배치로 나눠 실행합니다.

 

while (true)
{
    var deleted = await db.Sessions
        .Where(s => s.ExpiresAt < DateTime.UtcNow)
        .Take(5000)
        .ExecuteDeleteAsync();

    if (deleted == 0) break;
}

 

7. 실행 계획과 인덱스

 

  1. ToQueryString()으로 나간 SQL을 복사해 SSMS·Azure Data Studio에서 실제 실행 계획을 봅니다.
  2. Table Scan이나 Index Scan이 보이면 해당 조건 컬럼의 인덱스를 검토합니다.
  3. 복합 조건은 선택도가 높은 컬럼을 앞에 둡니다.
  4. Include 컬럼까지 인덱스에 넣으면 Key Lookup을 줄일 수 있습니다.

 

modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerId, o.CreatedAt })
    .IncludeProperties(o => new { o.Status, o.TotalAmount });

 

  1. 페이지네이션은 Skip/Take보다 키셋 방식이 안정적입니다. 깊은 페이지에서 OFFSET은 앞 행을 모두 읽습니다.

 

var page = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId && o.Id < lastSeenId)
    .OrderByDescending(o => o.Id)
    .Take(50)
    .ToListAsync();

 

정리

 

조회 경로에서는 AsNoTracking과 DTO 프로젝션을 기본으로 두고, 컬렉션 Include가 여러 개인 경로에 Split Query를 적용합니다. 반복 실행되는 쿼리는 컴파일된 쿼리로, 대량 변경은 ExecuteUpdate·ExecuteDelete로 처리하고, 인덱스는 실행 계획의 Scan을 근거로 추가합니다.