본문 바로가기

BackEnd

타입으로 도메인 모델링하기 with F# - 단순, 복합 타입과 함수

반응형

소스코드를 UML, 문서와 같이 사용할 수 있을까요?

F# 타입 시스템을 사용하여 코드에 대해 충분히 정확하게 도메인 모델을 캡처하면서

도메인 전문가 및 기타 비개발자가 읽고 이해할 수 있도록 하는 방법을 배웁니다.

타입이 대부분의 문서를 대체할 수 있으며 그 기능은 강력한 이점이 있음을 알 수 있습니다.

디자인이 코드 자체에 표현되기 때문에 구현이 설계와 반드시 동기화됩니다.

설계 리뷰하기

https://itchallenger.tistory.com/411

 

도메인을 문서화하기

기술적 구현에 대한 편견을 피하면서, 이러한 요구 사항을 어떻게 기록해야 할까요? 시각적 다이어그램(예: UML)을 사용할 수 있지만 작업하기 어렵고 도메인의 미묘한 부분을 포착할 만큼 충분

itchallenger.tistory.com

해당 모델을 코드로 변경해봅니다.

도메인 모델의 패턴 파악하기

각 도메인 모델은 다르지만 많은 패턴이 반복적으로 발생합니다.
  • 단순한 값. 이들은 문자열 및 정수와 같은 기본 타입으로 표현되는 기본 빌딩 블록입니다. 그러나 실제로는 문자열이나 정수가 아닙니다. 도메인 전문가는 int 및 string의 관점에서 생각하지 않고 대신 OrderId 및 ProductCode의 관점에서 생각합니다. 이 개념은 유비쿼터스 언어의 일부입니다.
  • AND와 값의 조합. 밀접하게 연결된 데이터 그룹입니다. 페이퍼 기반 업무를 수행하는 현실 세계에서 이들은 일반적으로 문서 또는 문서의 하위 구성요소(이름, 주소, 주문 등)입니다.
  • OR이 있는 초이스. Order 또는 Quote, UnitQuantity 또는 KilogramQuantity와 같이 도메인에서 선택을 나타내는 항목이 있습니다.
  • 워크플로. 마지막으로 입력과 출력이 있는 비즈니스 프로세스가 있습니다.

간단한 값 모델링

도메인 전문가는 일반적으로 int 및 string의 관점에서 생각하지 않고, OrderId 및 ProductCode와 같은 도메인 개념의 관점에서 생각합니다.
또한 OrderIds와 ProductCode가 혼동되지 않는 것이 중요합니다.
예를 들어 둘 다 int로 표시된다고 해서 상호 교환 가능하다는 의미는 아닙니다.
따라서 이러한 타입이 구별된다는 것을 명확히 하기 위해 "래퍼 타입"(primitive type 표현을 래핑하는 타입)을 생성합니다.
 
F#에서 래퍼 타입을 만드는 가장 쉬운 방법은 선택 항목이 하나만 있는 "단일 케이스" 유니온 타입을 만드는 것입니다.
​ 	​type​ CustomerId =
​ 	  | CustomerId ​of​ ​int​

 

케이스가 하나뿐이므로 다음과 같이 항상 전체 유형 정의를 한 줄에 작성합니다.
​type​ CustomerId = CustomerId ​of​ ​int​​
(주 : 타입스크립트에서 해당 기능을 사용하려면 newtype-ts나 브랜디드 타입을 사용합니다.)

 

우리는 이러한 종류의 wrapper type을 "simple type"이라고 하여 compound type(예: 레코드) 및 내부의 primitive type(예: string 및 int)과 구별합니다.
우리 도메인에서 simple type은 다음과 같이 모델링됩니다.
​ 	​type​ WidgetCode = WidgetCode ​of​ ​string​
​ 	​type​ UnitQuantity = UnitQuantity ​of​ ​int​
​ 	​type​ KilogramQuantity = KilogramQuantity ​of​ decimal
단일 케이스 유니온의 정의는 타입 이름과 "케이스" 레이블의 두 부분으로 구성됩니다.
​ 	​type​ CustomerId = CustomerId ​of​ ​int​
​ 	​//   ^type name   ^case label
위의 예에서 볼 수 있듯이 (단일) 케이스의 레이블은 일반적으로 타입의 이름과 동일합니다.
이것은 타입을 사용할 때 다음에 보게 될 것과 같이 타입을 구성하고 해체하는 데 동일한 이름을 사용할 수도 있음을 의미합니다.

