Share via


다대다 관계

다대다 관계는 한 엔터티 형식의 숫자 엔터티가 동일하거나 다른 엔터티 형식의 엔터티 수와 연결된 경우 사용됩니다. 예를 들어 Post에는 여러 개의 연결된 Tags가 있을 수 있으며 각 Tag는 어떠한 수의 Posts하고도 연결될 수 있습니다.

다대다 관계 이해

다대다 관계는 외래 키만 사용하여 간단한 방식으로 나타낼 수 없다는 점에서 일대다일대일 관계와 다릅니다. 대신 관계의 양쪽을 "조인"하려면 추가 엔터티 형식이 필요합니다. 이를 "조인 엔터티 형식"으로 알려져 있으며 관계형 데이터베이스의 "조인 테이블"에 매핑됩니다. 이 조인 엔터티 형식의 엔터티에는 각 쌍 중 하나가 관계의 한쪽에 있는 엔터티를 가리키고 다른 하나는 관계의 다른 쪽에 있는 엔터티를 가리키는 외래 키 값 쌍이 포함됩니다. 따라서 각 조인 엔터티 및 조인 테이블의 각 행은 관계의 엔터티 형식 간의 하나의 연결을 나타냅니다.

EF Core는 조인 엔터티 형식을 숨기고 백그라운드에서 관리할 수 있습니다. 이렇게 하면 다대다 관계의 탐색을 자연스럽게 사용하여 필요에 따라 각 쪽에서 엔터티를 추가하거나 제거할 수 있습니다. 그러나 전반적인 동작, 특히 관계형 데이터베이스에 매핑하는 것이 합리적이도록 백그라운드에서 발생하는 일을 이해하는 것이 유용합니다. 먼저 게시물과 태그 간의 다대다 관계를 나타내기 위한 관계형 데이터베이스 스키마 설정으로 시작해 보겠습니다.

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

이 스키마에서 PostTag는 조인 테이블입니다. 여기에는 Posts 테이블의 기본 키에 대한 외래 키인 PostsIdTags테이블의 기본 키에 대한 외래 키인 TagsId라는 두 개의 열이 포함됩니다. 따라서 이 테이블의 각 행은 하나의 Post 및 하나의 Tag 간의 연결을 나타냅니다.

EF Core에서 이 스키마에 대한 간단한 매핑은 각 테이블에 대해 하나씩 세 가지 엔터티 형식으로 구성됩니다. 이러한 각 엔터티 형식이 .NET 클래스로 표시되는 경우 해당 클래스는 다음과 같이 표시될 수 있습니다.

public class Post
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostsId { get; set; }
    public int TagsId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

이 매핑에는 다대다 관계가 아니라 조인 테이블에 정의된 각 외래 키에 대해 하나씩 두 개의 일대다 관계가 있습니다. 이는 이러한 테이블을 매핑하는 불합리한 방법이 아니지만, 두 개의 일대다 관계가 아닌 단일 다대다 관계를 나타내는 조인 테이블의 의도를 반영하지는 않습니다.

EF를 사용하면 관련된 Tags를 포함하는 Post 및 역으로 관련 Posts를 포함하는 Tag라는 두 개의 컬렉션 탐색을 도입하여 보다 자연스러운 매핑을 할 수 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
    public List<Post> Posts { get; } = [];
}

