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

EF Core 쿼리는 기본 설정만으로도 조용히 느려집니다. 읽기 전용 조회의 추적 비용, Include 중복 행, 매 요청 재컴파일이 대표적입니다. 이 글은 대용량 트래픽을 가정하고 조회 경로에서 먼저 손댈 지점을 순서대로 정리한 것입니다.
절차
- 현재 쿼리 확인
- AsNoTracking 적용
- 프로젝션으로 필요한 컬럼만
- Split Query로 중복 행 제거
- 컴파일된 쿼리와 DbContext 풀링
- 벌크 수정·삭제
- 실행 계획과 인덱스
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();
- 같은 엔티티가 여러 경로로 조회되어 인스턴스를 하나로 합쳐야 하면
AsNoTrackingWithIdentityResolution()을 씁니다. - 읽기 위주 컨텍스트라면 기본값을 바꿉니다.
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
- 수정이 필요한 조회에서만
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();
Select뒤에는 추적이 자동으로 해제됩니다.AsNoTracking은 그래도 명시합니다.- 집계를 SQL로 내리면 메모리에서 합산하지 않습니다.
- 익명 타입 대신 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);
AsSplitQuery()는 컬렉션 Include마다 별도 쿼리를 실행하고 메모리에서 합칩니다.- 기본값을 바꾸려면 전역 설정을 씁니다.
options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
- 단건 상세 조회처럼 컬렉션이 작고 조인 비용이 낮으면 단일 쿼리가 유리합니다. 목록 조회처럼 컬렉션이 여러 개 붙는 경로부터 Split을 적용합니다.
- 트랜잭션 격리 수준이 낮으면 분리 쿼리 사이에 데이터가 바뀔 수 있습니다. 정합성이 중요한 조회는 단일 쿼리나 스냅샷 격리를 씁니다.
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);
- 컴파일된 쿼리는 정적 필드에 두고 재사용합니다.
- 식이 복잡하거나 동적으로 조립되는 쿼리에는 적용하지 않습니다.
- 컨텍스트 생성 비용은 풀링으로 줄입니다.
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();
- 두 메서드는 변경 추적을 거치지 않으므로 이미 로드된 엔티티와 상태가 어긋납니다. 벌크 작업 후에는 컨텍스트를 새로 쓰거나
ChangeTracker.Clear()를 호출합니다. - 대량 삭제는 트랜잭션 로그가 커집니다. 배치로 나눠 실행합니다.
while (true)
{
var deleted = await db.Sessions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.Take(5000)
.ExecuteDeleteAsync();
if (deleted == 0) break;
}
7. 실행 계획과 인덱스
ToQueryString()으로 나간 SQL을 복사해 SSMS·Azure Data Studio에서 실제 실행 계획을 봅니다.Table Scan이나Index Scan이 보이면 해당 조건 컬럼의 인덱스를 검토합니다.- 복합 조건은 선택도가 높은 컬럼을 앞에 둡니다.
Include컬럼까지 인덱스에 넣으면 Key Lookup을 줄일 수 있습니다.
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt })
.IncludeProperties(o => new { o.Status, o.TotalAmount });
- 페이지네이션은
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을 근거로 추가합니다.