EF Core 7.0 과 EF Core 8.0 에서는 기존 버전의 EF 또는 RDB의 한계로 인해 다루기 힘들었던 형식들에 대한 지원이 대폭 추가되었습니다.
이 지원이 필요한 이유와 사용 방법에 대해 알아 봅니다.
Linq Filtering & Projection
우선, EF 에서 지원한다는 의미는 IQueryable Linq 에서 필터링(Where) 과 투사(Projection, Select)가 가능하다는 의미라는 점을 알아 두시는 게 좋습니다.
저장 객체
데이터 베이스에 저장될 객체는 대체로 아래 세 가지 큰 부류로 나눌 수 있습니다.
-
기본 자료형(Primitive Types)
필드의 집합으로 표현되지 않고, 단일 값을 나타냄. (int, char, text …). -
엔티티(Entity Types)
필드의 집합으로 표현되고 Key 가 있음. -
복합 자료형(Complex Value Types)
필드의 집합으로 표현되고 Key 가 없음. ???
1번은 코드 모델의 속성 ↔ 레코드로, 2번은 코드 모델(클래스) ↔ 테이블로 맵핑이 가능합니다.
그러나, 3번의 경우 코드 모델 입장에서는 문제가 없지만, Key를 전제하는 RDB의 입장에서는 처리할 방법이 없습니다. 즉, 코드 모델 → 데이터 베이스 맵핑에 관한 안전한 방법이 존재하지 않습니다.
복합 자료형(Complex Types)
EF 7 이전에는 복합 형식을 우회 맵핑하는 방법은 크게 세 가지였습니다.
- Owned Type 으로 선언
- Owner의 테이블을 공유
복합 자료형의 속성이 Owner 테이블의 필드로 삽입됨 - 묵시적 키를 보유한 테이블로 선언
Owner 의 키를 외래키로 보유한 테이블을 정의. 이 테이블의 Key는 별도로 정의.
- Owner의 테이블을 공유
- 직렬화를 통해 기본 자료형으로 변환
Owned Type 으로 선언하는 것은 이미 Key를 전제하는 Entity 이기에 key 가 없는 복합 자료형과 의미론적으로 부합하지 않고, Key를 인위적으로 정의해야 해서, 에러 유발 요소가 있습니다.
직렬화에는 아래 두 가지 방법이 있습니다.
- CSV (Comma Seperated Values)
- Json
직렬화의 가장 큰 문제점은 Linq 가 먹지 않는다는 점입니다.
(PostgreSQL 은 "Json 형식"을 지원하기 때문에 예외입니다)
이러한 복합 형식의 맵핑 어려움은, EF 7에 들어서, "Json Column"이라는 기능을 도입하여 해결했고, EF 8 은 "Complex Type"이라는 이름으로 다른 접근법을 제시합니다.
Json 형식 필드(Json Column)
데이터 베이스가 문자열 형 필드에 저장된 Json text를 Json 객체로 취급하는 기능을 가리키는데, 대부분의 RDB는 이에 대한 지원을 하고 있어, 쿼리의 대상(From)이 될 수 있습니다.
이 기능은 NoSQL 같은 document 데이터 베이스의 특징을 RDB에 입힌 것이라 할 수 있는데, 시장 방어적 차원에서 꼭 필요한 부분이었던 것 같습니다.
EF 는 버전 7부터, "Json Column"에 대한 지원을 추가했는데, Owned로 지정되는 복합 형식을 Json Column으로 지정하는 옵션을 제공하는 것이 주요 골자입니다.
Fluent Api
우선, EF 7에서는 Fluent API 방식으로 설정할 수 있게 되었습니다.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Author>().OwnsOne(
author => author.Contact, ownedNavigationBuilder =>
{
ownedNavigationBuilder.ToJson();
ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
});
}
위 코드에서 Author는 Entity입니다.
Author는 Contact 를 소유하고, Contact 는 다시 Address를 소유합니다.
Author 테이블에 Contact 를 Json Column 으로 지정하기 위해서, Contact 빌더의 ToJson() 메서드를 호출합니다. 그 결과로 Contact 객체는 [Author].[Contact] 필드에 Json 문자열로 저장됩니다.
그런데, Address 빌더에서는 이를 호출하지 않습니다. 왜냐하면 Address 의 Owner인 Contact 가 이미 Json 으로 지정되었기 때문에, Address 도 당연히 Json으로 지정되기 때문입니다.
Owns & Aggregate
EF의 Owns 관계는 원래 애그리것(Aggregate)을 나타내기 위한 것이었습니다.
즉, Owner 는 Aggregate Root, Owned 는 Aggregate 의 Sub Entity 를 가리키는데, 이 개념에 맞게 EF도 일정한 강제 사항을 부여합니다.
- Sub Entity 는 DbSet<T>의 대상으로 사용할 수 없습니다.
Owned 를 다른 Aggregate에서 참조하려면, 반드시 DbSet<TOwner>을 통하는 것이 강제됩니다.- Owned는 언제나 Onwer에 Include 되고, Cacade Delete 됩니다.
Json Column 의 지정은 Aggregate Root 엔티티에서만 가능합니다.
조회(Query)
이렇게 Json 필드로 지정된 복합 형식에 대해 아래와 같은 Linq 가 가능합니다.
필터링
var authorsInChigley = await context.Authors
.Where(author => author.Contact.Address.City == "Chigley")
.ToListAsync();
단순 투사
var postcodesInChigley = await context.Authors
.Where(author => author.Contact.Address.City == "Chigley")
.Select(author => author.Contact.Address.Postcode)
.ToListAsync();
조합 투사
var orderedAddresses = await context.Authors
.Where(
author => (author.Contact.Address.City == "Chigley"
&& author.Contact.Phone != null)
|| author.Name.StartsWith("D"))
.OrderBy(author => author.Contact.Phone)
.Select(
author => author.Name + " (" + author.Contact.Address.Street
+ ", " + author.Contact.Address.City
+ " " + author.Contact.Address.Postcode + ")")
.ToListAsync();
집합의 투사
var postsWithViews = await context.Posts
.Where(post => post.Metadata!.Views > 3000)
.AsNoTracking()
.Select(
post => new
{
post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
})
.ToListAsync();
Update
DbContext.SaveChanges 도 일반 Entity 처럼 다룰 수 있도록 지원합니다.
복합 자료형에 새로운 할당.
var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));
jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };
await context.SaveChangesAsync();
복합 자료형의 속성만 할당.
var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));
brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");
await context.SaveChangesAsync();
Array 인덱싱
EF 8 에서는 Json Column 으로 지정된 복합 객체의 집합 속성에 대해 Array Indexing 이 가능해졌습니다.
필터링
var cutoff = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(365));
var updatedPosts = await context.Posts
.Where(
p => p.Metadata!.Updates[0].UpdatedOn < cutoff
&& p.Metadata!.Updates[1].UpdatedOn < cutoff)
.ToListAsync();
투사
var postsAndRecentUpdates = await context.Posts
.Where(p => p.Metadata!.Updates[0].UpdatedOn != null
&& p.Metadata!.Updates[1].UpdatedOn != null)
.Select(p => new
{
p.Title,
LatestUpdate = p.Metadata!.Updates[0].UpdatedOn,
SecondLatestUpdate = p.Metadata.Updates[1].UpdatedOn
})
.ToListAsync();
캐싱된 집합을 통한 필터링
var searchTerms = new[] { "Search #2", "Search #3", "Search #5", "Search #8", "Search #13", "Search #21", "Search #34" };
var postsWithSearchTerms = await context.Posts
.Where(post => post.Metadata!.TopSearches.Any(s => searchTerms.Contains(s.Term)))
.ToListAsync();
복합 자료형(Complex Type)
EF 8에 도입된 이 기능은 말 그대로 복합 자료형을 다루는 기능의 이름입니다.
복합 자료형에 관한 지원은 EF 7에서는 Json Column 으로, EF 8 에서는 Complex Type(과 Json Column) 으로 지원되는 것이죠.
지정 방법
Attribute
ComplexTypeAttribute 를 모델 클래스에 부여합니다.
[ComplexType]
public class Address
{
public required string Line1 { get; set; }
public string? Line2 { get; set; }
public required string City { get; set; }
public required string Country { get; set; }
public required string PostCode { get; set; }
}
Fluent Api
EntityEntry.ComplexProperty() 메서드를 호출합니다.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.ComplexProperty(e => e.Address);
modelBuilder.Entity<Order>(b =>
{
b.ComplexProperty(e => e.BillingAddress);
b.ComplexProperty(e => e.ShippingAddress);
});
}
이렇게 지정되면, 이 모델 클래스의 속성들은 소유 객체의 필드로 맵핑됩니다.
INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);
이 기능은,
-
Owner 테이블의 필드로 강제 설정된다는 점에서 Json Column 과 다릅니다.
데이터 베이스의 Json 파싱 기능을 호출하지 않기 때문에 더 효율적이라고 할 수 있습니다. -
인스턴스가 특정 Owner에 속하는 것이 강제되지 않는다는 점에서 Owned 와 다릅니다.
특히, 두 번째 특징은 Complex Type의 존재의 이유라고 할 수 있습니다.
Owned Type 과 차이점
Owned Type 은 Onwer 의 PK를 FK 로 보유한 Entity 이고, 이를 바꿀 수 없습니다.
이러한 특징은 아래의 간단한 코드에 문제를 일으킵니다.
customer.Orders.Add(
new Order {
Contents = "Tesco Tasty Treats",
BillingAddress = customer.Address,
ShippingAddress = customer.Address,
});
await context.SaveChangesAsync();
warn: 8/20/2023 12:48:01.678 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update)
The same entity is being tracked as different entity types ‘Order.BillingAddress#Address’ and ‘Customer.Address#Address’ with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
// …
fail: 8/20/2023 12:48:01.709 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update)
An exception occurred in the database while saving changes for context type ‘NewInEfCore8.ComplexTypesSample+CustomerContext’.
System.InvalidOperationException: Cannot save instance of ‘Order.ShippingAddress#Address’ because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.
// …
Customer.Address 는 Customer 에 속한 객체인데, 이를 Orger.Address 에 할당하는 것은 (코드로는 문제가 전혀 없지만) 소유 관계가 틀어진다는 점을 말하고 있습니다.
Complex Type 은 정확히 이 문제를 해결합니다.
단순히 Customer의 필드 값들이, Order 의 필드값으로 복사되는 것입니다.
불변성
그러나, 주의해야 할 점이 있습니다.
Complex Type 인 경우, 아래의 코드를 실행하면,
customer.Orders.Add(
new Order {
Contents = "Tesco Tasty Treats",
BillingAddress = customer.Address,
ShippingAddress = customer.Address,
});
customer.Address.Line1 = "Peacock Lodge";
await context.SaveChangesAsync();
Order.BillingAddress, Order.ShippingAddress, Customer.Addresss 의 Line1 속성이 전부 “Peacock Lodge” 로 변경됩니다.
이는 Address 가 참조형 객체이기 때문에 당연한 결과입니다.
만약, 이 것이 원하는 결과라면, Address 는 Complext Type으로 선언되면 안되고, 별도의 Entity로 선언되어야 합니다.
만약, 원하지 않는 것이라면, Complex Type 을 불변 타입으로 선언하는 것이 좋습니다.
- record class
- readonly record struct
상태 트랙킹
Complex Type 은 EF에 의해 값이 추적됩니다.
var billingAddress = context.Entry(order)
.ComplexProperty(e => e.BillingAddress)
.CurrentValue;
var postCode = context.Entry(order)
.ComplexProperty(e => e.BillingAddress)
.Property(e => e.PostCode)
.CurrentValue;
참조