EF 모델 패턴 연구

엔티티 프레임웍 코어를 사용할 때 모델 우선 접근법을 채택하면 반드시 스키마를 나타내는 "저장 모델"을 설계해야 합니다.

참고로, 저장 영역의 모델과 도메인 영역의 모델은 서로 분리하는 게 각 영역 별 관심사를 독립적으로 유지하는데 유리합니다.

  • Domain
    • Model
      • Student
  • Infrastructure
    • Persistence
      • Model
        • Student

그렇다고 같은 의미를 갖고, 외형적으로도 차이가 없는 모델을 레이어 마다 정의하는 것은 코딩 효율성을 저해합니다.

따라서, 코딩 효율성과 저장소 안정성을 모두 추구할 수 있도록 저장 모델을 설계해야 하는데, 이 글은 그런 목적을 추구하면서도 범용적으로 사용할 수 있는 저장 모델의 형식을 찾기 위한 노력입니다.

결론으로 도출된 형식을 먼저 살펴 보고, 수정 사항이 발생하거나 심도 있는 논의가 필요한 경우, 이어지는 글에서 다룰 생각입니다.

참고로 이어지는 코드는 모던 닷넷(8.0) 기준(Nullable: enable)입니다

고유 식별자(Id)

Id 는 불변 값 객체로 정의하고, 코드 효율성을 위해 제너릭으로 정의합니다.

namespace MyProject.Domain.Values;

public readonly record struct Id<T> where T : class
{
    public readonly Guid Value;
    private Id(Guid value)
    {
        if (value.Equals(Guid.Empty))
            throw new InvalidOperationException("value should be unique");

        Value = value;
    }
    public static Id<T> New() => new(Guid.NewGuid());
    public static Id<T> From(Guid guid) => new(guid);
    /*
    public static Id<T> Empty() => new(Guid.Empty);

    public static Id<T>? From(string guidString) => 
       Guid.TryParse(guidString, out var guid) ? From(guid)
       : null;

    public static implicit operator Id<T>(string guidString) =>
        Guid.TryParse(guidString, out var guid) ? From(guid)
       : new(Guid.Empty);
    */
}

주석으로 처리된 부분들은 문법적으로는 가능하지만, 의미적으로 하지말아야 할 것을 나타냅니다.

  • Guid.Empty 로 초기화된 객체는 형식적으로는 문제가 없어도 의미적으로 고유한 식별자로서 기능은 결여되어 있습니다.

  • string 을 파싱하는 것은 이 객체의 책임이 아닙니다. 또한, string 을 암시적으로 형변환하는 것도 강력한 형식의 값 객체를 사용하는 의도를 희석합니다.
    string 파싱은 호출 객체에서 하든가 아니면 아래와 같이 별도의 확장 메서드를 통하는 것이 좋습니다.

public static class IdExtensions
{
    public static Id<T>? ToId<T>(this string guidString) where T : class
        =>  Guid.TryParse(guidString, out var guid) ? Id<T>.From(guid)
       : null;
}

명시적으로 확장 메서드를 호출하는 것 자체가 string과 Id<T> 사이에 숨은 연결 고리가 없음을 반증합니다.

Entity 베이스

저장 객체는 POCO(다른 객체를 상속하거나 구현하지 않는 객체)로 정의해야 하지만, Id 가 제너릭으로 구현되었기 때문에 어쩔 수 없이 베이스 객체를 파생하는 형식으로 정의해야 합니다.

using MyProject.Domain.Values;

namespace MyProject.Domain;

public abstract class Entity<T> : IEquatable<Entity<T>>
    where T : class
{
    public Id<T> Id { get; init; } = Id<T>.New();

    public override bool Equals(object? obj) =>
        obj is Entity<T> other && Id.Equals(other.Id);

    public bool Equals(Entity<T>? other) => Equals((object?)other);
    public override int GetHashCode() => Id.GetHashCode();

    public static bool operator ==(Entity<T> left, Entity<T> right) =>
        left.Equals(right);

    public static bool operator !=(Entity<T> left, Entity<T> right) =>
        !left.Equals(right);
}

이 객체를 파생하는 모든 저장 객체는 Id 값을 기반한 정체성 비교가 강제됩니다.
또한 해시 테이블 계열의 객체에 Key 값으로 사용될 때도 Id 값이 대신 사용됩니다.

