본문 바로가기

BackEnd

파이프라인으로 워크플로 모델링하기 - 개요 및 상태 머신

반응형

이전 두 장에서 타입을 사용하여 일반적인 방식으로 도메인 모델링을 수행하는 방법을 보았습니다.

이 장에서는 거기서 배운 내용을 주문 처리(order-placing ) 워크플로에 적용할 것입니다.

그 과정에서 모든 워크플로를 모델링하는 데 유용한 여러 기술을 살펴보겠습니다.

목표는 항상 그렇듯이 도메인 전문가가 읽을 수 있는 것을 만드는 것입니다.

 
이제 주문하기 워크플로의 단계를 다시 살펴보겠습니다. 다음은 모델링해야 할 사항에 대한 요약입니다.
​ 	workflow "Place Order" =
​ 	    input: UnvalidatedOrder
​ 	    output (on success):
​ 	        OrderAcknowledgmentSent
​ 	        AND OrderPlaced (to send to shipping)
​ 	        AND BillableOrderPlaced (to send to billing)
​ 	    output (on error):
​ 	        ValidationError
​ 	
​ 	    // step 1
​ 	    do ValidateOrder
​ 	    If order is invalid then:
​ 	        return with ValidationError
​ 	
​ 	    // step 2
​ 	    do PriceOrder
​ 	
​ 	    // step 3
​ 	    do AcknowledgeOrder
​ 	
​ 	    // step 4
​ 	    create and return the events

https://itchallenger.tistory.com/411?category=1086398 

 

도메인을 문서화하기

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

itchallenger.tistory.com

분명히 워크플로는 ValidateOrder, PriceOrder 등의 일련의 하위 단계로 구성됩니다.
이것은 물론 매우 일반적입니다. 많은 비즈니스 프로세스를 일련의 문서 변환(document transformations)으로 생각할 수 있으며
동일한 방식으로 워크플로를 모델링할 수 있음을 알 수 있습니다.
우리는 비즈니스 프로세스를 나타내는 "파이프라인"을 생성할 것이며,
이는 차례로 일련의 더 작은 "파이프"로 만들어질 것입니다.
각각의 작은 파이프는 하나의 변환을 수행한 다음 더 큰 파이프라인을 만들기 위해 더 작은 파이프를 함께 붙입니다.
이러한 프로그래밍 스타일을 "변환 지향 프로그래밍"이라고 합니다.
“transformation-oriented programming.”
함수형 프로그래밍 원칙에 따라 파이프라인의 각 단계의 stateless and without side effects를 보장하면서 설계합니다. (순수 함수)
즉, 각 단계를 독립적으로 테스트하고 이해할 수 있습니다.
파이프라인의 조각을 설계한 후에는 구현하고 조합하기만 하면 됩니다.

워크플로 입력

워크플로의 입력부터 살펴보겠습니다.
워크플로에 대한 입력은 항상 도메인 개체여야 합니다(입력이 이미 데이터 전송 개체에서 역직렬화되었다고 가정함).
이번 예제의 경우 객체는 이전에 모델링한 UnvalidatedOrder 유형입니다.
​ 	​type​ UnvalidatedOrder = {
​ 	  OrderId : ​string​
​ 	  CustomerInfo : UnvalidatedCustomerInfo
​ 	  ShippingAddress : UnvalidatedAddress
​ 	  ...
​ 	  ​}​

입력으로서의 Command

이 책의 시작 부분에서 워크플로가 이를 시작하는 커맨드와 연관이 있음을 보았습니다.

어떤 의미에서 워크플로에 대한 실제 입력은 실제로는 주문 양식(form)이 아니라 커맨드입니다.

주문 배치 워크플로의 경우 이 명령을 PlaceOrder라고 부르겠습니다.

커맨드에는 워크플로가 요청을 처리하는 데 필요한 모든 것이 포함되어야 합니다.

이 경우 위의 UnvalidatedOrder입니다.

또한 로깅 및 감사(logging and auditing)를 위해 커맨드, 타임스탬프 및 기타 메타데이터를 만든 사람을 추적하고 싶을 수도 있으므로 커맨드 유형은 다음과 같이 될 수 있습니다.

