다음을 통해 공유


값 객체 구현

팁 (조언)

이 콘텐츠는 .NET Docs 또는 오프라인으로 읽을 수 있는 다운로드 가능한 무료 PDF로 제공되는 컨테이너화된 .NET 애플리케이션용 .NET 마이크로 서비스 아키텍처인 eBook에서 발췌한 내용입니다.

컨테이너화된 .NET 애플리케이션을 위한 .NET 마이크로서비스 아키텍처 eBook의 표지 썸네일.

엔터티 및 집계에 대한 이전 섹션에서 설명한 것처럼 ID는 엔터티의 기본 사항입니다. 그러나 시스템에는 값 개체와 같은 ID 및 ID 추적이 필요하지 않은 많은 개체 및 데이터 항목이 있습니다.

값 개체는 다른 엔터티를 참조할 수 있습니다. 예를 들어 한 지점에서 다른 지점으로 가져오는 방법을 설명하는 경로를 생성하는 애플리케이션에서 해당 경로는 값 개체가 됩니다. 특정 경로의 지점 스냅샷이지만 내부적으로 도시, 도로 등과 같은 엔터티를 참조할 수 있더라도 이 제안된 경로에는 ID가 없습니다.

그림 7-13은 Order 집계 내의 Address 값 개체를 보여줍니다.

Order Aggregate 내의 Address 값 개체를 보여 주는 다이어그램

그림 7-13. Order 집계 내의 주소 값 개체

그림 7-13과 같이 엔터티는 일반적으로 여러 특성으로 구성됩니다. 예를 들어 Order 엔터티는 ID가 있는 엔터티로 모델링하고 OrderId, OrderDate, OrderItems 등의 특성 집합으로 내부적으로 구성할 수 있습니다. 그러나 단순히 국가/지역, 거리, 도시 등으로 구성된 복합 값이며 이 도메인에 ID가 없는 주소는 모델링되고 값 개체로 처리되어야 합니다.

중요한 값 객체의 특성

값 개체에는 다음 두 가지 주요 특징이 있습니다.

  • 그들은 ID가 없습니다.

  • 변경할 수 없습니다.

첫 번째 특징은 이미 논의되었습니다. 불변성은 중요한 요구 사항입니다. 개체를 만든 후에는 값 개체의 값을 변경할 수 없어야 합니다. 따라서 개체가 생성될 때 필요한 값을 제공해야 하지만 개체의 수명 동안 변경하도록 허용해서는 안 됩니다.

값 개체를 사용하면 변경할 수 없는 특성 덕분에 성능을 위해 특정 트릭을 수행할 수 있습니다. 특히 수천 개의 값 개체 인스턴스가 있을 수 있으며, 그 중 상당수는 동일한 값을 가진 시스템에서 특히 그렇습니다. 변경할 수 없는 특성을 통해 다시 사용할 수 있습니다. 값이 동일하고 ID가 없으므로 서로 교환 가능한 개체가 될 수 있습니다. 이러한 유형의 최적화는 느리게 실행되는 소프트웨어와 성능이 좋은 소프트웨어 간에 차이를 만들 수 있습니다. 물론 이러한 모든 경우는 애플리케이션 환경 및 배포 컨텍스트에 따라 달라집니다.

C#의 값 객체 구현

구현 측면에서 모든 특성(값 개체가 ID를 기반으로 해서는 안 됨) 및 기타 기본 특성 간의 비교를 기반으로 같음과 같은 기본 유틸리티 메서드가 있는 값 개체 기본 클래스를 가질 수 있습니다. 다음 예제에서는 eShopOnContainers에서 마이크로 서비스의 순서 지정에 사용되는 값 개체 기본 클래스를 보여줍니다.

public abstract class ValueObject
{
    protected static bool EqualOperator(ValueObject left, ValueObject right)
    {
        if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null))
        {
            return false;
        }
        return ReferenceEquals(left, right) || left.Equals(right);
    }

    protected static bool NotEqualOperator(ValueObject left, ValueObject right)
    {
        return !(EqualOperator(left, right));
    }

    protected abstract IEnumerable<object> GetEqualityComponents();

    public override bool Equals(object obj)
    {
        if (obj == null || obj.GetType() != GetType())
        {
            return false;
        }

        var other = (ValueObject)obj;

        return this.GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
    }

    public override int GetHashCode()
    {
        return GetEqualityComponents()
            .Select(x => x != null ? x.GetHashCode() : 0)
            .Aggregate((x, y) => x ^ y);
    }
    // Other utility methods
}

