EF Core 7.0의 새로운 기능

EF Core 7.0(EF7)은 2022년 11월에 릴리스되었습니다.

GitHub에서 샘플 코드를 다운로드하여 샘플을 실행하고 디버그할 수 있습니다. 각 섹션은 해당 섹션과 관련된 소스 코드에 연결됩니다.

EF7은 .NET 6을 대상으로 하므로 .NET 6(LTS) 또는 .NET 7과 함께 사용할 수 있습니다.

샘플 모델

아래 예제의 대부분은 블로그, 게시물, 태그 및 작성자가 있는 간단한 모델을 사용합니다.

public class Blog
{
    public Blog(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Author
{
    public Author(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

일부 예제에서는 다른 샘플에서 다양한 방식으로 매핑되는 집계 형식도 사용합니다. 연락처에는 하나의 집계 유형이 있습니다.

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

그리고 게시 메타데이터에 대한 두 번째 집계 형식은 다음과 같습니다.

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

샘플 모델은 BlogsContext.cs에서 찾을 수 있습니다.

JSON 열

대부분의 관계형 데이터베이스는 JSON 문서를 포함하는 열을 지원합니다. 이러한 열의 JSON은 쿼리를 사용하여 드릴인할 수 있습니다. 예를 들어 문서의 요소별로 필터링 및 정렬할 수 있으며 문서에서 결과로 요소를 프로젝션할 수 있습니다. JSON 열을 사용하면 관계형 데이터베이스가 문서 데이터베이스의 몇 가지 특징을 파악하여 둘 사이에 유용한 하이브리드를 만들 수 있습니다.

EF7에는 SQL Server 구현과 JSON 열에 대한 공급자 독립적 지원이 포함되어 있습니다. 이 지원을 사용하면 .NET 형식에서 JSON 문서로 빌드된 집계를 매핑할 수 있습니다. 일반적인 LINQ 쿼리는 집계에서 사용할 수 있으며 JSON으로 드릴인하는 데 필요한 적절한 쿼리 구문으로 변환됩니다. EF7은 JSON 문서의 변경 내용 업데이트 및 저장도 지원합니다.

참고 항목

JSON에 대한 SQLite 지원은 EF7 이후를 위해 계획되어 있습니다. PostgreSQL 및 Pomelo MySQL 공급자는 이미 JSON 열에 대한 일부 지원을 포함하고 있습니다. 이러한 공급자의 작성자와 협력하여 모든 공급자에서 JSON 지원을 맞춥니다.

JSON 열에 매핑

EF Core에서 집계 형식은 OwnsOneOwnsMany을(를) 사용하여 정의됩니다. 예를 들어 연락처 정보를 저장하는 데 사용되는 샘플 모델의 집계 형식을 고려합니다.

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

그런 다음, "소유자" 엔터티 형식에서 예를 들어, 작성자의 연락처 세부 정보를 저장하는 데 사용할 수 있습니다.

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

집계 형식은 OwnsOne을(를) 사용하여 OnModelCreating에 구성됩니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

여기에 표시된 코드는 JsonColumnsSample.cs에서 제공됩니다.

기본값으로 관계형 데이터베이스 공급자는 이와 같은 집계 형식을 소유 엔터티 형식과 동일한 테이블에 매핑합니다. 즉, ContactDetailsAddress 클래스의 각 속성은 Authors 테이블의 열에 매핑됩니다.

연락처 세부 정보가 있는 일부 저장된 작성자는 다음과 같습니다.

작성자

ID 속성 Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Maddy Montaquila 1 Main St Camberwick Green CW1 5ZH 영국 01632 12345
2 Jeremy Likness 2 Main St Chigley CW1 5ZH 영국 01632 12346
3 Daniel Roth 3 Main St Camberwick Green CW1 5ZH 영국 01632 12347
4 작성자: Vickers 15a Main St Chigley CW1 5ZH 영국 01632 22345
5 Brice Lambson 4 Main St Chigley CW1 5ZH 영국 01632 12349

원하는 경우 집계를 구성하는 각 엔터티 형식을 대신 자체 테이블에 매핑할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

그런 다음, 동일한 데이터가 세 테이블에 저장됩니다.

작성자

ID 속성
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 작성자: Vickers
5 Brice Lambson

연락처

AuthorId 전화
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

주소

ContactDetailsAuthorId 번지 시/군/구 Postcode Country
1 1 Main St Camberwick Green CW1 5ZH 영국
2 2 Main St Chigley CW1 5ZH 영국
3 3 Main St Camberwick Green CW1 5ZH 영국
4 15a Main St Chigley CW1 5ZH 영국
5 4 Main St Chigley CW1 5ZH 영국

이제, 흥미로운 내용을 살펴보겠습니다. EF7에서는 ContactDetails 집계 형식을 JSON 열에 매핑할 수 있습니다. 이렇게 하려면 집계 형식을 구성할 때 ToJson()을(를) 한 번만 호출해야 합니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

이제 Authors 테이블에는 각 작성자의 JSON 문서로 채워진 ContactDetails에 대한 JSON 열이 포함됩니다.

작성자

ID 속성 연락처
1 Maddy Montaquila {
  "Phone":"01632 12345",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"1 Main St"
  }
}
2 Jeremy Likness {
  "Phone":"01632 12346",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"2 Main St"
  }
}
3 Daniel Roth {
  "Phone":"01632 12347",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"3 Main St"
  }
}
4 작성자: Vickers {
  "Phone":"01632 12348",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"15a Main St"
  }
}
5 Brice Lambson {
  "Phone":"01632 12349",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"4 Main St"
  }
}

이러한 집계 사용은 Azure Cosmos DB용 EF Core 공급자를 사용할 때 JSON 문서가 매핑되는 방식과 매우 유사합니다. JSON 열은 문서 데이터베이스에 대해 EF Core를 사용하는 기능을 관계형 데이터베이스에 포함된 문서로 가져옵니다.

위에 표시된 JSON 문서는 매우 간단하지만 이 매핑 기능은 더 복잡한 문서 구조와 함께 사용할 수도 있습니다. 예를 들어 게시물에 대한 메타데이터를 나타내는 데 사용되는 샘플 모델의 다른 집계 형식을 고려합니다.

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

이 집계 형식에는 여러 중첩 형식 및 컬렉션이 포함됩니다. OwnsOneOwnsMany에 대한 호출은 이 집계 형식을 매핑하는 데 사용됩니다.

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

ToJson은(는) 전체 집계를 JSON 문서에 매핑하기 위해 집계 루트에만 필요합니다.

이 매핑을 사용하여 EF7은 다음과 같이 복잡한 JSON 문서를 만들고 쿼리할 수 있습니다.

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

참고 항목

공간 형식을 JSON에 직접 매핑하는 것은 아직 지원되지 않습니다. 위의 문서에서는 double 값을 해결 방법으로 사용합니다. 관심 있는 경우 JSON 열의 공간 형식 지원에 투표합니다.

참고 항목

기본 형식의 컬렉션을 JSON에 매핑하는 것은 아직 지원되지 않습니다. 위의 문서에서는 값 변환기를 사용하여 컬렉션을 쉼표로 구분된 문자열로 변환합니다. 관심 있는 경우 Json: 기본 형식 컬렉션에 대한 지원 추가에 투표합니다.

참고 항목

소유 형식을 JSON에 매핑하는 것은 TPT 또는 TPC 상속과 함께 아직 지원되지 않습니다. 관심 있는 경우 TPT/TPC 상속 매핑을 사용하여 JSON 속성 지원에 투표합니다.

JSON 열에 쿼리

JSON 열에 대한 쿼리는 EF Core의 다른 집계 형식으로 쿼리하는 것과 동일하게 작동합니다. 즉, LINQ를 사용하세요. 다음 몇 가지 예를 참조하십시오.

Chigley에 거주하는 모든 작성자를 위한 쿼리

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

이 쿼리는 SQL Server를 사용할 때 다음 SQL을 생성합니다.

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

JSON_VALUE을(를) 사용하여 JSON 문서 내부의 Address에서 City을(를) 가져옵니다.

Select은(는) JSON 문서에서 요소를 추출하고 프로젝트하는 데 사용할 수 있습니다.

var postcodesInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .Select(author => author.Contact.Address.Postcode)
    .ToListAsync();

이 쿼리는 다음 SQL을 생성합니다.

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

다음은 필터 및 프로젝션에서 좀 더 많은 작업을 수행하고 JSON 문서의 전화 번호로 주문하는 예제입니다.

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();

이 쿼리는 다음 SQL을 생성합니다.

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

그리고 JSON 문서에 컬렉션이 포함된 경우 결과에 프로젝션할 수 있습니다.

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();

이 쿼리는 다음 SQL을 생성합니다.

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

참고 항목

JSON 컬렉션과 관련된 더 복잡한 쿼리는 jsonpath을(를) 지원해야 합니다. 관심 있는 경우 jsonpath 쿼리 지원에 투표합니다.

JSON 문서에서 쿼리 성능을 향상시키기 위해 인덱스를 만드는 것이 좋습니다. 예를 들어 SQL Server를 사용할 때 Json 데이터 인덱싱을 참조하세요.

JSON 열 업데이트

SaveChangesSaveChangesAsync은(는) 일반적인 방법으로 JSON 열을 업데이트합니다. 광범위한 변경 내용을 위해 전체 문서가 업데이트됩니다. 예를 들어 작성자의 Contact 문서 대부분을 대체합니다.

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();

이 경우 전체 새 문서가 매개 변수로 전달됩니다.

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

그런 다음 UPDATE SQL에서 사용됩니다.

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

그러나 하위 문서만 변경된 경우 EF Core는 JSON_MODIFY 명령을 사용하여 하위 문서만 업데이트합니다. 예를 들어 Contact 문서 내부에서 Address을(를) 변경합니다.

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

다음 매개 변수를 생성합니다.

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

JSON_MODIFY 호출을 통해 UPDATE에서 사용되는 것은 다음과 같습니다.

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

마지막으로, 단일 속성만 변경된 경우 EF Core는 "JSON_MODIFY" 명령을 다시 사용합니다. 이번에는 변경된 속성 값만 패치합니다. 예시:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

다음 매개 변수를 생성합니다.

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

JSON_MODIFY와(과) 함께 다시 사용됩니다.

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

ExecuteUpdate 및 ExecuteDelete(대량 업데이트)

기본값으로 EF Core는 엔터티에 대한 변경 내용을 추적한 다음 SaveChanges 메서드 중 하나가 호출되면 데이터베이스에 업데이트를 보냅니다. 변경 내용은 실제로 변경된 속성 및 관계에 대해서만 전송됩니다. 또한 추적된 엔터티는 데이터베이스로 전송된 변경 내용과 동기화된 상태로 유지됩니다. 이 메커니즘은 범용 삽입, 업데이트 및 삭제를 데이터베이스에 보내는 효율적이고 편리한 방법입니다. 이러한 변경 내용은 데이터베이스 왕복 횟수를 줄이기 위해 일괄 처리됩니다.

그러나 변경 추적기를 포함하지 않고 데이터베이스에서 업데이트 또는 삭제 명령을 실행하는 것이 유용한 경우도 있습니다. EF7은 새 ExecuteUpdateExecuteDelete 메서드를 사용하여 이를 사용하도록 설정합니다. 이러한 메서드는 LINQ 쿼리에 적용되며 해당 쿼리의 결과에 따라 데이터베이스의 엔터티를 업데이트하거나 삭제합니다. 많은 엔터티를 단일 명령으로 업데이트할 수 있으며 엔터티는 메모리에 로드되지 않으므로 보다 효율적인 업데이트 및 삭제가 발생할 수 있습니다.

그러나 다음 사항에 유의하세요.

  • 변경할 특정 변경 내용은 명시적으로 지정해야 합니다. EF Core에서 자동으로 검색되지 않습니다.
  • 추적된 엔터티는 동기화된 상태로 유지되지 않습니다.
  • 데이터베이스 제약 조건을 위반하지 않도록 추가 명령을 올바른 순서로 보내야 할 수 있습니다. 예를 들어 보안 주체를 삭제하기 전에 종속 항목을 삭제합니다.

이 모든 것은 ExecuteUpdateExecuteDelete 메서드가 기존 SaveChanges 메커니즘을 대체하는 대신 보완한다는 것을 의미합니다.

기본 ExecuteDelete 예제

여기에 표시된 코드는 ExecuteDeleteSample.cs에서 제공됩니다.

DbSet에서 ExecuteDelete 또는 ExecuteDeleteAsync을(를) 호출하면 데이터베이스에서 해당 DbSet 엔터티의 모든 엔터티가 즉시 삭제됩니다. 예를 들어 모든 Tag 엔터티를 삭제하려면 다음을 수행합니다.

await context.Tags.ExecuteDeleteAsync();

이를 통해 SQL Server를 사용할 때 다음 SQL이 실행됩니다.

DELETE FROM [t]
FROM [Tags] AS [t]

더 흥미로운 것은 쿼리에 필터가 포함될 수 있습니다. 예시:

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

이렇게 하면 다음 SQL이 실행됩니다.

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

쿼리는 다른 형식에 대한 탐색을 포함하여 더 복잡한 필터를 사용할 수도 있습니다. 예를 들어 이전 블로그 게시물에서만 태그를 삭제하려면 다음을 수행합니다.

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

다음을 실행합니다.

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

기본 ExecuteUpdate 예제

여기에 표시된 코드는 ExecuteUpdateSample.cs에서 제공됩니다.

ExecuteUpdateExecuteUpdateAsync은(는) ExecuteDelete 메서드와 매우 유사한 방식으로 동작합니다. 주요 차이점은 업데이트하려면 업데이트할 속성과 업데이트 방법을 알고 있어야 한다는 것입니다. 이 작업은 SetProperty에 대한 하나 이상의 호출을 사용하여 수행됩니다. 예를 들어 모든 블로그의 Name을(를) 업데이트하려면 다음을 수행합니다.

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

SetProperty의 첫 번째 매개 변수는 업데이트할 속성을 지정합니다. 이 경우 Blog.Name입니다. 두 번째 매개 변수는 새 값을 계산하는 방법을 지정합니다. 이 경우 기존 값을 사용하고 "*Featured!*"을(를) 추가합니다. 결과 SQL은 다음과 같습니다.

UPDATE [b]
SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