(하스켈의 data 키워드와 비슷한데, wrapper type의 케이스가 type 키워드로 사용된다는 것이 차이점)

단일 케이스 유니온 사용하기

단일 케이스 유니온의 값을 생성하기 위해 케이스 이름을 생성자 함수로 사용합니다. 즉, 다음과 같이 간단한 타입을 정의했습니다.
​ 	​type​ CustomerId = CustomerId ​of​ ​int​
​ 	​//                ^this case name will be the constructor function
이제 케이스 이름을 생성자 함수로 사용하여 생성할 수 있습니다.
​ 	​let​ customerId = CustomerId 42
​ 	​//                ^this is a function with an int parameter
이와 같이 간단한 타입을 생성하면 실수로 다른 타입과 혼동하지 않을 수 있습니다.
예를 들어 CustomerId와 OrderId를 만들고 비교하려고 하면 컴파일러 오류가 발생합니다.
​ 	​// define a function using a CustomerId​
​ 	​let​ processCustomerId (id:CustomerId) = ...
​ 	
​ 	​// call it with an OrderId -- compiler error!​
​ 	processCustomerId orderId
​ 	​//                ^ This expression was expected to​
​ 	​//                have type 'CustomerId' but here has​
​ 	​//                type 'OrderId'​
단일 케이스 유니온을 해체하거나 풀기 위해 케이스 레이블을 사용하여 패턴 매칭할 수 있습니다.
​ 	​// construct​
​ 	​let​ customerId = CustomerId 42
​ 	
​ 	​// deconstruct​
​ 	​let​ (CustomerId innerValue) = customerId
​ 	​//              ^ innerValue is set to 42​
​ 	
​ 	printfn ​"%i"​ innerValue  ​// prints "42"
함수 정의의 파라미터에서 직접 분해하는 것은 매우 일반적입니다.
이렇게 하면 내부 값에 즉시 액세스할 수 있을 뿐만 아니라 F# 컴파일러도 올바른 형식을 유추합니다.
예를 들어 아래 코드에서 컴파일러는 입력 매개변수가 CustomerId라고 추론합니다.
​ 	​// deconstruct​
​ 	​let​ processCustomerId (CustomerId innerValue) =
​ 	  printfn ​"innerValue is %i"​ innerValue
​ 	
​ 	​// function signature​
​ 	​// val processCustomerId: CustomerId -> unit​

 

Constrained Value

거의 항상 simple-type은 특정 범위에 있거나 특정 패턴과 일치해야 하는 등 어떤 방식으로 제한됩니다.
현실 세계의 도메인에서 무한한 정수 또는 문자열을 갖는 것은 매우 드뭅니다.
추후 도메인의 무결성을 다루는 방법에서 상세히 알아봅니다.

Avoiding Performance Issues with Simple Types

primitive type을 simple type으로 래핑하는 것은 type-safety을 보장하고 컴파일 시간에 많은 오류를 방지하는 좋은 방법입니다.
그러나 메모리 사용 및 효율성 측면에서 비용이 발생합니다.
일반적인 비즈니스 응용 프로그램의 경우 약간의 성능 저하가 문제가 되지는 않지만
과학 또는 실시간 도메인과 같이 고성능이 필요한 도메인의 경우 더 주의해야 할 수 있습니다.
예를 들어, UnitQuantity 값의 큰 배열을 반복하는 것은 원시 int 배열을 반복하는 것보다 느립니다.
(주 : 내부적으로 구조체 혹은 클래스 형식으로 wrapping하기 때문인 듯)
 
단순 유형 대신 타입 alias을 사용하여 도메인을 문서화할 수 있습니다. 이것은 오버헤드가 없지만 type-safety의 손실을 의미합니다.
​type​ UnitQuantity = ​int​
F# 4.1부터 레퍼런스 타입 대신 밸류 타입(구조체)을 사용할 수 있습니다.
여전히 wrapper에서 오버헤드가 발생하지만 배열에 저장하면 메모리 사용량이 연속적이므로 캐시 친화적입니다.
 	[<Struct>]