ValueObjectabstract class 형식이지만, 이 예제에서는 == 연산자와 != 연산자를 오버로드하지 않습니다. 비교 작업을 Equals 재정의로 위임하도록 선택할 수 있습니다. 예를 들어 ValueObject 유형에 대한 다음 연산자 오버로드를 고려하십시오.

public static bool operator ==(ValueObject one, ValueObject two)
{
    return EqualOperator(one, two);
}

public static bool operator !=(ValueObject one, ValueObject two)
{
    return NotEqualOperator(one, two);
}

다음 예제와 같이 실제 값 개체를 구현할 때 이 클래스를 Address 사용할 수 있습니다.

public class Address : ValueObject
{
    public String Street { get; private set; }
    public String City { get; private set; }
    public String State { get; private set; }
    public String Country { get; private set; }
    public String ZipCode { get; private set; }

    public Address() { }

    public Address(string street, string city, string state, string country, string zipcode)
    {
        Street = street;
        City = city;
        State = state;
        Country = country;
        ZipCode = zipcode;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        // Using a yield return statement to return each element one at a time
        yield return Street;
        yield return City;
        yield return State;
        yield return Country;
        yield return ZipCode;
    }
}

이 값 개체 구현 Address에는 ID가 없기 때문에, Address 클래스 정의나 ValueObject 클래스 정의에도 ID 필드가 정의되어 있지 않습니다.

EF(Entity Framework)에서 사용할 클래스에 ID 필드가 없는 것은 EF Core 2.0까지 가능하지 않았기 때문에 ID가 없는 더 나은 값 개체를 구현하는 데 큰 도움이 됩니다. 이것이 바로 다음 섹션에 대한 설명입니다.

불변 값 객체는 읽기 전용이어야 한다고 주장할 수 있으며(즉, get-only 속성이 있어야 함), 이는 사실입니다. 값 객체는 일반적으로 메시지 큐를 통과하기 위해 직렬화되고 역직렬화됩니다. 읽기 전용 상태로 유지되어 역직렬화가 값을 할당하지 않도록 합니다. 따라서 private set는 읽기 전용으로 실용성 있게 그대로 두어야 합니다.

값 객체 비교 의미론

다음 모든 메서드를 사용하여 형식의 Address 두 인스턴스를 비교할 수 있습니다.

var one = new Address("1 Microsoft Way", "Redmond", "WA", "US", "98052");
var two = new Address("1 Microsoft Way", "Redmond", "WA", "US", "98052");

Console.WriteLine(EqualityComparer<Address>.Default.Equals(one, two)); // True
Console.WriteLine(object.Equals(one, two)); // True
Console.WriteLine(one.Equals(two)); // True
Console.WriteLine(one == two); // True

모든 값이 같으면 비교가 true로 올바르게 평가됩니다. ==!= 연산자를 오버로드하도록 선택하지 않은 경우, one == two의 마지막 비교는 false로 평가됩니다. 자세한 내용은 ValueObject 같음 연산자 오버로드를 참조하세요.

EF Core 2.0 이상을 사용하여 데이터베이스에 값 개체를 유지하는 방법

도메인 모델에서 값 개체를 정의하는 방법을 보았습니다. 그러나 일반적으로 ID가 있는 엔터티를 대상으로 하기 때문에 Entity Framework Core를 사용하여 데이터베이스에 실제로 유지하려면 어떻게 해야 할까요?

EF Core 1.1을 사용하는 배경 및 이전 접근 방식

백그라운드에서 EF Core 1.0 및 1.1을 사용하는 경우의 제한 사항은 기존 .NET Framework의 EF 6.x에 정의된 대로 복합 형식 을 사용할 수 없다는 것이었습니다. 따라서 EF Core 1.0 또는 1.1을 사용하는 경우 ID 필드가 있는 EF 엔터티로 값 개체를 저장해야 했습니다. 그런 다음 ID가 없는 값 개체처럼 보였으므로 해당 ID를 숨길 수 있으므로 도메인 모델에서 값 개체의 ID가 중요하지 않음을 분명히 할 수 있습니다. ID를 섀도 속성으로 사용하여 해당 ID를 숨길 수 있습니다. 모델에서 ID를 숨기기 위한 구성은 EF 인프라 수준에서 설정되므로 도메인 모델에 대해 일종의 투명합니다.

eShopOnContainers(.NET Core 1.1)의 초기 버전에서는 인프라 프로젝트에서 Fluent API를 사용하여 DbContext 수준에서 EF Core 인프라에 필요한 숨겨진 ID를 다음과 같이 구현했습니다. 따라서 ID는 도메인 모델 관점에서 숨겨졌지만 인프라에 여전히 존재합니다.

