EF Core 9의 새로운 기능: 알아야 할 기능
Entity Framework Core 9는 2024년 11월에 .NET 9와 함께 출시되었으며, 여러 프로젝트에서 이를 사용하여 상당한 시간을 투자한 결과, 한동안 가장 의미 있는 릴리스 중 하나라고 말할 수 있습니다. 바퀴를 재발명해서가 아니라 EF Core가 역사적으로 가장 많은 마찰을 일으켰던 영역(쿼리 변환, 성능, 최신 데이터 패턴 작업)을 개선하기 때문입니다.
이 게시물에서는 일상 업무에 가장 큰 영향을 미친 기능을 살펴보겠습니다. 아직 EF Core 8(또는 7)을 사용하고 있다면 업그레이드의 다른 측면에서 무엇이 기다리고 있는지 명확하게 알 수 있습니다.
.NET 9 생태계의 EF Core 9
EF Core 9는 .NET 8 및 .NET 9를 대상으로 합니다. 즉, 이러한 기능을 대부분 활용하기 위해 전체 애플리케이션을 반드시 .NET 9로 업그레이드할 필요는 없습니다. 즉, AOT 및 성능 개선 사항 중 일부는 .NET 9 런타임 변경 사항과 긴밀하게 결합되어 있으므로 끝까지 진행하면 최대한 활용할 수 있습니다.
릴리스는 Microsoft가 설정한 홀수/짝수 흐름을 따릅니다. 홀수 릴리스(예: .NET 9)는 18개월 지원이 포함된 STS(표준 기간 지원)이고, 짝수 릴리스(예: .NET 8)는 장기 지원(LTS)입니다. 업그레이드 일정을 계획할 때 이 점을 염두에 두십시오.
LINQ 번역 개선
대부분의 개발자는 바로 이 부분에서 차이를 느낄 것입니다. EF Core 9는 LINQ 식을 실제로 의미 있는 SQL로 변환하는 데 상당한 진전을 이루었습니다.
더 나은 GroupBy 번역
EF Core에서 GroupBy 쿼리를 작성했는데 클라이언트 측 평가 경고 또는 기괴한 SQL이 발생했다면 그 어려움을 아실 것입니다. EF Core 9는 훨씬 더 광범위한 GroupBy 시나리오 세트를 SQL에서 직접 처리합니다.
var salesByCategory = await context.Products
.GroupBy(p => p.Category.Name)
.Select(g => new
{
Category = g.Key,
TotalRevenue = g.Sum(p => p.Price * p.UnitsSold),
AveragePrice = g.Average(p => p.Price),
ProductCount = g.Count()
})
.OrderByDescending(x => x.TotalRevenue)
.ToListAsync();
이전 버전에서는 GroupBy 내부 탐색 속성에 대한 집계와 관련된 쿼리가 클라이언트 평가로 대체되는 경우가 있었습니다. EF Core 9는 이를 GROUP BY, SUM, AVG 및 COUNT를 사용하여 단일 SQL 쿼리로 명확하게 변환합니다.
복잡한 프로젝션 및 하위 쿼리
중첩된 하위 쿼리와 복잡한 프로젝션도 대폭 업그레이드되었습니다. 다음과 같은 것을 고려해보세요:
var orderSummaries = await context.Customers
.Select(c => new CustomerSummaryDto
{
Name = c.FullName,
TotalOrders = c.Orders.Count(),
MostRecentOrder = c.Orders
.OrderByDescending(o => o.OrderDate)
.Select(o => new OrderBriefDto
{
Id = o.Id,
Date = o.OrderDate,
Total = o.LineItems.Sum(li => li.Quantity * li.UnitPrice)
})
.FirstOrDefault(),
TopCategory = c.Orders
.SelectMany(o => o.LineItems)
.GroupBy(li => li.Product.Category.Name)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault()
})
.ToListAsync();
이제 EF Core 9에서는 클라이언트 측 평가를 트리거하지 않고도 이 전체 표현식을 SQL로 변환할 수 있습니다. 생성된 쿼리는 적절한 경우 상관 하위 쿼리와 측면 조인을 사용하며 SQL 계획은 이전 버전에서 생성된 것보다 훨씬 더 효율적입니다.
매개변수화된 기본 컬렉션
LINQ의 뛰어난 개선 사항 중 하나는 기본 값 컬렉션을 쿼리에 직접 전달하는 기능입니다.
var statusFilter = new List<string> { "Active", "Pending", "Review" };
var filteredOrders = await context.Orders
.Where(o => statusFilter.Contains(o.Status))
.ToListAsync();
EF Core 8에서는 인라인 값이 있는 IN 절을 사용하여 변환되었습니다. 즉, 목록이 변경되면 쿼리 계획 캐시를 재사용할 수 없습니다. EF Core 9는 이러한 컬렉션을 적절하게 매개 변수화하여 구조화된 매개 변수로 보냅니다. 이는 SQL Server 및 PostgreSQL의 쿼리 계획 캐싱에 있어 큰 문제입니다.## 대량 작업 — ExecuteUpdate 및 ExecuteDelete
ExecuteUpdate 및 ExecuteDelete는 EF Core 7에 도입되었지만 EF Core 9에서는 이를 사용하여 수행할 수 있는 작업을 의미 있는 방식으로 확장합니다.
더 복잡한 업데이트 표현식
이제 탐색 속성을 통한 다른 테이블에 대한 참조를 포함하여 ExecuteUpdate에서 더 복잡한 표현식을 사용할 수 있습니다.
await context.Products
.Where(p => p.Category.IsDiscontinued)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.IsAvailable, false)
.SetProperty(p => p.DiscontinuedDate, DateTimeOffset.UtcNow)
.SetProperty(p => p.Price, p => p.Price * 0.5m));
이렇게 하면 카테고리 테이블에 JOIN가 포함된 단일 UPDATE 문이 생성됩니다. 즉, 엔터티를 메모리에 로드할 필요가 없고 변경 내용 추적 오버헤드도 없습니다.
하위 쿼리를 사용한 조건부 대량 삭제
이제 하위 쿼리 필터를 사용한 대량 삭제가 완전히 지원됩니다.
await context.AuditLogs
.Where(log => log.CreatedAt < DateTime.UtcNow.AddYears(-2))
.Where(log => !context.ProtectedRecords
.Any(pr => pr.AuditLogId == log.Id))
.ExecuteDeleteAsync();
이는 NOT EXISTS 하위 쿼리가 있는 DELETE로 변환되며, 이는 정확히 직접 작성하는 것과 같습니다. 로드된 엔터티가 없고 왕복도 없습니다.
JSON 열 개선 사항
JSON 열은 최근 EF Core 릴리스에서 가장 흥미로운 기능 중 하나였으며 EF Core 9에서는 이를 더욱 발전시켰습니다.
JSON 내부 쿼리
이제 더 나은 번역 지원을 통해 JSON 열 내에서 데이터를 필터링하고 투영할 수 있습니다.
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; }
public ShippingAddress Address { get; set; } // Stored as JSON
public List<OrderNote> Notes { get; set; } // Stored as JSON
}
public class ShippingAddress
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
public string Country { get; set; }
}
public class OrderNote
{
public DateTime CreatedAt { get; set; }
public string Text { get; set; }
public string Author { get; set; }
}
DbContext의 구성:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(builder =>
{
builder.OwnsOne(o => o.Address, ab =>
{
ab.ToJson();
});
builder.OwnsMany(o => o.Notes, nb =>
{
nb.ToJson();
});
});
}
이제 JSON에 직접 쿼리할 수 있습니다.
var newYorkOrders = await context.Orders
.Where(o => o.Address.State == "NY")
.OrderByDescending(o => o.Notes.Count)
.Select(o => new
{
o.CustomerName,
o.Address.City,
LatestNote = o.Notes
.OrderByDescending(n => n.CreatedAt)
.Select(n => n.Text)
.FirstOrDefault()
})
.ToListAsync();
EF Core 9는 SQL Server(또는 다른 공급자의 경우 이에 상응하는 항목)에서 적절한 JSON_VALUE 및 JSON_QUERY 호출을 생성하며 변환은 이전보다 JSON 요소에 대한 훨씬 더 광범위한 LINQ 작업을 다룹니다.
JSON 속성 업데이트
EF Core 8의 문제점 중 하나는 JSON 열 내부의 단일 속성을 업데이트하면 전체 JSON 문서가 다시 작성된다는 것입니다. EF Core 9는 JSON 매핑 유형에 대한 보다 세부적인 변경 추적을 통해 이를 개선하여 가능한 경우 더 많은 대상 업데이트를 생성합니다.
var order = await context.Orders.FindAsync(orderId);
order.Address.ZipCode = "10001";
await context.SaveChangesAsync();
지원되는 공급자에서는 전체 Blob을 다시 작성하는 대신 보다 타겟팅된 JSON 수정을 생성할 수 있습니다.
복잡한 유형 - ID가 없는 값 개체
복합 유형은 도메인 중심 디자인 실무자가 기다려온 기능 중 하나입니다. 소유된 유형과 달리 복합 유형에는 ID가 없습니다. 즉, 순수한 값 개체입니다.
[ComplexType]
public record Money(decimal Amount, string Currency);
[ComplexType]
public record DateRange(DateTime Start, DateTime End);
public class Project
{
public int Id { get; set; }
public string Name { get; set; }
public Money Budget { get; set; }
public DateRange Timeline { get; set; }
}
이는 별도의 테이블이나 어떤 종류의 키도 필요 없이 상위 테이블(Budget_Amount, Budget_Currency, Timeline_Start, Timeline_End)에 평면화된 열로 저장됩니다.
소유 유형과의 주요 차이점: 복합 유형은 참조가 아닌 값으로 비교됩니다. Amount 및 Currency가 동일한 두 개의 Money 인스턴스는 속한 엔터티에 관계없이 동일한 것으로 간주됩니다.
var expensiveProjects = await context.Projects
.Where(p => p.Budget.Amount > 100_000m && p.Budget.Currency == "USD")
.OrderByDescending(p => p.Budget.Amount)
.ToListAsync();
이는 평면화된 열에 대한 필터링으로 직접 변환됩니다. 즉, 깔끔하고 효율적이며 기대했던 것과 정확히 일치합니다.
SQL Server에 대한 HierarchyId 지원
SQL Server의 계층적 데이터(조직 차트, 범주 트리, 파일 시스템)로 작업해 본 적이 있다면 HierarchyId가 이에 대한 기본 제공 유형이라는 것을 알고 있을 것입니다. EF Core 9는 이에 대한 최고 수준의 지원을 제공합니다.
public class Employee
{
public int Id { get; set; }
public string Name { get; set; }
public string Title { get; set; }
public HierarchyId PathFromCeo { get; set; }
}
이제 계층적 관계를 직접 쿼리할 수 있습니다.
var managerId = HierarchyId.Parse("/1/3/");
// All direct and indirect reports
var allReports = await context.Employees
.Where(e => e.PathFromCeo.IsDescendantOf(managerId))
.Where(e => e.PathFromCeo != managerId) // exclude the manager
.OrderBy(e => e.PathFromCeo)
.ToListAsync();
// Direct reports only (one level down)
var directReports = await context.Employees
.Where(e => e.PathFromCeo.GetAncestor(1) == managerId)
.ToListAsync();
// Get an employee's depth in the hierarchy
var employeesWithDepth = await context.Employees
.Select(e => new
{
e.Name,
e.Title,
Level = e.PathFromCeo.GetLevel()
})
.OrderBy(e => e.Level)
.ToListAsync();
```이들 모두는 SQL Server의 기본 `HierarchyId` 메서드로 변환됩니다. 자체 참조 외래 키와 재귀 CTE를 사용하여 트리 구조를 구현했다면 이 방법이 훨씬 더 깔끔한 접근 방식입니다.
## 컴파일된 모델 및 AOT 지원
성능에 민감한 개발자는 컴파일된 모델과 AOT(Ahead-of-Time) 컴파일 지원에 대한 지속적인 투자를 높이 평가할 것입니다.
### 컴파일된 모델
컴파일된 모델은 EF Core가 일반적으로 시작 시 빌드하는 모델 메타데이터를 미리 생성합니다. 대규모 모델(수백 개의 엔터티 생각)의 경우 콜드 스타트 시간을 크게 줄일 수 있습니다.
```bash
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
그런 다음 연결하십시오.
builder.Services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(connectionString)
.UseModel(MyApp.CompiledModels.AppDbContextModel.Instance));
EF Core 9에서는 컴파일된 모델이 더욱 완벽해졌습니다. 즉, 더 많은 매핑 기능을 지원하고 더 작은 출력을 생성합니다. 약 400개 엔터티가 있는 모델의 경우 시작 시간은 몇 초에서 거의 순간적으로 단축될 수 있습니다.
AOT 컴파일 진행
EF Core에 대한 완전한 기본 AOT 지원은 아직 진행 중인 작업이지만 EF Core 9는 상당한 발전을 이루었습니다. 반사가 많은 코드 경로 중 상당수가 트리밍에 적합하도록 리팩터링되었으며 컴파일된 모델은 AOT 스토리의 핵심 부분입니다. 콜드 스타트가 중요한 Azure Functions 또는 마이크로서비스와 같은 시나리오를 대상으로 하는 경우 이러한 개선 사항은 직접적인 관련이 있습니다.
Cosmos DB 공급자 업데이트
Azure Cosmos DB 공급자는 EF Core 9를 통해 계속 발전하고 있습니다. 몇 가지 주목할만한 개선 사항은 다음과 같습니다.
파티션 키 처리
이제 공급자는 계층적 파티션 키를 지원하고 파티션 키 필터를 보다 지능적으로 처리합니다.
public class TenantDocument
{
public string Id { get; set; }
public string TenantId { get; set; } // Partition key
public string Region { get; set; } // Sub-partition key
public string Content { get; set; }
}
// This query now correctly uses the partition key for routing
var docs = await context.TenantDocuments
.Where(d => d.TenantId == "tenant-42" && d.Region == "us-east")
.Where(d => d.Content.Contains("important"))
.ToListAsync();
LINQ에서 NoSQL로의 변환 개선
이제 Contains, Any, 중첩 배열 작업 및 수학 함수에 대한 더 나은 지원을 포함하여 더 많은 LINQ 작업이 Cosmos DB의 SQL 언어로 변환됩니다. 이전에 클라이언트 평가로 대체되었던 쿼리는 이제 서버 측에서 처리됩니다.
벡터 검색 지원
EF Core 9에는 Cosmos DB를 통한 벡터 유사성 검색에 대한 초기 지원이 도입되었습니다. 이는 임베딩 또는 AI 기반 검색과 통합되는 애플리케이션을 구축하는 경우에 유용합니다.
var results = await context.Documents
.OrderBy(d => EF.Functions.VectorDistance(d.Embedding, queryVector))
.Take(10)
.ToListAsync();
마이그레이션 개선
마이그레이션을 통해 팀 환경에서 마이그레이션 작업의 고통을 덜 수 있도록 삶의 질이 향상되었습니다.
마이그레이션의 임시 테이블
이제 마이그레이션은 기간 열 및 기록 테이블 이름 지정에 대한 적절한 지원을 통해 임시 테이블 구성을 보다 원활하게 처리합니다.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Employee>()
.ToTable("Employees", b => b.IsTemporal(t =>
{
t.HasPeriodStart("ValidFrom");
t.HasPeriodEnd("ValidTo");
t.UseHistoryTable("EmployeeHistory");
}));
}
멱등성 스크립트
Script-Migration 명령(및 이에 상응하는 CLI)은 기본적으로 특정 상태에 존재하는 데이터에 의존하는 스키마 변경과 관련된 엣지 케이스 처리가 개선되어 더 나은 멱등성 스크립트를 생성합니다.
마이그레이션 번들
마이그레이션을 배포용 독립 실행형 실행 파일로 패키징하는 마이그레이션 번들은 더 나은 오류 보고 및 일시적 오류에 대한 재시도 논리를 통해 EF Core 9에서 더욱 안정적입니다.
dotnet ef migrations bundle --self-contained -r linux-x64
그러면 배포 대상에 .NET SDK를 설치하지 않고도 CI/CD 파이프라인에서 실행할 수 있는 바이너리가 생성됩니다.
성능 벤치마크다음은 제가 직접 테스트한 대략적인 벤치마크입니다. 이는 BenchmarkDotNet으로 측정된 SQL Server 2022에 대해 실행되는 약 200개의 엔터티가 있는 프로젝트에서 가져온 것입니다. 수치는 다양하지만 상대적인 개선 정도는 비슷할 것입니다.
| 시나리오 | EF 코어 8 | EF 코어 9 | 개선 |
|---|---|---|---|
| 모델 빌드(콜드 스타트) | 1,850ms | 320ms | ~5.8배 더 빠름(컴파일됨) |
| 단순 쿼리(PK별 단일 엔터티) | 0.42ms | 0.38ms | ~10% 더 빨라짐 |
| 복잡한 쿼리(조인 + 집계) | 3.1ms | 2.4ms | ~23% 더 빨라짐 |
| 대량 업데이트(10,000개 행) | 145ms | 118ms | ~19% 더 빨라짐 |
| JSON 열 쿼리 | 2.8ms | 1.9ms | ~32% 더 빨라짐 |
| SaveChanges(엔티티 100개) | 48ms | 41ms | ~15% 더 빨라짐 |
컴파일된 모델 개선은 가장 극적이지만 전반적으로 꾸준한 개선이 이루어지고 있습니다. 특히 초당 수천 개의 쿼리를 실행하는 처리량이 높은 시나리오에서는 더욱 그렇습니다.
EF Core 8에서 업그레이드
EF Core 8을 사용하는 경우 업그레이드 경로가 비교적 원활합니다. 체크리스트는 다음과 같습니다.
1. 패키지 업데이트:
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
2. 주요 변경 사항을 확인하세요. EF Core 9의 목록은 일부 이전 릴리스에 비해 상대적으로 짧습니다. 가장 주목할만한 것 :
- 이전에 사용되지 않았던 일부 API가 제거되었습니다.
- 특정
GroupBy쿼리가 변환되는 방식 변경(이제 서버측으로 이동하여 클라이언트 평가에 의존하는 경우 동작이 변경됨) - 마이그레이션 스캐폴딩 출력의 사소한 변경
3. 컴파일된 모델을 사용하는 경우 다시 생성하세요. 형식이 변경되었으므로 이전에 컴파일된 모델은 EF Core 9에서 작동하지 않습니다.
4. 테스트 스위트를 실행하십시오. 이전에 클라이언트에서 평가된 쿼리에 특별한 주의를 기울이십시오. 이제 서버에서 평가될 수도 있습니다. 이는 일반적으로 더 좋지만 데이터 차이가 드러날 수 있습니다.
5. 해당 공급자를 사용하는 경우 Cosmos DB 쿼리를 확인하세요. 향상된 번역은 일부 쿼리가 다르게(일반적으로 더 빠르게) 실행된다는 것을 의미하지만 결과가 동일한지 확인할 가치가 있습니다.
일반적인 프로젝트의 최소 업그레이드는 다음과 같습니다.
dotnet add package Microsoft.EntityFrameworkCore --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.0
dotnet build
dotnet test
모든 것이 컴파일되고 테스트에 통과되면 아마도 좋은 상태일 것입니다. 문제가 발생하면 EF Core 9 주요 변경 설명서에 각 변경 사항에 대한 자세한 마이그레이션 지침이 있습니다.
마무리
EF Core 9는 혁명적인 릴리스가 아닙니다. 이는 진화적인 릴리스이며, 이것이 바로 필요한 것입니다. LINQ 개선만으로도 대부분의 프로젝트에 대한 업그레이드가 정당화되며 JSON 열 개선, 복합 유형 및 HierarchyId와 같은 기능은 이전에 어색하거나 불가능했던 개방형 패턴을 지원합니다.
내 프로젝트에 가장 큰 영향을 미친 세 가지 기능을 선택해야 한다면:
- 매개변수화된 기본 컬렉션 — 쿼리 계획 캐시 효율성이 규모에 따라 중요하기 때문
- JSON 열 개선 — 하이브리드 관계형 문서 패턴이 매우 유용하기 때문입니다.
- 컴파일된 모델 — 시작 시간이 개발자 생산성과 배포 속도에 직접적인 영향을 미치기 때문입니다.EF Core 팀은 EF Core 5 이후 탄탄한 궤도를 유지해 왔으며 버전 9에서는 이러한 추세를 이어갑니다. 이미 EF Core 8을 사용하고 있다면 업그레이드는 위험은 낮고 보상은 높습니다. 만약 당신이 오래된 무언가를 하고 있다면, 도약하기에 이보다 더 좋은 때는 없었습니다.
즐거운 코딩과 행복한 쿼리.