본문 바로가기

BackEnd

타입으로 코드 문서화하기 With F# - 타입을 조합하여 도메인 모델링

반응형
 

Domain Modeling Made Functional

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

pragprog.com

조합 가능한 타입 시스템은 타입 조립을 통해 복잡한 모델을 신속하게 생성할 수 있기 때문에 도메인 기반 설계를 수행하는 데 큰 도움이 됩니다. 예를 들어 전자 상거래 사이트에 대한 지불을 추적하려고 한다고 가정해 보겠습니다.
이것이 디자인 세션 동안 코드에서 어떻게 스케치되는지 봅시다.
 
먼저 CheckNumber와 같은 기본 타입에 대한 래퍼로 시작합니다.
이것들은 위에서 논의한 "Simple Types"입니다.
이렇게 하면 타입에 의미 있는 이름을 부여하고 나머지 도메인을 더 쉽게 이해할 수 있습니다.
​type​ CheckNumber = CheckNumber ​of​ ​int​
​type​ CardNumber = CardNumber ​of​ ​string
 
로우레벨 타입 조합을 만들어봅시다.
CardType은 OR 유형(Visa 또는 Mastercard 중에서 초이스)이며
CreditCardInfo는 AND 유형으로 CardType 및 CardNumber를 포함하는 레코드입니다.
​ 	​type​ CardType =
​ 	  Visa | Mastercard        ​// 'OR' type​
​ 	
​ 	​type​ CreditCardInfo = {    ​// 'AND' type (record)​
​ 	  CardType : CardType
​ 	  CardNumber : CardNumber
​ 	  }
다른 OR 유형인 PaymentMethod를 현금, 수표 또는 카드 중에서 선택하도록 정의합니다.
선택 항목 중 일부에 연관된 데이터가 있기 때문에 이것은 더 이상 단순한 "열거형 초이스타입"이 아닙니다. 
Check 케이스에는 CheckNumber가 있고 Card 케이스에는 CreditCardInfo가 있습니다.
​ 	​type​ PaymentMethod =
​ 	  | Cash
​ 	  | Check ​of​ CheckNumber
​ 	  | Card ​of​ CreditCardInfo
PaymentAmount 및 Currency와 같은 몇 가지 기본 타입을 더 정의할 수 있습니다.
​ 	​type​ PaymentAmount = PaymentAmount ​of​ decimal
​ 	​type​ Currency = EUR | USD
마지막으로 최상위 유형인 Payment는 PaymentAmount, 통화 및 PaymentMethod를 포함하는 레코드입니다.
​ 	​type​ Payment = {
​ 	  Amount : PaymentAmount
​ 	  Currency:  Currency
​ 	  Method:  PaymentMethod
​ 	  }
이것은 객체 지향 모델이 아니라 함수형 모델이기 때문에 이러한 타입과 직접적으로 관련된 동작은 없습니다.
취할 수 있는 액션을 문서화하기 위해 대신 함수을 나타내는 타입을 정의합니다.
예를 들어 Payment type을 사용하여 unpaid invoice을 지불하는 방법이 있음을 보여주고자 하는 경우,
최종 결과가 paid invoice인 경우 다음과 같은 함수 타입을 정의할 수 있습니다.
(미지불 청구서 -> 지불 -> 지불된 청구서 - 파라미터의 논리적 순서!)
​ 	​type​ PayInvoice =
​ 	  UnpaidInvoice -> Payment -> PaidInvoice
이는 다음을 의미합니다. UnpaidInvoice와  Payment가 차례로 주어지면 PaidInvoice를 생성할 수 있습니다.
또는 결제 통화를 변경하려면...
​ 	​type​ ConvertPaymentCurrency =
​ 	  Payment -> Currency -> Payment

여기서 첫 번째 지불은 입력이고 두 번째 매개변수(통화)는 변환할 통화이며 두 번째 지불(출력)은 변환 후의 결과입니다.

Modeling Optional Values, Errors, and Collections

도메인 모델링에 대해 논의하는 동안 몇 가지 일반적인 상황과 F# 타입 시스템으로 이를 표현하는 방법에 대해 이야기해 보겠습니다.
  • Optional or missing values - 널러블
  • Errors
  • Functions that return no value - void
  • Collections - 컬렉션 (리스트)

주 : Wrapper타입은 제네릭 타입과 모나드와 잘 어울립니다!

선택적 값 모델링 - 널러블