// Old approach with EF Core 1.1
// Fluent API within the OrderingContext:DbContext in the Infrastructure project
void ConfigureAddress(EntityTypeBuilder<Address> addressConfiguration)
{
    addressConfiguration.ToTable("address", DEFAULT_SCHEMA);

    addressConfiguration.Property<int>("Id")  // Id is a shadow property
        .IsRequired();
    addressConfiguration.HasKey("Id");   // Id is a shadow property
}

그러나 데이터베이스에 대한 해당 값 개체의 지속성은 다른 테이블의 일반 엔터티처럼 수행되었습니다.

EF Core 2.0 이상을 사용하면 값 개체를 유지하는 새롭고 더 나은 방법이 있습니다.

EF Core 2.0 이상에서 값 개체를 소유된 엔터티 형식으로 저장

DDD의 정식 값 개체 패턴과 EF Core의 소유 엔터티 형식 간에 약간의 차이가 있더라도 현재는 EF Core 2.0 이상을 사용하여 값 개체를 유지하는 가장 좋은 방법입니다. 이 섹션의 끝에서 제한 사항을 볼 수 있습니다.

소유 엔터티 형식 기능은 버전 2.0 이후 EF Core에 추가되었습니다.

소유된 엔터티 형식을 사용하면 도메인 모델에서 명시적으로 정의된 고유 ID가 없는 형식을 매핑할 수 있으며 엔터티 내에서 값 개체와 같은 속성으로 사용됩니다. 소유된 엔터티 형식은 동일한 CLR 형식을 다른 엔터티 형식(즉, 일반 클래스)과 공유합니다. 정의 탐색을 포함하는 엔터티는 소유자 엔터티입니다. 소유자를 쿼리할 때 소유된 형식은 기본적으로 포함됩니다.

도메인 모델을 보면, 그 소유 타입은 ID가 없는 것처럼 보입니다. 겉으로 드러나지 않더라도, 소유된 형식에는 ID가 있지만, 소유자 탐색 속성은 이 ID의 일부입니다.

소유된 형식 인스턴스의 정체성은 완전히 그들 자신의 것이 아닙니다. 다음 세 가지 구성 요소로 구성됩니다.

  • 소유자의 ID

  • 해당 속성을 가리키는 탐색 속성

  • 소유된 유형의 컬렉션의 경우, 독립적인 구성 요소(EF Core 2.2 이상에서 지원됨).

예를 들어 eShopOnContainers의 Ordering 도메인 모델에서 Order 엔터티의 일부로 Address 값 개체는 Order 엔터티인 소유자 엔터티 내의 소유 엔터티 형식으로 구현됩니다. Address 는 도메인 모델에 정의된 ID 속성이 없는 형식입니다. 특정 주문의 배송 주소를 지정하기 위한 Order 형식 속성으로 사용됩니다.

규칙에 따라 소유된 형식에 대해 섀도 기본 키가 만들어지고 테이블 분할을 사용하여 소유자와 동일한 테이블에 매핑됩니다. 이렇게 하면 기존 .NET Framework의 EF6에서 복합 형식을 사용하는 방법과 유사하게 소유 형식을 사용할 수 있습니다.

EF Core에서는 소유된 형식이 규칙에 의해 절대 자동으로 검색되지 않으므로 이를 명시적으로 선언하는 것이 중요합니다.

eShopOnContainers의 OrderingContext.cs 파일에서 OnModelCreating() 메서드 내에서 여러 인프라 구성이 적용됩니다. 그 중 하나는 주문 엔티티와 관련이 있습니다.

// Part of the OrderingContext.cs class at the Ordering.Infrastructure project
//
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new ClientRequestEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new PaymentMethodEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new OrderItemEntityTypeConfiguration());
    //...Additional type configurations
}

다음 코드에서 지속성 인프라는 Order 엔터티에 대해 정의됩니다.

// Part of the OrderEntityTypeConfiguration.cs class
//
public void Configure(EntityTypeBuilder<Order> orderConfiguration)
{
    orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);
    orderConfiguration.HasKey(o => o.Id);
    orderConfiguration.Ignore(b => b.DomainEvents);
    orderConfiguration.Property(o => o.Id)
        .ForSqlServerUseSequenceHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

    //Address value object persisted as owned entity in EF Core 2.0
    orderConfiguration.OwnsOne(o => o.Address);

    orderConfiguration.Property<DateTime>("OrderDate").IsRequired();

    //...Additional validations, constraints and code...
    //...
}

