본문 바로가기

BackEnd

타입으로 도메인 모델링하기 with F# - Value Object, Entitiy

반응형

https://pragprog.com/titles/swdddf/domain-modeling-made-functional/

 

Domain Modeling Made Functional

Use domain-driven design to effectively model your business domain, and implement that model with F#.

pragprog.com

도메인 타입(simple type, compound type)과 워크플로(함수)를 모델링 하였으므로,

데이터 타입의 persistent ID 여부로 VO와 Entity를 분류, F# 문법으로 구현하는 법을 알아봅니다.

 

DDD 용어에서
영구(persistent) ID가 있는 개체를 Entitiy라고 하고
영구(persistent) ID가 없는 개체를 Value Object라고 합니다. 먼저 Value Object에 대해 논의하는 것으로 시작하겠습니다.

Value Object

많은 경우에 우리가 다루고 있는 데이터 객체는 ID가 없으며 상호 교환이 가능합니다. (중복 가능)
예를 들어, 값이 "W1234"인 WidgetCode의 한 인스턴스는 값이 "W1234"인 다른 WidgetCode와 동일합니다.
구별이 필요가 없습니다. 동일합니다.

F#에서는 이렇게 사용합니다. (simple type - wrapper type)

​ 	​let​ widgetCode1 = WidgetCode ​"W1234"​
​ 	​let​ widgetCode2 = WidgetCode ​"W1234"​
​ 	printfn ​"%b"​ (widgetCode1 = widgetCode2)  ​// prints
"식별자 없는 값"의 개념은 도메인 모델에서 자주 나타나며, 단순 타입뿐만 아니라 복합 타입에서도 나타납니다.
예를 들어 PersonalName 레코드 유형에는 FirstName과 LastName이라는 두 개의 필드가 있을 수 있으므로 단순한 문자열보다 복잡합니다.
그러나 동일한 필드를 가진 두 개의 PersonalName(두 사람은 다르지만 이름은 같을 수 있죠)을 서로 바꿀 수 있기 때문에 VO이기도 합니다.
다음 F# 코드에서 이를 확인할 수 있습니다.​
​ 	​let​ name1 = {FirstName=​"Alex"​; LastName=​"Adams"​}
​ 	​let​ name2 = {FirstName=​"Alex"​; LastName=​"Adams"​}
​ 	printfn ​"%b"​ (name1 = name2)  ​// prints "true"​

 

"address" 유형도 값 개체입니다. 두 값의 거리 주소, 도시 및 우편 번호가 같으면 동일한 주소입니다.

let​ address1 = {StreetAddress=​"123 Main St"​; City=​"New York"​; Zip=​"90001"​}
let​ address2 = {StreetAddress=​"123 Main St"​; City=​"New York"​; Zip=​"90001"​}
printfn ​"%b"​ (address1 = address2)  ​// prints "true"
VO는 도메인에서 중복이 가능한 값을 의미합니다. 아래 문장을 읽어보세요
"Chris는 나와 같은 이름을 가지고 있습니다." 즉, Chris와 나는 다른 사람이지만 이름은 같습니다.
즉, 그들에게는 고유한 정체성이 없습니다.
마찬가지로 "Pat has the same postal address as me"는 내 주소와 Pat의 주소가 동일한 값이므로 동일하다는 의미입니다.

Value Object의 동등 비교

F# 대수 타입 시스템을 사용하여 도메인을 모델링할 때 생성하는 타입은 기본적으로 이러한 종류의 필드 기반 동등성 테스트를 구현합니다. 우리는 특별한 동등 비교 코드를 직접 작성할 필요가 없습니다.

정확히 말하면 두 개의 레코드 타입 값(동일한 형식)은 모든 필드가 같으면 F#에서 같고

초이스 타입은 동일한 케이스가 있고 해당 케이스와 연결된 데이터(wrapping 하는 데이터)도 같으면 동일합니다.

이것을 structural equality라고 합니다.

 

Entity

우리는 종종 실제 세계에서 컴포넌트가 변경되더라도 고유한 ID를 갖는 것을 모델링합니다.

예를 들어, 이름이나 주소를 변경해도 나는 여전히 나입니다.

 