public class PostTag
{
    public int PostsId { get; set; }
    public int TagsId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

이러한 새 탐색은 다대다 관계의 다른 쪽에 직접 액세스를 제공하기 위해 조인 엔터티를 건너뛰기 때문에 "탐색 건너뛰기"라고 합니다.

아래 예제와 같이 다대다 관계를 이러한 방식으로 매핑할 수 있습니다. 즉, 조인 엔터티에 대한 .NET 클래스와 두 개의 일대다 관계에 대한 탐색을 모두 사용하고, 그리고 엔터티 형식에 노출되는 탐색을 건너뛸 있습니다. 그러나 EF는 조인 엔터티에 대해 정의된 .NET 클래스가 없고 두 일대다 관계에 대한 탐색 없이 투명하게 조인 엔터티를 관리할 수 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

실제로 EF 모델 빌드 규칙은 기본적으로 여기에 표시된 PostTag 형식을 이 섹션의 맨 위에 있는 데이터베이스 스키마의 세 테이블에 매핑합니다. 조인 형식을 명시적으로 사용하지 않고 이 매핑은 일반적으로 "다대다"라는 용어를 의미합니다.

예제

다음 섹션에는 각 매핑을 달성하는 데 필요한 구성을 포함하여 다 대 다 관계의 예가 포함되어 있습니다.

아래의 모든 예제에 대한 코드는 OneToMany.cs에서 찾을 수 있습니다.

기본 다대다

다대다에 대한 가장 기본적인 경우 관계의 각 끝에 있는 엔터티 형식에는 모두 컬렉션 탐색이 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

이 관계는 규칙에 의해 매핑됩니다. 필요하지 않더라도 이 관계에 대한 동등한 명시적 구성은 아래 학습 도구로 표시됩니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts);
}

이러한 명시적 구성을 사용하더라도 관계의 많은 측면은 여전히 규칙에 따라 구성됩니다. 학습을 위해 보다 완전한 명시적 구성은 다음과 같습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            "PostTag",
            l => l.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagsId").HasPrincipalKey(nameof(Tag.Id)),
            r => r.HasOne(typeof(Post)).WithMany().HasForeignKey("PostsId").HasPrincipalKey(nameof(Post.Id)),
            j => j.HasKey("PostsId", "TagsId"));
}

Important

필요하지 않은 경우에도 모든 항목을 완전히 구성하지 마세요. 위에서 볼 수 있듯이 코드는 빠르게 복잡해지며 쉽게 실수를 할 수 있습니다. 위의 예제에서도 모델에는 규칙에 따라 구성된 많은 항목이 있습니다. EF 모델의 모든 항목을 항상 명시적으로 완전히 구성할 수 있다고 생각하는 것은 현실적이지 않습니다.

관계가 규칙에 따라 빌드되는지 또는 표시된 명시적 구성 중 하나를 사용하든 관계없이 결과 매핑된 스키마(SQLite 사용)는 다음과 같습니다.

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

데이터베이스 첫 번째 흐름을 사용하여 기존 데이터베이스에서 DbContext를 스캐폴드하는 경우 EF Core 6 이상에서는 데이터베이스 스키마에서 이 패턴을 찾고 이 문서에 설명된 대로 다대다 관계를 스캐폴드합니다. 이 동작은 사용자 지정 T4 템플릿을 사용하여 변경할 수 있습니다. 다른 옵션은 매핑된 조인 엔터티가 없는 다대다 관계가 이제 스캐폴드됨을 확인하세요.

Important

현재 EF Core는 Dictionary<string, object>를 사용하여 .NET 클래스가 구성되지 않은 조인 엔터티 인스턴스를 나타냅니다. 그러나 성능을 향상시키기 위해 향후 EF Core 릴리스에서 다른 형식을 사용할 수 있습니다. 명시적으로 구성되지 않은 경우 조인 유형이 Dictionary<string, object>라는 데 의존하지 마세요.

명명된 조인 테이블이 있는 다대다

이전 예제에서 조인 테이블은 규칙에 따라 PostTag라는 이름이 지정되었습니다. UsingEntity로 명시적 이름을 지정할 수 있습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity("PostsToTagsJoinTable");
}

매핑에 대한 다른 모든 항목은 동일하게 유지되며 조인 테이블의 이름만 변경됩니다.

CREATE TABLE "PostsToTagsJoinTable" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostsToTagsJoinTable" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostsToTagsJoinTable_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostsToTagsJoinTable_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

조인 테이블 외래 키 이름을 가진 다대다

