본문 바로가기

BackEnd

파이프라인으로 워크플로 모델링하기 - 함수 타입으로 워크플로 모델링, 이펙트 모델링

반응형

타입을 사용하여 워크플로의 각 단계 모델링

상태 기계 접근 방식은 주문 배치(order-placing) 워크플로를 모델링하는 데 완벽하므로 이제 각 단계의 세부 사항을 모델링하겠습니다.
​ 	workflow "Place Order" =
​ 	    input: OrderForm
​ 	    output:
​ 	       OrderPlaced event (put on a pile to send to other teams)
​ 	       OR InvalidOrder (put on appropriate pile)
​ 	
​ 	    // step 1 - 순수하지 않은 스텝
​ 	    do ValidateOrder
​ 	    If order is invalid then:
​ 	        add InvalidOrder to pile
​ 	        stop
​ 	
​ 	    // step 2
​ 	    do PriceOrder
​ 	
​ 	    // step 3
​ 	    do SendAcknowledgmentToCustomer
​ 	
​ 	    // step 4
​ 	    return OrderPlaced event (if no errors)​

 

(주 : 종속성의 입출력에 오직 primitive type과 도메인 타입만 존재하는 것을 봅시다. 사이드이펙트에도 주목하세요)

(ex - 검증로직 함수, 도메인 타입 객체를 입력으로 받아. 메일 템플릿(HtmlString of string)을 리턴하는 함수)

The Validation Step

유효성 검사부터 시작하겠습니다. 이전 토론에서 "ValidateOrder" 하위 단계를 다음과 같이 문서화했습니다.
​ 	substep "ValidateOrder" =
​ 	    input: UnvalidatedOrder
​ 	    output: ValidatedOrder OR ValidationError
​ 	    dependencies: CheckProductCodeExists, CheckAddressExists
앞에서 논의한 것과 같은 방식으로 입력 및 출력 유형(UnvalidatedOrder 및 ValidatedOrder)을 정의했다고 가정합니다.
입력 외에도 하위 단계에는 두 가지 종속성이 있음을 알 수 있습니다.
하나는 제품 코드가 있는지 확인하는 것이고 다른 하나는 그림과 같이 주소가 있는지 확인하는 것입니다.
종속성 다이어그램
우리는 입력과 출력이 있는 함수로 프로세스를 모델링하는 것에 대해 이야기해 왔습니다.
그러나 타입을 사용하여 이러한 종속성을 어떻게 모델링합니까?
간단합니다. 우리는 종속성도 함수로 취급합니다. 함수의 타입 시그니처는 나중에 구현해야 하는 "인터페이스"가 됩니다.
예를 들어 제품 코드가 있는지 확인하려면 ProductCode를 가져와 제품 카탈로그에 있으면 true를 반환하고 그렇지 않으면 false를 반환하는 함수가 필요합니다.
이를 나타내는 CheckProductCodeExists 유형을 정의할 수 있습니다.
​ 	​type​ CheckProductCodeExists =
​ 	  ProductCode -> ​bool​
​ 	  ​// ^input      ^output
두 번째 종속성으로 이동하여 UnvalidatedAddress를 사용하고 유효한 경우 수정된 주소를 반환하거나 주소가 유효하지 않은 경우 일종의 유효성 검사 오류를 반환하는 함수가 필요합니다.
또한 "확인된 주소"(원격 주소 확인 서비스의 출력)와 주소 도메인 개체를 구별하고 싶고 어느 시점에서 둘 사이를 변환해야 합니다.
지금은 CheckedAddress가 UnvalidatedAddress의 래핑된 버전이라고 말할 수 있습니다.

(주 : 프로덕트코드 검증 로직은 우리 도메인의 코드였다면 이번엔 외부 시스템을 이용해 검증합니다.)

​type​ CheckedAddress = CheckedAddress ​of​ UnvalidatedAddress​
그런 다음 서비스는 UnvalidatedAddress를 입력으로 사용하고
성공 사례에 대한 CheckedAddress 값과 실패 사례에 대한 AddressValidationError 값과 함께 Result type을 반환합니다.
​ 	​type​ AddressValidationError = AddressValidationError ​of​ ​string​
​ 	
​ 	​type​ CheckAddressExists =
​ 	  UnvalidatedAddress -> Result<CheckedAddress,AddressValidationError>
​ 	  ​// ^input                    ^output
종속성을 정의하면 이제 ValidateOrder 단계를 기본 입력(UnvalidatedOrder), 두 가지 종속성(CheckProductCodeExists 및 CheckAddressExists 서비스) 및 출력(ValidatedOrder 또는 오류)이 있는 함수로 정의할 수 있습니다.
타입 시그니처는 언뜻 보기에는 무섭게 보이지만 앞 문장과 같은 코드라고 생각하면 이해가 될 것입니다.
 	​type​ ValidateOrder =
    	// 프로덕트 코드 검증 종속성 함수