ExecuteDelete와(과) 마찬가지로 쿼리를 사용하여 업데이트되는 엔터티를 필터링할 수 있습니다. 또한 SetProperty에 대한 여러 호출을 사용하여 대상 엔터티에서 둘 이상의 속성을 업데이트할 수 있습니다. 예를 들어 2022년 이전에 게시된 모든 게시물의 TitleContent을(를) 업데이트하려면 다음을 수행합니다.

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

이 경우 생성된 SQL은 좀 더 복잡합니다.

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

마지막으로, ExecuteDelete과(와) 마찬가지로 필터는 다른 테이블을 참조할 수 있습니다. 예를 들어 이전 게시물의 모든 태그를 업데이트하려면 다음을 수행합니다.

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

다음을 생성합니다.

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

자세한 정보와 ExecuteUpdateExecuteDelete의 코드 샘플은 ExecuteUpdate 및 ExecuteDelete를 참조하세요.

상속 및 여러 테이블

ExecuteUpdateExecuteDelete은(는) 단일 테이블에 대해서만 작동할 수 있습니다. 이는 다양한 상속 매핑 전략을 사용할 때 영향을 줍니다. 일반적으로 TPH 매핑 전략을 사용할 때는 수정할 테이블이 하나만 있으므로 문제가 없습니다. 예를 들어 모든 FeaturedPost 엔터티를 삭제하는 경우 다음을 수행합니다.

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

TPH 매핑을 사용할 때 다음 SQL을 생성합니다.

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

또한 단일 테이블에 대한 변경만 필요하므로 TPC 매핑 전략을 사용할 때는 이 경우에 문제가 없습니다.

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

그러나 TPT 매핑 전략을 사용할 때 이를 시도하면 서로 다른 두 테이블에서 행을 삭제해야 하므로 실패합니다.

쿼리에 필터를 추가하면 TPC 및 TPT 전략 모두에서 작업이 실패하는 경우가 많습니다. 여러 테이블에서 행을 삭제해야 할 수도 있기 때문입니다. 예를 들어 다음 쿼리는

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

TPH를 사용할 때 다음 SQL을 생성합니다.

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

그러나 TPC 또는 TPT를 사용할 때 실패합니다.

문제 #10879는 이러한 시나리오에서 여러 명령을 자동으로 보내는 지원을 추가하는 것을 추적합니다. 구현된 것을 확인하려는 경우 이 문제에 투표합니다.

ExecuteDelete 및 관계

위에서 설명한 대로 관계의 보안 주체를 삭제하려면 종속 엔터티를 삭제하거나 업데이트해야 할 수 있습니다. 예를 들어 각 Post은(는) 연결된 Author에 종속적입니다. 즉, 게시물에서 계속 참조하는 경우 작성자를 삭제할 수 없습니다. 이렇게 하면 데이터베이스의 외래 키 제약 조건을 위반합니다. 예를 들어 다음을 시도합니다.

await context.Authors.ExecuteDeleteAsync();

SQL Server에 다음 예외가 발생합니다.

Microsoft.Data.SqlClient.SqlException (0x80131904): DELETE 문이 REFERENCE 제약 조건 "FK_Posts_Authors_AuthorId"와 충돌합니다. 데이터베이스 "TphBlogsContext", 테이블 "dbo.Posts", 열 'AuthorId'에서 충돌이 발생했습니다. 문이 종료되었습니다.

이 문제를 해결하려면 먼저 게시물을 삭제하거나 AuthorId 외래 키 속성을 null로 설정하여 각 게시물과 작성자 간의 관계를 끊어야 합니다. 예를 들어 삭제 옵션을 사용합니다.

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

TagWith은(는) 일반 쿼리에 태그를 지정하는 것과 동일한 방식으로 태그 ExecuteDelete 또는 ExecuteUpdate을(를) 지정하는 데 사용할 수 있습니다.

이렇게 하면 두 개의 별도의 명령이 생성됩니다. 먼저 종속 항목을 삭제합니다.

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

그리고 두 번째는 보안 주체를 삭제합니다.

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

Important

여러 ExecuteDelete 명령과 ExecuteUpdate 명령은 기본적으로 단일 트랜잭션에 포함되지 않습니다. 그러나 DbContext 트랜잭션 API를 일반적인 방법으로 사용하여 이러한 명령을 트랜잭션에 래핑할 수 있습니다.

단일 왕복으로 이러한 명령을 보내는 것은 문제 #10879에 따라 달라집니다. 구현된 것을 확인하려는 경우 이 문제에 투표합니다.

데이터베이스에서 하위 삭제를 구성하는 것은 여기에서 매우 유용할 수 있습니다. 모델에서 Blog과(와) Post 사이의 관계가 필요하며, 이로 인해 EF Core는 규칙에 따라 하위 삭제를 구성합니다. 즉, 데이터베이스에서 블로그가 삭제되면 모든 종속 게시물도 삭제됩니다. 그런 다음, 모든 블로그와 게시물을 삭제하려면 블로그만 삭제하면 됩니다.

await context.Blogs.ExecuteDeleteAsync();

그러면 다음 SQL이 생성됩니다.

DELETE FROM [b]
FROM [Blogs] AS [b]

또한 블로그를 삭제할 때, 구성된 하위 삭제로 인해 모든 관련 게시물이 삭제됩니다.

더 빠른 SaveChanges

EF7에서는 SaveChangesSaveChangesAsync의 성능이 크게 향상되었습니다. 일부 시나리오에서는 변경 내용 저장이 EF Core 6.0보다 최대 4배 더 빠릅니다.

이러한 개선 사항의 대부분은 다음에서 비롯됩니다.

  • 데이터베이스에 대한 더 적은 왕복 수행
  • 더 빠른 SQL 생성

이러한 개선 사항의 몇 가지 예는 아래와 같습니다.

참고 항목

이러한 변경 내용에 대한 자세한 내용은 .NET 블로그의 Entity Framework Core 7 Preview 6: Performance Edition 발표를 참조하세요.

여기에 표시된 코드는 SaveChangesPerformanceSample.cs에서 제공됩니다.

불필요한 트랜잭션이 제거됩니다.

모든 최신 관계형 데이터베이스는 (대부분의) 단일 SQL 명령문에 대한 트랜잭션성을 보장합니다. 즉, 오류가 발생하더라도 명령문은 부분적으로만 완료되지 않습니다. EF7은 이러한 경우 명시적 트랜잭션을 시작하지 않습니다.

예를 들어 SaveChanges에 대한 다음 호출에 대한 로깅을 확인합니다.

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

EF Core 6.0에서 INSERT 명령은 트랜잭션을 시작한 다음 커밋하는 명령으로 래핑됨을 보여 줍니다.

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7은 여기서 트랜잭션이 필요하지 않음을 감지하여 다음 호출을 제거합니다.

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

이렇게 하면 두 개의 데이터베이스 왕복이 제거되며, 특히 데이터베이스 호출 대기 시간이 높은 경우 전반적인 성능에 큰 차이를 만들 수 있습니다. 일반적인 프로덕션 시스템에서 데이터베이스는 애플리케이션과 동일한 컴퓨터에 공동 배치되지 않습니다. 즉, 대기 시간이 비교적 높은 경우가 많기 때문에 이러한 최적화는 실제 프로덕션 시스템에서 특히 효과적입니다.

간단한 ID 삽입을 위한 향상된 SQL

위의 경우는 IDENTITY 키 열이 있고 다른 데이터베이스 생성 값이 없는 단일 행을 삽입합니다. EF7은 OUTPUT INSERTED을(를) 사용하여 이 경우 SQL을 간소화합니다. 이 간소화는 다른 많은 경우에 유효하지는 않지만 이러한 종류의 단일 행 삽입은 많은 애플리케이션에서 매우 일반적이므로 개선해야 합니다.

여러 행 삽입

EF Core 6.0에서 여러 행을 삽입하는 기본 방법은 트리거가 있는 테이블에 대한 SQL Server 지원의 제한에 의해 주도되었습니다. 테이블의 트리거가 있는 소수의 사용자에게도 기본 환경이 작동하는지 확인하려고 했습니다. 이는 SQL Server 트리거에서 작동하지 않기 때문에 간단한 OUTPUT 절을 사용할 수 없음을 의미합니다. 대신, 여러 엔터티를 삽입할 때 EF Core 6.0은 상당히 복잡해진 SQL을 생성했습니다. 예를 들어 다음 SaveChanges을(를) 호출합니다.

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

EF Core 6.0을 사용하여 SQL Server 대해 실행할 때 다음 작업이 수행됩니다.

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Important

복잡하더라도 이와 같이 여러 삽입을 일괄 처리하면 각 삽입에 대해 단일 명령을 보내는 것보다 훨씬 빠릅니다.

EF7에서는 테이블에 트리거가 포함되어 있는 경우에도 이 SQL을 가져올 수 있지만, 일반적인 경우 여전히 다소 복잡한 경우 훨씬 더 효율적인 명령을 생성합니다.

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

MERGE은(는) 암시적 트랜잭션으로 보호되는 단일 문이므로 단일 삽입 사례에서와 같이 트랜잭션이 사라졌습니다. 또한 임시 테이블이 사라졌고 OUTPUT 절이 생성된 ID를 클라이언트로 직접 보냅니다. 애플리케이션과 데이터베이스 간의 대기 시간과 같은 환경 요인에 따라 EF Core 6.0보다 4배 더 빠를 수 있습니다.

트리거

테이블에 트리거가 있는 경우 위의 코드에서 SaveChanges을(를) 호출하면 예외가 발생합니다.

처리되지 않은 예외가 발생했습니다. Microsoft.EntityFrameworkCore.DbUpdateException:
대상 테이블에 데이터베이스 트리거가 있으므로 변경 내용을 저장할 수 없습니다. 그에 따라 엔터티 형식을 구성하세요. 자세한 내용은 https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers을(를) 참조하세요.
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
DML 문에 INTO 절 없이 OUTPUT 절만 포함되어 있을 경우 이 문의 대상 테이블 'BlogsWithTriggers'에 대해 트리거가 활성화될 수 없습니다.

다음 코드를 사용하여 테이블에 트리거가 있음을 EF Core에 알릴 수 있습니다.

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

그러면 이 테이블에 대한 삽입 및 업데이트 명령을 보낼 때 EF7이 EF Core 6.0 SQL로 되돌아갑니다.

트리거를 사용하여 매핑된 모든 테이블을 자동으로 구성하는 규칙을 비롯한 자세한 내용은 EF7 호환성이 손상되는 변경 설명서에서 트리거가 있는 SQL Server 테이블에 이제 특별한 EF Core 구성이 필요함을 참조하세요.

그래프 삽입을 위한 왕복 횟수 감소

새 보안 주체 엔터티를 포함하는 엔터티 그래프와 새 보안 주체를 참조하는 외래 키가 있는 새 종속 엔터티를 삽입하는 것이 좋습니다. 예시:

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

데이터베이스에서 보안 주체의 기본 키를 생성하는 경우 보안 주체가 삽입될 때까지 종속의 외래 키에 대해 설정할 값을 알 수 없습니다. EF Core는 두 개의 왕복을 생성합니다. 하나는 보안 주체를 삽입하고 새 기본 키를 다시 가져오는 것이고, 다른 하나는 외래 키 값이 설정된 종속성을 삽입하는 것입니다. 이에 대한 두 개의 문이 있으므로 트랜잭션이 필요합니다. 즉, 총 4개의 왕복이 있습니다.

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

그러나 경우에 따라 보안 주체를 삽입하기 전에 기본 키 값을 알 수 있습니다. 다음 내용이 포함됩니다.

  • 자동으로 생성되지 않는 키 값
  • Guid 키와 같이 클라이언트에서 생성되는 키 값
  • hi-lo 값 생성기를 사용하는 경우와 같이 서버에서 일괄 처리로 생성되는 키 값

EF7에서 이러한 경우는 이제 단일 왕복으로 최적화됩니다. 예를 들어 위의 SQL Server의 경우 hi-lo 생성 전략을 사용하도록 Blog.Id 기본 키를 구성할 수 있습니다.

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

위의 SaveChanges 호출은 이제 삽입에 대한 단일 왕복으로 최적화됩니다.

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

여기서는 트랜잭션이 여전히 필요합니다. 삽입이 두 개의 개별 테이블로 만들어지기 때문입니다.

또한 EF7은 EF Core 6.0이 둘 이상을 만드는 다른 경우에는 단일 일괄 처리를 사용합니다. 예를 들어 동일한 테이블에 행을 삭제하고 삽입하는 경우입니다.

SaveChanges의 값

여기에 나와 있는 몇 가지 예제에서 알 수 있듯이 데이터베이스에 결과를 저장하는 것은 복잡한 비즈니스일 수 있습니다. EF Core와 같은 항목을 사용하면 실제로 그 값이 표시됩니다. EF Core:

  • 여러 삽입, 업데이트 및 삭제 명령을 함께 일괄 처리하여 왕복을 줄입니다.
  • 명시적 트랜잭션이 필요한지 여부를 파악합니다.
  • 데이터베이스 제약 조건을 위반하지 않도록 엔터티를 삽입, 업데이트 및 삭제할 순서를 결정합니다.
  • 데이터베이스에서 생성된 값이 효율적으로 반환되고 엔터티로 다시 전파되도록 합니다.
  • 기본 키에 대해 생성된 값을 사용하여 외래 키 값을 자동으로 설정합니다.
  • 동시성 충돌 검색

또한 여러 데이터베이스 시스템에는 이러한 많은 경우에 서로 다른 SQL이 필요합니다. EF Core 데이터베이스 공급자는 EF Core와 함께 작동하여 각 사례에 대해 올바르고 효율적인 명령이 전송되도록 합니다.

TPC(구체적 형식당 테이블) 상속 매핑

기본적으로 EF Core는 .NET 형식의 상속 계층 구조를 단일 데이터베이스 테이블에 매핑합니다. 이를 TPH(테이블 단위 계층) 매핑 전략이라고 합니다. EF Core 5.0에서는 각 .NET 형식을 다른 데이터베이스 테이블에 매핑하는 것을 지원하는 TPT(형식당 하나의 테이블) 전략을 도입했습니다. EF7은 TPC(구체적 형식당 테이블) 전략을 도입합니다. 또한 TPC는 .NET 형식을 다른 테이블에 매핑하지만 TPT 전략과 관련된 몇 가지 일반적인 성능 문제를 해결하는 방식으로 매핑합니다.

여기에 표시된 코드는 TpcInheritanceSample.cs에서 제공됩니다.

EF 팀은 .NET Data Community Standup의 에피소드에서 TPC 매핑에 대해 자세히 설명하고 이야기했습니다. 모든 Community Standup 에피소드와 마찬가지로 이제 YouTube에서 TPC 에피소드를 볼 수 있습니다.