​ 	​type​ UnitQuantity = UnitQuantity ​of​ ​int​
마지막으로, 큰 배열로 작업하는 경우 기본 값의 전체 컬렉션을 simple-type 컬렉션보다 단일 타입으로 정의하는 것을 고려하십시오.
​type​ UnitQuantities = UnitQuantities ​of​ ​int​[]
"DataSample" 또는 "Measurements"와 같이 하나의 단위로 취급되는 이러한 종류의 컬렉션에 대해
유비쿼터스 언어로 된 단어가 있음을 발견할 수도 있습니다. 
추가로 해당 방식은 게임 개발에서도 많이 사용한다고 함 (데이터 주도 설계)

Modeling Complex Data

도메인을 문서화할 때 AND 및 OR을 사용하여 더 복잡한 모델을 나타냅니다.
레코드, 초이스 타입과 대수 데이터 타입을 이용해 도메인을 모델링해 봅시다.

Modeling with Record Types

우리 도메인에서 많은 데이터 구조가 AND 관계에서 구축되는 것을 보았습니다.
예를 들어 원래의 간단한 Order는 다음과 같이 정의되었습니다.
​ 	data Order =
​ 	    CustomerInfo
​ 	    AND ShippingAddress
​ 	    AND BillingAddress
​ 	    AND list of OrderLines
​ 	    AND AmountToBill
이것은 다음과 같이 F# 레코드 구조로 직접 변환됩니다.
​ 	​type​ Order = {
​ 	  CustomerInfo : CustomerInfo
​ 	  ShippingAddress : ShippingAddress
​ 	  BillingAddress : BillingAddress
​ 	  OrderLines : OrderLine ​list​
​ 	  AmountToBill : ...
​ 	  }
각 필드에 이름("CustomerInfo," "ShippingAddress")과 타입(CustomerInfo, ShippingAddress)을 지정했습니다.
이렇게 하면 도메인에 대해 아직 답이 없는 많은 질문이 표시됩니다.
현재 이러한 유형이 실제로 무엇인지 모릅니다. ShippingAddress는 BillingAddress와 같은 타입입니까?
"AmountToBill"을 나타내기 위해 어떤 타입을 사용해야 합니까?
이상적으로는 도메인 전문가에게 도움을 요청할 수 있습니다.
예를 들어 전문가가 청구서 수신 주소와 배송 주소를 서로 다른 것으로 이야기하는 경우 구조가 같더라도 논리적으로 분리하는 것이 좋습니다.
도메인 이해가 향상되거나 요구 사항이 변경됨에 따라 다른 방향으로 발전할 수 있습니다.

Modeling Unknown Types

디자인 프로세스의 초기 단계에서는 종종 일부 모델링 질문에 대한 명확한 답을 얻지 못할 수 있습니다.
예를 들어, 유비쿼터스 언어 덕분에 모델링 대상 타입의 이름은 알 수 있지만 내부 구조는 알 수 없습니다.
이것은 문제가 아닙니다. 알 수 없는 구조의 타입을 최상의 추측으로 나타낼 수 있습니다.
또는 설계 프로세스에서 나중에 더 잘 이해할 때까지 명시적으로 정의되지 않은 타입으로 모델링할 수 있습니다.
F#에서 정의되지 않은 타입을 나타내려면 예외 타입 exn을 사용하고 별칭을 Undefined로 지정할 수 있습니다.

(타입스크립트 type Undefined = Error)

​type​ Undefined = exn
다음과 같이 디자인 모델에서 Undefined 별칭을 사용할 수 있습니다.
​ 	​type​ CustomerInfo = Undefined
​ 	​type​ ShippingAddress = Undefined
​ 	​type​ BillingAddress = Undefined
​ 	​type​ OrderLine = Undefined
​ 	​type​ BillingAmount = Undefined
​ 	
​ 	​type​ Order = {
​ 	  CustomerInfo : CustomerInfo
​ 	  ShippingAddress : ShippingAddress
​ 	  BillingAddress : BillingAddress
​ 	  OrderLines : OrderLine ​list​
​ 	  AmountToBill : BillingAmount
​ 	  }
이 접근 방식은 타입으로 도메인을 계속 모델링하고 코드를 컴파일할 수 있음을 의미합니다.
그러나 타입을 처리하는 함수를 개발할 시엔, Undefined를 조금 더 나은 것으로 대체해야 합니다.

Modeling with Choice Types

우리 도메인에서는 다음과 같이 다른 것들 사이에서 선택되는 많은 것들을 보았습니다.
​ 	data ProductCode =
​ 	    WidgetCode
​ 	    OR GizmoCode
​ 	
​ 	data OrderQuantity =
​ 	    UnitQuantity
​ 	    OR KilogramQuantity

 F#에선 Choice Type을 이용해 해당 선택을 명시화 합니다.