​ 	  CheckProductCodeExists    ​// dependency​
		// 주소 검증 종속성 함수
​ 	    -> CheckAddressExists   ​// dependency​
		// 실제 데이터 입력
​ 	    -> UnvalidatedOrder     ​// input​
		// 실패할 수 있는 타입 Result로 표현!
​ 	    -> Result<ValidatedOrder,ValidationError>  ​// output
종속성 중 하나(CheckAddressExists 함수)가 Result를 반환하므로 함수의 전체 반환 값은 Result 여야 합니다.
Result가 어디에서나 사용될 때, 그것은 접촉하는 모든 것을 "오염"시키고 그것을 처리하는 최상위 함수에 도달할 때까지 "결과성"을 전달해야 합니다.
매개변수 순서에서 종속성을 먼저 배치하고 출력 유형 바로 앞에 입력 유형을 두 번째로 배치했습니다.
그 이유는 부분 적용을 더 쉽게 하기 위함입니다(종속성 주입과 기능적으로 동등함).
구현 장에서 이것이 실제로 어떻게 작동하는지 살펴봅니다.

가격 측정 단계

계속해서 "PriceOrder" 단계를 설계해 보겠습니다. 원본 도메인 문서는 다음과 같습니다.
​ 	substep "PriceOrder" =
​ 	    input: ValidatedOrder
​ 	    output: PricedOrder
​ 	    dependencies: GetProductPrice
다시, 우리는 종속성을 봅니다. 함수는 제품 코드가 주어진 가격을 반환합니다.

GetProductPrice 외부 종속성

이 종속성을 문서화하기 위해 GetProductPrice 유형을 정의할 수 있습니다.
​ 	​type​ GetProductPrice =
​ 	  ProductCode -> Price
 
PriceOrder 함수는 제품 카탈로그의 정보가 필요하지만,
일종의 무거운 IProductCatalog 인터페이스를 전달하는 대신 이 단계에서 제품 카탈로그에서 정확히 필요한 것을 나타내는 함수(GetProductPrice)를 전달합니다.
GetProductPrice는 추상화 역할을 합니다. 제품 카탈로그의 존재를 숨기고 가격을 얻기 위해 필요한 기능만 노출하고 더 이상은 노출하지 않습니다.
 
가격 책정 함수 자체는 다음과 같이 표시됩니다.
​ 	​type​ PriceOrder =
​ 	  GetProductPrice      ​// dependency​
​ 	    -> ValidatedOrder  ​// input​
​ 	    -> PricedOrder     ​// output​
이 함수는 항상 성공하므로 Result를 반환할 필요가 없습니다. (도메인 내부임을 뜻함)

주문 승인 단계

​ 	substep "SendAcknowledgmentToCustomer" =
​ 	    input: PricedOrder
​ 	    output: None
​ 	
​ 	    create acknowledgment letter and send it
​ 	    and the priced order to the customer
다음 단계에서는 확인 편지를 작성하여 고객에게 보냅니다.
승인 편지를 모델링하는 것으로 시작하겠습니다.
지금은 이메일로 보낼 HTML 문자열이 포함되어 있다고 가정해 보겠습니다.
HTML 문자열을 단순 타입으로 모델링하고
OrderAcknowledgement를 보낼 편지와 이메일 주소가 포함된 레코드 타입으로 모델링합니다.
​ 	​type​ HtmlString =
​ 	  HtmlString ​of​ ​string​
​ 	
​ 	​type​ OrderAcknowledgment = {
​ 	  EmailAddress : EmailAddress
​ 	  Letter : HtmlString
​ 	  }
편지의 내용이 무엇인지 어떻게 알 수 있습니까?
편지는 고객 정보 및 주문 세부 정보를 기반으로 하는 일종의 템플릿에서 만들어질 가능성이 있습니다.
그 논리를 워크플로에 포함시키는 대신 다른 사람의 문제로 만듭시다. (서비스 함수)
즉, 서비스 함수가 ​​콘텐츠를 생성하고 우리가 해야 할 일은 PricedOrder를 제공하기만 하면 된다고 가정합니다.
​ 	​type​ CreateOrderAcknowledgmentLetter =
​ 	  PricedOrder -> HtmlString​