TPC 데이터베이스 스키마

TPC 전략은 TPT 전략과 비슷하지만, 계층 구조의 모든 구체적인 형식에 대해 다른 테이블이 생성되지만 추상 형식에 대해 테이블이 만들어지지 않으므로 "구체적 형식당 테이블"이라는 이름이 있습니다. TPT와 마찬가지로 테이블 자체는 저장된 개체의 형식을 나타냅니다. 그러나 TPT 매핑과 달리 각 테이블에는 구체적인 형식 및 기본 형식의 모든 속성에 대한 열이 포함됩니다. TPC 데이터베이스 스키마는 비정규화됩니다.

예를 들어 다음 계층 구조를 매핑한다고 가정해 보겠습니다.

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

SQL Server를 사용하는 경우 이 계층 구조에 대해 만들어진 테이블은 다음과 같습니다.

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

다음에 유의합니다.

  • 개체 모델에 abstract이(가) 있으므로 Animal 또는 Pet 형식에 대한 테이블이 없습니다. C#에서는 추상 형식의 인스턴스를 허용하지 않으므로 추상 형식 인스턴스가 데이터베이스에 저장되는 상황은 없습니다.

  • 기본 형식의 속성 매핑은 각 구체적인 형식에 대해 반복됩니다. 예를 들어 모든 테이블에는 Name 열이 있고 고양이와 개에는 모두 Vet 열이 있습니다.

  • 이 데이터베이스에 일부 데이터를 저장하면 다음과 같은 결과가 발생합니다.

고양이 테이블

ID 속성 FoodId 동물병원 EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly 유치원
8 백스터 5dc5019e-6f72-454b-d4b0-08da7aca624f Bothell 애완 동물 병원 BSc

개 테이블

ID 속성 FoodId 동물병원 FavoriteToy
3 알림 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly 다람쥐 씨

FarmAnimals 테이블

ID 속성 FoodId
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 당나귀

인간 테이블

ID 속성 FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie null 8

TPT 매핑과 달리 단일 개체에 대한 모든 정보는 단일 테이블에 포함됩니다. 또한 TPH 매핑과 달리 모델에서 사용되지 않는 테이블에는 열과 행의 조합이 없습니다. 이러한 특성이 쿼리 및 스토리지에 어떻게 중요할 수 있는지 아래에서 살펴보겠습니다.

TPC 상속 구성

계층 구조를 EF Core와 매핑할 때 상속 계층 구조의 모든 형식을 모델에 명시적으로 포함해야 합니다. 이 작업은 모든 형식에 대해 DbContextDbSet 속성을 만들어 수행할 수 있습니다.

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

또는 OnModelCreating에서 Entity 메서드를 사용합니다.

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

Important

이는 동일한 어셈블리에 포함된 경우 매핑된 기본 형식의 파생 형식이 자동으로 발견되는 레거시 EF6 동작과 다릅니다.

기본 전략이기 때문에 계층 구조를 TPH로 매핑하기 위해 수행할 다른 작업은 없습니다. 그러나 EF7부터 계층 구조의 기본 형식에서 UseTphMappingStrategy을(를) 호출하여 TPH를 명시적으로 만들 수 있습니다.

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

대신 TPT를 사용하려면 UseTptMappingStrategy(으)로 변경합니다.

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

마찬가지로 UseTpcMappingStrategy은(는) TPC를 구성하는 데 사용됩니다.

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

각 경우에 각 형식에 사용되는 테이블 이름은 DbContextDbSet 속성 이름에서 가져오거나 ToTable 작성기 메서드 또는 [Table] 특성을 사용하여 구성할 수 있습니다.

TPC 쿼리 성능

쿼리의 경우 TPC 전략은 지정된 엔터티 인스턴스에 대한 정보가 항상 단일 테이블에 저장되도록 하기 때문에 TPT보다 개선되었습니다. 즉, 매핑된 계층 구조가 크고 각각 많은 수의 속성이 있는 많은 구체적인(일반적으로 리프) 형식이 있고 대부분의 쿼리에서 작은 형식 하위 집합만 사용되는 경우 TPC 전략이 유용할 수 있습니다.

세 개의 간단한 LINQ 쿼리에 대해 생성된 SQL을 사용하여 TPH 및 TPT와 비교할 때 TPC가 잘 수행되는 위치를 관찰할 수 있습니다. 이러한 쿼리는 다음과 같습니다.

  1. 쿼리는 계층 구조에 있는 모든 형식의 엔터티를 반환합니다.

    context.Animals.ToList();
    
  2. 쿼리는 계층 구조의 형식 하위 집합에서 엔터티를 반환합니다.

    context.Pets.ToList();
    
  3. 쿼리는 계층 구조의 단일 리프 형식에서 엔터티만 반환합니다.

    context.Cats.ToList();
    

TPH 쿼리

TPH를 사용하는 경우 세 쿼리는 모두 단일 테이블만 쿼리하지만 판별자 열에 다른 필터를 사용합니다.

  1. TPH SQL은 계층 구조에 있는 모든 형식의 엔터티를 반환합니다.

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL은 계층 구조의 형식 하위 집합에서 엔터티를 반환합니다.

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL은 계층 구조의 단일 리프 형식에서 엔터티만 반환합니다.

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

이러한 모든 쿼리는 특히 판별자 열에 적절한 데이터베이스 인덱스를 사용하여 잘 수행되어야 합니다.

TPT 쿼리

TPT를 사용하는 경우 지정된 구체적인 형식에 대한 데이터가 여러 테이블에 분할되므로 이러한 모든 쿼리는 여러 테이블을 조인해야 합니다.

  1. TPT SQL은 계층 구조에 있는 모든 형식의 엔터티를 반환합니다.

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL은 계층 구조의 형식 하위 집합에서 엔터티를 반환합니다.

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL은 계층 구조의 단일 리프 형식에서 엔터티만 반환합니다.

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

참고 항목

EF Core는 "판별자 합성"을 사용하여 데이터가 제공되는 테이블을 결정하므로 사용할 올바른 형식을 결정합니다. LEFT JOIN이 올바른 형식이 아닌 종속 ID 열("하위 테이블")에 대해 null을 반환하기 때문에 작동합니다. 따라서 개의 경우 [d].[Id]은(는) null이 아니고 다른 모든 (구체적) ID는 null이 됩니다.

이러한 모든 쿼리는 테이블 조인으로 인해 성능 문제가 발생할 수 있습니다. 이것이 TPT가 쿼리 성능에 적합한 선택이 되지 않는 이유입니다.

TPC 쿼리

TPC는 쿼리해야 하는 테이블 수가 줄어들기 때문에 이러한 모든 쿼리에 대해 TPT를 개선합니다. 또한 각 테이블의 결과는 행 간 일치 또는 행 중복 제거를 수행할 필요가 없으므로 테이블 조인보다 훨씬 빠를 수 있는 UNION ALL을(를) 사용하여 결합됩니다.

  1. TPC SQL은 계층 구조에 있는 모든 형식의 엔터티를 반환합니다.

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL은 계층 구조의 형식 하위 집합에서 엔터티를 반환합니다.

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL은 계층 구조의 단일 리프 형식에서 엔터티만 반환합니다.

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

TPC가 이러한 모든 쿼리의 TPT보다 낫지만 여러 형식의 인스턴스를 반환할 때 TPH 쿼리는 여전히 더 좋습니다. 이것이 TPH가 EF Core에서 사용하는 기본 전략인 이유 중 하나입니다.

쿼리 #3에 대한 SQL에서 알 수 있듯이 TPC는 단일 리프 형식의 엔터티를 쿼리할 때 실제로 탁월합니다. 쿼리는 단일 테이블만 사용하며 필터링이 필요하지 않습니다.

TPC 삽입 및 업데이트

또한 TPC는 단일 테이블에 단일 행만 삽입해야 하므로 새 엔터티를 저장할 때도 잘 수행됩니다. TPH의 경우에도 마찬가지입니다. TPT를 사용하면 많은 테이블에 행을 삽입해야 하므로 성능이 떨어지게 됩니다.

업데이트되는 모든 열이 TPT의 경우에도 동일한 테이블에 있는 경우 차이가 크지 않을 수 있지만 업데이트의 경우도 마찬가지입니다.

공간 고려 사항

자주 사용되지 않는 속성이 많은 하위 유형이 많은 경우 TPT와 TPC 모두 TPH보다 적은 스토리지를 사용할 수 있습니다. 이는 TPH 테이블의 모든 행이 사용되지 않는 각 속성에 대해 NULL을(를) 저장해야 하기 때문입니다. 실제로는 거의 문제가 되지 않지만 이러한 특성으로 많은 양의 데이터를 저장할 때는 고려할 필요가 있습니다.

데이터베이스 시스템에서 지원하는 경우(예: SQL Server) 거의 채워지지 않는 TPH 열에 "스파스 열"을 사용하는 것이 좋습니다.

키 생성

선택한 상속 매핑 전략은 기본 키 값이 생성되고 관리되는 방식에 영향을 줍니다. TPH의 키는 각 엔터티 인스턴스가 단일 테이블의 단일 행으로 표현되기 때문에 쉽습니다. 모든 종류의 키 값 생성을 사용할 수 있으며 추가 제약 조건이 필요하지 않습니다.

TPT 전략의 경우 계층 구조의 기본 형식에 매핑된 테이블의 행이 항상 있습니다. 이 행에서 모든 종류의 키 생성을 사용할 수 있으며 다른 테이블의 키는 외래 키 제약 조건을 사용하여 이 테이블에 연결됩니다.

TPC의 경우 상황이 좀 더 복잡해집니다. 첫째, EF Core를 사용하려면 엔터티의 형식이 다르더라도 계층 구조의 모든 엔터티에 고유한 키 값이 있어야 한다는 것을 이해하는 것이 중요합니다. 따라서 예제 모델을 사용하면 개가 고양이와 동일한 ID 키 값을 가질 수 없습니다. 둘째, TPT와 달리 키 값이 있고 생성될 수 있는 단일 위치 역할을 할 수 있는 공통 테이블은 없습니다. 즉, 단순 Identity 열을 사용할 수 없습니다.

시퀀스를 지원하는 데이터베이스의 경우 각 테이블에 대한 기본 제약 조건에서 참조되는 단일 시퀀스를 사용하여 키 값을 생성할 수 있습니다. 이는 위에 표시된 TPC 테이블에서 사용되는 전략이며 각 테이블에는 다음이 있습니다.

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence은(는) EF Core에서 만든 데이터베이스 시퀀스입니다. 이 전략은 SQL Server를 위해 EF Core 데이터베이스 공급자를 사용할 때 TPC 계층 구조에 기본적으로 사용됩니다. 시퀀스를 지원하는 다른 데이터베이스의 데이터베이스 공급자도 비슷한 기본값을 가져야 합니다. Hi-Lo 패턴과 같은 시퀀스를 사용하는 다른 키 생성 전략도 TPC와 함께 사용할 수 있습니다.

표준 ID 열은 TPC에서 작동하지 않지만 각 테이블에 대해 생성된 값이 충돌하지 않도록 각 테이블이 적절한 시드 및 증분으로 구성된 경우 ID 열을 사용할 수 있습니다. 예시:

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

SQLite는 시퀀스 또는 ID 시드/증분을 지원하지 않으므로 TPC 전략과 함께 SQLite를 사용할 때 정수 키 값 생성이 지원되지 않습니다. 그러나 클라이언트 쪽 생성 또는 전역적으로 고유한 키(예: GUID 키)는 SQLite를 비롯한 모든 데이터베이스에서 지원됩니다.

외래 키 제약 조건

TPC 매핑 전략은 비정규화된 SQL 스키마를 만듭니다. 이것이 일부 데이터베이스 순수주의자가 이에 반대하는 이유 중 하나입니다. 예를 들어 외래 키 열 FavoriteAnimalId을(를) 고려합니다. 이 열의 값은 일부 동물의 기본 키 값과 일치해야 합니다. TPH 또는 TPT를 사용할 때 간단한 FK 제약 조건을 사용하여 데이터베이스에 적용할 수 있습니다. 예시:

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

그러나 TPC를 사용할 때 동물의 기본 키는 해당 동물의 구체적인 유형에 대한 테이블에 저장됩니다. 예를 들어 고양이의 기본 키는 Cats.Id 열에 저장되고, 강아지의 기본 키는 Dogs.Id 열에 저장됩니다. 즉, 이 관계에 대해 FK 제약 조건을 만들 수 없습니다.

실제로 애플리케이션이 잘못된 데이터를 삽입하려고 시도하지 않는 한 문제가 되지 않습니다. 예를 들어 모든 데이터가 EF Core에 의해 삽입되고 탐색을 사용하여 엔터티를 연결하는 경우 FK 열에 항상 유효한 PK 값이 포함됩니다.

요약 및 참고 자료

요약하자면, TPC는 코드가 주로 단일 리프 형식의 엔터티를 쿼리할 때 사용할 수 있는 좋은 매핑 전략입니다. 스토리지 요구 사항이 더 작고 인덱스가 필요할 수 있는 판별자 열이 없기 때문입니다. 삽입 및 업데이트도 효율적입니다.

즉, TPH는 일반적으로 대부분의 애플리케이션에 적합하며 광범위한 시나리오에 적합한 기본값이므로 필요하지 않은 경우 TPC의 복잡성을 추가하지 마세요. 특히 코드가 주로 기본 형식에 대한 쿼리 작성과 같은 많은 형식의 엔터티를 쿼리하는 경우 TPC를 통해 TPH쪽으로 기울어 줍니다.

외부 요인에 의해 제한되는 경우에만 TPT를 사용합니다.

사용자 지정 리버스 엔지니어링 템플릿

이제 데이터베이스에서 EF 모델을 리버스 엔지니어링할 때 스캐폴드된 코드를 사용자 지정할 수 있습니다. 프로젝트에 기본 템플릿을 추가하여 시작합니다.

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

그런 다음 템플릿을 사용자 지정할 수 있으며 dotnet ef dbcontext scaffoldScaffold-DbContext에서 자동으로 사용됩니다.

자세한 내용은 사용자 지정 리버스 엔지니어링 템플릿을 참조하세요.

EF 팀은 .NET Data Community Standup의 에피소드에서 리버스 엔지니어링 템플릿에 대해 자세히 설명하고 이야기했습니다. 모든 Community Standup 에피소드와 마찬가지로 이제 YouTube에서 T4 템플릿 에피소드를 볼 수 있습니다.

