본문 바로가기

BackEnd

DDD 구현 : 오류 모델링

반응형

제품 코드의 형식이 잘못되었거나 고객 이름이 너무 길거나 주소 확인 서비스가 시간 초과되면 어떻게 될까요?

모든 시스템에는 오류가 있으며 이를 처리하는 방법이 중요합니다.

일관되고 투명한 오류 처리는 모든 종류의  시스템에 중요합니다.

 

Result type 시그니처를 다루는 방법을 알아봅니다.

우리는 못생긴 조건문과 try/catch 문으로 코드를 오염시키지 않고
오류를 우아하게 캡처하는 함수형 접근 방식을 탐구합니다.
또한 특정 종류의 오류를 도메인 오류로 처리해야 하는 이유를 살펴봅니다.
해당 오류들은 도메인 주도 설계의 연장입니다.
 

Result type으로 에러를 명확히 표현하기

함수형 프로그래밍 기술들은 무언가를(값, 타입, 오류 등) 가능한 한 명확하게 만드는 데 중점을 두며 이는 오류 처리에도 적용됩니다.
우리는 성공 여부, 실패했을 경우 오류 케이스가 무엇인지 명시적인 함수를 만들고 싶습니다.
 
자주 오류는 코드에서 2급 시민으로 취급됩니다. (변수에 할당하지 않고, 파라미터로 넘기지 않고, 리턴하지 않습니다.)
그러나 강력하고 생산 가치가 있는 시스템을 갖기 위해서는 오류를 일급 시민으로 취급해야 합니다.
도메인의 일부인 오류 더욱 말입니다!.
 
이전 장에서는 예외를 사용하여 오류를 발생시켰습니다.
그것은 편리했지만 모든 타입 시그니처가 오해의 소지가 있음을 의미했습니다.
주소를 확인하는 함수에는 다음 사그니처가 있습니다.
 
​ 	​type​ CheckAddressExists =
​ 	  UnvalidatedAddress -> CheckedAddress
 
 
무엇이 잘못될 수 있는지 나타내지 않기 때문에 매우 도움이 되지 않습니다. (오류 발생 가능성이 숨어있습니다.)
대신, 우리가 원하는 것은 가능한 모든 Result가 타입 서명에 의해 명시적으로 문서화되는 전체 함수(총 함수 참조)입니다.
Result 타입을 사용하여 함수가 성공하거나 실패할 수 있음을 분명히 하면 시그니처가 다음과 같이 보일 것입니다.
 
​ 	​type​ CheckAddressExists =
​ 	    UnvalidatedAddress -> Result<CheckedAddress,AddressValidationError>
​ 	
​ 	​and​ AddressValidationError =
​ 	  | InvalidFormat ​of​ ​string​
​ 	  | AddressNotFound ​of​ ​string
 

 

이것은 몇 가지 중요한 사실을 알려줍니다.
  • 입력은 UnvalidatedAddress입니다.
  • 유효성 검사가 성공적이면 출력은 (아마도 입력과 다른?) CheckedAddress입니다.
  • 유효성 검사에 실패한 경우 포맷이 잘못되었거나 주소를 찾을 수 없기 때문입니다.
이것은 함수 시그니처가 문서 역할을 하는 방법을 보여줍니다.
다른 개발자가 와서 이러한 함수를 사용해야 하는 경우 시그니처만 보고도 많은 것을 알 수 있습니다.

도메인 오류를 다루기

소프트웨어 시스템은 복잡하기에, 타입으로 모든 오류를 처리할 수 없습니다.
따라서 필요에 의해 오류를 분류하고 처리하는 일관된 접근 방식을 생각해 보겠습니다.
오류를 세 그룹으로 분류할 수 있습니다.
  • 도메인 오류. 이는 비즈니스 프로세스의 일부로 예상되는 오류이므로 billing(청구)에서 거부된 주문 또는 잘못된 제품 코드가 포함된 주문과 같이 도메인 설계에 포함되어야 합니다. 비즈니스는 이러한 종류의 문제를 처리하기 위한 절차를 이미 갖추고 있으며 코드는 이러한 프로세스를 반영해야 합니다.
  • 패닉. 처리할 수 없는 시스템 오류(예: "메모리 부족") 또는 프로그래머의 간과로 인한 오류 (예: "0으로 나누기" 또는 "널 참조")와 같이 시스템을 알 수 없는 상태로 만드는 오류입니다.
  • 인프라 오류. 이는 아키텍처의 일부로 예상되는 오류이지만 비즈니스 프로세스의 일부가 아니며 네트워크 시간 초과 또는 인증 실패와 같이 도메인에 포함되지 않은 오류입니다.