(HtmlString은 Simple Type으로 여러 도메인에서 같이 써도 무방한 타입입니다.)

그런 다음 이 단계의 종속성으로 이 유형의 함수를 사용합니다.
일단 편지를 받으면 보내야 합니다.
어떻게 해야 할까요? 일종의 API를 직접 호출해야 합니까, 아니면 메시지 대기열에 승인을 작성해야 합니까?
운 좋게도 지금 당장은 이러한 질문을 결정할 필요가 없습니다.
우리는 정확한 구현과 우리가 필요로 하는 인터페이스에만 집중할 수 있습니다.
이전과 마찬가지로 이 시점에서 디자인에 필요한 것은 OrderAcknowledgement를 입력으로 받아 우리를 위해 보내는 함수를 정의하는 것입니다. 우리는 어떻게 상관하지 않습니다.
​ 	​type​ SendOrderAcknowledgment =
​ 	  OrderAcknowledgment -> ​unit​
확인이 전송된 경우 전체 주문 배치 워크플로에서 OrderAcknowledgementSent 이벤트를 반환하고 싶지만
이 디자인에서는 전송되었는지 여부를 알 수 없습니다.
그래서 우리는 이것을 바꿔야 합니다. 명백한 선택은 대신 bool을 반환하는 것입니다.
그런 다음 이벤트를 생성할지 여부를 결정하는 데 사용할 수 있습니다.​
​ 	​type​ SendOrderAcknowledgment =
​ 	  OrderAcknowledgment -> ​bool​

부울은 일반적으로 디자인에서 좋지 않은 선택입니다.

왜냐하면 그것들은 매우 유익하지 않기 때문입니다.

bool 대신 간단한 Sent/NotSent 선택 유형을 사용하는 것이 좋습니다.

​ 	​type​ SendResult = Sent | NotSent
​ 	
​ 	​type​ SendOrderAcknowledgment =
​ 	  OrderAcknowledgment -> SendResult
아니면 서비스 자체가 (선택적으로) 우리 컨텍스트의 OrderAcknowledgementSent 이벤트 자체를 반환하도록 할까요?
​ 	​type​ SendOrderAcknowledgment =
​ 	  OrderAcknowledgment -> OrderAcknowledgmentSent option
하지만 그렇게 하면 이벤트 타입을 통해 도메인과 서비스 간에 커플링이 생성됩니다.
(주 : 주문 승인을 보내는 서비스가 우리 컨텍스트에 대해 알 필요가 없기에 커플링이 생갑니다. 해당 함수는 다른 컨텍스트에서도 사용할 수 있지 않을까요?)
여기에는 정답이 없으므로 지금은 Sent/NotSent 접근 방식을 고수할 것입니다. 나중에 언제든지 변경할 수 있습니다.
마지막으로 이 "Acknowledge Order" 단계의 출력은 무엇이어야 합니까? 생성된 경우 단순히 "보낸" 이벤트입니다. 이제 해당 이벤트 유형을 정의해 보겠습니다.
​ 	​type​ OrderAcknowledgmentSent = {
​ 	  OrderId : OrderId
​ 	  EmailAddress : EmailAddress
​ 	  }
마지막으로 이 모든 것을 함께 모아 이 단계의 함수 유형을 정의할 수 있습니다.
​ 	​type​ AcknowledgeOrder =
​ 	  CreateOrderAcknowledgmentLetter     ​// dependency​
​ 	    -> SendOrderAcknowledgment        ​// dependency​
​ 	    -> PricedOrder                    ​// input​
​ 	    -> OrderAcknowledgmentSent option ​// output
보시다시피, 이 함수는 승인이 전송되지 않았을 수 있으므로 선택적 이벤트를 반환합니다.

반환할 이벤트 만들기

지금까지 각 단계에서 리턴한 아웃풋들을 모아봅시다.

각 아웃풋은 다음 단계의 인풋으로 작동하였습니다.

주문 검증 : Result<ValidatedOrder,ValidationError>

가격 책정 : PricedOrder

주문 승인 : Option<OrderAcknowledgmentSent>

 

주문 승인 단계에서 OrderAcknowledgementSent 이벤트를 생성했지만
여전히 OrderPlaced 이벤트(for shipping) 및
BillableOrderPlaced 이벤트(for billing))를 생성해야 합니다.
정의하기 쉽습니다. OrderPlaced 이벤트는 PricedOrder의 타입 별칭일 수 있고
BillableOrderPlaced 이벤트는 PricedOrder의 서브셋일 뿐입니다.
 
    //>States2b
    type PricedOrder = {
       OrderId : DotDotDot
       CustomerInfo : CustomerInfo
       ShippingAddress : Address
       BillingAddress : Address
       // different from ValidatedOrder
       OrderLines : PricedOrderLine list 
       AmountToBill : BillingAmount      
       }
    //<