지금까지 사용한 타입(레코드 및 초이스 타입)은 F#에서 null이 허용되지 않습니다. 즉, 도메인 모델에서 타입을 참조할 때마다 값이 반드시 있어야 합니다.

그렇다면 누락된 데이터나 선택적 데이터를 어떻게 모델링할 수 있습니까?

 

답은 누락된 데이터가 무엇을 의미하는지 생각하는 것입니다.

존재하거나 부재합니다. 거기에 무언가가 있거나 거기에 아무것도 없습니다.

다음과 같이 정의된 Option이라는 초이스 타입으로 이를 모델링할 수 있습니다.

'a는 타입 파라미터, 제네릭 타입
즉, Option 유형을 사용하여 다른 유형을 래핑할 수 있습니다. C# 또는 Java는  Option<T> 입니다.
 	​type​ Option<​'​a> =
​ 	  | Some ​of​ ​'​a
​ 	  | None
Option 유형을 직접 정의할 필요가 없습니다. 표준 F# 라이브러리의 일부이며 함께 작동하는 다양한 도우미 함수 집합이 있습니다.

예를 들어 PersonalName 유형이 있고 이름과 성은 필수지만 중간 이니셜은 선택 사항인 경우 다음과 같이 모델링할 수 있습니다.

 	​type​ PersonalName = {
​ 	  FirstName : ​string​
​ 	  MiddleInitial: Option<​string​>  ​// optional​
​ 	  LastName : ​string​
​ 	  }
F#은 또한 읽기 쉽고 더 일반적으로 사용되는 유형 뒤에 옵션 레이블 사용을 지원합니다.
(주 - 진짜 읽기 쉬운가? -_-)
 	​type​ PersonalName = {
​ 	  FirstName : ​string​
​ 	  MiddleInitial: ​string​ option
​ 	  LastName : ​string​
​ 	  }

에러 모델링 - 실패 가능성의 모델링

실패 가능성이 있는 프로세스가 있다고 가정해 보겠습니다.
ex ) "결제가 성공적으로 이루어졌거나 카드가 만료되어 실패했습니다."
이것을 어떻게 모델링해야 할까요?
F#은 예외 처리를 지원하지만 오류가 발생할 수 있다는 사실을 타입 시그니처에 명시적으로 문서화하려는 경우가 많습니다.
이것은 두 가지 경우가 있는 초이스 타입을 필요로 합니다.
Result 유형을 정의해 보겠습니다.
​ 	​type​ Result<​'​Success,​'​Failure> =
​ 	  | Ok ​of​ ​'​Success
​ 	  | Error ​of​ ​'​Failure
함수가 성공할 때 값을 유지하기 위해 Ok 케이스를 사용하고 함수가 실패할 때 오류 데이터를 유지하기 위해 Error 케이스를 사용할 것입니다.
그리고 물론 우리는 이 유형이 모든 타입의 데이터를 포함할 수 있기를 원하므로 정의에서 제네릭 유형을 사용합니다.
 
F# 4.1 이상(또는 Visual Studio 2017)을 사용하는 경우 표준 F# 라이브러리의 일부이기 때문에 결과 유형을 직접 정의할 필요가 없습니다. 
함수가 실패할 수 있음을 나타내기 위해 Result 타입으로 출력을 래핑합니다.
예를 들어 PayInvoice 기능이 실패할 수 있는 경우 다음과 같이 정의할 수 있습니다.
​ 	​type​ PayInvoice =
​ 	  UnpaidInvoice -> Payment -> Result<PaidInvoice,PaymentError>
이것은 Ok 케이스와 연관된 유형이 PaidInvoice이고 Error 케이스와 연관된 유형이 PaymentError임을 보여줍니다.
그런 다음 가능한 각 오류에 대한 케이스가 있는 초이스 타입으로 PaymentError를 정의할 수 있습니다.
​ 	​type​ PaymentError =
​ 	  | CardTypeNotRecognized
​ 	  | PaymentRejected
​ 	  | PaymentProviderOffline

값이 전혀 없음을 모델링하기 - void

대부분의 프로그래밍 언어에는 함수나 메서드가 아무 것도 반환하지 않을 때 사용되는 void 개념이 있습니다.
F#과 같은 함수형 언어에서는 모든 함수가 무언가를 반환해야 하므로 void를 사용할 수 없습니다.
대신 unit이라는 특별한 내장 xkdlq을 사용합니다. 단위에는 한 쌍의 괄호(()) 값만 있습니다. (haskell의 IO())
데이터베이스의 고객 레코드를 업데이트하는 함수가 있다고 가정해 보겠습니다.
입력은 고객 레코드이지만 유용한 출력이 없습니다. F#에서는 다음과 같이 unit을 출력 형식으로 사용하여 형식 서명을 작성합니다.
​type​ SaveCustomer = Customer -> ​unit​
주 : 뮤테이션은 결과값을 리턴하지 않는게 CQRS의 개념입니다.