모델 빌드 규칙

EF Core는 메타데이터 “모델”을 사용하여 애플리케이션의 엔터티 형식이 기본 데이터베이스에 매핑되는 방법을 설명합니다. 이 모델은 약 60개의 "규칙" 집합을 사용하여 빌드됩니다. 그런 다음, 규칙에 따라 빌드된 모델은 매핑 특성(즉, "데이터 주석") 및/또는 DbModelBuilderOnModelCreating API 호출을 사용하여 사용자 지정할 수 있습니다.

EF7부터 애플리케이션은 이제 이러한 규칙을 제거하거나 대체할 수 있으며 새 규칙을 추가할 수 있습니다. 모델 빌드 규칙은 모델 구성을 제어하는 강력한 방법이지만 복잡하고 제대로 하기 어려울 수 있습니다. 대부분의 경우 기존 사전 규칙 모델 구성을 대신 사용하여 속성 및 형식에 대한 공통 구성을 쉽게 지정할 수 있습니다.

DbContext에서 사용하는 규칙에 대한 변경 내용은 DbContext.ConfigureConventions 메서드를 재정의하여 이루어집니다. 예시:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

모든 기본 제공 모델 빌드 규칙을 찾으려면 IConvention 인터페이스를 구현하는 모든 클래스를 찾습니다.

여기에 표시된 코드는 ModelBuildingConventionsSample.cs에서 제공됩니다.

기존 규칙 제거

경우에 따라 기본 제공 규칙 중 하나가 애플리케이션에 적합하지 않을 수 있습니다. 이 경우 제거할 수 있습니다.

예: 외래 키 열에 대한 인덱스를 만들지 마세요.

일반적으로 FK(외래 키) 열에 대한 인덱스를 만드는 것이 합리적이므로 다음의 ForeignKeyIndexConvention과 같은 기본 제공 규칙이 있습니다. BlogAuthor에 대한 관계가 있는 Post 엔터티 형식에 대한 모델 디버그 보기를 보면 두 개의 인덱스가 만들어진 것을 볼 수 있습니다. 하나는 BlogId FK용이고 다른 하나는 AuthorId FK용입니다.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

그러나 인덱스에는 오버헤드가 있으며 여기에 설명된 대로 모든 FK 열에 대해 인덱스를 만드는 것이 항상 적절하지는 않을 수 있습니다. 이를 위해 ForeignKeyIndexConvention은 모델을 빌드할 때 제거할 수 있습니다.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Post 현재 모델의 디버그 보기를 살펴보면 FK의 인덱스가 만들어지지 않은 것을 볼 수 있습니다.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

원하는 경우 IndexAttribute을(를) 사용하여 외래 키 열에 대해 인덱스를 명시적으로 만들 수 있습니다.

[Index("BlogId")]
public class Post
{
    // ...
}

또는 OnModelCreating의 구성을 사용하여 다음을 수행합니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

Post 엔터티 형식을 다시 살펴보면 이제 BlogId 인덱스가 포함되지만 AuthorId 인덱스는 포함되지 않습니다.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

모델에서 매핑 특성(즉, 데이터 주석)을 구성에 사용하지 않는 경우 AttributeConvention(으)로 끝나는 모든 규칙을 안전하게 제거하여 모델 빌드 속도를 높일 수 있습니다.

새 규칙 추가

기존 규칙을 제거하는 것은 시작이지만 완전히 새로운 모델 빌드 규칙을 추가하는 것은 어떨까요? EF7도 지원합니다!

예: 판별자 속성의 길이 제한

계층당 하나의 테이블 상속 매핑 전략에는 지정된 행에 표시되는 형식을 지정하기 위해 판별자 열이 필요합니다. 기본적으로 EF는 판별자용으로 바인딩되지 않은 문자열 열을 사용하므로 모든 판별자 길이에 대해 작동합니다. 그러나 판별자 문자열의 최대 길이를 제한하면 스토리지 및 쿼리의 효율성을 높일 수 있습니다. 그렇게 할 새 규칙을 만들어 보겠습니다.

EF Core 모델 빌드 규칙은 빌드할 때 모델의 변경 내용에 따라 트리거됩니다. 이렇게 하면 명시적 구성이 이루어지고, 매핑 특성이 적용되고, 다른 규칙이 실행되므로 모델을 최신 상태로 유지합니다. 이에 참여하기 위해 모든 규칙은 규칙이 트리거되는 시기를 결정하는 하나 이상의 인터페이스를 구현합니다. 예를 들어 새 엔터티 형식이 모델에 추가될 때마다 IEntityTypeAddedConvention을(를) 구현하는 규칙이 트리거됩니다. 마찬가지로 키 또는 외래 키가 모델에 추가될 때마다 IForeignKeyAddedConventionIKeyAddedConvention을(를) 모두 구현하는 규칙이 트리거됩니다.

한 지점에서 모델에 대한 구성이 나중에 변경되거나 제거될 수 있으므로 구현할 인터페이스를 아는 것은 까다로울 수 있습니다. 예를 들어 규칙에 따라 키를 만들 수 있지만 나중에 다른 키를 명시적으로 구성할 때 바뀝니다.

판별자 길이 규칙을 처음 구현하여 좀 더 구체적으로 만들어 보겠습니다.

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

이 규칙은 IEntityTypeBaseTypeChangedConvention을(를) 구현합니다. 즉, 엔터티 형식에 대한 매핑된 상속 계층 구조가 변경될 때마다 트리거됩니다. 그런 다음 규칙은 계층 구조에 대한 문자열 판별자 속성을 찾아 구성합니다.

이 규칙은 ConfigureConventions에서 Add을(를) 호출하여 사용됩니다.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

규칙의 인스턴스를 직접 추가하는 대신 Add 메서드는 규칙의 인스턴스를 만들기 위한 센터를 허용합니다. 이를 통해 규칙은 EF Core 내부 서비스 공급자의 종속성을 사용할 수 있습니다. 이 규칙에는 종속성이 없으므로 서비스 공급자 매개 변수의 이름은 _(이)고, 이는 사용되지 않음을 나타냅니다.

모델을 빌드하고 Post 엔터티 형식을 살펴보면 이것이 작동했음을 보이며 판별자 속성은 이제 최대 길이가 24로 구성됩니다.

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

그러나 이제 다른 판별자 속성을 명시적으로 구성하면 어떻게 될까요? 예시:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

모델의 디버그 보기를 살펴보면 판별자 길이가 더 이상 구성되지 않습니다.

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

이는 사용자 지정 판별자를 추가할 때 규칙에서 구성한 판별자 속성이 나중에 제거되었기 때문입니다. 판별자 변경에 대응하기 위해 규칙에 다른 인터페이스를 구현하여 이 문제를 해결하려고 시도할 수 있지만 구현할 인터페이스를 파악하는 것은 쉽지 않습니다.

다행히도, 훨씬 쉽게 접근하는 다른 방법이 있다. 많은 시간, 최종 모델이 올바른 한, 모델을 빌드하는 동안 모델이 어떻게 생겼는지는 중요하지 않습니다. 또한 적용하려는 구성은 대응하기 위해 다른 규칙을 트리거할 필요가 없는 경우가 많습니다. 따라서 규칙이 IModelFinalizingConvention을(를) 구현할 수 있습니다. 모델 종료 규칙은 다른 모든 모델 빌드가 완료된 후에 실행되므로 모델의 최종 상태에 액세스할 수 있습니다. 모델 종료 규칙은 일반적으로 모델 요소를 구성하는 전체 모델에서 반복됩니다. 따라서 이 경우 모델에서 모든 판별자를 찾아서 구성합니다.

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

이 새 규칙을 사용하여 모델을 빌드한 후에는 사용자 지정된 경우에도 판별자 길이가 올바르게 구성되었음을 알게 되었습니다.

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

재미를 위해 한 단계 더 나아가 최대 길이를 가장 긴 판별자 값의 길이로 구성해 보겠습니다.

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

이제 판별자 열 최대 길이는 8이며, 이는 사용 중인 가장 긴 판별자 값인 "Featured"의 길이입니다.

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

규칙에서 판별자 열에 대한 인덱스도 만들어야 하는지 궁금할 수 있습니다. GitHub에서 이에 대한 논의가 있습니다. 짧은 대답은 경우에 따라 인덱스가 유용할 수 있지만 대부분의 경우 그렇지 않을 수 있다는 것입니다. 따라서 항상 수행하는 규칙이 있는 대신 필요에 따라 여기에 적절한 인덱스를 만드는 것이 가장 좋습니다. 그러나 이에 동의하지 않으면 위의 규칙을 쉽게 수정하여 인덱스도 만들 수 있습니다.

예: 모든 문자열 속성의 기본 길이

마무리 규칙을 사용할 수 있는 또 다른 예제를 살펴보겠습니다. 이번에는 GitHub에서 요청한 대로 모든 문자열 속성에 대한 기본 최대 길이를 설정합니다. 규칙은 이전 예제와 매우 유사합니다.

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

이 규칙은 매우 간단합니다. 모델의 모든 문자열 속성을 찾아 최대 길이를 512로 설정합니다. Post의 속성에서 디버그 뷰를 보면 이제 모든 문자열 속성의 최대 길이가 512인 것을 볼 수 있습니다.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

그러나 Content 속성은 아마 512개 이상의 문자를 허용해야 하거나 모든 게시물은 매우 짧을 것입니다! 매핑 특성을 사용하여 이 속성의 최대 길이만 명시적으로 구성하여 규칙을 변경하지 않고 이 작업을 수행할 수 있습니다.

[MaxLength(4000)]
public string Content { get; set; }

또는 OnModelCreating의 코드가 있는 경우

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

이제 4000으로 명시적으로 구성된 Content 속성을 제외하고 모든 속성의 최대 길이는 512입니다.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

그렇다면 왜 우리의 규칙은 명시적으로 구성된 최대 길이를 재정의하지 않았을까요? 대답은 EF Core가 모든 구성이 어떻게 만들어졌는지 추적한다는 것입니다. ConfigurationSource 열거형으로 표시됩니다. 다양한 종류의 구성은 다음과 같습니다.

  • Explicit: 모델 요소가 OnModelCreating에서 명시적으로 구성되었습니다.
  • DataAnnotation: 모델 요소는 CLR 형식의 매핑 특성(즉, 데이터 주석)을 사용하여 구성되었습니다.
  • Convention: 모델 요소가 모델 빌드 규칙에 의해 구성되었습니다.

규칙은 DataAnnotation 또는 Explicit(으)로 표시된 구성을 재정의하지 않습니다. 이는 Builder 속성에서 가져온 "규칙 작성기"(예: IConventionPropertyBuilder)를 사용하여 수행됩니다. 예시:

property.Builder.HasMaxLength(512);

규칙 작성기에서 HasMaxLength을(를) 호출하면 매핑 특성 또는 OnModelCreating에서 아직 구성되지 않은 경우에만 최대 길이가 설정됩니다.

이와 같은 작성기 메서드에는 두 번째 매개 변수 fromDataAnnotation도 있습니다. 규칙이 매핑 특성을 대신하여 구성을 만드는 경우 true(으)로 설정합니다. 예시:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

이렇게 하면 ConfigurationSource이(가) DataAnnotation(으)로 설정됩니다. 즉, 이제 OnModelCreating에 대한 명시적 매핑을 통해 값을 재정의할 수 있지만 매핑이 아닌 특성 규칙에 의해 재정의될 수 있습니다.

마지막으로 이 예제를 나가기 전에 MaxStringLengthConventionDiscriminatorLengthConvention3을(를) 동시에 사용하면 어떻게 되나요? 그 대답은 모델 마무리 규칙이 추가된 순서대로 실행되기 때문에 추가되는 순서에 따라 달라집니다. 따라서 MaxStringLengthConvention이(가) 마지막으로 추가되면 마지막으로 실행되고 판별자 속성의 최대 길이가 512로 설정됩니다. 따라서 이 경우 다른 모든 문자열 속성을 512로 유지하면서 판별자 속성에 대한 기본 최대 길이를 재정의할 수 있도록 DiscriminatorLengthConvention3을(를) 마지막으로 추가하는 것이 좋습니다.

기존 규칙 바꾸기

때로는 기존 규칙을 완전히 제거하는 대신 기본적으로 동일한 작업을 수행하지만 동작이 변경된 규칙으로 대체하려고 합니다. 이는 기존 규칙이 적절하게 트리거되도록 필요한 인터페이스를 이미 구현하기 때문에 유용합니다.

예: 옵트인 속성 매핑

EF Core는 모든 공용 읽기-쓰기 속성을 규칙에 따라 매핑합니다. 엔터티 형식이 정의되는 방식에는 적합하지 않을 수 있습니다. 이를 변경하려면 OnModelCreating에 명시적으로 매핑되거나 Persist(이)라는 새 특성으로 표시되지 않는 한 속성을 매핑하지 않는 고유한 구현으로 PropertyDiscoveryConvention을(를) 바꿀 수 있습니다.

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

새 규칙은 다음과 같습니다.

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

기본 제공 규칙을 바꿀 때 새 규칙 구현은 기존 규칙 클래스에서 상속되어야 합니다. 일부 규칙에는 관계형 또는 공급자별 구현이 있으며, 이 경우 새 규칙 구현은 사용 중인 데이터베이스 공급자에 대한 가장 구체적인 기존 규칙 클래스에서 상속되어야 합니다.

그런 다음 ConfigureConventionsReplace 메서드를 사용하여 규칙이 등록됩니다.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

기존 규칙에 ProviderConventionSetBuilderDependencies 종속성 개체가 나타내는 종속성이 있는 경우입니다. 이러한 항목은 GetRequiredService을(를) 사용하여 내부 서비스 공급자에서 가져오고 규칙 생성자에 전달됩니다.

이 규칙은 지정된 엔터티 형식에서 읽을 수 있는 모든 속성과 필드를 가져오는 것으로 작동합니다. 멤버가 [Persist](으)로 특성이 지정된 경우 다음을 호출하여 매핑됩니다.

entityTypeBuilder.Property(memberInfo);

반면에 멤버가 매핑된 속성이면 다음을 사용하여 모델에서 제외됩니다.

entityTypeBuilder.Ignore(propertyInfo.Name);

이 규칙을 사용하면 필드가 [Persist](으)로 표시되는 한 속성 외에 매핑할 수 있습니다. 즉, 모델에서 프라이빗 필드를 숨겨진 키로 사용할 수 있습니다.