도메인 오류인지 아닌지 명확하지 않은 경우가 있습니다. 잘 모르겠다면 도메인 전문가에게 문의하세요!

(로드벨런서 엑세스가 불가능한 경우가 비즈니스 적으로 중요한지 말입니다.)
각 오류별로 다른 구현이 필요합니다.
 
도메인 오류는 도메인의 일부이므로 가능한 경우 도메인 전문가와 논의하여 타입 시스템에 문서화하고 도메인 모델링에 통합해야 합니다.
패닉은 워크플로를 포기하고 가장 적절한 수준(예: 응용 프로그램의 메인 함수 또는 이에 상응하는 함수)에서 catch되는 예외를 발생시켜 가장 잘 처리됩니다. 다음은 예입니다.
​ 	​/// A workflow that panics if it gets bad input​
​ 	​let​ workflowPart2 input =
​ 	  ​if​ input = 0 ​then​
​ 	    raise (DivideByZeroException())
​ 	  ...
​ 	
​ 	​/// Top level function for the application​
​ 	​/// which traps all exceptions from workflows.​
​ 	​let​ main() =
​ 	
​ 	  ​// wrap all workflows in a try/with block​
​ 	  ​try​
​ 	    ​let​ result1 = workflowPart1()
​ 	    ​let​ result2 = workflowPart2 result1
​ 	    printfn ​"the result is %A"​ result2
​ 	
​ 	  ​// top level exception handling​
​ 	  ​with​
​ 	  | :? OutOfMemoryException ->
​ 	    printfn ​"exited with OutOfMemoryException"​
​ 	  | :? DivideByZeroException ->
​ 	    printfn ​"exited with DivideByZeroException"​
​ 	  | ex ->
​ 	    printfn ​"exited with %s"​ ex.Message
 
인프라 오류는 위의 접근 방식 중 하나를 사용하여 처리할 수 있습니다. (도메인 오류처럼 명시화, 혹은 panic처럼 탑 레벨 핸들링)
정확한 선택은 사용 중인 아키텍처에 따라 다릅니다.
 
코드가 많은 소규모 서비스로 구성된 경우 예외처리가가 더 명확할 수 있지만 (패닉처럼)
거대한 모놀리식 서비스에선 좀 더 오류를 명시적으로 표현하고 싶을 수 있습니다.
 
사실, 많은 인프라 오류를 도메인 오류와 같은 방식으로 처리하는 것이 종종 유용합니다.
왜냐하면 개발자로서 무엇이 잘못될 수 있는지 생각하게 하기 때문입니다.
실제로 어떤 경우에는 이러한 종류의 오류를 도메인 전문가에게 에스컬레이션해야 합니다.
예를 들어 원격 주소 유효성 검사 서비스를 사용할 수 없는 경우 비즈니스 프로세스는 어떻게 변경되어야 합니까? 고객에게 무엇을 알려야 합니까?
이러한 종류의 질문은 개발 팀 혼자 해결할 수 없고 도메인 전문가와 프로덕트 오너도 고려해야 합니다.
 
이 장의 나머지 부분에서는 도메인의 일부로 명시적으로 모델링하려는 오류에만 초점을 맞출 것입니다.
우리가 모델링하고 싶지 않은 패닉과 오류는 위에 표시된 것처럼 예외를 발생시키고 최상위 함수에 의해 포착되어야 합니다.
 

타입으로 도메인 오류 모델링하기

도메인을 모델링할 때 문자열과 같은 primitives를 사용하지 않고 도메인 어휘(유비쿼터스 언어)를 사용하여 domain-specific한 타입을 만들었습니다.
 
오류는 동일한 대우를 받을 자격이 있습니다.
도메인에 대한 토론에서 특정 종류의 오류가 발생하면 도메인의 다른 모든 것과 마찬가지로 모델링해야 합니다.
일반적으로 우리는 특별한 주의가 필요한 각 유형의 오류에 대해 별도의 케이스와 함께 오류를 초이스 타입으로 모델링합니다.
예를 들어 주문 배치 워크플로의 오류를 다음과 같이 모델링할 수 있습니다.
​ 	​type​ PlaceOrderError =
​ 	  | ValidationError ​of​ ​string​
​ 	  | ProductOutOfStock ​of​ ProductCode
​ 	  | RemoteServiceError ​of​ RemoteServiceError
​ 	  ...
  • ValidationError Case는 길이 또는 형식 오류와 같은 속성의 유효성 검사에 사용됩니다.
  • ProductOutOfStock Case는 고객이 품절된 제품을 구매하려고 할 때 사용됩니다. 이를 처리하기 위한 특별한 비즈니스 프로세스가 있을 수 있습니다.
  • RemoteServiceError Case는 인프라 오류를 처리하는 방법의 예입니다. 예외를 던지는 것보다 포기하기 전에 특정 횟수만큼 재시도함으로써 이 경우를 처리할 수 있습니다.