또는 난수를 생성하는 함수와 같이 입력이 없는 함수가 유용한 것을 반환한다고 가정해 보겠습니다.

F#에서는 다음과 같이 단위와 함께 "입력 없음"도 표시합니다.​

​type​ NextRandom = ​unit​ -> ​int​
서명에서 Unit type(void)를 보면 사이드 이펙트가 있다는 강력한 표시입니다.
어딘가에서 상태가 바뀌고 있지만 그것은 당신에게 숨겨져 있습니다.
일반적으로 함수형 프로그래머는 사이드 이펙트를 피하거나 최소한의 코드 영역으로 범위를 제한하려 합니다.

리스트와 컬렉션 모델링

F#은 표준 라이브러리에서 다양한 컬렉션 형식을 지원합니다.

  • list는 고정 크기의 변경할 수 없는 컬렉션입니다(링크드 리스트로 구현됨).
  • array는 개별 요소를 인덱스로 가져와 할당할 수 있는 고정 크기의 변경 가능한 컬렉션입니다.
  • ResizeArray는 가변 크기 배열입니다. 즉, 배열에서 항목을 추가하거나 제거할 수 있습니다. C# List<T> 형식에 대한 F# 별칭입니다. seq는 요청 시 각 요소가 반환되는 지연 컬렉션입니다. C# IEnumerable<T> 형식에 대한 F# 별칭입니다.
  • Map(Dictionary와 유사) 및 Set에 대한 내장 유형도 있지만 도메인 모델에서 직접 사용되는 경우는 거의 없습니다.
도메인 모델링의 경우 항상 list 유형을 사용하는 것이 좋습니다. 옵션과 마찬가지로 다음과 같이 유형 뒤에 접미사로 사용할 수 있습니다.
​ 	​type​ Order = {
​ 	  OrderId : OrderId
​ 	  Lines : OrderLine ​list​ ​// a collection . List<OrderLine>
​ 	  }
리스트를 만들려면 대괄호와 세미콜론(쉼표 아님!)을 구분 기호로 사용하여 리스트 리터럴을 사용할 수 있습니다.
​let​ aList = [1; 2; 3]
또는 ::("cons"라고도 함) 연산자를 사용하여 기존 목록에 값을 추가할 수 있습니다.
(주 : 하스켈에서는 : 가 cons, 스칼라에서는 또 ::가 cons입니다. 재미있는건 하스켈에서는 일반적인 리스트 리터럴이 cons 오퍼레이터의 synthetic sugar일 뿐입니다.)
​let​ aNewList = 0 :: aList  ​// new list is [0;1;2;3]​
리스트의 요소에 액세스하기 위해 리스트 분해하려면 유사한 패턴을 사용합니다.
다음과 같이 리스트 리터럴에 패턴 매치를 적용합니다.
​ 	​let​ printList1 aList =
​ 	  ​// matching against list literals​
​ 	  ​match​ aList ​with​
​ 	  | [] ->
​ 	    printfn ​"list is empty"​
​ 	  | [x] ->
​ 	    printfn ​"list has one element: %A"​ x
​ 	  | [x;y] ->       ​// match using list literal​
​ 	    printfn ​"list has two elements: %A and %A"​ x y
​ 	  | longerList ->  ​// match anything else​
​ 	    printfn ​"list has more than two elements"

(주 : 패턴 매치는 데이터의 생김새로 if, switch문을 대체하며, 데이터가 해당 모양일 경우 값을 끌어쓸 수 있습니다.)

cons operator를 이용한 매칭도 가능합니다.

​ 	​let​ printList2 aList =
​ 	  ​// matching against "cons"​
​ 	  ​match​ aList ​with​
​ 	  | [] ->
​ 	    printfn ​"list is empty"​
​ 	  | first::rest ->
​ 	    printfn ​"list is non-empty with the first element being: %A"​ first
 
 

마무리

