EF Core 특수 맵핑

EF Core 7.0 과 EF Core 8.0 에서는 기존 버전의 EF 또는 RDB의 한계로 인해 다루기 힘들었던 형식들에 대한 지원이 대폭 추가되었습니다.

이 지원이 필요한 이유와 사용 방법에 대해 알아 봅니다.

Linq Filtering & Projection

우선, EF 에서 지원한다는 의미는 IQueryable Linq 에서 필터링(Where) 과 투사(Projection, Select)가 가능하다는 의미라는 점을 알아 두시는 게 좋습니다.

저장 객체

데이터 베이스에 저장될 객체는 대체로 아래 세 가지 큰 부류로 나눌 수 있습니다.

  1. 기본 자료형(Primitive Types)
    필드의 집합으로 표현되지 않고, 단일 값을 나타냄. (int, char, text …).

  2. 엔티티(Entity Types)
    필드의 집합으로 표현되고 Key 가 있음.

  3. 복합 자료형(Complex Value Types)
    필드의 집합으로 표현되고 Key 가 없음. ???

1번은 코드 모델의 속성 ↔ 레코드로, 2번은 코드 모델(클래스) ↔ 테이블로 맵핑이 가능합니다.

그러나, 3번의 경우 코드 모델 입장에서는 문제가 없지만, Key를 전제하는 RDB의 입장에서는 처리할 방법이 없습니다. 즉, 코드 모델 → 데이터 베이스 맵핑에 관한 안전한 방법이 존재하지 않습니다.

복합 자료형(Complex Types)

EF 7 이전에는 복합 형식을 우회 맵핑하는 방법은 크게 세 가지였습니다.

  1. Owned Type 으로 선언
    1. Owner의 테이블을 공유
      복합 자료형의 속성이 Owner 테이블의 필드로 삽입됨
    2. 묵시적 키를 보유한 테이블로 선언
      Owner 의 키를 외래키로 보유한 테이블을 정의. 이 테이블의 Key는 별도로 정의.
  2. 직렬화를 통해 기본 자료형으로 변환

Owned Type 으로 선언하는 것은 이미 Key를 전제하는 Entity 이기에 key 가 없는 복합 자료형과 의미론적으로 부합하지 않고, Key를 인위적으로 정의해야 해서, 에러 유발 요소가 있습니다.

직렬화에는 아래 두 가지 방법이 있습니다.

  1. CSV (Comma Seperated Values)
  2. 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도 일정한 강제 사항을 부여합니다.

  1. Sub Entity 는 DbSet<T>의 대상으로 사용할 수 없습니다.
    Owned 를 다른 Aggregate에서 참조하려면, 반드시 DbSet<TOwner>을 통하는 것이 강제됩니다.
  2. 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;

참조

6개의 좋아요