​ 	​type​ PlaceOrder = {
​ 	  OrderForm : UnvalidatedOrder
​ 	  Timestamp: DateTime
​ 	  UserId: ​string​
​ 	  ​// etc​
​ 	  }

제네릭을 사용하여 공통 구조 공유

물론 이것은 우리가 모델링할 유일한 커맨드는 아닙니다.
각 커맨드에는 고유한 워크플로에 필요한 데이터가 있지만 UserId 및 Timestamp와 같은 다른 모든 커맨드과 공통된 필드도 공유합니다. 동일한 필드를 계속해서 구현해야 합니까? 공유할 수 있는 방법이 없을까요? 우리가 객체 지향 디자인을 한다면, 분명한 해결책은 공통 필드를 포함하는 기본 클래스를 사용한 다음 각 특정 명령이 이 클래스에서 상속되도록 하는 것입니다.
함수형 세계에서 우리는 제네릭(타입 변수)을 사용하여 동일한 목표를 달성할 수 있습니다.
먼저 다음과 같이 커맨드별 데이터(Data 필드) 슬롯과 공통 필드 슬롯을 포함하는 커맨드 타입을 정의합니다.
 	​type​ Command<​'​data> = {
​ 	  Data : ​'​data
​ 	  Timestamp: DateTime
​ 	  UserId: ​string​
​ 	  ​// etc​
​ 	  }​
그런 다음 데이터 슬롯에 들어갈 타입을 지정하여 워크플로별 커맨드을 만들 수 있습니다.
​type​ PlaceOrder = Command<UnvalidatedOrder>​

여러 커맨드를 하나의 타입으로 결합

어떤 경우에는 바운디드 컨텍스트에 대한 모든 커맨드가 동일한 입력 채널(예: 메시지 대기열)에서 전송되므로 직렬화할 수 있는 하나의 데이터 구조로 통합할 방법이 필요합니다.

(하나의 메세지 큐에서 여러 케이스의 커맨드가 들어옴)

해결책은 명확합니다. 모든 커맨드를 포함하는 초이스 타입을 생성하기만 하면 됩니다.
예를 들어 PlaceOrder, ChangeOrder 및 CancelOrder 중에서 선택해야 하는 경우 다음과 같은 타입을 만들 수 있습니다.
​ 	​type​ OrderTakingCommand =
​ 	  | Place ​of​ PlaceOrder
​ 	  | Change ​of​ ChangeOrder
​ 	  | Cancel ​of​ CancelOrder
각 케이스에는 연관된 커맨드 타입이 있습니다.
우리는 이미 PlaceOrder 타입을 정의했으며
ChangeOrder 및 CancelOrder도 커맨드을 실행하는 데 필요한 정보를 포함하는 동일한 방식으로 정의됩니다.

 

(Data 필드만 다름! - 이부분만 다른 방법으로 직렬화해주면 됨.)
이 초이스 타입은 DTO에 매핑되고 입력 채널에서 직렬화 및 역직렬화됩니다.
바운디드 컨텍스트(Onion 아키텍처의 "인프라" 링)의 가장자리에 새로운 "라우팅" 혹은 "디스패칭" 입력 단계를 추가하기만 하면 됩니다.
어니언 아키텍처

 

라우팅(=디스패칭) 입력 단계 추가 - 커맨드핸들러에서 역직렬화 혹은 직렬화 수행

 

Order Domain을 상태 집합으로 모델링

이제 워크플로 파이프라인의 단계로 이동하겠습니다.
워크플로에 대한이해를 통해 주문이 단지 정적 문서가 아니라 실제로 일련의 다른 상태를 통해 전환된다는 것이 분명해졌습니다.
스테이트 머신

이러한 상태를 어떻게 모델링해야 합니까? 나이브한 접근 방식은 다음과 같이 플래그를 통해 모든 종류의 상태를 캡처하는 단일 레코드 유형을 만드는 것입니다.