이전 예제에 따라 조인 테이블의 외래 키 열 이름도 변경할 수 있습니다. 두 가지 방법으로 이 작업을 수행할 수 있습니다. 첫 번째는 조인 엔터티에서 외래 키 속성 이름을 명시적으로 지정하는 것입니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            l => l.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagForeignKey"),
            r => r.HasOne(typeof(Post)).WithMany().HasForeignKey("PostForeignKey"));
}

두 번째 방법은 속성에 규칙별 이름을 그대로 두고 이러한 속성을 다른 열 이름에 매핑하는 것입니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            j =>
            {
                j.Property("PostsId").HasColumnName("PostForeignKey");
                j.Property("TagsId").HasColumnName("TagForeignKey");
            });
}

두 경우 모두 매핑은 동일하게 유지되며 외래 키 열 이름만 변경됩니다.

CREATE TABLE "PostTag" (
    "PostForeignKey" INTEGER NOT NULL,
    "TagForeignKey" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostForeignKey", "TagForeignKey"),
    CONSTRAINT "FK_PostTag_Posts_PostForeignKey" FOREIGN KEY ("PostForeignKey") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagForeignKey" FOREIGN KEY ("TagForeignKey") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

여기에 표시되지는 않지만, 이전의 두 예제를 결합하여 조인 테이블 이름과 외래 키 열 이름을 매핑할 수 있습니다.

조인 엔터티에 대한 클래스가 있는 다대다

지금까지 예제에서 조인 테이블은 공유 형식 엔터티 형식에 자동으로 매핑되었습니다. 이렇게 하면 엔터티 형식에 대한 전용 클래스를 만들 필요가 없습니다. 그러나 이러한 클래스가 쉽게 참조될 수 있도록 하는 것이 유용할 수 있습니다. 특히 아래 예제와 같이 탐색 또는 페이로드가 클래스에 추가될 때 유용합니다. 이렇게 하려면 먼저 PostTag에 대한 기존 형식 외에도 조인 엔터티에 대한 PostTag 형식을 만듭니다.

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
}

클래스는 이름을 가질 수 있지만 관계의 양쪽 끝에 있는 형식의 이름을 결합하는 것이 일반적입니다.

이제 UsingEntity 메서드를 사용하여 관계의 조인 엔터티 형식으로 구성할 수 있습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

PostIdTagId는 자동으로 외래 키로 선택되며 조인 엔터티 형식의 복합 기본 키로 구성됩니다. 외래 키에 사용할 속성은 EF 규칙과 일치하지 않는 경우 명시적으로 구성할 수 있습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>().WithMany().HasForeignKey(e => e.TagId),
            r => r.HasOne<Post>().WithMany().HasForeignKey(e => e.PostId));
}

이 예제의 조인 테이블에 대한 매핑된 데이터베이스 스키마는 구조적으로 이전 예제와 동일하지만 열 이름은 다릅니다.

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

엔터티를 조인하는 탐색이 있는 다대다

이전 예제에 따라 조인 엔터티를 나타내는 클래스가 있으므로 이 클래스를 참조하는 탐색을 쉽게 추가할 수 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
}

Important

이 예제와 같이 다 대 다 관계의 양쪽 끝 사이의 건너뛰기 탐색 외에도 조인 엔터티 형식에 대한 탐색을 사용할 수 있습니다. 즉, 건너뛰기 탐색을 사용하여 자연스럽게 다 대 다 관계와 상호 작용할 수 있으며 조인 엔터티 자체에 대한 더 큰 제어가 필요할 때 조인 엔터티 형식에 대한 탐색을 사용할 수 있습니다. 어떤 의미에서 이 매핑은 간단한 다대다 매핑과 데이터베이스 스키마와 보다 명시적으로 일치하는 매핑 간에 두 가지 면에서 가장 좋은 기능을 제공합니다.

