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

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을 근거로 추가합니다.'Database' 카테고리의 다른 글
[EF Core] 마이그레이션 명령 정리, 자주 나는 오류 해결 (0) 2026.11.10 [Database] Linux-Docker-Mysql 데이터베이스 자동 백업 (0) 2021.11.29 [MySQL] 문자 형식 변경 방법(utf8_general_ci) (0) 2021.10.01 [Database] 문자열 합치기 (CONCAT, GROUP_CONCAT) (0) 2020.07.09 [Database] UTC 시간 기준으로 쿼리 값 입력하기 (0) 2020.02.03