​ 	​type​ Order = {
​ 	  OrderId : OrderId
​ 	  ...
​ 	  IsValidated : ​bool​  ​// set when validated​
​ 	  IsPriced : ​bool​     ​// set when priced​
​ 	  AmountToBill : decimal option ​// also set when priced​
​ 	  }
그러나 이 접근 방식에는 많은 문제가 있습니다.
  • (암시적 스테이트) 다양한 플래그를 통해 상태를 표현하지만, 상태는 암시적이며 처리하려면 많은 조건부 코드가 필요합니다.
  • (옵셔널 문제) 일부 상태에는 다른 상태에 필요하지 않은 데이터가 있으며, 모두 하나의 레코드에 넣으면 설계가 복잡해집니다. 예를 들어 AmountToBill은 "가격이 책정된" 상태에서만 필요하지만 다른 상태에는 필요 없기 때문에 필드를 선택 사항으로 만들어야 합니다.
  • (필드와 플래그의 상관관계) 어떤 필드가 어떤 플래그와 함께 사용되는지 명확하지 않습니다. AmountToBill은 IsPriced가 설정되어 있을 때 값을 설정해야 하지만 디자인은 이를 강제하지 않으며 데이터 일관성을 유지하기 위해 주석에 의존해야 합니다.
주석에 의존하는 설계가 좋은 설계일까요? 
 
도메인을 모델링하는 훨씬 더 좋은 방법은 주문의 각 상태에 대해 새 타입을 만드는 것입니다.
이를 통해 암시적 상태와 조건부 필드를 제거할 수 있습니다.
유형은 이전에 만든 도메인 문서에서 직접 정의할 수 있습니다.
예를 들어 ValidatedOrder에 대한 도메인 문서는 다음과 같습니다.
​ 	data ValidatedOrder =
​ 	   ValidatedCustomerInfo
​ 	   AND ValidatedShippingAddress
​ 	   AND ValidatedBillingAddress
​ 	   AND list of ValidatedOrderLine
다음은 ValidatedOrder에 대한 타입 정의입니다.
이것은 간단한 번역입니다(주문 ID가 워크플로 전체에서 유지되어야 하기 때문에 추가하였습니다.).
​ 	​type​ ValidatedOrder = {
​ 	  OrderId : OrderId
​ 	  CustomerInfo : CustomerInfo
​ 	  ShippingAddress : Address
​ 	  BillingAddress : Address
​ 	  OrderLines : ValidatedOrderLine ​list​
​ 	  }
가격 정보에 대한 추가 필드를 사용하여 같은 방식으로 PricedOrder에 대한 타입을 생성할 수 있습니다.
​ 	​type​ PricedOrder = {
​ 	  OrderId : ...
​ 	  CustomerInfo : CustomerInfo
​ 	  ShippingAddress : Address
​ 	  BillingAddress : Address
​ 	  ​// different from ValidatedOrder​
​ 	  OrderLines : PricedOrderLine ​list​
​ 	  AmountToBill : BillingAmount
​ 	  }
마지막으로 모든 상태 중에서 선택할 수 있는 최상위 타입을 만들 수 있습니다. (애그리거트)
​ 	​type​ Order =
​ 	  | Unvalidated ​of​ UnvalidatedOrder
​ 	  | Validated ​of​ ValidatedOrder
​ 	  | Priced ​of​ PricedOrder
​ 	  ​// etc
이것은 라이프 사이클에서 언제든지 주문을 나타내는 객체입니다.
그리고 이것은 스토리지에 영속하거나 다른 컨텍스트와 통신할 수 있는 타입입니다.
완전히 다른 워크플로이기 때문에 이 초이스 세트에 Quote 를 포함하지 않을 것입니다.

요구 사항 변경에 따라 새 상태 타입 추가

각 상태에 대해 별도의 타입을 사용하는 것에 대한 한 가지 좋은 점은
기존 코드를 손상시키지 않고 새 상태를 추가할 수 있다는 것입니다.
예를 들어 환불을 지원해야 하는 요구 사항이 있는 경우
해당 상태에 필요한 모든 정보와 함께 새 RefundedOrder 상태를 추가할 수 있습니다.
다른 상태는 독립적으로 정의되기 때문에 이를 사용하는 코드는 변경 사항의 영향을 받지 않습니다.

