본문 바로가기

BackEnd

도메인의 무결성(Integrity)과 일관성(Consistency) 관리하기 - Make illegal states unrepresentable

반응형

해당 장의 요약 : 비즈니스 제약 사항을 주석이 아니라 타입 시스템을 이용해 작성하라! 타입 체킹을 통해 불필요한 테스트코드 작성을 피할 수 있다.

 

지금까지 F# 타입 시스템을 사용한 도메인 모델링의 기본 사항을 살펴보았습니다.

우리는 도메인을 나타면서도 컴파일 가능하고 구현을 안내하는 데 사용할 수 있는 풍부한 타입 세트를 구축했습니다.

도메인 모델에 맞게 모든 데이터가 유효하고 일관성이 있는지 확인하기 위해 몇 가지 예방 조치를 취해야 합니다.
목표는 신뢰할 수 없는 외부 세계와 구별되는 신뢰할 수 있는 데이터를 항상 포함하는 바운디드 컨텍스트를 만드는 것입니다.
모든 데이터 값이 항상 유효하다는 것을 확신할 수 있다면 구현을 깨끗하게 유지할 수 있고 방어적 코딩을 하지 않아도 됩니다.

바운디드 컨텍스트는 아름답고 깨끗한 도메인 모델의 세계입니다.

이 장에서는 신뢰할 수 있는 도메인의 두 가지 측면인 무결성과 일관성을 모델링하는 방법을 살펴보겠습니다.

 

DDD 맥락에서 무결성(또는 유효성 - Integrity (or validity))은 데이터가 올바른 비즈니스 규칙을 따른다는 것을 의미합니다.
예를 들어:
  • 우리는 UnitQuantity가 1에서 1000 사이여야 함을 알고 있습니다.. 우리 코드에서 이것을 여러 번 확인해야 합니까, 아니면 항상 참이라고 믿을 수 있습니까?
  • 주문에는 항상 하나 이상의 주문 라인이 있어야 합니다.
  • 주문은 배송 부서로 보내지기 전에 확인된 배송 주소가 있어야 합니다.

 

일관성(Consistency)이란 도메인 모델의 여러 데이터가 사실에 대해 동의함을 의미합니다. (데이터의 일치 여부)
예를 들어 :
  • 주문에 대해 청구할 총 금액은 개별 라인의 합계여야 합니다. 합계가 다르면 데이터가 일치하지 않습니다.
  • 주문이 접수되면 해당 송장을 생성해야 합니다. 주문이 있지만 송장이 없으면 데이터가 일치하지 않습니다.
  • 할인 쿠폰 코드를 주문과 함께 사용하는 경우 쿠폰 코드를 사용한 것으로 표시해야 다시 사용할 수 없습니다. 주문이 해당 바우처를 참조하지만 바우처가 사용된 것으로 표시되지 않은 경우 데이터가 일치하지 않습니다.
 타입 시스템에서 캡처할 수 있는 정보가 많을수록 필요한 문서가 줄어들고 코드가 올바르게 구현될 가능성이 높아집니다.

필드의 무결성

필드 모델링 시에도 primitive value가 아니라 WidgetCode, UnitQuantity와 같은 도메인 중심 타입으로 표현해야 함을 배웠습니다.

그러나 여기서 멈추지 말아야 합니다.
왜냐하면 실제 도메인에서 무한한 정수나 문자열을 갖는 것은 매우 드물기 때문입니다. 거의 항상 이러한 값은 어떤 방식으로든 제한됩니다.
  • OrderQuantity는 부호 있는 정수로 표시될 수 있지만 비즈니스에서 음수 또는 40억을 원할 가능성은 거의 없습니다.
  • CustomerName은 문자열로 표시될 수 있지만 이것이 탭 문자나 줄 바꿈을 포함해야 함을 의미하지는 않습니다.
우리 도메인에서는 이러한 제한된 유형 중 일부를 이미 보았습니다.
WidgetCode 문자열은 특정 문자로 시작해야 했고 UnitQuantity는 1에서 1000 사이여야 했습니다.
다음은 제약 조건에 대한 설명과 함께 지금까지 정의한 방법입니다.
​ 	​type​ WidgetCode = WidgetCode ​of​ ​string​   ​// starting with "W" then 4 digits​
​ 	​type​ UnitQuantity = UnitQuantity ​of​ ​int​  ​// between 1 and 1000​
​ 	​type​ KilogramQuantity = KilogramQuantity ​of​ decimal ​// between 0.05 and 100.00​
타입 사용자가 주석을 읽도록 하는 대신 이러한 타입의 값이 제약 조건을 충족하지 않는 한 생성될 수 없도록 하고 싶습니다.
그 이후에는 데이터가 불변하기 때문에 내부 값을 다시 확인할 필요가 없습니다.
방어 코딩을 할 필요 없이 어디에서나 WidgetCode 또는 UnitQuantity를 자신 있게 사용할 수 있습니다.
 
좋아보이네요. 그렇다면 제약 조건이 적용되도록 하려면 어떻게 해야 합니까?
 
