EF Core 를 통해 코드 우선 데이터베이스 디자인을 하면서 발견한 것들

저는 정식 서비스를 위한 개발 경험이 거의 없고, 쿼리 학습이라는 부차적 목적도 병행해야 했기 때문에, 과거의 모든 프로젝트에서 데이터 베이스 테이블은 쿼리로 설계하고, 도메인 엔티티는 코드로 설계하곤 했습니다.

그런데, 이번에 새롭게 시작한 프로젝트는 정식 서비스를 염두해 둔 것이라 과거보다는 많은 개체를 정의해야 했습니다.

그런데, 기존에 해왔던 방법으로는 업무량이 도저히 감당이 안되더군요.

무엇보다 정의한 개체가 너무 많아서, 이들을 위한 테이블을 쿼리로 처리하는 것 자체가 큰 부담이었습니다. 테이블 생성 쿼리는 뭐 큰 일이 아니지만, 문제는 개체가 끊임없이 수정되다 보니, 쿼리와 C#을 왔다 갔다 하는 것 자체가 비효율적이더군요. 멀미로 토할 뻔.

또한 엔티티 관계에 관해 깊은 배움이 있는 것도 아니어서, 데이터 베이스 설계 측면에서 부족한 역량이 시시 때때로 발목을 잡기도 했습니다.

그러한 이유로, 이번에는 데이터 베이스 디자인에 관해서는 EF Core의 도움을 받기로 했고, 프로젝트 진행하는 와중에 느꼈던 점들을 기록을 위해 남깁니다.

이 글에서는

  1. Entity는 "개체"로 표현하며, 매소드가 없는 POCO 형태의 클래스라는 의미로 한정합니다.
  2. 비지니스 로직에 사용되는 개체를 "도메인 개체"라고 부르고, 데이터 베이스 테이블을 위한 개체를 "데이터 베이스 개체"로 구분해서 부릅니다.
  3. EF의 데이터 베이스 디자인 측면 만을 다룹니다.

마이그레이션

EF core의 기본 맵핑 컨벤션은 분명 저보다 나은, 아니, 꽤나 수준이 높은 전문가에 의해서 설계되었기 때문에, 데이터 베이스가 저의 부족함에 좌우되지 않을 것이라는 안도감이 생겼습니다.

뿐만 아니라, 제가 그 못난 실력으로 직접 데이터 베이스를 설계할 때 소요되는 시간과 에너지를 거의 대부분 줄여줬기 때문에 너무 편리했습니다.

개체의 정의가 아무리 많이 바뀌어도 이를 묵묵히 처리해주는 마이그레이션은 마치, 수석 졸업한 DBA 가 바짝 붙어 있는 듯한 든든함마저 느끼게 해줍니다.

네비게이션 속성

내가 그의 이름을 불러 주기 전에는
하나의 기반 클래스에 지나지 않았다.
내가 그의 이름을 불러 주었을 때,
그는 내게로 와
테이블이 되었다

학습을 위해서 EF를 배울 때, 가장 피상적으로 느껴졌던 부분이 “네비게이션” 속성이었습니다.
학습을 하니까, What & How 는 배울 수 있어도, Why는 제대로 배우지 못했던 거죠.

학습할 때 예제로 보았던 데이터 베이스 개체들은 보통 FK 에 해당하는 속성과 네비게이션 속성이 함께 정의된 경우가 많았습니다.

class DependentEntity
{
     //...

    // FK
    public Guid PrincipalId {get; set;}
    // 네비게이션
    public PrincipalEntity Principal {get; set; }
}

학습 차원에 머무를 때는, 저런 이중 정의가 왠지 비효율적으로 보였지만, “그렇게 하는 거구나” 정도로만 이해하고 넘어 갔습니다.

그런데, 이번에 네비게이션이야 말로, EF가 테이블을 만들지 말 지를 결정하는 가장 중요한 근거가 된다는 점을 알게 되었습니다.

예를 위한 도메인 개체들의 슈도 코드입니다.

abstract class EntityBase { Guid Id }

abstract class Organization : EntityBase { string Name }
class User : EntityBase { string Name }