​ 	​type​ OrderPlaced = PricedOrder
​ 	​type​ BillableOrderPlaced = {
​ 	  OrderId : OrderId
​ 	  BillingAddress: Address
​ 	  AmountToBill : BillingAmount
​ 	  }
실제로 이벤트를 반환하려면 다음과 같이 이벤트를 보관할 특수 타입을 만들 수 있습니다.
​ 	​type​ PlaceOrderResult = {
​ 	  OrderPlaced : OrderPlaced
​ 	  BillableOrderPlaced : BillableOrderPlaced
​ 	  OrderAcknowledgmentSent : OrderAcknowledgmentSent option
​ 	  }​
그러나 시간이 지남에 따라 이 워크플로에 새 이벤트를 추가할 가능성이 매우 높으며
이와 같은 특수 레코드 타입을 정의하면 변경하기가 더 어려워집니다.

대신, 워크플로가 이벤트 목록을 반환한다고 합시다.
여기서 이벤트는 OrderPlaced, BillableOrderPlaced 또는 OrderAcknowledgementSent 중 하나일 수 있습니다.

 

즉, 다음과 같이 초이스 타입인 OrderPlacedEvent를 정의합니다.
​ 	​type​ PlaceOrderEvent =
​ 	  | OrderPlaced ​of​ OrderPlaced
​ 	  | BillableOrderPlaced ​of​ BillableOrderPlaced
​ 	  | AcknowledgmentSent  ​of​ OrderAcknowledgmentSent
그런 다음 워크플로의 마지막 단계에서 다음 이벤트 목록을 리턴합니다.
​ 	​type​ CreateEvents =
​ 	  PricedOrder -> PlaceOrderEvent ​list​
새 이벤트를 추가해야 하는 경우 전체 워크플로를 바꾸지 않고 초이스 항목에 추가할 수 있습니다.
그리고 동일한 이벤트가 도메인의 여러 워크플로에 나타나는 것을 발견하면
한 단계 더 올라가서 도메인의 모든 이벤트에 대한 초이스 타입으로 보다 일반적인 OrderTakingDomainEvent를 만들 수도 있습니다.
(주 : 애그리거트 끼리는 모놀리식이어도 이벤트와 커맨드 기반으로 소통해야 확장이 가능합니다!)
 

사이드 이펙트를 문서화하기

사이드 이펙트의 타입 시그니처는 상위 함수 타입 시그니트에 이펙트를 줍니다.
우리는 타입 시그니처의 이펙트를 문서화하는 것에 대해 이야기했습니다.
이 함수는 어떤 효과를 가질 수 있습니까? 오류를 반환할 수 있습니까? I/O를 수행합니까?
모든 종속성을 빠르게 다시 방문하여 이와 같은 효과에 대해 명시적으로 설명해야 하는지 다시 확인하겠습니다.

검증 단계에서의 이펙트

유효성 검사 단계에는 CheckProductCodeExists 및 CheckAddress Exists의 두 가지 종속성이 있습니다.
CheckProductCodeExists부터 시작하겠습니다.
​type​ CheckProductCodeExists = ProductCode -> ​bool​
오류를 반환할 수 있으며 원격 호출입니까?
둘 다 아니라고 가정해 봅시다. 대신, 우리는 제품 카탈로그의 로컬 캐시 사본을 사용할 수 있고(자율성에 대해 말한 것을 기억하십시오) 신속하게 액세스할 수 있을 것으로 기대합니다.
반면, CheckAddressExists 함수는 도메인 내부의 로컬 서비스가 아닌 원격 서비스를 호출한다는 것을 알고 있으므로 Result Effect뿐 아니라 Async 효과도 가져야 합니다.
실제로 Async 및 Result 효과는 너무 자주 함께 사용되므로 일반적으로 AsyncResult 별칭을 사용하여 하나의 type으로 결합합니다.
​type​ AsyncResult<​'​success,​'​failure> = Async<Result<​'​success,​'​failure>>
이를 통해 이제 CheckAddressExists의 반환 타입을 Result에서 AsyncResult로 변경하여 함수에 비동기 및 오류 효과가 모두 있음을 나타낼 수 있습니다.
​ 	​type​ CheckAddressExists =
​ 	  UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
이제 CheckAddressExists 함수가 I/O를 수행하고 있고 실패할 수 있다는 것이 유형 서명에서 명확해졌습니다.
앞서 바운디드 컨텍스트에 대해 이야기할 때 자율성이 핵심 요소라고 말했습니다.
그렇다면 주소 유효성 검사 서비스의 로컬 버전을 만들어야 한다는 뜻입니까?
이것이 가능하다면 서비스의 가용성이 매우 높다고 확신할 수 있습니다.
자율성을 원하는 주된 이유는 성능이 아니라 특정 수준의 가용성과 서비스를 약속할 수 있도록 하기 위함이라는 것을 기억하십시오.
구현이 타사에 의존하는 경우 타사를 정말로 신뢰해야 합니다. (그렇지 않으면 서비스 문제를 해결해야 함).
 
