상속

EF는 .NET 형식 계층 구조를 데이터베이스에 매핑할 수 있습니다. 이렇게 하면 기본 및 파생 형식을 사용하여 .NET 엔터티를 코드로 작성하고 EF가 적절한 데이터베이스 스키마, 문제 쿼리 등을 원활하게 만들 수 있습니다. 형식 계층이 매핑되는 방법에 대한 실제 세부 정보는 공급자에 따라 다릅니다. 이 페이지에서는 관계형 데이터베이스의 컨텍스트에서 상속 지원을 설명합니다.

엔터티 형식 계층 구조 매핑

규칙에 따라 EF는 기본 또는 파생 형식을 자동으로 검색하지 않습니다. 즉, 계층 구조의 CLR 형식을 매핑하려면 모델에서 해당 형식을 명시적으로 지정해야 합니다. 예를 들어 계층의 기본 형식만 지정해도 EF Core가 모든 하위 형식을 암시적으로 포함하지는 않습니다.

다음 샘플에서는 Blog에 대한 DbSet 및 해당 하위 클래스 RssBlog를 노출합니다. Blog에 다른 하위 클래스가 있는 경우 모델에 포함되지 않습니다.

internal class MyContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<RssBlog> RssBlogs { get; set; }
}

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
}

public class RssBlog : Blog
{
    public string RssUrl { get; set; }
}

참고 항목

데이터베이스 열은 TPH 매핑을 사용할 때 필요에 따라 자동으로 null 허용으로 설정됩니다. 예를 들어 일반 Blog 인스턴스에는 해당 속성이 없으므로 RssUrl 열은 null을 허용합니다.

계층 구조에서 하나 이상의 엔터티에 대해 DbSet를 노출하지 않으려면 Fluent API를 사용하여 모델에 포함되도록 할 수도 있습니다.

규칙을 사용하지 않는 경우 HasBaseType을 사용하여 기본 형식을 명시적으로 지정할 수 있습니다. .HasBaseType((Type)null)을 사용하여 계층에서 엔터티 형식을 제거할 수도 있습니다.

계층별 테이블 및 판별자 구성

기본적으로 EF는 TPH(계층당 하나의 테이블) 패턴을 사용하여 상속을 매핑합니다. TPH는 단일 테이블을 사용하여 계층 구조의 모든 형식에 대한 데이터를 저장하고 판별자 열을 사용하여 각 행이 나타내는 형식을 식별합니다.

위의 모델은 다음 데이터베이스 스키마에 매핑됩니다(각 행에 어떤 Blog의 형식이 저장되는지 식별하는 암시적으로 생성된 Discriminator 열에 유의).

Screenshot of the results of querying the Blog entity hierarchy using table-per-hierarchy pattern

계층의 각 형식을 식별하는 데 사용되는 판별자 열의 이름과 형식 및 값을 구성할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator<string>("blog_type")
        .HasValue<Blog>("blog_base")
        .HasValue<RssBlog>("blog_rss");
}

위의 예제에서 EF는 계층 구조의 기본 엔터티에 섀도 속성으로 암시적으로 판별자를 추가했습니다. 이 속성은 다른 속성과 마찬가지로 구성할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .Property("Discriminator")
        .HasMaxLength(200);
}

마지막으로, 판별자를 엔터티의 일반 .NET 속성에 매핑할 수도 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator(b => b.BlogType);

    modelBuilder.Entity<Blog>()
        .Property(e => e.BlogType)
        .HasMaxLength(200)
        .HasColumnName("blog_type");
        
    modelBuilder.Entity<RssBlog>();
}

TPH 패턴을 사용하는 파생 엔터티를 쿼리할 때 EF Core는 쿼리에서 판별자 열에 대한 조건자를 추가합니다. 이 필터는 결과에 없는 기본 형식 또는 형제 형식에 대한 추가 행을 얻지 않도록 합니다. 기본 엔터티에 대한 쿼리는 계층의 모든 엔터티에 대한 결과를 가져오기 때문에 이 필터 조건자는 기본 엔터티 형식에 대해 건너뜁습니다. 쿼리에서 결과를 구체화할 때 모델의 엔터티 형식에 매핑되지 않은 판별자 값이 발생하면 결과를 구체화하는 방법을 모르기 때문에 예외를 throw합니다. 이 오류는 데이터베이스에 EF 모델에 매핑되지 않은 판별자 값이 있는 행이 포함된 경우에만 발생합니다. 이러한 데이터가 있는 경우 EF Core 모델의 판별자 매핑을 불완전한 것으로 표시하여 계층 구조의 모든 형식을 쿼리하기 위한 필터 조건자를 항상 추가해야 함을 나타낼 수 있습니다. IsComplete(false)는 판별자 구성에 대한 호출은 매핑이 불완전하도록 표시합니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator()
        .IsComplete(false);
}

공유 열

기본적으로 계층의 두 형제 엔터티 형식에 이름이 같은 속성이 있는 경우 두 개의 개별 열에 매핑됩니다. 그러나 해당 형식이 동일한 경우 동일한 데이터베이스 열에 매핑할 수 있습니다.

public class MyContext : DbContext
{
    public DbSet<BlogBase> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(b => b.Url)
            .HasColumnName("Url");

        modelBuilder.Entity<RssBlog>()
            .Property(b => b.Url)
            .HasColumnName("Url");
    }
}