위 초이스 타입은 코드에서 잘못될 수 있는 케이스에 대한 명시적인 문서 역할을 합니다.
그리고 오류와 관련된 추가 정보도 명시적으로 표시됩니다.
또한 요구 사항이 변경됨에 따라 초이스 타입을 확장(또는 축소)하기가 쉬울 뿐만 아니라
컴파일러가 이 리스트과 패턴 일치하는 모든 코드에 대/소문자를 놓친 경우 경고가 표시되도록 보장하기 때문에 안전합니다.
 
이전에 워크플로를 설계할 때 오류가 발생할 수 있다는 것을 알고 있었지만 오류가 무엇일 수 있는지에 대해 깊이 파고 들지 않았습니다.
그것은 의도적이었습니다.
설계 단계에서 사전에 가능한 모든 오류를 정의하려고 할 필요가 없습니다.
일반적으로 오류 사례는 응용 프로그램이 개발될 때 발생하며 도메인 오류로 처리할지 여부를 결정할 수 있습니다.
케이스가 도메인 오류인 경우 초이스 타입에 추가할 수 있습니다.
 
초이스 타입에 새 케이스를 추가하면 일부 코드에서 모든 케이스를 처리하지 않았다는 경고가 발생할 수 있습니다.
이제 그 경우에 정확히 무엇을 해야 하는지에 대해 도메인 전문가 또는 프로덕트 오너와 논의해야 하기 때문에 좋습니다.
이와 같이 초이스 타입을 사용하면 실수로 엣지 케이스를 간과하기 어렵습니다.
 

오류처리는 코드를 보기 흉하게 합니다.

예외에 대한 한 가지 좋은 점은 "행복한 경로" 코드를 깨끗하게 유지한다는 것입니다.

예를 들어, 이전 장의 validateOrder 함수는 다음과 같았습니다. *수도 코드입니다.

​ 	​let​ validateOrder unvalidatedOrder =
​ 	  ​let​ orderId = ... create order id (​or​ throw ​exception​)
​ 	  ​let​ customerInfo = ... create info (​or​ throw ​exception​)
​ 	  ​let​ shippingAddress = ... create ​and​ validate shippingAddress...
​ 	  ​// etc
 
각 단계에서 오류를 반환하면 코드가 훨씬 더 보기 흉해집니다.
일반적으로 각 잠재적 오류 뒤에 조건문이 있을 뿐만 아니라
잠재적 예외를 트래핑하기 위한 try/catch 블록이 있습니다.
다음은 이를 보여주는 수도 코드입니다.

 

 	​let​ validateOrder unvalidatedOrder =
​ 	  ​let​ orderIdResult = ... create order id (​or​ ​return​ Error)
​ 	  ​if​ orderIdResult is Error ​then​
​ 	      ​return​
​ 	
​ 	  ​let​ customerInfoResult = ... create name (​or​ ​return​ Error)
​ 	  ​if​ customerInfoResult is Error ​then​
​ 	      ​return​
​ 	
​ 	  ​try​
​ 	      ​let​ shippingAddressResult = ... create valid address (​or​ ​return​ Error)
​ 	      ​if​ shippingAddress is Error ​then​
​ 	        ​return​
​ 	
​ 	      ​// ...​
​ 	
​ 	  ​with​
​ 	    | ?: TimeoutException -> Error ​"service timed out"​
​ 	    | ?: AuthenticationException -> Error ​"bad credentials"​
​ 	
​ 	  ​// etc
 
 
이 접근 방식의 문제는 코드의 3분의 2가 이제 오류 처리에 할당된다는 것입니다.
원래의 간단하고 깨끗한 코드는 이제 망가졌습니다.
파이프라인 모델의 우아함을 유지하면서 어떻게 적절한 오류 처리를 도입할 수 있습니까? 

 




 

 

반응형