조인 엔터티에 대한 탐색은 규칙에 따라 선택되므로 UsingEntity 호출에서 아무것도 변경할 필요가 없습니다. 따라서 이 예제의 구성은 마지막 예제와 동일합니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

규칙에 따라 확인할 수 없는 경우 탐색을 명시적으로 구성할 수 있습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>().WithMany(e => e.PostTags),
            r => r.HasOne<Post>().WithMany(e => e.PostTags));
}

매핑된 데이터베이스 스키마는 모델에 탐색을 포함하여 영향을 받지 않습니다.

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

조인 엔터티에 대한 탐색이 있는 다대다

이전 예제에서는 다대다 관계의 양쪽 끝에 있는 엔터티 형식에서 조인 엔터티 형식에 대한 탐색을 추가했습니다. 탐색은 다른 방향이나 양방향으로 추가할 수도 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

조인 엔터티에 대한 탐색은 규칙에 따라 선택되므로 UsingEntity 호출에서 아무것도 변경할 필요가 없습니다. 따라서 이 예제의 구성은 마지막 예제와 동일합니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

규칙에 따라 확인할 수 없는 경우 탐색을 명시적으로 구성할 수 있습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags),
            r => r.HasOne<Post>(e => e.Post).WithMany(e => e.PostTags));
}

매핑된 데이터베이스 스키마는 모델에 탐색을 포함하여 영향을 받지 않습니다.

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

탐색 및 변경된 외래 키가 있는 다대다

이전 예제에서는 조인 엔터티 형식을 탐색하는 다대다를 보여 줍니다. 이 예제는 사용된 외래 키 속성도 변경된다는 점을 제외하고 동일합니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostForeignKey { get; set; }
    public int TagForeignKey { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

다시 UsingEntity 메서드는 다음을 구성하는 데 사용됩니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags).HasForeignKey(e => e.TagForeignKey),
            r => r.HasOne<Post>(e => e.Post).WithMany(e => e.PostTags).HasForeignKey(e => e.PostForeignKey));
}

이제 매핑된 데이터베이스 스키마는 다음과 같습니다.

CREATE TABLE "PostTag" (
    "PostForeignKey" INTEGER NOT NULL,
    "TagForeignKey" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostForeignKey", "TagForeignKey"),
    CONSTRAINT "FK_PostTag_Posts_PostForeignKey" FOREIGN KEY ("PostForeignKey") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagForeignKey" FOREIGN KEY ("TagForeignKey") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

단방향 다대다

참고 항목

단방향 다대다 관계는 EF Core 7에서 도입되었습니다. 이전 릴리스에서는 프라이빗 탐색을 해결 방법으로 사용할 수 있습니다.

다대다 관계의 양쪽에 탐색을 포함할 필요는 없습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
}

EF는 일대다 관계가 아니라 다대다 관계여야 한다는 것을 알기 위해 몇 가지 구성이 필요합니다. 이 작업은 HasManyWithMany를 사용하여 수행되지만 탐색 없이 측면에 인수가 전달되지 않습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany();
}

탐색을 제거해도 데이터베이스 스키마에는 영향을 주지 않습니다.

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagsId" INTEGER 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);

페이로드가 있는 다대다 및 조인 테이블

지금까지 예제에서 조인 테이블은 각 연결을 나타내는 외래 키 쌍을 저장하는 데만 사용되었습니다. 그러나 연결에 대한 정보(예: 생성된 시간)를 저장하는 데 사용할 수도 있습니다. 이러한 경우 조인 엔터티의 형식을 정의하고 이 형식에 "연결 페이로드" 속성을 추가하는 것이 가장 좋습니다. 다대다 관계에 사용되는 "탐색 건너뛰기" 외에도 조인 엔터티에 대한 탐색을 만드는 것이 일반적입니다. 이러한 추가 탐색을 통해 조인 엔터티를 코드에서 쉽게 참조할 수 있으므로 페이로드 데이터를 쉽게 읽고/또는 변경할 수 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public DateTime CreatedOn { get; set; }
}