​ 	​type​ ProductCode =
​ 	  | Widget ​of​ WidgetCode
​ 	  | Gizmo ​of​ GizmoCode
​ 	
​ 	​type​ OrderQuantity =
​ 	  | Unit ​of​ UnitQuantity
​ 	  | Kilogram ​of​ KilogramQuantity
각 케이스에 대해 "태그" 또는 케이스 레이블("of" 앞)과 해당 사례와 연결된 데이터 타입("of" 뒤)의 두 부분을 만들어야 합니다.
위의 예는 케이스 레이블(예: Widget)이 연결된 유형(WidgetCode)의 이름과 같을 필요가 없음을 보여줍니다.

Modeling Workflows with Functions

이제 유비쿼터스 언어의 "명사"인 모든 데이터 구조를 모델링할 수 있는 방법이 생겼습니다.
그러나 비즈니스 프로세스인 "동사"는 어떻습니까? 이 책에서는 워크플로와 기타 프로세스를 함수 타입으로 모델링합니다.
예를 들어 주문 양식을 검증하는 워크플로 단계가 있는 경우 다음과 같이 문서화할 수 있습니다.
 
​type​ ValidateOrder = UnvalidatedOrder-> ValidatedOrder
이 코드에서 ValidateOrder 프로세스가 검증되지 않은 주문을 검증된 주문으로 변환한다는 것이 분명합니다.

복잡한 입출력 작업

모든 기능에는 하나의 입력과 하나의 출력만 있지만 일부 워크플로에는 여러 입력과 출력이 있을 수 있습니다.
이런 경우 어떻게 어떻게 모델링할 수 있나요?
출력부터 시작하겠습니다. 워크플로에 outputA와 outputB가 있는 경우 둘 다 저장할 레코드 타입을 만들 수 있습니다.
order-placing  워크플로에서 이를 확인했습니다.
출력은 세 가지 다른 이벤트여야 하므로 하나의 레코드로 저장할 복합 타입을 만들어 보겠습니다.​
​ 	​type​ PlaceOrderEvents = {
​ 	  AcknowledgmentSent : AcknowledgmentSent
​ 	  OrderPlaced : OrderPlaced
​ 	  BillableOrderPlaced : BillableOrderPlaced
​ 	  }​

이 접근 방식을 사용하면 Primitive UnvalidatedOrder를 입력으로 시작하고 PlaceOrderEvents 레코드를 반환하는 함수 타입으로 Order-Placing 워크플로를 작성할 수 있습니다.

​type​ PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents
반면에 워크플로에 outputA 혹은 outputB가 있는 경우 (둘 중 하나) 둘 다 저장할 선택 타입을 만들 수 있습니다.
예를 들어, 인바운드 메일을 견적 또는 주문으로 분류하는 것에 대해 간략하게 이야기했습니다.
해당 프로세스에는 출력에 대해 최소한 두 가지 다른 선택 사항이 있었습니다.
​ 	workflow "Categorize Inbound Mail" =
​ 	    input: Envelope contents
​ 	    output:
​ 	        QuoteForm (put on appropriate pile)
​ 	        OR OrderForm (put on appropriate pile)
​ 	        OR ...​
이 워크플로를 모델링하는 것은 쉽습니다.
CategorizedMail과 같이 선택 항목을 나타내는 새 타입을 만든 다음 CategorizeInboundMail이 해당 타입을 반환하도록 하면 됩니다. 
type​ EnvelopeContents = EnvelopeContents ​of​ ​string​
​ 	​type​ CategorizedMail =
​ 	  | Quote ​of​ QuoteForm
​ 	  | Order ​of​ OrderForm
​ 	  ​// etc​
​ 	
​ 	​type​ CategorizeInboundMail = EnvelopeContents -> CategorizedMail
입력의 모델을 살펴보겠습니다.
워크플로에 다른 입력(OR)을 선택할 수 있는 경우 초이스 타입을 만들 수 있습니다.
그러나 프로세스에 "가격 계산"(아래)과 같이 모두 필수(AND)인 여러 입력이 있는 경우 두 가지 가능한 접근 방식 중에서 선택할 수 있습니다.
​ 	"Calculate Prices" =
​ 	    input: OrderForm, ProductCatalog
​ 	    output: PricedOrder​
첫 번째이자 가장 간단한 방법은 다음과 같이 각 입력을 별도의 매개변수로 전달하는 것입니다.
​ 	​type​ CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder
또는 이 CalculatePricesInput 타입과 같이 둘 다 포함하는 새 레코드 유형을 만들 수 있습니다.
​ 	​type​ CalculatePricesInput = {
​ 	  OrderForm : OrderForm
​ 	  ProductCatalog : ProductCatalog
​ 	  }