class Company : Organization {  string TaxId }
class Team : Organization { Company Management}
class Worker : User { Organization Management }

위와 같이 정의된 경우, EF의 컨벤션 맵핑은 추상 클래스인 EntityBase를 위한 테이블은 생성하지 않지만, 동일한 추상 클래스인 Organization 테이블은 생성합니다.

이러한 차이가 발생한 원인은 EntityBase 개체는 다른 개체에서 참조되지 않은 반면, Organization은 Worker 객체에서 참조되었기 때문입니다.

EF에서, 참조 자료형 속성을 "네비게이션 속성"이라고 별도로 부르는 이유가, 네비게이션으로 사용된 개체는 반드시 PK 를 가진 테이블을 가지고 있어야 하기 때문입니다.
이 PK가 네비게이션의 목적지를 의미하기 때문입니다.

즉, EF 는 개체가 가진 FK 속성을 믿지 않고, 자신 만의 네비게이션 체제를 기반으로 관계 그래프를 그리는 것이죠.

이렇게 생성된 organization 테이블의 필드는 Organization이 가지는 속성과 EntityBase가 가지는 속성들로 채워집니다.

런타임 객체를 기준으로 한다면, 이 속성들은 사실 Company 인스턴스가 보유한 것들입니다.
인스턴스가 가진 속성들이 엉뚱한 테이블(organization)에 저장되어 있는 것이죠.

여기에서 흥미로운 점은 필드들을 organization 테이블에게 모두 뺏겨서 껍데기만 남은 company 테이블입니다. 아래는 EF가 생성한 company 테이블 생성 쿼리의 일부입니다.

    CONSTRAINT "PK_company" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_company_Organization_Id" FOREIGN KEY ("Id")
        REFERENCES public."Organization" ("Id") MATCH SIMPLE
        ON UPDATE NO ACTION
        ON DELETE CASCADE

company.Id 와 organization.Id 는 항상 같은 값을 가지고, 두 레코드가 Cascase Delete되도록 선언했습니다.

이처럼 강력하고 무식한 테이블 제약은 저는 감히 상상하기도 어려웠을 것입니다.
그저 예쁘고 소박한 예제들로만 공부를 한 탓이겠죠 ^^

이로 인해, company 레코드와 organization 레코드가 동기화되기 때문에 코드든 쿼리든 company를 삭제하면, organization 에도 영향을 받게 되는데, 이는 정확히 제가 원하는 방향입니다.

데이터 베이스에 Cascase Delete 제약을 함부로 걸지 말라는 격언에도 불구하고, EF가 생성한 제약은 상당히 일리 있어 보입니다. 물론 저 제약을 없앨지 말지는 아직도 고민 중이기는 합니다만, 대세에 크게 지장 없는 고민인 것 같습니다.

Fluent Api 맵핑

코드 개체를 데이터 베이스 테이블과 맵핑할 때 사용할 수 있는 C# 도구에는 크게 두 가지가 있습니다.

  1. 어노테이션
    System.ComponentModel 에서 제공하는 특성(attribute) 객체로 개체의 속성을 데코레이션.
  2. Fluent Api
    Microsoft.EntityFrameworkCore 에서 제공하는 IEntityTypeConfiguration<‘TEntity’> 를 사용

강좌를 전전하던 제 수준에서는 지금까지 어노테이션 방식만을 사용했으나, 이번에는 Fluent API 방식을 적용했습니다. EF 메뉴얼 문서를 읽다 보니, 음으로 양으로 후자가 더 좋다는 뉘앙스가 느껴졌기 때문입니다.

장점

  1. 도메인 개체가 깔끔해짐
  2. 프로젝트 모듈의 세분화 가능함.

단점

  1. 모르는 상태라면 학습이 필요함.
    (이 방식을 이해하고, 익숙해지기까지 만 하루 정도 공부가 필요했습니다)

특히 장점 2 는 관심사를 더 세세한 모듈 단위로 분리할 수 있게 해줬습니다.