또한 페이로드 속성(예: 연결 행이 삽입될 때 자동으로 설정되는 데이터베이스 타임스탬프)에 생성된 값을 사용하는 것이 일반적입니다. 이렇게 하려면 몇 가지 구성이 필요합니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

결과는 행이 삽입될 때 타임스탬프가 자동으로 설정된 엔터티 형식 스키마에 매핑됩니다.

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

여기에 표시된 SQL은 SQLite용입니다. SQL Server/Azure SQL에서는 .HasDefaultValueSql("GETUTCDATE()")를 사용하고 TEXT의 경우 datetime을 읽습니다.

조인 엔터티로 사용자 지정 공유 형식 엔터티 형식

이전 예제에서는 PostTag 형식을 조인 엔터티 형식으로 사용했습니다. 이 형식은 게시물 태그 관계에 따라 다릅니다. 그러나 셰이프가 같은 조인 테이블이 여러 개 있는 경우 모든 조인 테이블에 동일한 CLR 형식을 사용할 수 있습니다. 예를 들어 모든 조인 테이블에 CreatedOn 열이 있다고 상상해 보세요. 공유 형식 엔터티 형식으로 매핑된 JoinType 클래스를 사용하여 매핑할 수 있습니다.

public class JoinType
{
    public int Id1 { get; set; }
    public int Id2 { get; set; }
    public DateTime CreatedOn { get; set; }
}

그런 다음 여러 다대다 관계를 통해 이 형식을 조인 엔터티 형식으로 참조할 수 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<JoinType> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<JoinType> PostTags { get; } = [];
}

public class Blog
{
    public int Id { get; set; }
    public List<Author> Authors { get; } = [];
    public List<JoinType> BlogAuthors { get; } = [];
}

public class Author
{
    public int Id { get; set; }
    public List<Blog> Blogs { get; } = [];
    public List<JoinType> BlogAuthors { get; } = [];
}

그런 다음 이러한 관계를 적절하게 구성하여 각 관계에 대해 조인 유형을 다른 테이블에 매핑할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<JoinType>(
            "PostTag",
            l => l.HasOne<Tag>().WithMany(e => e.PostTags).HasForeignKey(e => e.Id1),
            r => r.HasOne<Post>().WithMany(e => e.PostTags).HasForeignKey(e => e.Id2),
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));

    modelBuilder.Entity<Blog>()
        .HasMany(e => e.Authors)
        .WithMany(e => e.Blogs)
        .UsingEntity<JoinType>(
            "BlogAuthor",
            l => l.HasOne<Author>().WithMany(e => e.BlogAuthors).HasForeignKey(e => e.Id1),
            r => r.HasOne<Blog>().WithMany(e => e.BlogAuthors).HasForeignKey(e => e.Id2),
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

그러면 데이터베이스 스키마에서 다음 테이블이 생성됩니다.

CREATE TABLE "BlogAuthor" (
    "Id1" INTEGER NOT NULL,
    "Id2" INTEGER NOT NULL,
    "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_BlogAuthor" PRIMARY KEY ("Id1", "Id2"),
    CONSTRAINT "FK_BlogAuthor_Authors_Id1" FOREIGN KEY ("Id1") REFERENCES "Authors" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BlogAuthor_Blogs_Id2" FOREIGN KEY ("Id2") REFERENCES "Blogs" ("Id") ON DELETE CASCADE);


CREATE TABLE "PostTag" (
    "Id1" INTEGER NOT NULL,
    "Id2" INTEGER NOT NULL,
    "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("Id1", "Id2"),
    CONSTRAINT "FK_PostTag_Posts_Id2" FOREIGN KEY ("Id2") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_Id1" FOREIGN KEY ("Id1") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

대체 키가 있는 다대다

지금까지 모든 예제에서는 조인 엔터티 형식의 외래 키가 관계의 양쪽에 있는 엔터티 형식의 기본 키로 제한되는 것을 보여 줍니다. 각 외래 키 또는 둘 다 대신 대체 키로 제한될 수 있습니다. 예를 들어 TagPost에 대체 키 속성이 있는 이 모델에 대해 생각해 보세요.

public class Post
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Post> Posts { get; } = [];
}

이 모델의 구성은 다음과 같습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            l => l.HasOne(typeof(Tag)).WithMany().HasPrincipalKey(nameof(Tag.AlternateKey)),
            r => r.HasOne(typeof(Post)).WithMany().HasPrincipalKey(nameof(Post.AlternateKey)));
}