이제 함수 타입은 다옴과 같습니다.

​type​ CalculatePrices = CalculatePricesInput -> PricedOrder
어떤 접근 방식이 더 낫습니까?
ProductCatalog가 "실제" 입력이 아닌 종속성인 경우 별도의 매개변수 접근 방식을 사용하려고 합니다.
이를 통해 종속성 주입과 동등한 기능을 사용할 수 있습니다.
이것은 order-processing 파이프라인을 구현할 때 ​Injecting Dependencies​에서 자세히 논의할 것입니다.
반면에 두 입력이 항상 필요하고 서로 강력하게 연결되어 있는 경우 레코드 타입을 통해 이를 명확하게 알 수 있습니다.
(경우에 따라 튜플을 단순 레코드 타입의 대안으로 사용할 수 있지만 일반적으로 명명된 타입을 사용하는 것이 좋습니다.)

Documenting Effects in the Function Signature

함수형 프로그래밍에선, 기본 출력 외에 함수가 수행하는 작업을 설명하기 위해 Effect라는 용어를 사용합니다.

우리는 ValidateOrder 프로세스가 다음과 같이 작성될 수 있음을 보았습니다.
​type​ ValidateOrder = UnvalidatedOrder -> ValidatedOrder
그러나 이는 유효성 검사(validation)가 항상 작동하고 ValidatedOrder가 항상 반환된다고 가정합니다.
물론 실제로 이것은 사실이 아니므로 함수 시그니처에서 Result 타입을 반환하여 이 상황을 나타내는 것이 좋습니다.
(실패 가능성!)
​ 	​type​ ValidateOrder =
​ 	  UnvalidatedOrder -> Result<ValidatedOrder,ValidationError ​list​>
​ 	
​ 	​and​ ValidationError = {
​ 	  FieldName : ​string​
​ 	  ErrorDescription : ​string​
​ 	  }
이 서명은 입력이 UnvalidatedOrder이고 성공하면 출력이 ValidatedOrder임을 보여줍니다.
그러나 유효성 검사가 실패하면 결과는 ValidationError 리스트가 되며 여기에는 오류에 대한 설명과 오류가 적용되는 필드가 포함됩니다.

함수형 프로그래밍에선, 기본 출력 외에 함수가 수행하는 작업을 설명하기 위해 효과라는 용어를 사용합니다. 여기에서 Result를 사용하여 ValidateOrder에 "error Effect"가 있을 수 있음을 문서화했습니다. 이것은 함수가 항상 성공할 것이라고 가정할 수 없으며 오류를 처리할 준비가 되어 있어야 한다는 것을 타입 시그니처에서 분명히 합니다.
마찬가지로 프로세스가 비동기임을 문서화할 수 있습니다. (결과가 즉시 반환되지 않습니다.)
F#에서는 Async 형식을 사용하여 함수에 "비동기 효과(asynchronous effects)"가 있음을 보여줍니다. 따라서 ValidateOrder에 오류 효과뿐만 아니라 비동기 효과가 있는 경우 다음과 같이 함수 유형을 작성합니다.
​ 	​type​ ValidateOrder =
​ 	  UnvalidatedOrder -> Async<Result<ValidatedOrder,ValidationError ​list​>>
이 타입 시그니처는 이제
(a) 반환 값의 내용을 가져오려고 할 때, 코드가 즉시 반환하지 않으며
(b) 반환될 때 결과가 오류일 수 있음을 문서화합니다.
이와 같이 모든 효과를 명시적으로 나열하는 것은 유용하지만 타입 서명을 보기 흉하고 복잡하게 만들기 때문에
이에 대한 타입 별칭을 만듭니다.
​type​ ValidationResponse<​'​a> = Async<Result<​'​a,ValidationError ​list​>>
그러면 함수는 다음과 같이 문서화될 수 있습니다.
​ 	​type​ ValidateOrder =
​ 	  UnvalidatedOrder -> ValidationResponse<ValidatedOrder>

 

반응형