저장 객체의 생성자

어찌 보면 이 글에서 가장 중요한 부분입니다.

저장 객체의 멤버들은 각각의 형식의 종류에 따라 생성자의 매개 변수에 포함될 것인지 여부를 결정해야 합니다.

namespace MyProject.Domain.Models;

public class Student : Entity<Student>
{
    public string Name { get; }
    public DateTime Birth { get; }

    public static Student New(string name, DateTime birth, School? school) =>
        new(name, birth) 
        { 
            School = school 
        };

   // 참조 형식 멤버는 생성자에서 제외
    protected Student(string name, DateTime birth)
    {
        Name = name;
        Birth = birth;
    }
    
    // 참조 형식들
    public required School? School { get; set; }
}
namespace MyProject.Domain.Models;

public class School : Entity<School>
{
    public string Name { get; }
    public DateTime Established { get; }

    public static School New(string name, DateTime established, Location location) =>
        new(name, established)
        { 
            Location = location 
        };
    protected School(string name, DateTime established)
    {
        Name = name;
        Established = established;
    }

    // 참조 형식
    public required Location Location { get; set; }
    public ICollection<Student> Students { get; init; } = [];
}
namespace MyProject.Domain.Models;

public class Location : Entity<Location>
{
   // ...
}

생성자의 매개 변수를 구분하는 이유는 EF 코어가 테이블로부터 모델을 생성할 때, 생성자의 매개 변수가 참조 형식인 경우, 에러를 유발하는데, 이는 EF 코어는 테이블 => 인스턴스 맵핑 시에 내부적으로 Join 을 사용하기 때문입니다.

두 테이블의 관계가 Has 관계인 경우,
기본적으로는 Join 하지 않지만, Include 혹은 Where 혹은 Select 내부에서 참조하는 경우 Join 을 수행합니다.

반대로 Owns 관계인 경우 무조건 Join 합니다.

required

객체는 불완전한 상태로 생성되어서는 안됩니다.

그런데, EF 를 쓰는 경우 생성자에 참조 형식을 쓸 수 없다는 제한은 이 원칙의 준수를 어렵게 합니다.

이러한 모순은 required 키워드로 해결이 가능합니다.

모델의 인스턴스를

  • 코드로 생성 시에는 팩토리 메서드를 호출합니다.
  • EF 코어가 맵핑할 때는 값 속성은 생성자를 통해, 참조 속성은 setter를 통해 초기화합니다.

required 키워드는 팩토리 메서드를 통하지 않더라도 그 속성이 반드시 초기화될 것을 강제합니다. (안하면 컴파일 에러)
이러한 강제는 모든 파생 객체에도 적용이 되기에, 객체는 언제나 정상적인 상태로 초기화됩니다.

맵핑 보장

그런데 required 키워드는 EF 코어에 영향을 미치지 않습니다.

맵핑 시에 참조 형식은 required 유무와 상관 없이 Join 이 수행되면 할당되고, 아니면 null 이 됩니다.

매핑된 인스턴스를 코드에서 안전하게 사용하기 위해서는, nullable 로 선언하든가

public required School? School { get; set; }

아래와 같이 nullable 이 아닌 경우, 반드시 Join 이 수행되는 것을 보장하기 위해,

public required Location Location { get; set; }

Owns 관계로 설정하든가, Has 관계로 설정된 경우 Include 을 호출해야 합니다.

class SchoolRepo : ISchoolRepository
{
   private _context;
   // ...
   public School? Get(Id<School> schoolId) 
   {
       return _context.Schools
         .Include(x => x.Location)
         .FirstOrDefault(x => x.Id.Equals(schoolId));
   }

   // ...  
}

통일성

위 형식성은 EF 코어의 제한 때문에 만들어졌습니다만, 이는 도메인 객체를 정의할 때도 그대로 사용할 수 있습니다.

즉, 객체가 어떤 레이어에 속하냐를 따지지 않고, 이 형식성을 따르면 되는 것입니다.

보통은 도메인 모델을 우선 정의하기 마련입니다.
나중에 저장 모델이 필요한 경우, 도메인 모델과 큰 차이가 없다면, 별도로 정의할 필요 없어 코딩 효율적이라 할 수 있습니다.

7개의 좋아요