DDD 용어로 이러한 것을 엔티티라고 합니다.
비즈니스 컨텍스트에서 엔터티는 주문, 견적, 송장, 고객 프로필, 제품 시트 등과 같은 일종의 문서인 경우가 많습니다.
그들은 라이프 사이클을 가지고 있으며 다양한 비즈니스 프로세스에 의해 한 상태에서 다른 상태로 변환됩니다.

"VO"와 "Entitiy"의 구분은 컨텍스트에 따라 다릅니다.

 

예를 들어 휴대폰의 수명 주기를 생각해 봅시다.

제조 과정에서 각 전화기에는 고유한 일련 번호(고유 ID)가 부여되므로 해당 컨텍스트에서 전화기는 엔터티로 모델링됩니다.

 

그러나 판매 시 일련 번호는 사양과 관련이 없기에, 동일한 사양의 모든 전화기는 상호 교환 가능하며 VO로 모델링할 수 있습니다.

 

특정 전화가 특정 고객에게 판매되면 ID가 다시 해당 단말과 관계가 있으며, Entitiy로 모델링되어야 합니다. (ex - AS를 위해)

고객은 화면이나 배터리를 교체한 후에도 동일한 전화로 생각합니다.

엔터티 식별자

엔터티는 변경에도 불구하고 안정적인 정체성을 가져야 합니다.
따라서 모델링할 때 "주문 ID" 또는 "고객 ID"와 같은 고유 식별자 또는 키를 제공해야 합니다.
예를 들어 아래 연락처 유형에는 PhoneNumber 또는 EmailAddress 필드가 변경되더라도 동일하게 유지되는 ContactId가 있습니다.
 	​type​ ContactId = ContactId ​of​ ​int​
​ 	
​ 	​type​ Contact = {
​ 	  ContactId : ContactId
​ 	  PhoneNumber : ...
​ 	  EmailAddress: ...
​ 	  }
이러한 식별자는 어디에서 왔습니까?
때때로 식별자는 실제 도메인 자체에서 제공됩니다.
페이퍼 기반 주문 양식, 청구서는 항상 그 자체로 고유합니다. (청구서 번호)
때로는 직접 만들어야 합니다.
(ex UUIDs, an auto-incrementing database table, or an ID-generating service)
앞으로의 예제에서는 클라이언트가 제공한다 가정합니다.
 

Adding Identifiers to Data Definitions

도메인 객체를 엔터티로 식별한 경우 해당 정의에 식별자를 어떻게 추가합니까?
레코드 유형에 식별자를 추가하는 것은 간단합니다. 필드만 추가하면 됩니다.
하지만 초이스 타입에 식별자를 추가하는 것은 어떻습니까?
식별자를 내부(각 사례와 연결됨) 또는 외부(어떤 사례와도 연결되지 않음)에 넣어야 합니까?
예를 들어 'invoice'에 대해 지불 및 미지급의 두 가지 선택지가 있다고 가정해 보겠습니다.
"외부" 접근 방식을 사용하여 모델링하는 경우
'InvoiceId'가 포함된 레코드가 있고
해당 레코드 내에서 각 타입의 invoice한 정보가 있는 선택 유형 'InvoiceInfo'가 있습니다.
코드는 다음과 같습니다.​
​ 	​// Info for the unpaid case (without id)​
​ 	​type​ UnpaidInvoiceInfo = ...
​ 	
​ 	​// Info for the paid case (without id)​
​ 	​type​ PaidInvoiceInfo = ...
​ 	
​ 	​// Combined information (without id)​
​ 	​type​ InvoiceInfo =
​ 	  | Unpaid ​of​ UnpaidInvoiceInfo
​ 	  | Paid ​of​ PaidInvoiceInfo
​ 	
​ 	​// Id for invoice​
​ 	​type​ InvoiceId = ...
​ 	
​ 	​// Top level invoice type​
​ 	​type​ Invoice = {
​ 	  InvoiceId : InvoiceId ​// "outside" the two child cases​
​ 	  InvoiceInfo : InvoiceInfo
​ 	  }
이 접근 방식의 문제점은 하나의 케이스에 대한 데이터가 서로 다른 구성 요소에 분산되어 있기 때문에 쉽게 작업하기 어렵다는 것입니다.

 

실제로는 "내부" 접근 방식을 사용하여 ID를 저장하는 것이 더 일반적입니다.

여기서 각 케이스에는 식별자 사본이 있습니다.

