EFCore 에서 Entity 매핑할 때 business logic인 base class에 primary key를 넣지 않는 방법

안녕하세요, 김예건입니다.

최근 서버 개발 설계를 하면서 개인적으로 EFCore 설계 관련 질문과 답변을 했는데, 다른 분들에게도 도움이 될 수 있을 것 같아, 한글로 검색될 수 있도록 국내 커뮤니티에 공유하기 위해 글 올립니다.

원 질문과 답변은 Issue #27421 · dotnet/efcore (github.com) 에서 확인하실 수 있습니다. Stack Overflow에도 올렸습니다.

질문

Microsoft 사에서 제공하는 eShopOnWeb 설계를 보면 Business Logic 에 Primary Key 가 들어 있습니다.

저는 데이터베이스 구조가 Business Logic 코드에 영향을 미치는 건 문제가 있다고 생각하여 아래와 같이 코드를 구성했더니, 아래와 같은 예외가 발생했습니다.

System.InvalidOperationException: ‘A key cannot be configured on ‘ManagerEntity’ because it is a derived type. The key must be configured on the root type ‘Manager’. If you did not intend for ‘Manager’ to be included in the model, ensure that it is not referenced by a DbSet property on your context, referenced in a configuration call to ModelBuilder, or referenced from a navigation on a type that is included in the model.’

저의 질문은 위 예외를 해결하는 동시에, Clean Architecture를 유지할 수 있는 방법을 알려 달라는 질문이었습니다.

Business Logic 에 해당하는 Core 코드

public class Manager
{
	public Manager(Guid identifier, string email)
	{
		Identifier = identifier;
		Email = email;
	}

	public Guid Identifier { get; }
	public string Email { get; }

	public void FixPrinter(Printer printer)
	{
	    printer.IsOutOfControl = true;
	}
}
public class Printer
{
	public Printer(Guid token)
	{
		Token = token;
		Manager = null;
		IsOutOfControl = false;
	}

	public Guid Token { get; }

	public Manager? Manager { get; set; }

	public bool IsOutOfControl { get; set; }
}

Business Logic 인 Core를 바탕으로 Database를 설계하는 Infrastructure 코드

public class ApplicationContext
	: DbContext
{
    // ...

	public DbSet<ManagerEntity> ManagerSet { get; set; }
	public DbSet<PrinterEntity> PrinterSet { get; set; }

	protected override void OnModelCreating(ModelBuilder modelBuilder)
	{
		base.OnModelCreating(modelBuilder);
		modelBuilder.ApplyConfiguration(new ManagerEntityConfiguration(Database));
		modelBuilder.ApplyConfiguration(new PrinterEntityConfiguration(Database));
	}
}

Manager

public sealed class ManagerEntity
	: Manager
{
	public ManagerEntity(string email)
		: base(Guid.Empty, email)
	{
	}

    // DB 에서만 활용할 속성인 Primary Key
	public long Id { get; }
}
internal sealed class ManagerEntityConfiguration
	: IEntityTypeConfiguration<ManagerEntity>
{
	private readonly DatabaseFacade _database;

	public ManagerEntityConfiguration(DatabaseFacade database)
	{
		_database = database;
	}

	public void Configure(EntityTypeBuilder<ManagerEntity> builder)
	{
		builder
			.Property(e => e.Id)
			.ValueGeneratedOnAdd();

        // The exception occurs here.
		builder
			.HasKey(e => e.Id);

        // ...
	}
}

Printer

public sealed class PrinterEntity
	: Printer
{
	public PrinterEntity()
		: base(Guid.Empty)
	{
	}

    // DB 에서만 활용할 속성인 Primary Key
	public long Id { get; }
}
internal sealed class PrinterEntityConfiguration
	: IEntityTypeConfiguration<PrinterEntity>
{
	private readonly DatabaseFacade _database;

	public PrinterEntityConfiguration(DatabaseFacade database)
	{
		_database = database;
	}

	public void Configure(EntityTypeBuilder<PrinterEntity> builder)
	{
		builder
			.Property(e => e.Id)
			.ValueGeneratedOnAdd();

		builder
			.HasKey(e => e.Id);

        // ...
	}
}

Core 와 Infrastructure 를 바탕으로 동작하는 Web API App

app.MapPost("/printer", async (ApplicationContext context) =>
{
	PrinterEntity printer = new()
	{
		Manager = new ManagerEntity("master@google.com"),
	};

	await context.PrinterSet.AddAsync(printer);
	await context.SaveChangesAsync();

	return printer;
});

전체 구조를 확인할 수 있는 Github 샘플 프로젝트

답변

답변은 Microsoft 사의 Entity Framework 팀 멤버이신 Shay Rojansky 님께서 달아주셨습니다.

일단 EFCore 는 convention (관습이라고 해야 하나요?)에 따라 inheritance mapping 이 적용되어, Business logic에 해당하는 Manager클래스도 DB 테이블로 매핑되는데 이 때 root 클래스가 되는 Manager 클래스에 Id 가 없기 때문에 발생하는 예외라고 설명해주셨습니다.

이를 해결하는 방법으로 2가지 방법을 제시해주셨는데,

첫 번째 방법은 Backing Fields를 활용하여 Managerprivate long _id필드를 정의하고 EFCore에서만 사용하는 방법입니다.

두 번째 방법은 Shadow and Indexer Properties를 활용하여 필드를 정의하지 않고, EFCore에서 ManagerEntity를 configure할 때 modelBuilder.Entity<ManagerEntity>().Property<long>("Id");로 정의하는 방법입니다.

아래 코드는 Shay Rojansky이 작성하신 동일한 문제를 재현할 수 있는 Minimal API 코드입니다.

await using var ctx = new BlogContext();
await ctx.Database.EnsureDeletedAsync();
await ctx.Database.EnsureCreatedAsync();

public class BlogContext : DbContext
{
    public DbSet<Manager> Managers { get; set; }
    public DbSet<ManagerEntity> ManagerEntities { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(@"Server=localhost;Database=test;User=SA;Password=Abcd5678;Connect Timeout=60;ConnectRetryCount=0")
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ManagerEntity>().HasKey(b => b.Id);
    }
}

public class Manager
{
    public string Name { get; set; }
}

public class ManagerEntity : Manager
{
    public int Id { get; set; }
}

마지막으로 제가 발견한 방법은 modelBuilder.Ignore<Manager>()로, EFCore 가 Manager클래스를 무시하도록 하는 방법입니다.

7개의 좋아요

멋집니다! 감사해요

1개의 좋아요

오…저도 참고하기에 좋은 글이군요…이따 회의끝나고 빡시게 봐야겠습니다 ^^ 감사합니다!

1개의 좋아요

안녕하세요. 닷넷데브 커뮤니티 운영에 참여하고 있는 김상현입니다.
해당 게시글은 Q&A 보다 '튜토리얼, 팁, 강좌’에 대한 성격이 더 강한것으로 사료됩니다.
이에따라 해당 게시물을 '튜토리얼, 팁, 강좌’로 이동합니다.

3개의 좋아요