그리고 대체 키가 있는 테이블도 포함하여 명확성을 위해 결과 데이터베이스 스키마를 생성합니다.

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Posts_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Tags_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "PostTag" (
    "PostsAlternateKey" INTEGER NOT NULL,
    "TagsAlternateKey" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsAlternateKey", "TagsAlternateKey"),
    CONSTRAINT "FK_PostTag_Posts_PostsAlternateKey" FOREIGN KEY ("PostsAlternateKey") REFERENCES "Posts" ("AlternateKey") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsAlternateKey" FOREIGN KEY ("TagsAlternateKey") REFERENCES "Tags" ("AlternateKey") ON DELETE CASCADE);

조인 엔터티 형식이 .NET 형식으로 표시되는 경우 대체 키 사용에 대한 구성은 약간 다릅니다. 예시:

public class Post
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

이제 구성에서 제네릭 UsingEntity<> 메서드를 사용할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags).HasPrincipalKey(e => e.AlternateKey),
            r => r.HasOne<Post>(e => e.Post).WithMany(e => e.PostTags).HasPrincipalKey(e => e.AlternateKey));
}

그리고 결과 스키마는 다음과 같습니다.

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Posts_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Tags_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("AlternateKey") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("AlternateKey") ON DELETE CASCADE);

별도의 기본 키가 있는 다대다 및 조인 테이블

지금까지 모든 예제의 조인 엔터티 형식에는 두 개의 외래 키 속성으로 구성된 기본 키가 있습니다. 이러한 속성에 대한 값의 각 조합이 한 번에 발생할 수 있기 때문입니다. 따라서 이러한 속성은 자연 기본 키를 형성합니다.

참고 항목

EF Core는 컬렉션 탐색에서 중복 엔터티를 지원하지 않습니다.

데이터베이스 스키마를 제어하는 경우 조인 테이블에 추가 기본 키 열이 있을 이유가 없지만 기존 조인 테이블에 기본 키 열이 정의되어 있을 수 있습니다. EF는 여전히 일부 구성을 사용하여 이에 매핑할 수 있습니다.

조인 엔터티를 나타내는 클래스를 만들어 이 작업을 가장 쉽게 수행할 수 있습니다. 예시:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

public class PostTag
{
    public int Id { get; set; }
    public int PostId { get; set; }
    public int TagId { get; set; }
}

PostTag.Id 속성은 이제 규칙에 따라 기본 키로 선택되므로 필요한 유일한 구성은 PostTag 형식을 위한 UsingEntity에 대한 호출입니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

그리고 조인 테이블에 대한 결과 스키마는 다음과 같습니다.

CREATE TABLE "PostTag" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_PostTag" PRIMARY KEY AUTOINCREMENT,
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

기본 키는 클래스를 정의하지 않고 조인 엔터티에 추가할 수도 있습니다. 예를 들어 PostTag 형식만 있는 경우 다음과 같습니다.

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

이 구성을 사용하여 키를 추가할 수 있습니다.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            j =>
            {
                j.IndexerProperty<int>("Id");
                j.HasKey("Id");
            });
}

그러면 별도의 기본 키 열이 있는 조인 테이블이 생성됩니다.

CREATE TABLE "PostTag" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_PostTag" PRIMARY KEY AUTOINCREMENT,
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