예를 들어 다음 엔터티 형식을 살펴봅니다.

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

이러한 엔터티 형식에서 빌드된 모델은 다음과 같습니다.

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

일반적으로 IsClean은(는) 매핑되었지만 (청결이 세탁의 영구적 속성이 아니기 때문에) [Perist](으)로 표시되지 않으므로 이제 매핑되지 않은 속성으로 처리됩니다.

속성 매핑은 매핑된 속성을 추가로 구성하기 위해 실행할 다른 많은 규칙을 트리거하기 때문에 이 규칙을 모델 종료 규칙으로 구현할 수 없습니다.

저장 프로시저 매핑

기본적으로 EF Core는 테이블 또는 업데이트 가능한 뷰에서 직접 작동하는 삽입, 업데이트 및 삭제 명령을 생성합니다. EF7에서는 이러한 명령을 저장 프로시저에 매핑할 수 있도록 지원합니다.

EF Core는 항상 저장 프로시저를 통한 쿼리를 지원합니다. EF7의 새로운 지원은 삽입, 업데이트 및 삭제에 저장 프로시저를 사용하는 것에 대해 명시적으로 지원됩니다.

Important

저장 프로시저 매핑을 지원한다고 해서 저장 프로시저가 권장되는 것은 아닙니다.

저장 프로시저는 InsertUsingStoredProcedure, UpdateUsingStoredProcedureDeleteUsingStoredProcedure을(를) 사용하여 OnModelCreating에 매핑됩니다. 예를 들어 Person 엔터티 형식에 대한 저장 프로시저를 매핑하려면 다음을 수행합니다.

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

이 구성은 SQL Server를 사용할 때 다음 저장 프로시저에 매핑됩니다.

삽입의 경우

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

업데이트의 경우

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

삭제의 경우

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

저장 프로시저는 모델의 모든 형식 또는 지정된 형식의 모든 작업에 사용할 필요가 없습니다. 예를 들어 지정된 형식에 대해 DeleteUsingStoredProcedure만이 지정된 경우 EF Core는 삽입 및 업데이트 작업에 대해 정상적으로 SQL을 생성하고 삭제에는 저장 프로시저만 사용합니다.

각 메서드에 전달된 첫 번째 인수는 저장 프로시저 이름입니다. 생략할 수 있습니다. 이 경우 EF Core는 "_Insert", "_Update" 또는 "_Delete"가 추가된 테이블 이름을 사용합니다. 따라서 위의 예제에서 테이블을 "People"이라고 하므로 기능을 변경하지 않고 저장 프로시저 이름을 제거할 수 있습니다.

두 번째 인수는 매개 변수, 반환 값 및 결과 열을 포함하여 저장 프로시저의 입력 및 출력을 구성하는 데 사용되는 작성기입니다.

매개 변수

매개 변수는 저장 프로시저 정의에 표시되는 순서와 동일한 순서로 작성기에 추가해야 합니다.

참고 항목

매개 변수의 이름은 지정할 수 있지만 EF Core는 항상 명명된 인수가 아닌 위치 인수를 사용하여 저장 프로시저를 호출합니다. 이름으로 호출하는 것이 관심 있는 경우 호출에 매개 변수 이름을 사용하도록 스프록 매핑 구성 허용에 투표합니다.

각 매개 변수 작성기 메서드에 대한 첫 번째 인수는 매개 변수가 바인딩된 모델의 속성을 지정합니다. 람다 식일 수 있습니다.

storedProcedureBuilder.HasParameter(a => a.Name);

또는 섀도 속성을 매핑할 때 특히 유용한 문자열입니다.

storedProcedureBuilder.HasParameter("Name");

매개 변수는 기본적으로 "input"에 대해 구성됩니다. 중첩된 작성기에서 "Output" 또는 "input/output" 매개 변수를 구성할 수 있습니다. 예시:

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

매개 변수의 다양한 버전에 대한 세 가지 작성기 메서드가 있습니다.

  • HasParameter은(는) 지정된 속성의 현재 값에 바인딩된 일반 매개 변수를 지정합니다.
  • HasOriginalValueParameter은(는) 지정된 속성의 원래 값에 바인딩된 매개 변수를 지정합니다. 원래 값은 알려진 경우 데이터베이스에서 쿼리할 때 속성이 가진 값입니다. 이 값을 알 수 없는 경우 현재 값이 대신 사용됩니다. 원래 값 매개 변수는 동시성 토큰에 유용합니다.
  • HasRowsAffectedParameter은(는) 저장 프로시저의 영향을 받는 행 수를 반환하는 데 사용되는 매개 변수를 지정합니다.

원래 값 매개 변수는 "update" 및 "delete" 저장 프로시저의 키 값에 사용해야 합니다. 이렇게 하면 변경 가능한 키 값을 지원하는 EF Core의 이후 버전에서 올바른 행이 업데이트됩니다.

값 반환

EF Core는 저장 프로시저에서 값을 반환하는 세 가지 메커니즘을 지원합니다.

  • 위와 같이 출력 매개 변수입니다.
  • HasResultColumn 작성기 메서드를 사용하여 지정된 결과 열입니다.
  • 반환 값은 영향을 받는 행 수를 반환하는 것으로 제한되며 HasRowsAffectedReturnValue 작성기 메서드를 사용하여 지정됩니다.

저장 프로시저에서 반환되는 값은 Identity 키 또는 계산 열과 같이 생성된, 기본값 또는 계산된 값에 자주 사용됩니다. 예를 들어 다음 구성은 네 개의 결과 열을 지정합니다.

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

다음을 반환하는 데 사용됩니다.

  • Id 속성에 대해 생성된 키 값입니다.
  • FirstRecordedOn 속성에 대한 데이터베이스에서 생성된 기본값입니다.
  • RetrievedOn 속성에 대한 데이터베이스에서 생성된 계산 값입니다.
  • RowVersion 속성에 대해 자동으로 생성된 rowversion 동시성 토큰입니다.

이 구성은 SQL Server를 사용할 때 다음 저장 프로시저에 매핑됩니다.

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

낙관적 동시성

낙관적 동시성은 저장 프로시저를 사용하지 않을 때와 마찬가지로 저장 프로시저와 동일하게 작동합니다. 저장 프로시저는 다음을 수행해야 합니다.

  • WHERE 절에서 동시성 토큰을 사용하여 유효한 토큰이 있는 경우에만 행이 업데이트되도록 합니다. 동시성 토큰에 사용되는 값은 일반적으로 동시성 토큰 속성의 원래 값일 필요는 없습니다.
  • 영향을 받는 행 수를 반환하여 EF Core가 영향을 받는 예상 행 수와 비교하고 값이 일치하지 않으면 DbUpdateConcurrencyException이(가) 발생할 수 있습니다.

예를 들어 다음 SQL Server 저장 프로시저는 rowversion 자동 동시성 토큰을 사용합니다.

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

다음을 사용하여 EF Core에서 구성됩니다.

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

다음에 유의합니다.

  • RowVersion 동시성 토큰의 원래 값이 사용됩니다.
  • 저장 프로시저는 WHERE 절을 사용하여 RowVersion 원래 값이 일치하는 경우에만 행이 업데이트되도록 합니다.
  • RowVersion에 대해 생성된 새 값이 임시 테이블에 삽입됩니다.
  • 영향을 받는 행 수(@@ROWCOUNT)와 생성된 RowVersion 값이 반환됩니다.

상속 계층 구조를 저장 프로시저에 매핑

EF Core를 사용하려면 저장 프로시저가 계층 구조의 형식에 대한 표 레이아웃을 따라야 합니다. 이는 다음을 의미합니다.

  • TPH를 사용하여 매핑된 계층 구조에는 매핑된 단일 테이블을 대상으로 하는 단일 삽입, 업데이트 및/또는 삭제 저장 프로시저가 있어야 합니다. 삽입 및 업데이트 저장 프로시저에는 판별자 값에 대한 매개 변수가 있어야 합니다.
  • TPT를 사용하여 매핑된 계층 구조에는 추상 형식을 비롯한 모든 형식에 대한 삽입, 업데이트 및/또는 삭제 저장 프로시저가 있어야 합니다. EF Core는 필요에 따라 모든 테이블에서 행을 업데이트, 삽입 및 삭제하기 위해 여러 차례 호출합니다.
  • TPC를 사용하여 매핑된 계층 구조에는 모든 구체적인 형식에 대한 삽입, 업데이트 및/또는 삭제 저장 프로시저가 있어야 하지만 추상 형식은 없어야 합니다.

참고 항목

매핑 전략에 관계없이 구체적인 형식별로 단일 저장 프로시저를 사용하는 것이 관심 있는 경우 상속 매핑 전략에 관계없이 구체적인 형식당 단일 스프록을 사용하여 지원에 투표합니다.

소유 형식을 저장 프로시저에 매핑

소유 형식에 대한 저장 프로시저 구성은 중첩된 소유 형식 작성기에서 수행됩니다. 예시:

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

참고 항목

현재 삽입, 업데이트 및 삭제에 대한 저장 프로시저는 지원 소유 형식만 별도의 테이블에 매핑해야 합니다. 즉, 소유 형식은 소유자 테이블의 열로 나타낼 수 없습니다. 제거하려는 제한 사항인 경우 CUD 스프록 매핑에 "테이블" 분할 지원 추가에 투표하세요.

다대다 조인 엔터티를 저장 프로시저에 매핑

다대다 조인 엔터티의 저장 프로시저 구성은 다대다 구성의 일부로 수행할 수 있습니다. 예시:

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

새롭고 향상된 인터셉터 및 이벤트

EF Core 인터셉터를 사용하면 EF Core 작업의 인터셉션, 수정 및/또는 제거를 할 수 있습니다. EF Core에는 기존 .NET 이벤트로깅도 포함됩니다.

EF7에는 인터셉터에 대한 다음과 같은 향상된 기능이 포함되어 있습니다.

또한 EF7에는 다음과 같은 새로운 기존 .NET 이벤트가 포함되어 있습니다.

다음 섹션에서는 이러한 새로운 가로채기 기능을 사용하는 몇 가지 예를 보여 줍니다.

엔터티 만들기에 대한 간단한 작업

여기에 표시된 코드는 SimpleMaterializationSample.cs에서 제공됩니다.

IMaterializationInterceptor은(는) 엔터티 인스턴스가 만들어지기 전과 후,그리고 해당 인스턴스의 속성이 초기화되기 전과 후에 가로채기를 지원합니다. 인터셉터에서 각 지점에서 엔터티 인스턴스를 변경하거나 바꿀 수 있습니다. 이를 통해 다음이 가능합니다.

  • 매핑되지 않은 속성 설정 또는 유효성 검사, 계산된 값 또는 플래그에 필요한 호출 메서드입니다.
  • 센터를 사용하여 인스턴스 만들기
  • EF와 다른 엔터티 인스턴스를 만드는 것은 일반적으로 캐시 또는 프록시 형식의 인스턴스와 같이 만듭니다.
  • 엔터티 인스턴스에 서비스를 삽입합니다.

예를 들어 데이터를 편집하는 사용자에게 표시될 수 있도록 데이터베이스에서 엔터티가 검색된 시간을 추적하려고 한다고 상상해 보세요. 이를 위해 먼저 인터페이스를 정의합니다.

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

인터페이스를 사용하면 동일한 인터셉터가 다양한 엔터티 형식에서 작동할 수 있으므로 인터셉터에서 일반적입니다. 예시:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

[NotMapped] 특성은 이 속성이 엔터티를 사용하는 동안에만 사용되며 데이터베이스에 유지되어서는 안 됨을 나타내는 데 사용됩니다.

그런 다음 인터셉터는 IMaterializationInterceptor에서 적절한 메서드를 구현하고 검색된 시간을 설정해야 합니다.

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

이 인터셉터의 인스턴스는 DbContext을(를) 구성할 때 등록됩니다.

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

이 인터셉트는 상태 비저장인 것이 일반적이므로 모든 DbContext 인스턴스 간에 단일 인스턴스가 만들어지고 공유됩니다.

이제 데이터베이스에서 Customer을(를) 쿼리할 때마다 Retrieved 속성이 자동으로 설정됩니다. 예시:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

출력을 생성합니다.

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

엔터티에 서비스 삽입

여기에 표시된 코드는 InjectLoggerSample.cs에서 제공됩니다.

EF Core는 이미 일부 특수 서비스를 컨텍스트 인스턴스에 삽입하기 위한 기본 제공 지원을 제공합니다. 예를 들어 ILazyLoader 서비스를 삽입하여 작동하는 프록시 없이 지연 로드를 참조하세요.

IMaterializationInterceptor을(를) 사용하여 모든 서비스에 일반화할 수 있습니다. 다음 예제에서는 엔터티에 ILogger을(를) 삽입하여 자체 로깅을 수행할 수 있도록 하는 방법을 보여 주는 예제입니다.

참고 항목

엔터티에 서비스를 삽입하면 이러한 엔터티 형식이 삽입된 서비스에 결합되며, 어떤 사람들은 안티패턴으로 간주합니다.

이전과 마찬가지로 인터페이스는 수행할 수 있는 작업을 정의하는 데 사용됩니다.

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

그리고 로깅할 엔터티 형식은 이 인터페이스를 구현해야 합니다. 예시:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

이번에는 인터셉터에서 모든 엔터티 인스턴스를 만들고 해당 속성 값을 초기화한 후에 호출되는 IMaterializationInterceptor.InitializedInstance을(를) 구현해야 합니다. 인터셉터는 컨텍스트에서 ILogger을(를) 가져오고 이를 사용하여 IHasLogger.Logger을(를) 초기화합니다.

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

이번에는 가져온 ILogger이(가) DbContext 인스턴스별로 변경될 수 있고 ILogger이(가) 인터셉터에 캐시되므로 각 DbContext 인스턴스에 대해 인터셉터의 새 인스턴스가 사용됩니다.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

이제 Customer.PhoneNumber이(가) 변경될 때마다 이 변경 내용이 응용 프로그램 로그에 기록됩니다. 예시:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

LINQ 식 트리 가로채기

여기에 표시된 코드는 QueryInterceptionSample.cs에서 제공됩니다.

EF Core는 .NET LINQ 쿼리를 사용합니다. 일반적으로 C#, VB 또는 F# 컴파일러를 사용하여 식 트리를 빌드한 다음 EF Core에서 적절한 SQL로 변환합니다. 예를 들어 고객의 페이지를 반환하는 메서드를 고려합니다.

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