Core 프로젝트 : 도메인 개체, 로직을 정의함
Database.Configuration 프로젝트 : 개체-테이블 맵핑 정보를 관리함. (아답터에 영향 받지 않음)
Database.PostgreSQL 프로젝트 : PostgreSQL 아답터.
(Database.Sqlite 프로젝트 : Sqlite 아답터, 시험적)
Dao 프로젝트 : 데이터 베이스의 데이터 서비스.
UI/Service 프로젝트 : Dao/Core를 소비.

한계

데이터 베이스를 하나의 관심사로 분리해서, 별도의 모듈로 정의할 경우, Core 프로젝트에 정의된 EntityBase 는 사실, Database.Configuration 모듈에서만 의미 있는 데이터 베이스 개체라고 할 수 있습니다.

abstract class EntityBase { Guid Id }

Core 프로젝트의 도메인 개체들은 이 데이터 베이스 개체의 존재 여부를 몰라야 진정한 관심사 분리라고 할 수 있습니다.

abstract class Organization { string Name }
class User { string Name }

class Company : Organization {  string TaxId }
class Team : Organization { Company BelongsTo }
class Worker : User { Organization Workfor }

현실적으로도 Core 프로제트와 데이터 베이스 프로젝트를 두 명의 담당자로 나눈 경우, 데이터 베이스 담당자가 책임져야 하는 EntityBase를 Core 담당자가 보유하고 있으면 안되죠.

데이터 베이스 담당자의 주 책임은 데이터 베이스 개체를 정의하는 것입니다.
그런데, 도메인 개체의 정의가 이미 충분해서 EntityBase를 상속하는 수준으로만 정의해도 되는 경우가 있을 것입니다. (적지는 않을 것 같습니다)

이러한 경우, 아래와 같이 도메인 개체와 EntityBase 개체의 속성을 한번에 가져 오는 것이 가능하다면, 데이터 베이스 개체를 중복으로 정의하는 수고를 할 필요가 없을 것입니다.

class CompanyTable : Company, EntityBase

그러나, 클래스의 다중 상속이 불가능한 C#에서는 이 편리함을 누릴 수 없습니다.

이러한 언어적 한계는 ORM이 해결해줘야 하는 문제인 것 같습니다.
물론, EF 가 제공하는 Shadow Property (잠시 후에 살펴 봄)를 사용하면 해결 가능하기는 합니다.
그러나, 아래와 같이 간편하고 안전한 방법은 아닙니다.

namespace Database.Configuration; 

public class EntityBase
{
    public Guid Id { get; set; }
    public DateTime Created { get; set; }
    public DateTime Modified { get; set; } = DateTime.Now;
    public bool IsActive { get; set; } = true;
}
using Database.Configuration;

namespace Database.PostgreSQL;

public class ApplicationContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 있으면 좋을 것 같음.
        modelBuilder.ImportKeyHolder<EntityBase>();
    }
}

Shadow Property

그림자 속성은 도메인 개체에는 포함되지 않지만 데이터 베이스 개체에는 포함되는 속성을 가리킵니다.
주로, 데이터 베이스에 저장된 중요한 정보가 도메인 인스턴스에 포함되지 않게 만들 때 사용됩니다.

그런데, 도메인 개체의 속성과 데이터 베이스 개체의 속성을 합칠 때에도 사용할 수 있습니다.
아래는 Fluent API 방식으로 그림자 속성을 정의하는 예를 보여 주는데, EntityBase의 속성들을 그림자 속성으로 정의하는 과정입니다.

public class BaseEntityConfiguration<T> : IEntityTypeConfiguration<T> where T : class
{
    public virtual void Configure(EntityTypeBuilder<T> builder)
    {
        builder.Property<Guid>(nameof(EntityBase.Id));
        
        builder.Property<DateTime>(nameof(EntityBase.Created))
            .IsRequired();

        builder.Property<DateTime>(nameof(EntityBase.Modified));

        builder.Property<bool>(nameof(EntityBase.IsActive))
            .IsRequired();

        builder.ToTable(typeof(T).Name.ToLower());

        // Id 를 선언한 후에 설정해야 함.
        builder.HasKey(nameof(EntityBase.Id));
    }
}