State Machine

위 섹션에서 플래그가 있는 단일 타입을 각각 특정 워크플로 단계용으로 설계된 별도 타입 세트로 변환했습니다.
EmailAddress 예제에서 플래그가 있는 디자인을 각 상태에 대해 "미확인" 및 "확인됨"이라는 두 가지 선택 사항이 있는 디자인으로 변환했습니다.
이러한 상황은 비즈니스 모델링 시나리오에서 매우 일반적입니다.
"상태"를 일반 도메인 모델링 도구로 사용하는 방법을 살펴보겠습니다.
일반적인 모델에서 문서 또는 레코드는 하나 이상의 상태에 있을 수 있으며,
그림과 같이 일종의 명령에 의해 트리거되는 한 상태에서 다른 상태로의 경로("전환")가 있습니다. 이것을 상태 머신이라고 합니다.
상태 기계
이제 언어 파서 및 정규식에 사용되는 것과 같이 수십 또는 수백 개의 상태가 있는 복잡한 상태 시스템에 익숙할 것입니다.
우리는 그것에 대해 이야기하지 않을 것입니다.
여기에서 논의할 상태 머신의 종류는 훨씬, 훨씬 더 간단합니다. 적은 수의 전환과 함께 기껏해야 몇 가지 경우에 불과합니다.
 

몇 가지 예:

방금 언급한 이메일 주소에는 "미확인" 및 "확인됨" 상태가 있을 수 있습니다. 여기에서 사용자에게 확인 이메일의 링크를 클릭하도록 요청하여 "미확인" 상태에서 "확인됨" 상태로 전환할 수 있습니다. .
이메일 예제

장바구니에는 "비어 있음", "활성(상품 있음)" 및 "지불됨(추가 지불 및 상품 추가 불가능)" 상태가 있을 수 있습니다.

장바구니 예제

패키지 배송에는 "배송되지 않음", "배송 예정" 및 "배송됨"의 세 가지 상태가 있을 수 있습니다.

여기서 패키지를 배송 트럭에 올려서 "배송되지 않음" 상태에서 "배송 준비 중" 상태로 전환할 수 있습니다.

패키지 배송

상태 머신을 사용하는 이유

다음과 같은 경우 상태 머신을 사용하면 여러 가지 이점이 있습니다.

  • 각 상태는 다른 허용되는 동작을 가질 수 있습니다. 예를 들어 장바구니 예에서는 상품이 있는 장바구니만 지불할 수 있으며 이미 지불된 장바구니에는 상품을 추가할 수 없습니다. 이전 장에서 확인되지 않은/확인된 이메일 디자인에 대해 논의할 때 확인된 이메일 주소로만 비밀번호 재설정을 보낼 수 있다는 비즈니스 규칙을 보았습니다. 각 상태에 대해 고유한 타입을 사용하여 비즈니스 규칙이 준수되었는지 확인하기 위해 컴파일러를 사용하여 해당 요구 사항을 함수 시그니처에서 직접 인코딩할 수 있습니다.
  • 모든 상태는 명시적으로 문서화되어 있습니다. 암묵적이지만 문서화되지 않은 중요한 상태를 갖는 것은 쉽습니다. 장바구니 예에서 "빈 장바구니"는 "활성(상품이 있는) 장바구니"와 동작이 다르지만 코드에서 명시적으로 문서화되는 경우는 드뭅니다.
  • 발생할 수 있는 모든 가능성에 대해 생각하게 하는 디자인 도구입니다. 설계 오류의 일반적인 원인은 특정 엣지 케이스가 처리되지 않는다는 것입니다.  상태 머신은 모든 경우를 고려하도록 합니다. 예를 들어:
    • 이미 확인된 이메일을 확인하려고 하면 어떻게 됩니까?
    • 빈 장바구니에서 항목을 제거하려고 하면 어떻게 됩니까?
    • 이미 "Delivered" 상태에 있는 패키지를 배송하려고 하면 어떻게 됩니까? 등등.

상태 측면에서 디자인에 대해 생각하면 이러한 질문이 표면화되고 도메인 논리가 명확해질 수 있습니다.