이전 코드에서 메서드는 orderConfiguration.OwnsOne(o => o.Address) 속성이 Address 형식의 Order 소유 엔터티임을 지정합니다.

기본적으로 EF Core 규칙은 소유 엔터티 형식의 속성에 대한 데이터베이스 열의 이름을 .로 지정 EntityProperty_OwnedEntityProperty합니다. 따라서 Address의 내부 속성은 Orders 테이블에 Address_Street, Address_City (그리고 State, Country, ZipCode) 등의 이름으로 나타납니다.

Fluent 메서드를 추가하여 해당 열의 Property().HasColumnName() 이름을 바꿀 수 있습니다. public 속성인 경우 Address 매핑은 다음과 같습니다.

orderConfiguration.OwnsOne(p => p.Address)
                            .Property(p=>p.Street).HasColumnName("ShippingStreet");

orderConfiguration.OwnsOne(p => p.Address)
                            .Property(p=>p.City).HasColumnName("ShippingCity");

플루언트 매핑에서 OwnsOne 메서드를 체이닝할 수 있습니다. 다음 가상 예에서, OrderDetailsBillingAddressShippingAddress를 소유하고 있으며, 두 항목 모두 Address 유형입니다. 그리고 OrderDetailsOrder 형식입니다.

orderConfiguration.OwnsOne(p => p.OrderDetails, cb =>
    {
        cb.OwnsOne(c => c.BillingAddress);
        cb.OwnsOne(c => c.ShippingAddress);
    });
//...
//...
public class Order
{
    public int Id { get; set; }
    public OrderDetails OrderDetails { get; set; }
}

public class OrderDetails
{
    public Address BillingAddress { get; set; }
    public Address ShippingAddress { get; set; }
}

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
}

소유된 엔터티 형식에 대한 추가 세부 정보

  • OwnsOne 유창한 API를 사용하여 특정 유형에 대한 탐색 속성을 구성할 때 소유된 유형이 정의됩니다.

  • 메타데이터 모델에서 소유된 형식의 정의는 소유자 형식, 탐색 속성 및 소유 형식의 CLR 형식의 복합 형식입니다.

  • 스택에 있는 소유 형식 인스턴스의 ID(키)는 소유자 형식의 ID와 소유된 형식의 정의의 복합체입니다.

소유 개체 기능

  • 소유된 형식은 다른 엔터티에 대한 참조를 포함할 수 있습니다. 이러한 참조는 소유된 엔터티(중첩된 소유 형식) 또는 소유되지 않은 엔터티(정규 참조 탐색 속성)일 수 있습니다.

  • 별도의 탐색 속성을 통해 동일한 소유자 엔터티의 다른 소유 형식과 동일한 CLR 형식을 매핑할 수 있습니다.

  • 테이블 분할은 규칙에 따라 설정되지만 ToTable을 사용하여 소유 형식을 다른 테이블에 매핑하여 옵트아웃할 수 있습니다.

  • 즉시 로드는 소유된 형식에서 자동으로 수행되므로 쿼리에서 .Include()를 호출할 필요가 없습니다.

  • EF Core 2.1 이상을 사용하여 특성을 [Owned]사용하여 구성할 수 있습니다.

  • 소유 형식의 컬렉션을 처리할 수 있습니다(버전 2.2 이상 사용).

소유 엔터티 제한 사항

  • 소유된 유형의 DbSet<T>를 생성할 수 없습니다 (디자인에 의해).

  • 현재 설계상 소유된 형식에서는 ModelBuilder.Entity<T>()을 호출할 수 없습니다.

  • 동일한 테이블의 소유자와 매핑되는 선택적(즉, nullable) 소유 형식(즉, 테이블 분할 사용)을 지원하지 않습니다. 이는 각 속성에 대해 매핑이 수행되므로 null 복소수 값 전체에 대한 별도의 sentinel이 없기 때문입니다.

  • 소유된 형식에 대한 상속 매핑 지원은 없지만 동일한 상속 계층 구조의 두 리프 형식을 다른 소유 형식과 매핑할 수 있어야 합니다. EF Core는 동일한 계층 구조의 일부라는 사실을 추론하지 않습니다.

EF6의 복합 형식과 주요 차이점

  • 테이블 분할은 선택 사항입니다. 즉, 필요에 따라 별도의 테이블에 매핑할 수 있으며 여전히 소유 형식일 수 있습니다.

추가 리소스