이 쿼리는 EF.Property 메서드를 사용하여 정렬 기준 속성을 지정합니다. 이렇게 하면 애플리케이션이 속성 이름을 동적으로 전달할 수 있으므로 엔터티 형식의 속성을 기준으로 정렬할 수 있습니다. 인덱싱되지 않은 열을 기준으로 정렬하는 속도가 느릴 수 있습니다.

정렬에 사용되는 속성이 항상 안정적인 순서를 반환하는 한 제대로 작동합니다. 그러나 이러한 방식으로 매핑되지 않는 경우도 있습니다. 예를 들어 위의 LINQ 쿼리는 Customer.City을(를) 기준으로 정렬할 때 SQLite에서 다음을 생성합니다.

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

동일한 City을(를) 가진 여러 고객이 있는 경우 이 쿼리의 순서가 안정적이지 않습니다. 이로 인해 사용자가 데이터를 통해 페이지로 결과를 누락하거나 중복할 수 있습니다.

이 문제를 해결하는 일반적인 방법은 기본 키별로 보조 정렬을 수행하는 것입니다. 그러나 EF7은 모든 쿼리에 수동으로 추가하는 대신 보조 순서를 동적으로 추가할 수 있는 쿼리 식 트리의 가로채기를 허용합니다. 이를 위해 이번에는 정수 기본 키가 있는 엔터티에 대해 인터페이스를 다시 사용합니다.

public interface IHasIntKey
{
    int Id { get; }
}

이 인터페이스는 관심 있는 엔터티 형식에 의해 구현됩니다.

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

그런 다음, IQueryExpressionInterceptor을(를) 구현하는 인터셉터가 필요합니다.

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

이것은 아마도 꽤 복잡해 보입니다--그리고 복잡한 것이 사실입니다. 식 트리를 사용하는 것은 일반적으로 쉽지 않습니다. 무슨 일이 일어나고 있는지 살펴보겠습니다.

  • 기본적으로 인터셉터에서는 ExpressionVisitor을(를) 캡슐화합니다. 방문자는 쿼리 식 트리에 메서드를 호출할 때마다 호출되는 VisitMethodCall을(를) 재정의합니다.

  • 방문자는 이것이 관심 있는 OrderBy 메서드에 대한 호출인지 여부를 확인합니다.

  • 이 경우 방문자는 제네릭 메서드 호출이 IHasIntKey 인터페이스를 구현하는 형식에 대한 것인지 추가로 확인합니다.

  • 이 시점에서 메서드 호출은 OrderBy(e => ...) 형식입니다. 이 호출에서 람다 식을 추출하고 해당 식에 사용되는 매개 변수(즉, e)를 가져옵니다.

  • 이제 Expression.Call 작성기 메서드를 사용하여 새 MethodCallExpression을(를) 빌드합니다. 이 경우 호출되는 메서드는 ThenBy(e => e.Id)입니다. 위에서 추출한 매개 변수와 IHasIntKey 인터페이스의 Id 속성에 대한 속성 액세스를 사용하여 빌드합니다.

  • 이 호출에 대한 입력은 원래 OrderBy(e => ...)(이)므로 최종 결과는 OrderBy(e => ...).ThenBy(e => e.Id)에 대한 식입니다.

  • 이 수정된 식은 방문자로부터 반환됩니다. 즉, 이제 LINQ 쿼리가 ThenBy 호출을 포함하도록 적절하게 수정되었습니다.

  • EF Core는 이 쿼리 식을 계속하여 사용 중인 데이터베이스에 적합한 SQL로 컴파일합니다.

이 인터셉터는 첫 번째 예제와 동일한 방식으로 등록됩니다. 이제 GetPageOfCustomers을(를) 실행하면 다음 SQL이 생성됩니다.

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

이제 동일한 City을(를) 가진 여러 고객이 있더라도 항상 안정적인 순서가 생성됩니다.

휴우! 이는 쿼리를 간단하게 변경하는 많은 코드입니다. 더 나쁜 것은 모든 쿼리에서 작동하지 않을 수도 있다는 것입니다. 인식해야 하는 모든 쿼리 셰이프를 인식하고 인식해서는 안 되는 쿼리 셰이프를 인식하지 않는 식 방문자를 작성하는 것은 어렵기로 악명 높습니다. 예를 들어 하위 쿼리에서 순서를 지정하는 경우 작동하지 않을 수 있습니다.

이렇게 하면 인터셉터에 대한 위험 지점이 됩니다. 항상 원하는 작업을 더 쉽게 수행할 수 있는 방법이 있는지 자문해 보세요. 인터셉터는 강력하지만 잘못되기 쉽습니다. 속담처럼 제 발등을 제가 찍기 쉬운 방법입니다.

예를 들어 다음과 같이 GetPageOfCustomers 메서드를 대신 변경한 경우를 상상해 보세요.

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

이 경우 ThenBy은(는) 단순히 쿼리에 추가됩니다. 예, 모든 쿼리와 별도로 수행해야 할 수도 있지만 간단하고 이해하기 쉽고 항상 작동합니다.

낙관적 동시성 가로채기

여기에 표시된 코드는 OptimisticConcurrencyInterceptionSample.cs에서 제공됩니다.

EF Core는 실제로 업데이트 또는 삭제의 영향을 받는 행 수가 영향을 받을 것으로 예상되는 행 수와 동일한지 확인하여 낙관적 동시성 패턴을 지원합니다. 이는 종종 동시성 토큰과 결합됩니다. 즉, 예상 값을 읽은 이후 행이 업데이트되지 않은 경우에만 예상 값과 일치하는 열 값입니다.

EF는 DbUpdateConcurrencyException을(를) 발생시켜 낙관적 동시성 위반을 알릴 수 있습니다. EF7에서 ISaveChangesInterceptor에는 DbUpdateConcurrencyException이(가) 발생하기 전에 호출되는 새 메서드 ThrowingConcurrencyExceptionThrowingConcurrencyExceptionAsync이(가) 있습니다. 이러한 가로채기 지점을 사용하면 예외를 표시하지 않을 수 있으며, 비동기 데이터베이스 변경 내용과 결합하여 위반을 해결할 수 있습니다.

예를 들어 두 요청이 거의 동시에 동일한 엔터티를 삭제하려고 하면 데이터베이스의 행이 더 이상 존재하지 않으므로 두 번째 삭제가 실패할 수 있습니다. 이는 괜찮을 수 있습니다. 최종 결과는 엔터티가 어쨌든 삭제되었다는 것입니다. 다음 인터셉터는 이 작업을 수행하는 방법을 보여 줍니다.

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

이 인터셉터에 대해 주목할 만한 몇 가지 사항이 있습니다.

  • 동기 및 비동기 가로채기 메서드가 모두 구현됩니다. 애플리케이션에서 SaveChanges 또는 SaveChangesAsync을(를) 호출할 수 있는 경우 이 값이 중요합니다. 그러나 모든 애플리케이션 코드가 비동기인 경우 ThrowingConcurrencyExceptionAsync만 구현하면 됩니다. 마찬가지로 애플리케이션에서 비동기 데이터베이스 메서드를 사용하지 않는 경우 ThrowingConcurrencyException만 구현하면 됩니다. 이는 일반적으로 동기 및 비동기 메서드를 사용하는 모든 인터셉터에 해당합니다. (일부 동기/비동기 코드가 들어오는 경우를 대비하여 애플리케이션에서 발생시키는 데 사용하지 않는 메서드를 구현하는 것이 좋습니다.)
  • 인터셉터에는 저장되는 엔터티에 대한 EntityEntry 개체에 대한 액세스 권한이 있습니다. 이 경우 삭제 작업에 대해 동시성 위반이 발생하는지 여부를 확인하는 데 사용됩니다.
  • 애플리케이션이 관계형 데이터베이스 공급자를 사용하는 경우 ConcurrencyExceptionEventData 개체를 RelationalConcurrencyExceptionEventData 개체로 캐스팅할 수 있습니다. 이렇게 하면 수행 중인 데이터베이스 작업에 대한 관계형 관련 추가 정보가 제공됩니다. 이 경우 관계형 명령 텍스트가 콘솔에 인쇄됩니다.
  • InterceptionResult.Suppress()을(를) 반환하면 EF Core에서 수행하려는 작업을 표시하지 않습니다. 이 경우 DbUpdateConcurrencyException이(가) 발생합니다. EF Core가 수행하는 작업을 관찰하는 것이 아니라 EF Core의 동작을 변경하는 이 기능은 인터셉터의 가장 강력한 기능 중 하나입니다.

연결 문자열 초기화 지연

여기에 표시된 코드는 LazyConnectionStringSample.cs에서 제공됩니다.

연결 문자열은 종종 구성 파일에서 읽은 고정적 자산입니다. DbContext을(를) 구성할 때 쉽게 UseSqlServer에 전달하거나 유사하게 전달할 수 있습니다. 그러나 경우에 따라 각 컨텍스트 인스턴스에 대해 연결 문자열이 변경될 수 있습니다. 예를 들어 다중 테넌트 시스템의 각 테넌트는 서로 다른 연결 문자열을 가질 수 있습니다.

EF7을 사용하면 IDbConnectionInterceptor을(를) 개선하여 동적 연결 및 연결 문자열을 보다 쉽게 처리할 수 있습니다. 연결 문자열 없이 DbContext을(를) 구성하는 기능으로 시작합니다. 예시:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

그런 다음 IDbConnectionInterceptor 메서드 중 하나를 구현하여 연결이 사용되기 전에 구성할 수 있습니다. ConnectionOpeningAsync은(는) 연결 문자열을 가져오고 액세스 토큰을 찾는 등의 비동기 작업을 수행할 수 있으므로 좋은 선택입니다. 예를 들어 현재 테넌트를 이해하는 현재 요청으로 범위가 지정된 서비스를 상상해 보세요.

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Warning

필요할 때마다 연결 문자열, 액세스 토큰 또는 이와 유사한 비동기 조회를 수행하는 것은 매우 느릴 수 있습니다. 이러한 항목을 캐싱하고 캐시된 문자열 또는 토큰만 주기적으로 새로 고치는 것이 좋습니다. 예를 들어 액세스 토큰은 새로 고쳐야 하기 전에 상당한 기간 동안 사용할 수 있습니다.

생성자 삽입을 사용하여 각 DbContext 인스턴스에 삽입할 수 있습니다.

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

이 서비스는 컨텍스트에 대한 인터셉터 구현을 생성할 때 사용됩니다.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

마지막으로 인터셉터에서는 이 서비스를 사용하여 연결 문자열을 비동기적으로 가져오고 연결이 처음 사용될 때 설정합니다.

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

참고 항목

연결 문자열은 연결이 처음 사용될 때만 가져옵니다. 그런 다음 DbConnection에 저장된 연결 문자열은 새 연결 문자열을 조회하지 않고 사용됩니다.

이 인터셉터에서는 비동기 코드 경로에서 연결 문자열을 가져오는 서비스를 호출해야 하므로 발생시킬 비동기 ConnectionOpening 메서드를 재정의합니다.

쿼리 통계 SQL Server 로깅

여기에 표시된 코드는 QueryStatisticsLoggerSample.cs에서 제공됩니다.

마지막으로 응용 프로그램 로그에 SQL Server 쿼리 통계를 보내기 위해 함께 작동하는 두 개의 인터셉터를 만들어 보겠습니다. 통계를 생성하려면 두 가지 작업을 수행할 IDbCommandInterceptor이(가) 필요합니다.

먼저 인터셉터에서 SET STATISTICS IO ON을(를) 사용하여 명령 접두사를 지정합니다. 이 명령은 결과 집합을 사용한 후 SQL Server가 클라이언트에 통계를 보내도록 지시합니다.

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

둘째, 인터셉터에서 새 DataReaderClosingAsync 메서드를 구현합니다. 이 메서드는 DbDataReader이(가) 결과 사용 완료 후, 그러나 닫히기 전에 호출됩니다. SQL Server 통계를 보낼 때 판독기에서 두 번째 결과를 표시하므로 이 시점에서 인터셉터가 연결에 통계를 채우는 NextResultAsync을(를) 호출하여 해당 결과를 읽습니다.

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

두 번째 인터셉터는 연결에서 통계를 가져와서 응용 프로그램 로거에 쓰는 데 필요합니다. 이를 위해 IDbConnectionInterceptor을(를) 사용하여 새 ConnectionCreated 메서드를 구현합니다. ConnectionCreated은(는) EF Core가 연결을 만든 직후에 호출되므로 해당 연결의 추가 구성을 수행하는 데 사용할 수 있습니다. 이 경우 인터셉터에서 ILogger을(를) 가져온 다음 SqlConnection.InfoMessage 이벤트에 연결하여 메시지를 로그합니다.

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Important

ConnectionCreatingConnectionCreated 메서드는 EF Core에서 DbConnection을(를) 만들 때만 호출됩니다. 애플리케이션이 DbConnection을(를) 만들고 EF Core에 전달하는 경우 호출되지 않습니다.

이러한 인터셉터를 사용하는 일부 코드를 실행하면 로그에 SQL Server 쿼리 통계가 표시됩니다.

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

쿼리 기능 향상

EF7에는 LINQ 쿼리 번역의 많은 개선 사항이 포함되어 있습니다.

최종 연산자로의 GroupBy

여기에 표시된 코드는 GroupByFinalOperatorSample.cs에서 제공됩니다.

EF7은 쿼리에서 GroupBy을(를) 최종 연산자로 사용할 수 있습니다. 예를 들어 다음 LINQ 쿼리는

var query = context.Books.GroupBy(s => s.Price);

SQL Server를 사용하는 경우 다음 SQL로 변환됩니다.

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

참고 항목

GroupBy 형식은 SQL로 직접 변환되지 않으므로 EF Core는 반환된 결과에 대한 그룹화 작업을 수행합니다. 그러나 이로 인해 서버에서 추가 데이터가 전송되지는 않습니다.

최종 연산자로의 GroupJoin

여기에 표시된 코드는 GroupJoinFinalOperatorSample.cs에서 제공됩니다.

EF7은 쿼리에서 GroupJoin을(를) 최종 연산자로 사용할 수 있습니다. 예를 들어 다음 LINQ 쿼리는

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

SQL Server를 사용하는 경우 다음 SQL로 변환됩니다.

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

GroupBy 엔터티 형식

여기에 표시된 코드는 GroupByEntityTypeSample.cs에서 제공됩니다.

EF7은 엔터티 형식별로 그룹화할 수 있습니다. 예를 들어 다음 LINQ 쿼리는

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