A: 모든 프로그래밍 언어에서와 같은 방식으로 생성자를 private로 만들고 유효한 값을 생성하고
무효한 값을 거부하여 대신 오류를 반환하는 별도의 함수를 사용합니다.
FP 커뮤니티에서는 이를 스마트 생성자 접근 방식이라고 합니다.
다음은 UnitQuantity에 적용된 이 접근 방식의 예입니다.
​ 	​type​ UnitQuantity = ​private​ UnitQuantity ​of​ ​int​
​ 	​//                  ^ private constructor
이제 private 생성자로 인해 포함 모듈 외부에서 UnitQuantity 값을 생성할 수 없습니다.
그러나 위의 유형 정의가 포함된 동일한 모듈에 코드를 작성하면 생성자에 액세스할 수 있습니다.

이 사실을 사용하여(생성자는 private, 함수는 public) 타입을 조작하는 데 도움이 되는 몇 가지 함수를 정의해 보겠습니다.
정확히 같은 이름(UnitQuantity)을 가진 하위 모듈을 만드는 것으로 시작하겠습니다.
그 안에서 우리는 int를 받아들이고 성공 또는 실패를 반환하기 위해 (오류 모델링에서 논의된 대로)
결과 유형을 반환하는 생성 함수를 정의할 것입니다.
이 두 가지 가능성은 함수 서명에 명시되어 있습니다. int -> Result<UnitQuantity,string>.​
주의! 아래 함수는 타입이 아니라 implementation 입니다.
​ 	​// define a module with the same name as the type​
​ 	​module​ UnitQuantity =
​ 	
​ 	  ​/// Define a "smart constructor" for UnitQuantity​
​ 	  ​/// int -> Result<UnitQuantity,string>​
​ 	  ​let​ create qty =
​ 	    ​if​ qty < 1 ​then​
​ 	      ​// failure​
​ 	      Error ​"UnitQuantity can not be negative"​
​ 	    ​else​ ​if​ qty > 1000 ​then​
​ 	      ​// failure​
​ 	      Error ​"UnitQuantity can not be more than 1000"​
​ 	    ​else​
​ 	      ​// success -- construct the return value​
​ 	      Ok (UnitQuantity qty)​

이전 버전의 F#과의 호환성