위의 맵핑 설정자는 모든 개체에 EntityBase의 속성을 추가하므로, 이를 파생한 맵핑 설정자를 사용하여 도메인 개체를 맵핑하면, 테이블에는 EntityBase의 속성들이 필드로 포함됩니다.

아래는 도메인 개체인 Company 를 저장할 테이블의 필드에 EntityBase의 속성들을 포함하는 경우이고,

class CompanyConfig : BaseEntityConfiguration<Company>
{
    public override void Configure(EntityTypeBuilder<Company> builder)
    {
        base.Configure(builder);

        builder.Property( c  => c.Name)
               .HasMaxLength(100);
    }
}

아래는 그렇게 하지 않을 경우입니다.

class CompanyConfig : IEntityTypeConfiguration<Company>
{
    public void Configure(EntityTypeBuilder<Company> builder)
    {
        base.Configure(builder);

        string idColName = "id";

        builder.Property( c  => c.Name)
               .HasMaxLength(100);

        builder.Property<Guid>(idColName);

        builder.ToTable(nameof(Company).ToLower());

        // Id 를 선언한 후에 설정해야 함.
        builder.HasKey(idColName);
    }
}

그러나, 위 두 가지 어떤 방식을 사용해도 수동으로 설정하는 것에 지나지 않습니다.
수동 설정 방식은 설정자의 역량에 따라 EF 의 맵핑 컨벤션 결과물 보다는 품질이 떨어질 수 있고, error-prone 한 면이 있습니다.

따라서, EF 에서 간편하고 안전하게 사용할 수 있는 다중 출처 맵핑 도구를 제공하기를 희망해 봅니다.

관계도 위아래가 있다

EF 의 맵핑 컨벤션은 관계를 맺는 두 개체의 자료형 이름과 속성의 이름 사이의 유사성을 통해 관계를 유추합니다.
아래와 같이 이름을 통해 쉽게 유추 가능한 경우, 컨벤션은 문제 없이 네비게이션을 지정합니다.

class Worker{ Organization Organization }
class Organization { IEnumerable<Worker> Workers}

만약, 쉽게 유추할 수 없는 이름을 사용한 경우, 컨벤션은 맵핑에 실패하고, 마이그레이션은 중지됩니다.

class Worker{ Organization Management}
class Organization { IEnumerable<Worker> Members}

이 경우에도 수동 네비게이션이 필요합니다.

개체의 관계는 거울과도 같습니다. 한쪽에서 설정한 관계는 다른 당사자에게도 적용이 되기 때문입니다.

“우리 아빠야” 라고 소개한 사람은 그 아빠의 자식인 것이 분명한 것과 유사합니다.

네비게이션 지정도 마찬 가지로 당사자 중 어느 한 쪽에서만 선언하면 됩니다.

// 둘 중에 하나만 해도 됨

class OrganizationConfig : BaseEntityTypeConfiguration<Organization>
{
    public override void Configure(EntityTypeBuilder<Organization> builder)
    {
        base.Configure(builder);

        builder.HasMany(o => o.Members)
            .WithOne(w => w.Employment);
    }
}

class WorkerConfig : BaseEntityTypeConfiguration<Worker>
{
    public override void Configure(EntityTypeBuilder<Worker> builder)
    {
        base.Configure(builder);

        builder.HasOne(w => w.Employment)
            .WithMany(o => o.Members);
    }
}

그런데 재미있는 것은 아래와 같이 맵핑을 한번에 처리하도록 한 경우입니다.

public class ApplicationContext : DbContext
{
    public ApplicationContext(DbContextOptions<ApplicationContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // 현재 어셈블리에 정의된 모든 Configuration 을 적용하도록
        modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
    }
}

이 경우, WorkerConfig 클래스를 제거해도 맵핑에 문제가 없습니다.

그러나, OrganizationConfig를 제거하면 마이그레이션할 때 네비게이션을 찾을 수 없다는 에러가 발생합니다.