Resut와 마찬가지로 Async 효과는 이를 포함하는 모든 코드에 전염됩니다.
따라서 AsyncResult를 반환하도록 CheckAddressExists를 변경하면
AsyncResult도 반환하도록 전체 ValidateOrder 단계를 변경해야 합니다.
​ 	​type​ ValidateOrder =
​ 	  CheckProductCodeExists    ​// dependency​
​ 	    -> CheckAddressExists   ​// AsyncResult dependency​
​ 	    -> UnvalidatedOrder     ​// input​
​ 	    -> AsyncResult<ValidatedOrder,ValidationError ​list​>  ​// output​

가격 책정 단계의 사이드 이펙트

가격 책정 단계에는 GetProductPrice라는 하나의 종속성만 있습니다.
제품 카탈로그가 로컬(예: 메모리에 캐시됨)이므로 비동기 효과가 없다고 다시 가정합니다.
액세스할 수 있는 한 오류를 반환할 수도 없습니다. 그래서 거기에 이펙트가 없습니다.
그러나 PriceOrder 단계 자체가 오류를 반환할 수도 있습니다.
항목의 가격이 잘못 책정되어 전체 AmountToBill이 매우 크거나(또는 음수) 있다고 가정해 보겠습니다.
이런 일이 일어났을 때 잡아야 하는 것입니다. 매우 가능성이 희박한 경우일 수 있지만 이와 같은 오류로 인해 실제 세계에서 많은 당황스러운 일이 일어납니다.
 
지금 Result를 반환하려는 경우에는 함께 사용할 오류 타입도 필요합니다.
우리는 이것을 PricingError라고 부를 것입니다. 이제 PriceOrder 함수는 다음과 같습니다.
​ 	​type​ PricingError = PricingError ​of​ ​string​
​ 	
​ 	​type​ PriceOrder =
​ 	  GetProductPrice                       ​// dependency​
​ 	    -> ValidatedOrder                   ​// input​
​ 	    -> Result<PricedOrder,PricingError> ​// output​

승인 단계의 이펙트

AcknowledgeOrder 단계에는 CreateOrderAcknowledgementLetter 및 SendOrderAcknowledgement라는 두 가지 종속성이 있습니다.

 

CreateOrderAcknowledgementLetter 함수가 오류를 반환할 수 있습니까? 아마 아닐 것입니다.

로컬이고 캐시된 템플릿을 사용한다고 가정합니다.

 

따라서 전체적으로 CreateOrderAcknowledgementLetter 함수에는 타입 서명에 문서화해야 하는 이펙트가 없습니다.

 

반면에 SendOrderAcknowledgement가 I/O를 수행할 것이라는 것을 알고 있으므로 비동기 이펙트가 필요합니다.

오류는 어떻습니까? 이 경우 우리는 오류 세부 사항에 신경 쓰지 않고 오류가 있더라도 무시하고 행복한 경로를 계속하고 싶습니다.

즉, 수정된 SendOrderAcknowledgement에는 Async 유형이 있지만 Result 유형은 없습니다.​

(주 : SendResult를 체크해서 재전송하는 루틴을 반복할 뿐이며 오류를 보고하지 않습니다.)

​ 	​type​ SendOrderAcknowledgment =
​ 	  OrderAcknowledgment -> Async<SendResult>
물론 비동기 효과는 상위 함수에도 파급됩니다.
​ 	​type​ AcknowledgeOrder =
​ 	  CreateOrderAcknowledgmentLetter     ​// dependency​
​ 	    -> SendOrderAcknowledgment        ​// Async dependency​
​ 	    -> PricedOrder                    ​// input​
​ 	    -> Async<OrderAcknowledgmentSent option> ​// Async output
반응형