SQLite를 사용하는 경우 다음 SQL로 변환됩니다.

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

기본 키와 같은 고유 속성을 기준으로 그룹화하면 항상 엔터티 형식별로 그룹화하는 것보다 더 효율적입니다. 그러나 엔터티 형식별 그룹화는 키 및 키가 없는 엔터티 형식 모두에 사용할 수 있습니다.

또한 기본 키를 사용하여 엔터티 형식별로 그룹화하면 모든 엔터티에 고유한 키 값이 있어야 하므로 항상 엔터티 인스턴스당 하나의 그룹이 발생합니다. 경우에 따라 쿼리의 원본을 전환하여 그룹화할 필요가 없도록 하는 것이 좋습니다. 예를 들어 다음 쿼리는 이전 쿼리와 동일한 결과를 반환합니다.

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

SQLite를 사용하는 경우 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

하위 쿼리는 외부 쿼리에서 그룹화되지 않은 열을 참조하지 않습니다.

여기에 표시된 코드는 UngroupedColumnsQuerySample.cs에서 제공됩니다.

EF Core 6.0에서 GROUP BY 절은 외부 쿼리의 열을 참조하며, 일부 데이터베이스에서는 실패하고 다른 데이터베이스에서는 비효율적입니다. 예를 들어 다음과 같은 쿼리를 고려해 보겠습니다.

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

SQL Server의 EF Core 6.0에서는 다음으로 변환되었습니다.

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

EF7에서 번역은 다음과 같습니다.

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

읽기 전용 컬렉션을 Contains에 사용할 수 있습니다.

여기에 표시된 코드는 ReadOnlySetQuerySample.cs에서 제공됩니다.

EF7은 검색할 항목이 IReadOnlySet이나 IReadOnlyCollection 또는 IReadOnlyList에 포함된 경우 Contains사용을 지원합니다. 예를 들어 다음 LINQ 쿼리는

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

SQL Server를 사용하는 경우 다음 SQL로 변환됩니다.

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

집계 함수에 대한 번역

EF7은 공급자가 집계 함수를 번역할 수 있는 더 나은 확장성을 도입합니다. 이와 이 영역의 다른 작업으로 인해 공급자 간에 다음을 비롯한 몇 가지 새로운 번역이 수행되었습니다.

참고 항목

IEnumerable 인수에 대해 작동하는 집계 함수는 일반적으로 GroupBy 쿼리에서만 변환됩니다. 이 제한을 제거하려는 경우 JSON 열에서 지원 공간 형식에 투표합니다.

문자열 집계 함수

여기에 표시된 코드는 StringAggregateFunctionsSample.cs에서 제공됩니다.

적절한 경우 JoinConcat을(를) 사용하는 쿼리가 이제 번역됩니다. 예시:

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

SQL Server를 사용하는 경우 이 쿼리는 다음으로 변환됩니다.

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

다른 문자열 함수와 결합하면 이러한 번역을 통해 서버에서 일부 복잡한 문자열 조작을 수행할 수 있습니다. 예시:

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

SQL Server를 사용하는 경우 이 쿼리는 다음으로 변환됩니다.

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

공간 집계 함수

여기에 표시된 코드는 SpatialAggregateFunctionsSample.cs에서 제공됩니다.

이제 NetTopologySuite를 지원하는 데이터베이스 공급자가 다음 공간 집계 함수를 변환할 수 있습니다.

이러한 번역은 팀에서 SQL Server 및 SQLite용으로 구현했습니다. 다른 공급자의 경우 공급자 유지 관리자에게 문의하여 해당 공급자에 대해 구현된 경우 지원을 추가합니다.

예시:

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

SQL Server를 사용하는 경우 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

통계 집계 함수

여기에 표시된 코드는 StatisticalAggregateFunctionsSample.cs에서 제공됩니다.

SQL Server 번역은 다음 통계 함수에 대해 구현되었습니다.

이러한 번역은 SQL Server를 위해 팀에서 구현했습니다. 다른 공급자의 경우 공급자 유지 관리자에게 문의하여 해당 공급자에 대해 구현된 경우 지원을 추가합니다.

예시:

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

SQL Server를 사용하는 경우 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

string.IndexOf의 번역

여기에 표시된 코드는 MiscellaneousTranslationsSample.cs에서 제공됩니다.

이제 EF7은 LINQ 쿼리에서 String.IndexOf을(를) 변환합니다. 예시:

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

SQL Server를 사용하는 경우 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

엔터티 형식에 대한 GetType의 번역

여기에 표시된 코드는 MiscellaneousTranslationsSample.cs에서 제공됩니다.

이제 EF7은 LINQ 쿼리에서 Object.GetType()을(를) 변환합니다. 예시:

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

TPH 상속과 함께 SQL Server를 사용하는 경우 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

이 쿼리는 실제로 Post 형식인 Post 인스턴스만 반환하고 파생 형식의 인스턴스는 반환하지 않습니다. 파생 형식의 인스턴스도 반환하는 is 또는 OfType을(를) 사용하는 쿼리와 다릅니다. 예를 들어 다음 쿼리를 생각해 보세요.

var query = context.Posts.OfType<Post>();

이는 다른 SQL로 변환됩니다.

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

PostFeaturedPost 엔터티를 모두 반환합니다.

AT TIME ZONE 지원

여기에 표시된 코드는 MiscellaneousTranslationsSample.cs에서 제공됩니다.

EF7에는 DateTimeDateTimeOffset에 대한 새로운 AtTimeZone 함수가 도입되었습니다. 이러한 함수는 생성된 SQL의 AT TIME ZONE 절로 변환됩니다. 예시:

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

SQL Server를 사용하는 경우 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

이러한 번역은 SQL Server를 위해 팀에서 구현했습니다. 다른 공급자의 경우 공급자 유지 관리자에게 문의하여 해당 공급자에 대해 구현된 경우 지원을 추가합니다.

숨겨진 탐색에 필터링 적용 포함

여기에 표시된 코드는 MiscellaneousTranslationsSample.cs에서 제공됩니다.

이제 Include 메서드EF.Property와(과) 함께 사용할 수 있습니다. 이렇게 하면 프라이빗 탐색 속성 또는 필드로 표시되는 프라이빗 탐색에 대해서도 필터링 및 순서를 지정할 수 있습니다. 예시:

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

다음 코드와 동일합니다.

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

그러나 Blog.Posts 공개적으로 액세스할 필요는 없습니다.

SQL Server를 사용하는 경우 위의 두 쿼리는 다음으로 변환됩니다.

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Regex.IsMatch에 대한 Cosmos 번역

여기에 표시된 코드는 CosmosQueriesSample.cs에서 제공됩니다.

EF7은 Azure Cosmos DB에 대한 LINQ 쿼리에서 Regex.IsMatch 사용을 지원합니다. 예시:

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

다음 SQL로 변환됩니다.

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

DbContext API 및 동작 향상

EF7에는 DbContext 및 관련 클래스에 대한 다양한 작은 개선 사항이 포함되어 있습니다.

이 섹션의 샘플 코드는 DbContextApiSample.cs에서 제공됩니다.

초기화를 취소한 DbSet 속성에 대한 억제기

DbContext의 공용의 설정 가능한 DbSet 속성은 DbContext이(가) 생성될 때 EF Core에 의해 자동으로 초기화됩니다. 예를 들어 다음 DbContext 정의 살펴보세요.

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

Blogs 속성은 DbContext 인스턴스 생성의 일부로 DbSet<Blog> 인스턴스로 설정 됩니다. 이렇게 하면 추가 단계 없이 쿼리에 컨텍스트를 사용할 수 있습니다.

그러나 C# nullable 참조 형식이 도입된 후 컴파일러는 이제 nullable이 아닌 Blogs 속성이 초기화되지 않음을 경고합니다.

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

이것은 가짜 경고입니다. 속성은 EF Core에 의해 null이 아닌 값으로 설정됩니다. 또한 속성을 nullable로 선언하면 경고가 사라지지만 개념적으로 속성이 null을 허용하지 않고 null이 되지 않기 때문에 이는 좋은 생각이 아닙니다.

EF7에는 컴파일러가 이 경고를 생성하지 못하도록 하는 DbContextDbSet 속성에 대한 DiagnosticSuppressor가 포함되어 있습니다.

이 패턴은 C# 자동 속성이 매우 제한된 시대에 시작되었습니다. 최신 C#에서는 자동 속성을 읽기 전용으로 만든 다음 DbContext 생성자에서 명시적으로 초기화하거나 필요한 경우 컨텍스트에서 캐시된 DbSet 인스턴스를 가져오는 것이 좋습니다. 예: public DbSet<Blog> Blogs => Set<Blog>().

로그의 오류와 취소 구분

경우에 따라 애플리케이션은 쿼리 또는 다른 데이터베이스 작업을 명시적으로 취소합니다. 이 작업은 일반적으로 작업을 수행하는 메서드에 전달된 CancellationToken을(를) 사용하여 수행됩니다.

EF Core 6에서 작업이 취소될 때 로그된 이벤트는 다른 이유로 인해 작업이 실패할 때 로그된 이벤트와 동일합니다. EF7은 취소된 데이터베이스 작업을 위해 특별히 새 로그 이벤트를 도입합니다. 이러한 새 이벤트는 기본적으로 Debug 수준에서 로그됩니다. 다음 표에서는 관련 이벤트 및 해당 기본 로그 수준을 보여 줍니다.

이벤트 설명 기본 로그 수준
CoreEventId.QueryIterationFailed 쿼리 결과를 처리하는 동안 오류가 발생했습니다. LogLevel.Error
CoreEventId.SaveChangesFailed 변경 내용을 데이터베이스에 저장하는 동안 오류가 발생했습니다. LogLevel.Error
RelationalEventId.CommandError 데이터베이스 명령이 실행되는 동안 오류가 발생했습니다. LogLevel.Error
CoreEventId.QueryCanceled 쿼리가 취소되었습니다. LogLevel.Debug
CoreEventId.SaveChangesCanceled 변경 내용을 저장하는 동안 데이터베이스 명령이 취소되었습니다. LogLevel.Debug
RelationalEventId.CommandCanceled DbCommand의 실행이 취소되었습니다. LogLevel.Debug

참고 항목

취소 토큰을 확인하는 대신 예외를 확인하여 취소가 검색됩니다. 즉, 취소 토큰을 통해 트리거되지 않은 취소는 여전히 검색되고 이러한 방식으로 로그됩니다.

EntityEntry 메서드에 대한 새 IPropertyINavigation 오버로드

EF 모델로 작업하는 코드에는 속성 또는 탐색 메타데이터를 나타내는 IProperty 또는 INavigation이(가) 있는 경우가 많습니다. 그런 다음 EntityEntry를 사용하여 속성/탐색 값을 얻거나 상태를 쿼리합니다. 그러나 EF7 이전에는 속성 또는 탐색의 이름EntityEntry의 메서드에 전달해야 했습니다. 그러면 IProperty 또는 INavigation을(를) 다시 조회할 수 있습니다. EF7에서는 IProperty 또는 INavigation을(를) 직접 전달하여 추가 조회를 방지할 수 있습니다.

예를 들어 지정된 엔터티의 모든 형제를 찾는 메서드를 고려합니다.

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

이 메서드는 지정된 엔터티의 부모를 찾은 다음 부모 엔터티의 Collection 메서드에 역방향 INavigation을(를) 전달합니다. 그런 다음 이 메타데이터를 사용하여 지정된 부모의 모든 형제를 반환합니다. 사용 예제는 다음과 같습니다.


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

출력은 다음과 같습니다.

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

공유 형식 엔터티 형식에 대한 EntityEntry

EF Core는 여러 엔터티 형식에 대해 동일한 CLR 형식을 사용할 수 있습니다. 이는 "공유 형식 엔터티 형식"으로 알려져 있으며 엔터티 형식의 속성에 사용되는 키/값 쌍을 사용하여 사전 형식을 매핑하는 데 자주 사용됩니다. 예를 들어 전용 CLR 형식을 정의하지 않고 BuildMetadata 엔터티 형식을 정의할 수 있습니다.

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

공유 형식 엔터티 형식은 이름이 있어야 합니다. 이 경우 이름은 BuildMetadata입니다. 그런 다음 이러한 엔터티 형식은 이름을 사용하여 가져온 엔터티 형식에 대해 DbSet을(를) 사용하여 액세스합니다. 예시:

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

DbSet은(는) 엔터티 인스턴스를 추적하는 데 사용할 수 있습니다.

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

쿼리를 실행합니다.

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

이제 EF7에는 아직 추적되지 않더라도 인스턴스의 상태를 가져오는 데 사용할 수 있는 DbSetEntry 메서드도 있습니다. 예시:

var state = context.BuildMetadata.Entry(build).State;

ContextInitialized은(는) 이제 Debug(으)로 로깅됩니다.

EF7에서 ContextInitialized 이벤트는 Debug 수준에서 로그됩니다. 예시:

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

이전 릴리스에서는 Information 수준에서 로그되었습니다. 예시:

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

원하는 경우 로그 수준을 Information(으)로 다시 변경할 수 있습니다.

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator은(는) 공개적으로 사용할 수 있습니다.

EF7에서는 애플리케이션에서 IEntityEntryGraphIterator 서비스를 사용할 수 있습니다. 추적할 엔터티 그래프를 검색할 때 내부적으로 사용되는 서비스이며 TrackGraph에서도 사용됩니다. 다음은 일부 시작 엔터티에서 도달할 수 있는 모든 엔터티를 반복하는 예제입니다.

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

참고:

  • 콜백 대리자가 false을(를) 반환하면 반복기가 지정된 노드에서 트래버스를 중지합니다. 이 예제에서는 방문한 엔터티를 추적하고 엔터티가 이미 방문되었을 때 false을(를) 반환합니다. 이렇게 하면 그래프의 주기로 인한 무한 루프가 방지됩니다.
  • EntityEntryGraphNode<TState> 개체를 사용하면 상태를 대리자로 캡처하지 않고도 전달될 수 있습니다.
  • 첫 번째 노드가 아닌 방문한 모든 노드에 대해 검색된 노드와 검색된 탐색이 콜백에 전달됩니다.

모델 빌드 기능 향상

EF7에는 모델 빌드의 다양한 작은 개선 사항이 포함되어 있습니다.

이 섹션의 샘플 코드는 ModelBuildingSample.cs에서 제공됩니다.

인덱스는 오름차순 또는 내림차순일 수 있습니다.