이 예에 적용하면 각각의 경우에 대해 하나씩(UnpaidInvoice 및 PaidInvoice) 두 가지 개별 타입을 생성합니다.

이 타입에는 모두 고유한 InvoiceId가 있고 상위 Invoice 타입은 두 케이스를 포함하는 초이스 타입입니다.
​ 	​type​ UnpaidInvoice = {
​ 	  InvoiceId : InvoiceId ​// id stored "inside"​
​ 	  ​// and other info for the unpaid case​
​ 	  }
​ 	
​ 	​type​ PaidInvoice = {
​ 	  InvoiceId : InvoiceId ​// id stored "inside"​
​ 	  ​// and other info for the paid case​
​ 	  }
​ 	
​ 	​// top level invoice type​
​ 	​type​ Invoice =
​ 	  | Unpaid ​of​ UnpaidInvoice
​ 	  | Paid ​of​ PaidInvoice
 
이 접근 방식의 이점은 패턴 매치를 수행할 때 ID를 포함해 한 곳에서 모든 데이터에 액세스할 수 있다는 것입니다.
​ 	​let​ invoice = Paid {InvoiceId = ...}
​ 	
​ 	​match​ invoice ​with​
​ 	  | Unpaid unpaidInvoice ->
​ 	    printfn ​"The unpaid invoiceId is %A"​ unpaidInvoice.InvoiceId
​ 	  | Paid paidInvoice ->
​ 	    printfn ​"The paid invoiceId is %A"​ paidInvoice.InvoiceId​

Implementing Equality for Entities

F#의 동등 테스트는 기본적으로 레코드의 모든 필드를 사용합니다.
그러나 엔터티를 비교할 때 식별자라는 하나의 필드만 사용하려고 합니다.
즉, F#에서 엔터티를 올바르게 모델링하려면 기본 동작을 변경해야 합니다.
이를 수행하는 한 가지 방법은 식별자만 사용되도록 동등성 테스트를 재정의하는 것입니다. 기본값을 변경하려면 다음을 수행해야 합니다.
  • Equals 메서드 재정의
  • GetHashCode 메서드 재정의
  • CustomEquality 및 NoComparison 속성을 유형에 추가하여 컴파일러에 기본 동작을 변경하고 싶다고 알립니다.
​ 	[<CustomEquality; NoComparison>]
​ 	​type​ Contact = {
​ 	  ContactId : ContactId
​ 	  PhoneNumber : PhoneNumber
​ 	  EmailAddress: EmailAddress
​ 	  }
​ 	  ​with​
​ 	  ​override​ this.Equals(obj) =
​ 	    ​match​ obj ​with​
​ 	    | :? Contact ​as​ c -> this.ContactId = c.ContactId
​ 	    | _ -> false
​ 	  ​override​ this.GetHashCode() =
​ 	    hash this.ContactId
이것은F#의 객체 지향 구문입니다.
여기서는 동등성 테스트 재정의를 보여주기 위해서만 사용하고 있지만
객체 지향 F#은 앞으로 사용하지 않습니다.
타입을 정의하면 하나의 연락처를 만들 수 있습니다.
​ 	​let​ contactId = ContactId 1
​ 	
​ 	​let​ contact1 = {
​ 	  ContactId = contactId
​ 	  PhoneNumber = PhoneNumber ​"123-456-7890"​
​ 	  EmailAddress = EmailAddress ​"bob@example.com"​
​ 	  }
동일한 ContactId로 다른 연락처를 만듭니다.
​ 	​// same contact, different email address​
​ 	​let​ contact2 = {
​ 	  ContactId = contactId
​ 	  PhoneNumber = PhoneNumber ​"123-456-7890"​
​ 	  EmailAddress = EmailAddress ​"robert@example.com"​
​ 	  }
마지막으로 =를 사용하여 비교할 때 결과는 true입니다.
​ 	​// true even though the email addresses are different​
​ 	printfn ​"%b"​ (contact1 = contact2)
이것은 객체 지향 설계에서 일반적인 접근 방식이지만
기본 동작을 변경하면 때때로 문제가 발생할 수 있습니다.
따라서 (종종 선호되는) 대안은 다음과 같이 NoEquality 타입 주석을 추가하여 개체에 대한 동등성 테스트를 모두 허용하지 않는 것입니다.
 