이는 ApplyConfigurationsFromAssembly() 메서드가 어셈블리에 포함된 Configuration 파일들을 이름 순서 대로 읽어서 네비게이션을 설정하다 보니, 순서 상 먼저인 Organization과 관련한 수동 네비게이션을 발견하지 못하면, 맵핑을 실패한 것으로 처리하도록 구현된 것이 원인인 것 같습니다.

저는 처음에 WorkerConfig 만 정의했는데, 이 때 마이그레이션이 계속 실패했습니다.
그 이유를 못 찾아서 한 참 해 맸었죠.

다음 번 업데이트에서는 이러한 사소한 문제로 인한 불편이 없었으면 좋겠습니다.

[추가] - 아래는 중요한 내용인데, 빼먹었네요.

디자인 도구 제약

EF 를 사용해서 데이터 베이스를 설계할 때는 EF 에서 제공하는 cli 도구를 사용합니다.

그런데, 이 도구가 데이터 베이스 설계(Design)와 관련한 행위 (마이그레이션, 데이터 베이스 업데이트, 스크립트 생성 등)를 할 때는 두 가지 전제 조건이 충족되어야 합니다.

  1. 디자인 패키지 필요
    https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Design/8.0.0-preview.3.23174.2

  2. Dotnet Core 런타임 프로젝트

첫 번째 조건은 수긍이 가지만 두 번째 조건은 약간 이해하기 힘든 면이 있습니다.
왜 필요한지는 문서의 아래 부분에서 설명하고 있는데,

요약하자면, EF 도구는 닷넷 코어 런타임으로부터 DbContext, Entity, Configuration 에 관한 정보를 취득하기 때문이랍니다.

이렇게 설계한 이유는 .Net Standard Library 프로젝트를 사용하는 프레임워크의 존재때문인 것 같습니다. Xamarin.Forms 가 대표적인 예인데, 이 프레임워크의 "공유 프로젝트"는 닷넷 스탠다드 라이브러리입니다.

닷넷 스탠다드는 구현 프레임워크가 아니라 명세(Specification)이기 때문에, 이 형식의 프로젝트에 정의된 모델은 특정 구현 프레임워크의 해석을 거쳐야지만 구체화될 수 있습니다.

그 해석을 위해 런타임이 필요한 것이죠.

EF 도구는 해석 용 런타임을 제공하는 프로젝트를 startup project 라고 부르는데, 아래와 같이 명령어의 옵션으로 지정할 수 있습니다.

dotnet ef {명령어} --project {프로젝트} --startup-project {프로젝트}

–project : 명령을 적용할 프로젝트 (결과 파일 등을 저장)
–startup-project : 해석 용 런타임을 제공하는 프로젝트

두 옵션 모두 명령이 실행되는 현재 디렉토리의 .csproj 가 기본값입니다.

두번 째 조건은 EF 가 일반적으로 사용되는 Asp.Net Core 프로젝트에서는 아무런 문제가 되지 않습니다. Asp.Net Core는 런타임 프로젝트이니까요.

그러나, 저는 데이터 베이스를 별도의 프로젝트 모듈로 분리했기 때문에, 이 조건을 충족하기 위한 대책이 필요합니다.

대책

  1. DbContext를 콘솔 프로젝트에 정의

  2. 더미 콘솔 프로젝트 생성
    DbContext는 라이브러리 프로젝트에 정의하고, 이 프로젝트를 참조하는 콘솔 프로젝트 생성.
    더미 콘솔 프로젝트는 해석 제공이 목적이라, 그 자체는 아무런 실행 코드가 없습니다.

저는 데이터 베이스 아답터 별로 DbContext와 Migration 정보를 저장하기를 원했기 때문에, 각 아답터 프로젝트를 콘솔 프로젝트로 정의하는 첫 번째 해결책을 선택했습니다.

결과적으로, 프로젝트 관련 옵션을 별도로 설정할 필요가 없는 편리함을 얻었습니다.

c:\project\database.postgres> dotnet ef migrations add Initial
c:\project\database.postgres> dotnet ef migrations database update
c:\project\database.postgres> dotnet ef dbcontext script -o dbDesignQueries

8개의 좋아요