기본적으로 EF Core는 오름차순 인덱스를 만듭니다. EF7은 내림차순 인덱스 생성도 지원합니다. 예시:

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

또는 Index 매핑 특성을 사용하여 다음을 수행합니다.

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

데이터베이스가 양방향으로 정렬하는 데 동일한 인덱스를 사용할 수 있으므로 단일 열의 인덱스에는 거의 유용하지 않습니다. 그러나 각 열의 순서가 중요할 수 있는 여러 열에 대한 복합 인덱스의 경우는 그렇지 않습니다. EF Core는 여러 열이 각 열에 대해 서로 다른 순서를 정의할 수 있도록 하여 이를 지원합니다. 예시:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

또는 매핑 특성을 사용하여 다음을 수행합니다.

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

SQL Server를 사용하는 경우 다음 SQL이 생성됩니다.

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

마지막으로 인덱스 이름을 지정하여 순서가 같은 열 집합에 대해 여러 인덱스를 만들 수 있습니다. 예시:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

또는 매핑 특성을 사용하여 다음을 수행합니다.

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

SQL Server에 다음 SQL을 생성합니다.

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

복합 키에 대한 매핑 특성

EF7에는 모든 엔터티 형식의 기본 키 속성 또는 속성을 지정하기 위한 새 매핑 특성(즉, "데이터 주석")이 도입되었습니다. System.ComponentModel.DataAnnotations.KeyAttribute와(과)는 달리 PrimaryKeyAttribute은(는) 키 속성이 아닌 엔터티 형식 클래스에 배치됩니다. 예시:

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

이렇게 하면 복합 키를 정의하는 데 자연스럽게 적합합니다.

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

클래스에서 인덱스 정의는 일반적으로 EF 모델을 빌드할 때 무시되더라도 프라이빗 속성 또는 필드를 키로 지정하는 데 사용할 수 있습니다. 예시:

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

DeleteBehavior 매핑 특성

EF7은 관계의 DeleteBehavior을(를) 지정하는 매핑 특성(즉, "데이터 주석")을 도입합니다. 예를 들어 필수 관계는 기본적으로 DeleteBehavior.Cascade을(를) 사용하여 만들어집니다. 기본적으로 DeleteBehaviorAttribute을(를) 사용하여 DeleteBehavior.NoAction(으)로 변경할 수 있습니다.

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

그러면 Blog-Posts 관계에 대한 하위 삭제가 비활성화됩니다.

다른 열 이름에 매핑된 속성

일부 매핑 패턴으로 인해 동일한 CLR 속성이 서로 다른 각 테이블의 열에 매핑됩니다. EF7을 사용하면 이러한 열의 이름이 다를 수 있습니다. 예를 들어 간단한 상속 계층 구조를 고려합니다.

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

TPT 상속 매핑 전략을 사용하면 이러한 형식이 세 개의 테이블에 매핑됩니다. 그러나 각 테이블의 기본 키 열은 다른 이름을 가질 수 있습니다. 예시:

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7을 사용하면 중첩된 테이블 작성기를 사용하여 이 매핑을 구성할 수 있습니다.

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

TPC 상속 매핑을 사용하면 Breed 속성이 다른 테이블의 다른 열 이름에 매핑될 수도 있습니다. 다음 TPC 테이블을 예로 들 수 있습니다.

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7은 이 테이블 매핑을 지원합니다.

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

단방향 다대다 관계

EF7은 한쪽 또는 다른 쪽에 탐색 속성이 없는 다대다 관계를 지원합니다. 예를 들어 PostTag 형식을 고려합니다.

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Post 형식에는 태그 목록에 대한 탐색 속성이 있지만 Tag 형식에는 게시물에 대한 탐색 속성이 없습니다. EF7에서는 여전히 다대다 관계로 구성할 수 있으므로 여러 다른 게시물에 동일한 Tag 개체를 사용할 수 있습니다. 예시:

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

그러면 적절한 조인 테이블에 매핑됩니다.

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

그리고 관계는 정상적인 방법으로 다대다로 사용할 수 있습니다. 예를 들어 공통 집합의 다양한 태그를 공유하는 일부 게시물을 삽입합니다.

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

엔터티 분할

엔터티 분할은 단일 엔터티 형식을 여러 테이블에 매핑합니다. 예를 들어 고객 데이터를 보유하는 세 개의 테이블이 있는 데이터베이스를 고려해 보세요.

  • 고객 정보에 관한 Customers 테이블
  • 고객의 전화 번호에 관한 PhoneNumbers 테이블
  • 고객 주소에 관한 Addresses 테이블

다음은 SQL Server에서 이러한 테이블에 대한 정의입니다.

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

이러한 각 테이블은 일반적으로 형식 간의 관계를 사용하여 고유한 엔터티 형식에 매핑됩니다. 그러나 세 테이블이 모두 항상 함께 사용되는 경우 모두 단일 엔터티 형식에 매핑하는 것이 더 편리할 수 있습니다. 예시:

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

이는 엔터티 형식의 각 분할에 대해 SplitToTable을(를) 호출하여 EF7에서 수행됩니다. 예를 들어 다음 코드는 Customer 엔터티 형식을 위에 표시된 Customers, PhoneNumbersAddresses 테이블로 분할합니다.

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

또한 필요한 경우 각 테이블에 대해 다른 기본 키 열 이름을 지정할 수 있습니다.

SQL Server UTF-8 문자열

ncharnvarchar 데이터 형식이 나타내는 SQL Server 유니코드 문자열은 UTF-16으로 저장됩니다. 또한 charvarchar 데이터 형식은 다양한 문자 집합을 지원하는 유니코드가 아닌 문자열을 저장하는 데 사용됩니다.

SQL Server 2019부터 시작하여 charvarchar 데이터 형식을 사용하여 UTF-8 인코딩으로 유니코드 문자열을 대신 저장할 수 있습니다. 이는 UTF-8 데이터 정렬 중 하나를 설정하여 달성됩니다. 예를 들어 다음 코드는 CommentText 열에 대한 SQL Server UTF-8 문자열에 변수 길이를 구성합니다.

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

이 구성은 다음 SQL Server 열 정의를 생성합니다.

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

임시 테이블은 소유 엔터티를 지원합니다.

EF Core SQL Server 임시 테이블 매핑은 테이블 공유를 지원하기 위해 EF7에서 향상되었습니다. 특히 소유된 단일 엔터티에 대한 기본 매핑은 테이블 공유를 사용합니다.

예를 들어 소유자 엔터티 형식 Employee와(과) 소유 엔터티 형식 EmployeeInfo을(를) 고려합니다.

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

이러한 형식이 동일한 테이블에 매핑되는 경우 EF7에서 해당 테이블을 임시 테이블로 만들 수 있습니다.

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

참고 항목

이 구성을 더 쉽게 만드는 것은 문제 #29303에서 추적됩니다. 구현된 것을 확인하려는 경우 이 문제에 투표합니다.

향상된 값 생성

EF7에는 키 속성에 대한 값 자동 생성의 두 가지 중요한 개선 사항이 포함되어 있습니다.

이 섹션의 샘플 코드는 ValueGenerationSample.cs에서 제공됩니다.

DDD 보호 형식의 값 생성

DDD(도메인 기반 디자인)에서 "보호된 키"는 키 속성의 형식 안전성을 향상시킬 수 있습니다. 이는 키 사용과 관련된 다른 형식으로 키 형식을 래핑하여 수행됩니다. 예를 들어 다음 코드는 제품 키의 ProductId 형식과 범주 키의 CategoryId 형식을 정의합니다.

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

그런 다음 ProductCategory 엔터티 형식에서 사용됩니다.

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

따라서 실수로 범주의 ID를 제품에 할당하거나 그 반대로 할당할 수 없습니다.

Warning

많은 DDD 개념과 마찬가지로 이 향상된 형식 안전성으로 코드 복잡성이 추가로 발생합니다. 예를 들어 제품 ID를 범주에 할당하는 것이 발생할 가능성이 있는 것인지를 고려해야 합니다. 작업을 단순하게 유지하는 것이 코드베이스에 전반적으로 더 도움이 될 수 있습니다.

여기에 표시된 보호된 키 형식은 모두 int 키 값을 래핑합니다. 즉, 정수 값이 매핑된 데이터베이스 테이블에 사용됩니다. 이 작업은 형식에 대한 값 변환기를 정의하여 수행됩니다.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

참고 항목

여기서 코드는 struct 형식을 사용합니다. 즉, 키로 사용할 적절한 값 형식 의미 체계가 있습니다. class 형식을 대신 사용하는 경우 같음 의미 체계를 재정의하거나 값 비교자를 지정해야 합니다.

EF7에서 값 변환기를 기반으로 하는 키 형식은 기본 형식이 이를 지원하는 한 자동으로 생성된 키 값을 사용할 수 있습니다. 이는 ValueGeneratedOnAdd을(를) 사용하여 일반적인 방식으로 구성됩니다.

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

기본적으로 다음과 같이 SQL Server와 함께 사용할 경우 IDENTITY 열이 생성됩니다.

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

엔터티를 삽입할 때 키 값을 생성하는 일반적인 방법으로 사용됩니다.

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

SQL Server에 대한 시퀀스 기반 키 생성

EF Core는 SQL Server IDENTITY을 사용하여 키 값 생성을 지원하거나 데이터베이스 시퀀스에서 생성된 키 블록을 기반으로 하는 Hi-Lo 패턴을 지원합니다. EF7은 키의 열 기본 제약 조건에 연결된 데이터베이스 시퀀스 지원을 도입합니다. 가장 간단한 형식에서는 키 속성에 시퀀스를 사용하도록 EF Core에 알려야 합니다.

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

그러면 데이터베이스에 시퀀스가 정의됩니다.

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

그런 다음 키 열 기본 제약 조건에서 사용됩니다.

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

참고 항목

이 키 생성 형식은 기본적으로 TPC 매핑 전략을 사용하여 엔터티 형식 계층 구조에서 생성된 키에 사용됩니다.

원하는 경우 시퀀스에 다른 이름과 스키마를 지정할 수 있습니다. 예시:

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

시퀀스의 추가 구성은 모델에서 명시적으로 구성하여 형성됩니다. 예시:

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

마이그레이션 도구 개선 사항

EF7에는 EF Core 마이그레이션 명령줄 도구를 사용할 때 두 가지 중요한 개선 사항이 포함되어 있습니다.

UseSqlServer 등. 수락 null

구성 파일에서 연결 문자열을 읽은 다음, 해당 연결 문자열을 UseSqlServer, UseSqlite 또는 다른 공급자에 해당하는 메서드에 전달하는 것이 매우 일반적입니다. 예시:

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

또한 마이그레이션을 적용할 때 연결 문자열을 전달하는 것이 일반적입니다. 예시:

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

또는 마이그레이션 번들을 사용하는 경우.

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

이 경우 구성에서 읽은 연결 문자열이 사용되지 않더라도 애플리케이션 시작 코드는 여전히 구성에서 읽어 UseSqlServer에 전달하려고 시도합니다. 구성을 사용할 수 없는 경우 null을 UseSqlServer에 전달합니다. EF7에서는 --connection을(를) 명령줄 도구에 전달하는 것과 같이 연결 문자열이 궁극적으로 나중에 설정되는 한 허용됩니다.

참고 항목

UseSqlServerUseSqlite에 대해 변경되었습니다. 다른 공급자의 경우 해당 공급자에 대해 아직 수행되지 않은 경우 공급자 유지 관리자에게 문의하여 동등한 변경을 수행합니다.

도구가 실행 중일 때 감지

EF Core는 dotnet-ef 또는 PowerShell 명령을 사용할 때 애플리케이션 코드를 실행합니다. 디자인 타임에 부적절한 코드가 실행되지 않도록 이 상황을 감지해야 할 수도 있습니다. 예를 들어 시작 시 마이그레이션을 자동으로 적용하는 코드는 디자인 타임에 이 작업을 수행하지 않을 수 있습니다. EF7에서는 EF.IsDesignTime 플래그를 사용하여 검색할 수 있습니다.

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

EF Core는 도구를 대신하여 애플리케이션 코드가 실행될 때 IsDesignTime을(를) true(으)로 설정합니다.

프록시에 대한 성능 향상

EF Core는 지연 로드변경 내용 추적을 위해 동적으로 생성된 프록시를 지원합니다. EF7에는 다음 프록시를 사용할 때 두 가지 성능이 향상됩니다.

  • 이제 프록시 형식이 지연적으로 만들어집니다. 즉, 프록시를 사용할 때 초기 모델 빌드 시간이 EF Core 6.0보다 EF7에서 훨씬 더 빠를 수 있습니다.
  • 이제 프록시를 컴파일된 모델과 함께 사용할 수 있습니다.

다음은 449개의 엔터티 형식, 6390개의 속성 및 720개의 관계가 있는 모델에 대한 몇 가지 성능 결과입니다.

시나리오 메서드 평균 오류 StdDev
프록시가 없는 EF Core 6.0 TimeToFirstQuery 1.085 s 0.0083 s 0.0167 s
변경 내용 추적 프록시가 있는 EF Core 6.0 TimeToFirstQuery 13.01 s 0.2040 s 0.4110 s
프록시가 없는 EF Core 7.0 TimeToFirstQuery 1.442 s 0.0134 s 0.0272 s
변경 내용 추적 프록시가 있는 EF Core 7.0 TimeToFirstQuery 1.446 s 0.0160 s 0.0323 s
변경 내용 추적 프록시 및 컴파일된 모델을 사용하는 EF Core 7.0 TimeToFirstQuery 0.162 s 0.0062 s 0.0125 s

따라서 이 경우 변경 내용 추적 프록시가 있는 모델은 EF Core 6.0에서 가능했던 것보다 EF7에서 첫 번째 쿼리를 80배 더 빠르게 실행할 수 있습니다.

일류 Windows Forms 데이터 바인딩

Windows Forms 팀은 Visual Studio 디자이너 환경을 크게 개선했습니다. 여기에는 EF Core와 잘 통합되는 데이터 바인딩에 대한 새로운 환경이 포함됩니다.

간단히 말해서 새 환경은 ObjectDataSource을(를) 만들기 위한 Visual Studio U.I.를 제공합니다.

Choose Category data source type

그런 다음 몇 가지 간단한 코드를 사용하여 EF Core DbSet에 바인딩할 수 있습니다.

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

전체 연습 및 다운로드 가능한 WinForms 응용 프로그램 예제Windows Forms 시작을 참조하세요.