public abstract class BlogBase
{
    public int BlogId { get; set; }
}

public class Blog : BlogBase
{
    public string Url { get; set; }
}

public class RssBlog : BlogBase
{
    public string Url { get; set; }
}

참고 항목

SQL Server 같은 관계형 데이터베이스 공급자는 캐스트를 사용할 때 공유 열을 쿼리할 때 판별자 조건자를 자동으로 사용하지 않습니다. 또한 쿼리 Url = (blog as RssBlog).Url은 형제 Blog 행의 Url 값을 반환합니다. 쿼리를 RssBlog 엔터티로 제한하려면 Url = blog is RssBlog ? (blog as RssBlog).Url : null과 같이 판별자에서 필터를 수동으로 추가해야 합니다.

형식당 하나의 테이블 구성

TPT 매핑 패턴에서 모든 형식은 개별 테이블에 매핑됩니다. 기본 형식 또는 파생 형식에만 속하는 속성은 해당 형식에 매핑되는 테이블에 저장됩니다. 파생 형식에 매핑되는 테이블은 파생 테이블을 기본 테이블과 조인하는 외래 키도 저장합니다.

modelBuilder.Entity<Blog>().ToTable("Blogs");
modelBuilder.Entity<RssBlog>().ToTable("RssBlogs");

각 엔터티 형식에서 ToTable을 호출하는 대신 각 루트 엔터티 형식에서 modelBuilder.Entity<Blog>().UseTptMappingStrategy()를 호출할 수 있으며 테이블 이름은 EF에서 생성됩니다.

각 테이블의 기본 키 열에 대해 서로 다른 열 이름을 구성하려면 테이블별 패싯 구성을 참조하세요.

EF는 위의 모델에 대해 다음 데이터베이스 스키마를 만듭니다.

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL IDENTITY,
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId]),
    CONSTRAINT [FK_RssBlogs_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([BlogId]) ON DELETE NO ACTION
);

참고 항목

기본 키 제약 조건의 이름을 바꾸면 계층 구조에 매핑된 모든 테이블에 새 이름이 적용됩니다. 이후 EF 버전에서는 문제 19970이 해결될 때 특정 테이블에 대해서만 제약 조건의 이름을 바꿀 수 있습니다.

대량 구성을 사용하는 경우 GetColumnName(IProperty, StoreObjectIdentifier)을 호출하여 특정 테이블의 열 이름을 검색할 수 있습니다.

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    var tableIdentifier = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table);

    Console.WriteLine($"{entityType.DisplayName()}\t\t{tableIdentifier}");
    Console.WriteLine(" Property\tColumn");

    foreach (var property in entityType.GetProperties())
    {
        var columnName = property.GetColumnName(tableIdentifier.Value);
        Console.WriteLine($" {property.Name,-10}\t{columnName}");
    }

    Console.WriteLine();
}

Warning

대부분의 경우 TPT는 TPH에 비해 성능이 낮습니다. 자세한 내용은 성능 설명서를 참조하세요.

주의

파생 형식의 열은 서로 다른 테이블에 매핑되므로 상속된 속성과 선언된 속성을 모두 사용하는 복합 FK 제약 조건 및 인덱스는 데이터베이스에서 만들 수 없습니다.

구체적인 형식별 테이블 구성

참고 항목

TPC(구체적인 클래스당 하나의 테이블) 기능은 EF Core 7.0에서 도입되었습니다.

TPC 매핑 패턴에서 모든 형식은 개별 테이블에 매핑됩니다. 각 테이블에는 해당 엔터티 형식의 모든 속성에 대한 열이 포함되어 있습니다. 이는 TPT 전략의 몇 가지 일반적인 성능 문제를 해결합니다.

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

modelBuilder.Entity<Blog>().UseTpcMappingStrategy()
    .ToTable("Blogs");
modelBuilder.Entity<RssBlog>()
    .ToTable("RssBlogs");

각 엔터티 형식에서 ToTable을 호출하는 대신 각 루트 엔터티 형식에서 modelBuilder.Entity<Blog>().UseTpcMappingStrategy()를 호출하면 규칙에 따라 테이블 이름이 생성됩니다.

각 테이블의 기본 키 열에 대해 서로 다른 열 이름을 구성하려면 테이블별 패싯 구성을 참조하세요.

EF는 위의 모델에 대해 다음 데이터베이스 스키마를 만듭니다.

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId])
);

TPC 데이터베이스 스키마

TPC 전략은 계층 구조의 모든 구체적인 형식에 대해 다른 테이블이 생성되지만 추상 형식에 대해 테이블이 만들어지지 않으므로 이름이 "table-per-concrete-type"이라는 점을 제외하고 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 Hill 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

인간 테이블

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

TPT 매핑과 달리 단일 개체에 대한 모든 정보는 단일 테이블에 포함됩니다. 또한 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));

Important

이 전략을 사용하면 계층의 총 형식 수를 미리 알려야 하므로 나중에 파생 형식을 추가하기가 더 어려워집니다.

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 값이 포함됩니다.

요약 및 참고 자료

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

즉, TPC는 코드가 주로 단일 리프 유형의 엔터티를 쿼리하고 벤치마크가 TPH에 비해 개선된 것으로 표시될 때 사용할 수 있는 좋은 매핑 전략이기도 합니다.

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