​ 	[<NoEquality; NoComparison>]
​ 	​type​ Contact = {
​ 	  ContactId : ContactId
​ 	  PhoneNumber : PhoneNumber
​ 	  EmailAddress: EmailAddress
​ 	  }

​ 	​// compiler error!​
​ 	printfn ​"%b"​ (contact1 = contact2)
​ 	​//            ^ the Contact type does not​
​ 	​//              support equality
물론 다음과 같이 ContactId 필드를 직접 비교할 수 있습니다.
printfn ​"%b"​ (contact1.ContactId = contact2.ContactId) ​// true​
"NoEquality" 접근 방식의 이점은 객체 수준에서 평등이 무엇을 의미하는지에 대한 모호성을 제거하고
우리가 명시적으로 동등성 비교를 위해 사용하는 필드를 이용하도록 강제한다는 것입니다.
마지막으로 어떤 상황에서는 평등을 테스트하는 데 사용되는 여러 필드가 있을 수 있습니다.
이 경우 이들을 결합한 합성 Key 속성을 쉽게 노출할 수 있습니다.
​ 	[<NoEquality;NoComparison>]
​ 	​type​ OrderLine = {
​ 	  OrderId : OrderId
​ 	  ProductId : ProductId
​ 	  Qty : ​int​
​ 	  }
​ 	  ​with​
​ 	  ​member​ this.Key =
​ 	    (this.OrderId,this.ProductId)
그런 다음 비교를 수행해야 할 때 다음과 같이 키 필드를 사용할 수 있습니다.
printfn ​"%b"​ (line1.Key = line2.Key)

요약 :Entity의 경우 Equal 오버라이딩 하지 말고, 동등 비교를 키 비교를 통해 명시하자.

Immutability and Identity

F#과 같은 함수형 프로그래밍 언어의 값은 기본적으로 불변입니다.

이것이 우리 디자인에 어떤 영향을 줍니까?

 

Value Object의 경우 불변성이 필요합니다.
우리가 일반적인 대화에서 어떻게 사용하는지 생각해보십시오.
예를 들어 개인 이름의 일부를 변경하면 데이터가 다른 동일한 이름이 아니라 새롭고 고유한 이름이라고 부릅니다.
 
Entity의 경우 다른 문제입니다. Entity와 관련된 데이터는 시간이 지남에 따라 변경될 것으로 예상합니다.
그것이 constant 식별자를 갖는 요점입니다. 그렇다면 어떻게 불변 데이터 구조가 이런 식으로 작동하도록 만들 수 있습니까?
대답은 ID를 유지하면서 변경된 데이터로 Entity의 복사본을 만드는 것입니다.
이 모든 복사는 많은 추가 작업처럼 보일 수 있지만 실제로는 문제가 되지 않습니다.
사실, 이 책 전반에 걸쳐 우리는 모든 곳에서 불변 데이터를 사용할 것이며, 불변성은 거의 문제가 되지 않는다는 것을 알게 될 것입니다.
 

다음은 F#에서 엔터티를 업데이트하는 방법의 예입니다. 먼저 초기 값으로 시작합니다.

​let​ initialPerson = {PersonId=PersonId 42; Name=​"Joseph"​}

일부 필드만 변경하면서 레코드의 복사본을 만들기 위해 F#은 다음과 같이 with 키워드를 사용합니다.

​let​ updatedPerson = {initialPerson ​with​ Name=​"Joe"​}

updatedPerson는 initialPerson과 Name은 다르지만 PersonId는 동일합니다.

불변 데이터 구조를 사용할 때의 이점은 모든 변경 사항이 타입 시그니처에서 명시적으로 이루어져야 한다는 것입니다.
예를 들어 Person의 Name 필드를 변경하는 함수를 작성하려는 경우 다음과 같이 서명이 있는 함수를 사용할 수 없습니다.
​type​ UpdateName = Person -> Name -> ​unit​

이 함수에는 출력이 없습니다. 이는 아무 것도 변경되지 않았음을 의미합니다 (또는 Person가 side-effect에 의해 mutated 되었음을 의미합니다).

대신, 우리 함수는 다음과 같이 출력으로 Person 타입이 있는 서명을 가져야 합니다.

​type​ UpdateName = Person -> Name -> Person

이것은 Person과 Name이 주어지면 원래 Person의 변형이 반환된다는 것을 분명히 나타냅니다.

 

요약 : vo와 entitiy 전부 불변입니다.





 

 

반응형