계단식 삭제가 없는 다대다

위에 표시된 모든 예제에서 조인 테이블과 다대다 관계의 양면 간에 생성된 외래 키는 계단식 삭제 동작으로 만들어집니다. 이는 관계의 양쪽에 있는 엔터티가 삭제되면 해당 엔터티에 대한 조인 테이블의 행이 자동으로 삭제된다는 것을 의미하기 때문에 매우 유용합니다. 즉, 엔터티가 더 이상 존재하지 않는 경우 다른 엔터티와의 관계도 더 이상 존재하지 않습니다.

이 동작을 변경하는 것이 유용할 때는 상상하기 어렵지만 원하는 경우 수행할 수 있습니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            l => l.HasOne(typeof(Tag)).WithMany().OnDelete(DeleteBehavior.Restrict),
            r => r.HasOne(typeof(Post)).WithMany().OnDelete(DeleteBehavior.Restrict));
}

조인 테이블의 데이터베이스 스키마는 외래 키 제약 조건에서 제한된 삭제 동작을 사용합니다.

CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE RESTRICT,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE RESTRICT);

일대다 자체 참조

다대다 관계의 양쪽 끝에서 동일한 엔터티 형식을 사용할 수 있습니다. 이를 "자체 참조" 관계라고 합니다. 예시:

public class Person
{
    public int Id { get; set; }
    public List<Person> Parents { get; } = [];
    public List<Person> Children { get; } = [];
}

이는 PersonPerson이라는 조인 테이블에 매핑되며, 두 외신 키는 모두 People 테이블을 다시 가리킵니다.

CREATE TABLE "PersonPerson" (
    "ChildrenId" INTEGER NOT NULL,
    "ParentsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PersonPerson" PRIMARY KEY ("ChildrenId", "ParentsId"),
    CONSTRAINT "FK_PersonPerson_People_ChildrenId" FOREIGN KEY ("ChildrenId") REFERENCES "People" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PersonPerson_People_ParentsId" FOREIGN KEY ("ParentsId") REFERENCES "People" ("Id") ON DELETE CASCADE);

대칭 자체 참조 다대다

경우에 따라 다대다 관계는 자연스럽게 대칭입니다. 즉, 엔터티 A가 엔터티 B와 관련된 경우 엔터티 B도 엔터티 A와 관련이 있습니다. 단일 탐색을 사용하여 자연스럽게 모델링됩니다. 예를 들어 사람 A가 B와 친구인 경우 B는 사람 A와 친구가 되는 경우를 상상해 보세요.

public class Person
{
    public int Id { get; set; }
    public List<Person> Friends { get; } = [];
}

아쉽게도 매핑하기가 쉽지 않습니다. 관계의 양쪽 끝에 동일한 탐색을 사용할 수 없습니다. 가장 좋은 방법은 단방향 다대다 관계로 매핑하는 것입니다. 예시:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Person>()
        .HasMany(e => e.Friends)
        .WithMany();
}

그러나 두 사람이 서로 관련되어 있는지 확인하려면 각 사람을 다른 사람의 Friends 컬렉션에 수동으로 추가해야 합니다. 예시:

ginny.Friends.Add(hermione);
hermione.Friends.Add(ginny);

조인 테이블 직접 사용

위의 모든 예제에서는 EF Core 다대다 매핑 패턴을 사용합니다. 그러나 조인 테이블을 일반 엔터티 형식에 매핑하고 모든 작업에 대해 두 개의 일대다 관계를 사용할 수도 있습니다.

예를 들어 이러한 엔터티 형식은 다대다 관계를 사용하지 않고 두 개의 일반 테이블과 조인 테이블의 매핑을 나타냅니다.

public class Post
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = new();
}

public class Tag
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = new();
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

이는 일반 일대다 관계가 있는 일반 엔터티 형식이므로 특별한 매핑이 필요하지 않습니다.

추가 리소스