제네릭이 아닌 타입과 이름이 같은 모듈은 v4.1(VS2017) 이전의 F# 버전에서 오류를 일으키므로 다음과 같이 CompilationRepresentation 특성을 포함하도록 모듈 정의를 변경해야 합니다.
​ 	​type​ UnitQuantity = ...
​ 	
​ 	[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
​ 	​module​ UnitQuantity =
​ 	  ...​
private 생성자의 한 가지 단점은 더 이상 패턴 일치 및 래핑된 데이터 추출에 사용할 수 없다는 것입니다.
이에 대한 한 가지 해결 방법은 UnitQuantity 모듈에서도 내부 값을 추출하는 별도의 값 함수를 정의하는 것입니다.​
​ 	​/// Return the wrapped value​
​ 	​let​ value (UnitQuantity qty) = qty
실제로 어떻게 작동하는지 봅시다. 먼저 UnitQuantity를 직접 생성하려고 하면 컴파일러 오류가 발생합니다.
​ 	​let​ unitQty = UnitQuantity 1
​ 	​//            ^ The union cases of the type 'UnitQuantity'​
​ 	​//              are not accessible
그러나 UnitQuantity.create 함수를 대신 사용하면 Result를 반환하고 다음과 비교할 수 있습니다.
​ 	​let​ unitQtyResult = UnitQuantity.create 1
​ 	
​ 	​match​ unitQtyResult ​with​
​ 	| Error msg ->
​ 	  printfn ​"Failure, Message is %s"​ msg
​ 	| Ok uQty ->
​ 	  printfn ​"Success. Value is %A"​ uQty
/////// UnitQuantity.value에 unitQuantity값을 전달해 Int 값을 빼옵니다.
​ 	  ​let​ innerValue = UnitQuantity.value uQty
​ 	  printfn ​"innerValue is %i"​ innerValue
이와 같이 private type이 많은 경우 생성자에 대한 공통 코드가 포함된 도우미 모듈을 사용하여 반복을 줄일 수 있습니다.
샘플 코드에 있는 Domain.SimpleTypes.fs 파일에 예제가 있습니다.
(주 : 가서 볼 사람이 있을지는 모르겠지만 : https://pragprog.com/titles/swdddf/domain-modeling-made-functional/ 의 Resource 부분에 있습니다.)
 
마지막으로, private을 사용하는 것이 F#에서 생성자를 숨길 수 있는 유일한 방법은 아니라는 점은 말할 가치가 있습니다.
시그니처 파일을 사용하는 것과 같은 다른 기술이 있지만 여기서는 다루지 않습니다.

측정 단위 (Units of Measure)

숫자 값의 경우 타입 안전성을 보장하면서 요구 사항을 문서화하는 또 다른 방법은 측정 단위를 사용하는 것입니다.
측정 단위 접근 방식을 사용하면 숫자 값에 사용자 지정 "측정값"이 주석으로 표시됩니다.
예를 들어 다음과 같이 kg(킬로그램) 및 m(미터)에 대한 일부 측정 단위를 정의할 수 있습니다.
 
(주 : 타입스크립트의 경우 newtype-ts 혹은 브랜디드 타입을 사용하면 됩니다. F#의 경우 단위 기능을 랭귀지 차원에서 지원합니다.)
​ 	[<Measure>]
​ 	​type​ kg
​ 	
​ 	[<Measure>]
​ 	​type​ m​
그리고 다음과 같이 측정 단위로 일부 값에 주석을 답니다.
​ 	​let​ fiveKilos = 5.0<kg>
​ 	​let​ fiveMeters = 5.0<m>
모든 SI 단위에 대해 측정 유형을 정의할 필요는 없습니다. Microsoft.FSharp.Data.UnitSystems.SI 네임스페이스에서 사용할 수 있습니다.
이 작업이 완료되면 컴파일러는 측정 단위 간의 호환성을 적용하고 일치하지 않으면 오류를 표시합니다.
​ 	​// compiler error​
​ 	fiveKilos = fiveMeters
​ 	​//          ^ Expecting a float<kg> but given a float<m> ​
​ 	
​ 	​let​ listOfWeights = [
​ 	  fiveKilos
​ 	  fiveMeters  ​// <-- compiler error​
​ 	  ​//             The unit of measure 'kg'​
​ 	  ​//             does not match the unit of measure 'm'​
​ 	  ]
우리 도메인에서는 측정 단위를 사용하여 KilogramQuantity가 실제로 kg임을 강제할 수 있으므로 실수로 파운드 값으로 초기화하지 않도록 할 수 있습니다. 이것을 다음과 같은 타입으로 인코딩할 수 있습니다.
​type​ KilogramQuantity = KilogramQuantity ​of​ decimal<kg>
이제 두 가지 검사가 있습니다. <kg>은 숫자의 단위가 올바른지 확인하고
KilogramQuantity는 최대값과 최소값에 대한 제약 조건을 적용합니다.
이것은 아마도 우리의 특정 도메인에 대한 과도한 디자인이지만 다른 상황에서는 유용할 수 있습니다.
 
측정 단위는 물리적 단위에만 사용할 필요는 없습니다.
이를 사용하여 timout(초와 밀리초 혼합 방지) 또는 공간 차원(x축과 y축 혼합 방지) 또는 통화 등에 대한 올바른 단위를 문서화할 수 있습니다.
 
측정 단위 사용으로 인한 성능 저하는 없습니다. F# 컴파일러에서만 사용되며 런타임에 오버헤드가 없습니다.
 
(참고로 newtype-ts, io-ts, branded type과 같이 타입 기능을 주로 사용하는 ts 라이브러리들도 런타임 오버헤드가 없습니다. js로 컴파일되기 때문입니다.)

타입 시스템에 불변(Invariant) 조건 적용 - 비즈니스 제약사항

불변(invariant) 조건은 다른 일이 일어나더라도 참으로 유지되는 조건입니다.
예를 들어, UnitQuantity는 항상 1과 1000 사이에 있어야 한다고 말했습니다. 이것이 불변 조건의 예입니다.
 
또한 Order에는 항상 하나 이상의 OrderLine이 있어야 한다고 말했습니다. UnitQuantity의 경우와 달리 이것은 타입 시스템에서 직접 캡처할 수 있는 불변의 예입니다.
목록이 비어 있지 않은지 확인하려면 NonEmptyList 타입을 정의하기만 하면 됩니다.
F#에 내장되어 있지는 않지만 자신을 정의하기 쉽습니다.
​ 	​type​ NonEmptyList<​'​a> = {
​ 	  First: ​'​a
​ 	  Rest: ​'​a ​list​
​ 	  }​

주 : 타입스크립트의 경우도 해당 타입 사용이 가능합니다. 구현을 찾아보세요!
 
정의 자체에서는 항상 하나 이상의 요소가 있어야 하므로 NonEmptyList는 결코 비어 있지 않음이 보장됩니다.
물론 추가, 제거 같은 기능도 필요합니다. (ex - ts 튜플 타입이 Array 메소드를 사용하지 못하는 이유와 비슷합니다.)
직접 정의하거나 FSharpx.Collections와 같이 이 유형을 제공하는 타사 라이브러리 중 하나를 사용할 수 있습니다.
 
이제 일반 리스트 타입 대신 이 타입을 사용하도록 Order type을 다시 작성할 수 있습니다.
​ 	​type​ Order = {
​ 	  ...
​ 	  OrderLines : NonEmptyList<OrderLine>
​ 	  ...
​ 	  }

이 변경으로 "주문에는 항상 하나 이상의 주문 라인이 있습니다"라는 제약 조건이 이제 자동으로 시행됩니다.
코드를 자체 문서화하고 요구 사항에 대한 단위 테스트를 작성할 필요가 없습니다.

 

타입 시스템에서 비즈니스 규칙 파악하기

타입 시스템만 사용하여 비즈니스 규칙을 문서화할 수 있습니까?

즉, 규칙이 유지되는지 확인하기 위해 런타임 검사나 코드 주석에 의존하는 대신
F# 타입 시스템을 사용하여 유효한지 또는 잘못된지를 나타내어 컴파일러가 확인할 수 있도록 하고 싶습니다.
 
현실 세계의 예시입니다. 우리 회사인 Widgets Inc가 고객의 이메일 주소를 저장한다고 가정합니다.
그러나 모든 이메일 주소가 같은 방식으로 취급되어서는 안 된다고 가정해 봅시다. 일부 이메일 주소는 확인되었습니다. 즉, 고객이 확인 이메일을 받고 확인 링크를 클릭한 반면, 다른 이메일 주소는 확인되지 않아 유효한지 확인할 수 없습니다.
또한 다음과 같은 일부 비즈니스 규칙이 이메일 주소의 검증 여부를 기반으로 한다고 가정해 보겠습니다.
  • 보안 침해를 방지하기 위해 확인된 이메일 주소로만 비밀번호 재설정 이메일을 보내야 합니다.
  • 확인 이메일은 확인되지 않은 이메일 주소로만 보내야 합니다(기존 고객에게 스팸 발송을 방지하기 위해).
이제 디자인에서 두 가지 다른 상황을 어떻게 나타낼 수 있습니까?
일반적인 접근 방식은 플래그를 사용하여 다음과 같이 확인이 발생했는지 여부를 나타내는 것입니다.
​ 	​type​ CustomerEmail = {
​ 	  EmailAddress : EmailAddress
​ 	  IsVerified : ​bool​
​ 	  }
그러나 이 접근 방식에는 여러 가지 심각한 문제가 있습니다.
  • 첫째, IsVerified 플래그를 설정하거나 설정 해제해야 하는 시기와 이유가 명확하지 않습니다. 예를 들어 고객의 이메일 주소가 변경된 경우 다시 false로 설정해야 합니다(새 이메일이 아직 확인되지 않았기 때문에).  그러나 디자인의 어떤 것도 그 규칙을 명시하지 않습니다.
  • 둘째, 개발자가 이메일이 변경될 때 실수로 이 작업을 잊어버리거나 더 심하게는 규칙을 완전히 인식하지 못할 가능성이 있습니다.(어딘가의 일부 주석에 숨겨져 있기 때문입니다).
보안 침해의 가능성도 있습니다. 개발자는 확인되지 않은 이메일에 대해서도 실수로 플래그를 true로 설정하여 비밀번호 재설정 이메일을 확인되지 않은 주소로 보내는 코드를 작성할 수 있습니다.
그렇다면 이것을 모델링하는 더 좋은 방법은 무엇입니까?
대답은 언제나 그렇듯이 도메인에 주의를 기울이는 것입니다.
도메인 전문가가 "확인된" 이메일과 "확인되지 않은" 이메일에 대해 이야기할 때 별도의 것으로 모델링해야 합니다.
이 경우 도메인 전문가가 "고객의 이메일이 확인되었거나 확인되지 않았습니다."라고 말하면
이를 다음과 같은 두 가지 유형 중에서 선택하도록 모델링해야 합니다.
​ 	​type​ CustomerEmail =
​ 	  | Unverified ​of​ EmailAddress
​ 	  | Verified ​of​ EmailAddress
그러나 Unverified인 EmailAddress를 전달하여 실수로 Verified Case를 생성하는 것을 방지하지는 못합니다.
그 문제를 해결하기 위해 우리는 항상 하던대로 새로운 타입을 만들 것입니다!
특히 일반 EmailAddress 유형과 다른 VerifiedEmailAddress 유형을 생성합니다. 이제 우리의 선택은 다음과 같습니다.

(주 : Verified 케이스는 VerifiedEmailAddress를 통해서만 생성할 수 있음을 의미합니다.

해당 책의 예제에서는, CustomerEmail이라는 최상위 모듈에서,

create라는 함수로 EmailAddress혹은 VerifiedEmailAddress Unverified, Verified를 리턴합니다.

value라는 함수로 Unverified인지, Verified 내부의 EmailAddress혹은 VerifiedEmailAddress 내부의 string 값을 리턴합니다 (...)

​ 	​type​ CustomerEmail =
​ 	  | Unverified ​of​ EmailAddress
​ 	  | Verified ​of​ VerifiedEmailAddress ​// different from normal EmailAddress​​
VerifiedEmailAddress에 private 생성자를 제공하여 일반 코드가 해당 타입의 값을 생성할 수 없도록 할 수 있습니다.
validation 서비스만 해당 타입을 생성할 수 있습니다.
즉, 새 이메일 주소가 있는 경우 VerifiedEmailAddress가 없기 때문에 Unverified Case를 사용하여 고객 이메일을 생성해야 합니다. Verified 케이스를 생성할 수 있는 유일한 방법은 VerifiedEmailAddress가 있고 VerifiedEmailAddress를 얻을 수 있는 유일한 방법은 이메일 확인 서비스 뿐입니다.
 
이것은 "Make illegal states unrepresentable"라는 중요한 디자인 지침의 한 예입니다. 우리는 타입 시스템에서 비즈니스 규칙을 캡처하려고 합니다.
이 작업을 제대로 수행하면 코드에 잘못된 상황이 존재할 수 없으며 이에 대한 단위 테스트를 작성할 필요가 없습니다. 대신 "컴파일 시간" 단위 테스트가 있습니다.
 
이 접근 방식의 또 다른 중요한 이점은 실제로 도메인을 더 잘 문서화한다는 것입니다.
두 가지 역할을 수행하려고 하는 단순한 EmailAddress가 아니라 서로 다른 규칙을 가진 두 가지 유형이 있습니다.
그리고 일반적으로 이러한 보다 세분화된 타입을 만든 후에는 즉시 용도를 찾습니다.
 
예를 들어 이제 암호 재설정 메시지를 보내는 워크플로가 일반 전자 메일 주소가 아닌 VerifiedEmailAddress 매개 변수를 입력으로 가져와야 함을 명시적으로 문서화할 수 있습니다.
​type​ SendPasswordResetEmail = VerifiedEmailAddress -> ...
이 정의를 사용하면 누군가가 실수로 정상적인 EmailAddress를 전달하는, 즉 문서를 읽지 않았기 때문에 비즈니스 규칙을 위반하는 것에 대해 걱정할 필요가 없습니다. (정적 에러 발생)

 

여기 또 다른 예가 있습니다. 고객에게 연락할 방법이 필요하다는 비즈니스 규칙이 있다고 가정해 보겠습니다.
"고객은 이메일과 우편 주소 중 하나는 있어야 합니다."
이것을 어떻게 표현해야 할까요? 명백한 접근 방식은 다음과 같이 Email 및 Address 속성을 모두 사용하여 레코드를 만드는 것입니다.​
​ 	​type​ Contact = {
​ 	  Name: Name
​ 	  Email: EmailContactInfo
​ 	  Address: PostalContactInfo
​ 	  }
그러나 이것은 잘못된 디자인입니다. 이메일과 주소가 모두 필요하다는 의미입니다. 그래서 그것들을 Option 타입으로 표현합니다.
 	​type​ Contact = {
​ 	  Name: Name
​ 	  Email: EmailContactInfo option
​ 	  Address: PostalContactInfo option
​ 	  }
하지만 이것도 옳지 않습니다. 그대로 이메일과 주소가 모두 누락될 수 있으며 이는 비즈니스 규칙을 위반할 수 있습니다.

 

물론, 이런 일이 발생하지 않도록 하기 위해 특별한 런타임 유효성 검사를 추가할 수 있습니다.
하지만, 타입 시스템에서 체크할 수 있을까요?

 

방법은 규칙을 자세히 살펴보는 것입니다. 이는 고객이 다음과 같은 상태가 가능하다는 것을 의미합니다.
  • An email address only
  • A postal address only
  • Both an email address and a postal address
그것은 단지 세 가지 가능한 케이스 뿐입니다. 이 세 가지를 어떻게 나타낼 수 있습니까? 초이스 타입으로 나타낼 수 있습니다.
​ 	​type​ BothContactMethods = {
​ 	  Email: EmailContactInfo
​ 	  Address : PostalContactInfo
​ 	  }
​ 	
​ 	​type​ ContactInfo =
​ 	    | EmailOnly ​of​ EmailContactInfo
​ 	    | AddrOnly ​of​ PostalContactInfo
​ 	    | EmailAndAddr ​of​ BothContactMethods
그리고 다음과 같이 기본 연락처 유형에서 이 선택 유형을 사용할 수 있습니다.
​ 	​type​ Contact = {
​ 	  Name: Name
​ 	  ContactInfo : ContactInfo
​ 	  }
다시 한 번 우리가 한 일은 개발자에게 좋지만(우연히 연락처 정보가 없을 수는 없습니다. 작성해야 할 테스트가 하나 줄어듭니다)
디자인에도 좋습니다.
디자인은 오직 세 가지 경우만 가능하며 이 세 가지 경우가 정확히 무엇인지를 매우 분명하게 보여줍니다.
문서를 볼 필요가 없습니다. 우리는 코드 자체를 볼 수 있습니다.

Making Illegal States Unrepresentable in Our Domain

이 접근 방식을 실제로 적용할 수 있는 비슷한 예시가 있을까요?
이메일 유효성 검사 예제와 매우 유사한 디자인의 한 측면을 생각할 수 있습니다.
검증 프로세스에서 검증되지 않은 우편 주소(예: UnvalidatedAddress)와 검증된 우편 주소(ValidatedAddress)가 있음을 문서화했습니다.
우리는 이 두 가지 경우를 혼동하지 않고 다음을 수행하여 유효성 검사 기능을 적절하게 사용하도록 할 수 있습니다.
  • UnvalidatedAddress 및 ValidatedAddress의 두 가지 고유한 타입을 만듭니다.
  • ValidatedAddress에 private 생성자를 제공합니다
  • 주소 유효성 검사 서비스에서만 ValidateAddress를 생성할 수 있는지 확인합니다. (패턴!)
​ 	​type​ UnvalidatedAddress = ...
// 유효한 타입은 private 생성자로만 가능!
​ 	​type​ ValidatedAddress = ​private​ ...​
유효성 검사 서비스는 UnvalidatedAddress를 사용하고 Option<ValidatedAddress>를 반환합니다
(Option은 유효성 검사가 실패할 수 있음을 보여줍니다.)
​ 	​type​ AddressValidationService =
​ 	  UnvalidatedAddress -> ValidatedAddress option
유효성 검사가 끝난 주소를 활용해 봅시다.
 
주문을 shipping 부서로 보내기 전에 검증된 배송 주소가 있어야 한다는 규칙을 강제하기 위해
두 가지 유형(UnvalidatedOrder 및 ValidatedOrder)을 더 만들고
ValidatedOrder 레코드에 ValidatedAddress인 배송 주소를 포함합니다.
​ 	​type​ UnvalidatedOrder = {
​ 	  ...
​ 	  ShippingAddress : UnvalidatedAddress
​ 	  ...
​ 	  ​}​
​ 	
​ 	type ValidatedOrder = {
​ 	  ...
​ 	  ShippingAddress : ValidatedAddress
​ 	  ...
​ 	  ​}​

이제 테스트를 작성하지 않고도 ValidatedOrder의 주소가 주소 유효성 검사 서비스에 의해 처리되었음을 보장할 수 있습니다!

Consistency

이 장의 시작 부분에서 일관성 요구 사항의 몇 가지 예를 보았습니다.
  • 주문 총액은 개별 라인의 합계여야 합니다. 합계가 다르면 데이터가 일치하지 않습니다.
  • 주문이 접수되면 해당 송장을 생성해야 합니다. 주문이 있지만 송장이 없으면 데이터가 일치하지 않습니다.
  • 할인 쿠폰 코드를 주문과 함께 사용하는 경우 쿠폰 코드를 사용한 것으로 표시해야 다시 사용할 수 없습니다. 주문이 해당 바우처를 참조하지만 바우처가 사용된 것으로 표시되지 않은 경우 데이터가 일치하지 않습니다.
여기에 설명된 것처럼 일관성은 기술 용어가 아니라 비즈니스 용어이며 일관성이 의미하는 바는 항상 상황에 따라 다릅니다.
예를 들어, 제품 가격이 변경되면 배송되지 않은 주문이 새 가격을 사용하도록 즉시 업데이트되어야 합니까?
고객의 기본 주소가 변경되면 어떻게 됩니까? 배송되지 않은 주문이 새 주소로 즉시 업데이트되어야 합니까?
이러한 질문에 대한 정답은 없으며 비즈니스 요구 사항에 따라 다릅니다.
일관성은 디자인에 큰 부담을 주고 개발 비용이 많이 들 수 있으므로 가능하면 일관성 유지를 피하고 싶습니다.
요구 사항을 수집하는 동안 종종 product owner는 바람직하지 않고 비현실적인 일관성 수준을 요구할 것입니다.
그러나 많은 경우 일관성의 필요성을 피하거나 지연시킬 수 있습니다.
일관성과 지속성의 원자성이 연결되어 있음을 인식하는 것이 중요합니다.
(it’s important to recognize that consistency and atomicity of persistence are linked. )
예를 들어, 주문이 원자적으로 유지되지 않을 경우 주문이 내부적으로 일관성이 있는지 확인하는 것은 의미가 없습니다.
주문의 다른 부분이 별도로 유지되고 한 부분이 저장되지 않으면
나중에 주문을 로드하는 사람은 내부적으로 일관성이 없는 주문을 로드하게 됩니다. (변경 전 정보를 리턴)

Consistency Within a Single Aggregate - 단일 집합체 내 일관성

5장, ​타입을 사용한 도메인 모델링​에서 우리는 Aggregate의 개념을 소개하고 이것이 일관성 경계(consistency boundary)와 지속성 단위(unit of persistence) 모두로 작용한다는 점에 주목했습니다. 이것이 실제로 어떻게 작동하는지 봅시다. (추가로 엔터티(루트)이며 불변입니다!)

주문에 대한 총액이 개별 라인의 합계여야 한다고 가정해 보겠습니다.

일관성을 보장하는 가장 쉬운 방법은 계산 결과를 저장하는 대신 필요 시마다 sum을 구하는 것입니다.
이 경우 메모리에서 또는 SQL 쿼리를 사용하여 합계가 필요할 때마다 주문 라인을 합산할 수 있습니다.

 

 

추가 데이터를 유지해야 하는 경우(예: 루트 엔터티-Aggregate인 Order에 저장된 추가 AmountToBill 필드) 동기화 상태를 유지해야 합니다.

이 경우 OrderLine 중 하나가 업데이트되면 데이터 일관성을 유지하기 위해 합계도 업데이트되어야 합니다.

일관성을 유지하는 방법을 "알고 있는" 유일한 구성 요소는 Order라는 것이 분명합니다.

이것이 라인 수준이 아닌 Order 수준에서 모든 업데이트를 수행하는 좋은 이유입니다.

Order은 일관성 경계(consistency boundary)를 적용하는 Aggregate입니다.

다음은 이것이 어떻게 작동하는지 보여주는 몇 가지 코드입니다.

  • 1. Order 자체를 새로 만듬
  • 2. Order 내에서 필요한 계산은 전부 다 수행함
module Consistency = 

    type OrderLine = {
      OrderLineId : int
      Price : float
      // etc
      }

    type Order = {
      OrderLines : OrderLine list
      AmountToBill : float
      // etc
      }

    let findOrderLine orderLineId (lines:OrderLine list) =
        lines |> List.find (fun ol -> ol.OrderLineId = orderLineId )

    let replaceOrderLine orderLineId newOrderLine lines = 
        lines // no implementation! - 구현 생략 더미

    //>Consistency1
    /// We pass in three parameters: 
    /// * the top-level order
    /// * the id of the order line we want to change
    /// * the new price
    let changeOrderLinePrice order orderLineId newPrice =

       // find orderLine in order.OrderLines using orderLineId   
       let orderLine = order.OrderLines |> findOrderLine orderLineId 
   
       // make a new version of the OrderLine with new price
       let newOrderLine = {orderLine with Price = newPrice}                  

       // create new list of lines, replacing old line with new line   
       let newOrderLines = 
           order.OrderLines |> replaceOrderLine orderLineId newOrderLine

       // make a new AmountToBill
       let newAmountToBill = newOrderLines |> List.sumBy (fun line -> line.Price)
   
       // make a new version of the order with the new lines
       let newOrder = {
            order with 
              OrderLines = newOrderLines
              AmountToBill = newAmountToBill
            }
   
       // return the new order
       newOrder
    //<
Aggregate는 원자성 단위이기도 하므로 이 주문을 관계형 데이터베이스에 저장하는 경우, 주문의 다른 필드들과 Orderlines가 모두 동일한 트랜잭션에 삽입되거나 업데이트되도록 해야 합니다.
 

 

 

Consistency Between Different Contexts (다른 컨텍스트 간 일관성)

서로 다른 컨텍스트 간에 조정(coordinate)해야 하는 경우 어떻게 해야 합니까? 위 목록의 두 번째 예를 살펴보겠습니다.

주문이 접수되면 청구서(송장-invoice)을 생성해야 합니다. 주문이 있지만 invoice가 없으면 데이터가 일치하지 않습니다.

 

invoice는 주문(order-taking ) 도메인이 아닌 청구(billing) 도메인의 일부입니다.

그것은 우리가 다른 도메인에 접근하여 그 개체를 조작해야 한다는 것을 의미합니까?

물론 아닙니다. 각 바운디드 컨텍스트를 격리 및 분리된 상태로 유지해야 합니다.

 

다음과 같이 청구 컨텍스트의 공개 API를 사용하는 것은 어떻습니까?

​ 	Ask billing context to create invoice
​ 	If successfully created:
​ 	   create order in order-taking context
이 접근 방식은 업데이트 실패를 처리해야 하기 때문에 보기보다 훨씬 까다롭습니다.
별도의 시스템에서 업데이트를 올바르게 동기화하는 방법(예: two-phase commit)이 있지만 실제로는 이것이 필요한 경우는 드뭅니다.
그의 기사 "Starbucks는 2단계 커밋을 사용하지 않습니다" 에서 Gregor Hohpe는 현실 세계에서 비즈니스는 일반적으로 모든 서브 시스템이 한 단계를 마칠 때까지 기다리면서 단계적으로 프로세스를 수행할 필요는 없다고 합니다.)
대신, 조정은 메시지를 사용하여 비동기적으로 수행됩니다. 때때로 일이 잘못될 수도 있지만 드문 오류를 처리하는 비용은 모든 것을 동기화 상태로 유지하는 비용보다 훨씬 적은 경우가 많습니다.
예를 들어 인보이스를 즉시 생성하도록 요구하는 대신 청구 도메인에 메시지(또는 이벤트)를 보내고 나머지 주문 처리를 계속한다고 가정해 보겠습니다.
이제 해당 메시지가 손실되고 송장이 생성되지 않으면 어떻게 됩니까?
  • 한 가지 옵션은 아무것도 하지 않는 것입니다. 그러면 고객은 무료로 물건을 받고 기업은 비용을 상각해야 합니다. 오류가 드물고 비용이 적은 경우(커피숍에서와 같이) 완벽하게 적절한 솔루션이 될 수 있습니다.
  • 또 다른 옵션은 메시지가 손실되었음을 감지하고 다시 보내는 것입니다. 이것은 기본적으로 조정 프로세스가 하는 일입니다. 두 데이터 세트를 비교하고 일치하지 않으면 오류를 수정합니다.
  • 세 번째 옵션은 이전 작업을 "실행 취소"하거나 오류를 수정하는 보상 작업을 만드는 것입니다. 주문을 받는 시나리오에서 이는 주문을 취소하고 고객에게 상품을 다시 보내달라고 요청하는 것과 같습니다! 더 현실적으로 보상 조치는 주문 오류 수정 또는 환불 처리와 같은 작업을 수행하는 데 사용될 수 있습니다.

세 가지 경우 모두 경계 컨텍스트 간에 엄격한 조정이 필요하지 않습니다.

일관성에 대한 요구 사항이 있는 경우 두 번째 또는 세 번째 옵션을 구현해야 합니다. 그러나 이러한 일관성은 즉시 적용되지 않습니다.
대신, 시스템은 "최종 일관성 - “eventual consistency.”"라는 개념을 통해, 즉 어느 정도 시간이 경과한 후에만 일관성을 갖게 됩니다. 최종 일관성은 "선택적 일관성"이 아닙니다.
시스템이 미래의 어느 시점에서 일관성을 유지하는 것은 여전히 ​​매우 중요합니다.
 
 
다음은 예입니다. 제품 가격이 변경된 경우 아직 배송되지 않은 모든 주문의 가격을 업데이트하려고 한다고 가정해 보겠습니다.
즉각적인 일관성이 필요한 경우 제품 레코드의 가격을 업데이트할 때 영향을 받는 모든 주문도 업데이트해야 하며 이 모든 작업을 동일한 트랜잭션 내에서 수행해야 합니다. 시간이 좀 걸릴 수 있습니다.
 
그러나 제품 가격이 변경되었을 때 즉각적인 일관성이 필요하지 않은 경우
일련의 UpdateOrderWithChangedPrice 커맨드를 트리거하는 PriceChanged 이벤트를 생성할 수 있습니다.
이러한 명령은 제품 레코드의 가격이 변경된 후 몇 초 후 또는 몇 시간 후에 처리됩니다.
결과적으로 주문이 업데이트되고 시스템이 일관성을 유지할 것입니다.
 

동일한 컨텍스트에서 Aggregates 간의 일관성

동일한 바운디드 컨텍스트에서 Aggregate 간의 일관성을 보장하는 것은 어떻습니까?
두 Aggregate가 서로 일관성을 유지해야 한다고 가정해 보겠습니다.
동일한 트랜잭션에서 함께 업데이트해야 합니까, 아니면 최종 일관성을 사용하여 별도로 업데이트해야 합니까?
어떤 접근 방식을 취해야 합니까?

언제나 그렇듯이 답은 상황에 달려 있다는 것입니다.
일반적으로 유용한 지침은 "트랜잭션당 하나의 집계만 업데이트"하는 것입니다.
두 집계가 동일한 바운디드 컨텍스트 내에 있더라도 위에서 설명한 대로 메시지 기반 커뮤니케이션 및 최종 일관성을 사용해야 합니다.
그러나 때로는(특히 워크플로가 비즈니스에서 단일 트랜잭션으로 간주되는 경우) 영향을 받는 모든 엔터티를 트랜잭션에 포함하는 것이 가치가 있을 수 있습니다. 전형적인 예는 두 계좌 간에 돈을 이체하는 것인데, 여기서 한 계좌는 증가하고 다른 계좌는 감소합니다.

​ 	Start transaction
​ 	Add X amount to accountA
​ 	Remove X amount from accountB
​ 	Commit transaction​

 

 

Account가이 Account 집계로 표시되는 경우 동일한 트랜잭션에서 두 개의 다른 Aggregate를 업데이트합니다.

그것이 반드시 문제는 아니지만 도메인에 대한 더 깊은 통찰력을 얻기 위해 리팩토링할 수 있는 단서가 될 수 있습니다.

예를 들어, 이와 같은 경우 트랜잭션에는 고유한 식별자가 있는 경우가 많으며 이는 자체적으로 DDD 엔터티임을 의미합니다.

그렇다면 왜 그렇게 모델링하지 않습니까?

​ 	​type​ MoneyTransfer = {
​ 	  Id: MoneyTransferId
​ 	  ToAccount : AccountId
​ 	  FromAccount : AccountId
​ 	  Amount: Money
​ 	  }

 

이 변경 후에도 Account 엔터티는 계속 존재하지만 더 이상 돈을 추가하거나 제거하는 직접적인 책임이 없습니다.
대신 계좌의 현재 잔액은 이제 이를 참조하는 MoneyTransfer 레코드를 반복하여 계산됩니다.
우리는 디자인을 리팩토링했을 뿐만 아니라 도메인에 대해서도 배웠습니다.
또한 합당하지 않은 경우 Aggregate를 재사용할 의무가 없다는 것을 보여줍니다.
새로운 유즈 케이스에 새로운 Aggregate가 필요한 경우, 새로 만드십시오.
 

동일한 데이터에 대해 동작하는 여러 Aggregates (integrity constraints)

앞에서 Aggregate가 무결성 제약 조건을 적용하는 역할을 한다고 강조했는데,
동일한 데이터에 대해 동작하는 여러 Aggregate가 있는 경우 제약 조건을 어떻게 일관되게 적용할까요?
예를 들어, 계좌 잔액에 작용하고 잔액이 음수가 되지 않도록 해야 하는 계좌 Aggregate 및 MoneyTransfer Aggregate가 있을 수 있습
니다.
 
많은 경우 타입을 사용하여 모델링하면 여러 애그리거트 간에 제약 조건을 공유할 수 있습니다.
예를 들어 계정 잔액이 0보다 작아서는 안 된다는 요구 사항은 NonNegativeMoney 유형으로 모델링할 수 있습니다.
이 타입이 적용되지 않는 경우 유효성 검사 함수를 공유하여 사용할 수 있습니다.
이것은 객체 지향 모델에 비해 함수형 모델의 장점 중 하나입니다.
유효성 검사 함수는 특정 객체에 연결되지 않고 전역 상태에 의존하지 않으므로 다른 워크플로에서 쉽게 재사용할 수 있습니다.

요약 

  • 무결성은 코드의 비즈니스 규칙, 일관성은 데이터 간의 팩트 일치를 말합니다.
  • 스마트 생성자와 Make Illegal State Unrepresentable(가능한 상태를 모두 코드로 표현)을 통해 코드만으로 많은 무결성 규칙을 확인할 수 있다.
    • 즉 런타임 검사와 테스트 케이스, 문서화가 덜 필요하다.
  • 바운디드 컨텍스트 간 협력은 최종 일관성을 기반으로 설계한다.
반응형