타입의 개념과 그것이 함수형 프로그래밍과 어떤 관련이 있는지 살펴보았고
F#의 대수 타입 시스템을 사용하여 타입의 합성을 사용하여 작은 타입에서 큰 타입을 만드는 방법도 살펴보았습니다.
데이터를 AND로 연결하여 만든 레코드 타입과 데이터를 함께 OR 연결하여 만든 초이스 타입(차별화된 합집합이라고도 함)과
이를 기반으로 하는 기타 일반 타입(예: Option 및 Result)에 대해 알아보았습니다.
 
 

선택사항 : F#의 타입과 파일, 프로젝트 구조화

마지막으로 알아야 할 것이 있습니다.
F#에는 선언 순서에 대한 엄격한 규칙이 있습니다.
한 파일 안에서 위에 선언된 타입은 더 아래에 있는 다른 타입을 참조할 수 없습니다.
그리고 컴파일 순서의 이전 파일은 컴파일 순서의 나중 파일을 참조할 수 없습니다.
즉, 타입을 코딩할 때 타입을 구조화하는 방법에 대해 생각해야 합니다.
일반적인 접근 방식은 모든 도메인 타입(예: Types.fs 또는 Domain.fs)을 하나의 파일에 넣은 다음
이에 종속된 함수를 나중 컴파일 순서에 넣는 것입니다.
타입이 많고 여러 파일로 분할해야 하는 경우 공유되는 파일을 먼저 배치하고
하위 도메인별 파일을 뒤에 배치합니다. 파일 목록은 다음과 같을 수 있습니다.
​ 	Common.Types.fs
​ 	Common.Functions.fs
​ 	OrderTaking.Types.fs
​ 	OrderTaking.Functions.fs
​ 	Shipping.Types.fs
​ 	Shipping.Functions.fs
파일 내에서 이 규칙은 종속성 순서로 간단한 타입을 맨 위에 놓고
더 복잡한 타입(이에 종속된)을 더 아래에 배치해야 함을 의미합니다.
​ 	​module​ Payments =
​ 	  ​// simple types at the top of the file​
​ 	  ​type​ CheckNumber = CheckNumber ​of​ ​int​
​ 	
​ 	  ​// domain types in the middle of the file​
​ 	  ​type​ PaymentMethod =
​ 	    | Cash
​ 	    | Check ​of​ CheckNumber ​// defined above​
​ 	    | Card ​of​ ...
​ 	
​ 	  ​// top-level types at the bottom of the file​
​ 	  ​type​ Payment = {
​ 	    Amount: ...
​ 	    Currency: ...
​ 	    Method: PaymentMethod  ​// defined above​
​ 	    }
위에서 아래로 모델을 개발할 때 종속성 순서 제약 조건이 때때로 불편할 수 있습니다.
왜냐하면 종종 상위 수준 유형 아래에 하위 수준 유형을 작성하기를 원하기 때문입니다.
F# 4.1에서는 모듈 또는 네임스페이스 수준에서 "rec" 키워드를 사용하여 이 문제를 해결할 수 있습니다.
rec 키워드를 사용하면 선언 순서에 상관없이 타입 참조가 가능합니다.
(원래 아래 참조 불가능한데 가능하게 해줍니다.)
​ 	​module​ ​rec​ Payments =
​ 	  ​type​ Payment = {
​ 	    Amount: ...
​ 	    Currency: ...
​ 	    Method: PaymentMethod  ​// defined BELOW​
​ 	    }
​ 	
​ 	  ​type​ PaymentMethod =
​ 	    | Cash
​ 	    | Check ​of​ CheckNumber ​// defined BELOW​
​ 	    | Card ​of​ ...
​ 	
​ 	  ​type​ CheckNumber = CheckNumber ​of​ ​int​
이전 버전의 F#의 경우 "and" 키워드를 사용하여 타입 정의가 바로 아래에 있는 형타입을 참조하도록 할 수 있습니다.
​ 	​type​ Payment = {
​ 	    Amount: ...
​ 	    Currency:  ...
​ 	    Method:  PaymentMethod ​// defined BELOW​
​ 	    }
​ 	
​ 	​and​ PaymentMethod =
​ 	  | Cash
​ 	  | Check ​of​ CheckNumber   ​// defined BELOW​
​ 	  | Card ​of​ ...
​ 	
​ 	​and​ CheckNumber = CheckNumber ​of​ ​int
이 비순차적 접근 방식은 스케치에 적합하지만
일단 디자인이 확정되고 개발 준비가 되면 타입을 올바른 종속성 순서로 배치하는 것이 일반적으로 더 좋습니다.
이렇게 하면 다른 F# 코드와 일관성을 유지하고 다른 개발자가 더 쉽게 읽을 수 있습니다.
반응형