F#에서 간단한 상태 머신을 구현하는 방법

언어 파서 등에 사용되는 복잡한 상태 기계는 규칙 집합이나 문법에서 생성되며 구현하기가 상당히 복잡합니다.

그러나 위에서 설명한 간단한 비즈니스 지향 상태 머신은 특별한 도구나 라이브러리 없이 수동으로 코딩할 수 있습니다.

그렇다면 이러한 간단한 상태 머신을 어떻게 구현해야 할까요?

우리가 원하지 않는 한 가지는 플래그, 열거형 또는 다른 종류의 조건부 논리를 사용하여 모든 상태를 공통 레코드로 결합하여 구별하는 것입니다.

훨씬 더 나은 접근 방식은 각 상태에 해당 상태(있는 경우)와 관련된 데이터를 저장하는 고유한 타입을 만드는 것입니다.

그런 다음 각 상태에 대한 케이스가 있는 초이스 타입으로 전체 상태 세트를 나타낼 수 있습니다.

다음은 장바구니 상태 머신을 사용하는 예입니다.

​ 	​type​ Item = ...
​ 	​type​ ActiveCartData = { UnpaidItems: Item ​list​ }
​ 	​type​ PaidCartData = { PaidItems: Item ​list​; Payment: ​float​ }
​ 	
​ 	​type​ ShoppingCart =
​ 	  | EmptyCart  ​// no data​
​ 	  | ActiveCart ​of​ ActiveCartData
​ 	  | PaidCart ​of​ PaidCartData
ActiveCartData 및 PaidCartData 상태에는 각각 고유한 타입이 있습니다.
EmptyCart 상태에는 연결된 데이터가 없으므로 특별한 타입이 필요하지 않습니다.
그런 다음 커맨드 핸들러는 전체 상태 시스템(초이스 타입)을 수락하고 새 버전(업데이트된 초이스 타입)을 반환하는 함수로 표시됩니다.
장바구니에 항목을 추가하고 싶다고 가정해 보겠습니다. 상태 전환 함수 addItem은 다음과 같이 ShoppingCart 매개변수와 추가할 항목을 사용합니다.
// 파라미터 : 쇼핑카트 추가할_아이템
​ 	​let​ addItem cart item =
​ 	  ​match​ cart ​with​
​ 	  | EmptyCart ->
​ 	    ​// create a new active cart with one item​
​ 	    ActiveCart {UnpaidItems=[item]}
​ 	
​ 	  | ActiveCart {UnpaidItems=existingItems} ->
​ 	    ​// create a new ActiveCart with the item added​
​ 	    ActiveCart {UnpaidItems = item :: existingItems}
​ 	
​ 	  | PaidCart _ ->
​ 	    ​// ignore​
​ 	    cart​
결과는 새 상태일 수도 있고 아닐 수도 있는 새 ShoppingCart입니다("유료" 상태인 경우).
또는 장바구니에 있는 항목에 대해 비용을 지불하고 싶다고 말합니다.
상태 전환 함수 makePayment는 다음과 같이 ShoppingCart 매개변수와 결제 정보를 사용합니다.
​ 	​let​ makePayment cart payment =
​ 	  ​match​ cart ​with​
​ 	  | EmptyCart ->
​ 	    ​// ignore​
​ 	    cart
​ 	
​ 	  | ActiveCart {UnpaidItems=existingItems} ->
​ 	    ​// create a new PaidCart with the payment​
​ 	    PaidCart {PaidItems = existingItems; Payment=payment}
​ 	
​ 	  | PaidCart _ ->
​ 	    ​// ignore​
​ 	    cart
결과는 "유료" 상태일 수도 있고 아닐 수도 있는 새 ShoppingCart입니다(이미 "비어 있음" 또는 "유료" 상태에 있는 경우).
API, 함수 호출자의 관점에서 상태 집합은 일반적인 조작(ShoppingCart 타입)을 위해 하나의 것으로 처리되지만, (하나의 타입)
내부적으로 이벤트를 처리할 때 각 상태가 개별적으로 처리됨을 알 수 있습니다. (여러 개의 케이스)

 

반응형