아 ef 관한 deep 한 강좌 훌륭하십니다. 솔직히 이해할려고 몇번을 봤습니다.
저는 보통 EntityBase만 상속하는데 Business 부모 클래스를 다중 상속하실려고
방법을 고민하고 찾으시다니 ㄷㄷㄷ 처음에는 shadow property 가 뭔가 했습니다.
제가 제대로 이해했는지 모르지만
base entity는 configuration 에 정의하고 각 entity 에 부모 business (또는 scope)
상속 받아 관계를 맺으시다니 제가 알기로는 상당히 드문 사례이신것 같은데
많이 찾아보시고 고민하셨을것 같아요
근데 말씀대로 migration 할때 MS 에서 EF에 이것 까지 고려해서 개발했을것 같지 않네요

3개의 좋아요

토나올 뻔 했습니다. ㅠㅠ

보다 정확히는 다른 계층에서 음으로 양으로 사용된 데이터 베이스 관련 객체 혹은 속성을 모두 데이터 베이스 계층으로 긁어 모으는 것입니다.

사실, 이번에 이렇게 작심하고 파고든 데는 아래의 글에서 영감을 받은 부분이 작지 않습니다.

EF 강좌는 거의 대부분 Asp.Net Core 강좌와 붙어 있는데, 이러한 강좌들의 코드 구조는 은연 중에 뷰 계층과 데이터 계층이 강하게 결합된 상태가 됩니다.

이러한 강좌들만 본 저로서는, Entity 가 도대체 무엇을 가리키는지 명확하지 않은 상태였습니다.
또한, 저의 솔루션은 UI로 Asp.Core 도 쓸 예정이고, Maui 도 쓸 예정이어서, 데이터 계층이 뷰로부터 완전히 독립적이길 바라고 있었습니다.
물론 뷰 뿐만 아니라, 다른 어떤 계층도 데이터 베이스에 관한 코드 하나 남겨두지 않겠다는 마음이었죠.

그 와중에 위에 링크된 글에서 그렇게 세분화되어 있는 것이 오히려 더 좋은 구조라고 말씀하시기에, 개념도 정리하고, 향후에 재사용 가능한 솔루션 구조를 만들기로 맘 먹었었습니다.

사실, C#과 관련한 자료가 거의 없어서 힘들긴 했으나, 결국은 닷넷 문서에서 답을 찾았습니다.
그런데, 이 놈의 닷넷 문서는 알고 보면 참 멋진 글인데, 모르고 보면 읽기 전이나 읽은 후나 똑 같다는^^

그리고, Shadow Property는 EF의 정식 용어입니다.
Shadow and Indexer Properties - EF Core | Microsoft Learn

2개의 좋아요

MSDN 은 답은 있는데 머리속에 들어오지 않는 뭐 성경 같습니다 ^^;;(저한테는)
사담으로 저도 재사용성을 Model 분리에 관해 많이 고민해서 나온 결론은
Clean Archtecture 였습니다.
간단히 얘기하자면
DB Entity가 솔루션에 영향을 최소한 한다 주의로 나온 방법이
DB Entity 와 DTO를 분리하고
Cqrs 패턴으로 개발후 이 dto 와 db 는 서로 mapper 툴을 이용해 맵핑을 한다 했을때
많은 문제가 해결됬습니다.

솔직히 저는 개발스킬은 msdn이나 c#에서 배웠어도 패턴이나 기법은 java 쪽이 훨씬
많이 발전하고 방법이 많다고 봅니다.

3개의 좋아요

저도 사실은 클린 아키텍처로 설계 중입니다.

포럼 서버가 문제 있는 지 그림이 안 올라가네요.
대략 아래와 같은 구조입니다.

  • core
    – Models
    — Entities
    — Values
  • infra
    – database
  • ui
    – WebBlazor
    – MAUI
  • usecases
    └ …
    └ …
    └ …
    └ …
2개의 좋아요

@BigSquare 넵 지금 서버가 불안정합니다.
정상 복구 되면 도식화 업로드 부탁드려요!!

그리고 좋은 글 올려주셔서 감사합니다. :smiling_face_with_three_hearts:

2개의 좋아요