본문으로 건너뛰기

에러 체계화

Error.New("Invalid denominator value: 0")라는 에러 메시지만으로 어떤 도메인에서, 어떤 이유로, 어떤 값이 문제를 일으켰는지 파악할 수 있나요? "DomainErrors.클래스.이유" 형식의 구조화된 에러 코드와 실패 당시의 값 정보를 함께 관리하면, 디버깅과 모니터링의 효율성이 크게 향상됩니다.

이 장을 마치면 다음을 할 수 있습니다.

  1. DomainErrors.클래스.이유 형식의 구조화된 에러 코드 시스템을 설계할 수 있습니다
  2. 실패 당시의 값과 에러 코드를 함께 관리하는 타입 안전한 에러 처리 시스템을 구축할 수 있습니다
  3. 기존 LanguageExt의 Error 타입과 완전히 호환되는 에러 처리 프레임워크를 설계할 수 있습니다

이전 단계 11-ValueObject-Framework에서는 프레임워크를 통해 값 객체의 생성과 검증을 체계화했습니다. 그러나 실제 운영 환경에서 에러가 발생했을 때 세 가지 문제가 있었습니다. 기존 Error.New 방식은 단순한 문자열 메시지만 제공하여 에러의 출처를 체계적으로 파악하기 어렵고, 실패한 값 정보가 메시지에 하드코딩되어 동적 분석이 불가능하며, 값 객체마다 다른 형식의 에러 메시지를 사용하여 일관성이 부족했습니다.

구조화된 에러 코드 시스템은 에러 발생 시점의 도메인 정보, 실패 이유, 실패한 값을 체계적으로 분리하여 관리합니다.

에러를 "DomainErrors.클래스.이유" 형식의 계층적 코드로 분류합니다. 도메인 영역, 구체적인 클래스, 실패 이유가 코드에 명시되어 에러의 출처와 성격을 즉시 식별할 수 있습니다.

기존 방식과 구조화된 방식의 에러 생성을 비교합니다.

// 이전 방식 (구조화되지 않은 방식) - 디버깅과 모니터링이 어려움
var error = Error.New("Invalid denominator value: 0");
// 개선된 방식 (구조화된 방식) - 체계적인 에러 관리
var error = ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Denominator)}.{nameof(Zero)}",
errorCurrentValue: 0,
errorMessage: $"Denominator cannot be zero. Current value: '0'");

Create<T>, Create<T1, T2> 등 제네릭 오버로딩을 통해 실패한 값의 타입 정보를 보존합니다. 컴파일 타임에 타입 안전성이 보장되고, 런타임에 정확한 값 정보를 활용할 수 있습니다.

// 다양한 타입의 에러 정보를 타입 안전하게 관리
var stringError = ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Name)}.{nameof(TooShort)}",
errorCurrentValue: "i@name",
errorMessage: $"Name is too short. Current value: 'i@name'");
var intError = ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Age)}.{nameof(Invalid)}",
errorCurrentValue: 150,
errorMessage: $"Age is out of range. Current value: '150'");
var multiValueError = ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Coordinate)}.{nameof(OutOfRange)}",
errorCurrentValue1: 1500,
errorCurrentValue2: 2000,
errorMessage: $"Coordinate is out of range. Current values: '1500', '2000'");

값 객체와 관련된 에러 정의를 같은 파일 내에 위치시켜 높은 응집도를 달성합니다. 새 값 객체를 생성할 때 에러 정의도 함께 작성하므로 개발 생산성이 향상됩니다.

public sealed class Denominator : SimpleValueObject<int>
{
// ... 기존 코드 ...
internal static class DomainErrors
{
public static Error Zero(int value) =>
ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Denominator)}.{nameof(Zero)}",
errorCurrentValue: value,
errorMessage: $"Denominator cannot be zero. Current value: '{value}'");
}
}

다음 장에서는 이 에러 코드 시스템에 Fluent API를 적용하여 더 간결한 에러 정의 방식을 구현합니다.

=== 체계적인 에러 처리 패턴 ===
=== Comparable 테스트 ===
--- CompositeValueObjects 하위 폴더 ---
=== CompositeValueObjects 에러 테스트 ===
--- Currency 에러 테스트 ---
빈 통화 코드: ErrorCode: DomainErrors.Currency.Empty, ErrorCurrentValue:
3자리가 아닌 형식: ErrorCode: DomainErrors.Currency.NotThreeLetters, ErrorCurrentValue: AB
지원하지 않는 통화: ErrorCode: DomainErrors.Currency.Unsupported, ErrorCurrentValue: XYZ
--- Price 에러 테스트 ---
음수 가격: ErrorCode: DomainErrors.MoneyAmount.OutOfRange, ErrorCurrentValue: -100
--- PriceRange 에러 테스트 ---
최솟값이 최댓값을 초과하는 가격 범위: ErrorCode: DomainErrors.PriceRange.MinExceedsMax, ErrorCurrentValue: MinPrice: KRW (한국 원화) ₩ 1,000.00, MaxPrice: KRW (한국 원화) ₩ 500.00
--- PrimitiveValueObjects 하위 폴더 ---
=== PrimitiveValueObjects 에러 테스트 ===
--- Denominator 에러 테스트 ---
0 값: ErrorCode: DomainErrors.Denominator.Zero, ErrorCurrentValue: 0
--- CompositePrimitiveValueObjects 하위 폴더 ---
=== CompositePrimitiveValueObjects 에러 테스트 ===
--- DateRange 에러 테스트 ---
시작일이 종료일 이후인 날짜 범위: ErrorCode: DomainErrors.DateRange.StartAfterEnd, ErrorCurrentValue: StartDate: 2024-12-31 오전 12:00:00, EndDate: 2024-01-01 오전 12:00:00
=== ComparableNot 폴더 테스트 ===
--- CompositeValueObjects 하위 폴더 ---
=== CompositeValueObjects 에러 테스트 ===
--- Address 에러 테스트 ---
빈 거리명: ErrorCode: DomainErrors.Street.Empty, ErrorCurrentValue:
빈 도시명: ErrorCode: DomainErrors.City.Empty, ErrorCurrentValue:
잘못된 우편번호: ErrorCode: DomainErrors.PostalCode.NotFiveDigits, ErrorCurrentValue: 1234
--- Street 에러 테스트 ---
빈 거리명: ErrorCode: DomainErrors.Street.Empty, ErrorCurrentValue:
--- City 에러 테스트 ---
빈 도시명: ErrorCode: DomainErrors.City.Empty, ErrorCurrentValue:
--- PostalCode 에러 테스트 ---
빈 우편번호: ErrorCode: DomainErrors.PostalCode.Empty, ErrorCurrentValue:
5자리 숫자가 아닌 형식: ErrorCode: DomainErrors.PostalCode.NotFiveDigits, ErrorCurrentValue: 1234
--- PrimitiveValueObjects 하위 폴더 ---
=== PrimitiveValueObjects 에러 테스트 ===
--- BinaryData 에러 테스트 ---
null 바이너리 데이터: ErrorCode: DomainErrors.BinaryData.Empty, ErrorCurrentValue: null
빈 바이너리 데이터: ErrorCode: DomainErrors.BinaryData.Empty, ErrorCurrentValue: 0
--- CompositePrimitiveValueObjects 하위 폴더 ---
=== CompositePrimitiveValueObjects 에러 테스트 ===
--- Coordinate 에러 테스트 ---
범위를 벗어난 X 좌표: ErrorCode: DomainErrors.Coordinate.XOutOfRange, ErrorCurrentValue: -1
범위를 벗어난 Y 좌표: ErrorCode: DomainErrors.Coordinate.YOutOfRange, ErrorCurrentValue: 1001
  1. ErrorCodeFactory의 제네릭 오버로딩: Create<T>, Create<T1, T2> 메서드를 통해 다양한 타입의 에러 정보를 타입 안전하게 관리
  2. 내부 DomainErrors 클래스 패턴: 값 객체 내부에 internal static class DomainErrors를 정의하여 응집도 높은 에러 관리
  3. 구체적인 에러 이유 명명: Empty, NotThreeLetters, NotFiveDigits, MinExceedsMax 등 검증 조건과 정확히 일치하는 명명 규칙
  4. LanguageExt 호환성: 기존 Error 타입을 상속받아 생태계와 완전한 호환성 보장
ErrorCode/ # 메인 프로젝트
├── Program.cs # 메인 실행 파일 (ValueObjects 폴더 구조와 일치하는 테스트)
├── ErrorCode.csproj # 프로젝트 파일
├── Framework/ # 에러 처리 프레임워크
│ ├── Abstractions/
│ │ └── Errors/
│ │ ├── ErrorCodeFactory.cs # 에러 생성 팩토리
│ │ ├── ErrorCodeExpected.cs # 구조화된 에러 타입들
│ │ └── ErrorCodeExceptional.cs # 예외 기반 에러
│ └── Layers/
│ └── Domains/
│ ├── ValueObject.cs # 기본 값 객체 클래스
│ ├── SimpleValueObject.cs # 단일 값 객체 클래스
│ └── AbstractValueObject.cs # 추상 값 객체 클래스
└── ValueObjects/ # 값 객체 구현 (폴더 구조별 분류)
├── Comparable/ # 비교 가능한 값 객체들
│ ├── CompositeValueObjects/
│ │ ├── Currency.cs # 통화 값 객체 (SmartEnum 기반)
│ │ ├── MoneyAmount.cs # 금액 값 객체 (ComparableSimpleValueObject<decimal>)
│ │ ├── Price.cs # 가격 값 객체 (MoneyAmount + Currency 조합)
│ │ └── PriceRange.cs # 가격 범위 값 객체 (Price 조합)
│ ├── PrimitiveValueObjects/
│ │ └── Denominator.cs # 분모 값 객체
│ └── CompositePrimitiveValueObjects/
│ └── DateRange.cs # 날짜 범위 값 객체
└── ComparableNot/ # 비교 불가능한 값 객체들
├── CompositeValueObjects/
│ ├── Address.cs # 주소 값 객체
│ ├── Street.cs # 거리명 값 객체
│ ├── City.cs # 도시명 값 객체
│ └── PostalCode.cs # 우편번호 값 객체
├── PrimitiveValueObjects/
│ └── BinaryData.cs # 바이너리 데이터 값 객체
└── CompositePrimitiveValueObjects/
└── Coordinate.cs # 좌표 값 객체

ErrorCodeFactory — 에러 생성 팩토리

섹션 제목: “ErrorCodeFactory — 에러 생성 팩토리”
public static class ErrorCodeFactory
{
// 기본 에러 생성
[Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Error Create(string errorCode, string errorCurrentValue, string errorMessage) =>
new ErrorCodeExpected(errorCode, errorCurrentValue, errorMessage);
// 제네릭 단일 값 에러 생성
[Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Error Create<T>(string errorCode, T errorCurrentValue, string errorMessage) where T : notnull =>
new ErrorCodeExpected<T>(errorCode, errorCurrentValue, errorMessage);
// 제네릭 다중 값 에러 생성
[Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Error Create<T1, T2>(string errorCode, T1 errorCurrentValue1, T2 errorCurrentValue2, string errorMessage)
where T1 : notnull where T2 : notnull =>
new ErrorCodeExpected<T1, T2>(errorCode, errorCurrentValue1, errorCurrentValue2, errorMessage);
// 예외 기반 에러 생성
[Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Error CreateFromException(string errorCode, Exception exception) =>
new ErrorCodeExceptional(errorCode, exception);
// 에러 코드 포맷팅
[Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string Format(params string[] parts) =>
string.Join('.', parts);
}

Denominator — 내부 DomainErrors 패턴 적용

섹션 제목: “Denominator — 내부 DomainErrors 패턴 적용”
public sealed class Denominator : SimpleValueObject<int>, IComparable<Denominator>
{
// ... 기존 구현 ...
public static Validation<Error, int> Validate(int value)
{
if (value == 0)
return DomainErrors.Zero(value);
return value;
}
// 내부 DomainErrors 클래스 - 응집도 높은 에러 정의
internal static class DomainErrors
{
public static Error Zero(int value) =>
ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Denominator)}.{nameof(Zero)}",
errorCurrentValue: value,
errorMessage: $"Denominator cannot be zero. Current value: '{value}'");
}
}

Currency — SmartEnum 기반 에러 정의

섹션 제목: “Currency — SmartEnum 기반 에러 정의”

SmartEnum에서도 동일한 내부 DomainErrors 패턴을 적용합니다.

public sealed class Currency : SmartEnum<Currency, string>, IValueObject
{
public static readonly Currency KRW = new(nameof(KRW), "KRW", "한국 원화", "");
public static readonly Currency USD = new(nameof(USD), "USD", "미국 달러", "$");
// ... 기타 통화들 ...
public static Validation<Error, string> Validate(string currencyCode) =>
ValidateNotEmpty(currencyCode)
.Bind(ValidateFormat)
.Bind(ValidateSupported);
private static Validation<Error, string> ValidateNotEmpty(string currencyCode) =>
string.IsNullOrWhiteSpace(currencyCode)
? DomainErrors.Empty(currencyCode)
: currencyCode;
private static Validation<Error, string> ValidateFormat(string currencyCode) =>
currencyCode.Length != 3 || !currencyCode.All(char.IsLetter)
? DomainErrors.NotThreeLetters(currencyCode)
: currencyCode.ToUpperInvariant();
// 내부 DomainErrors 클래스 - SmartEnum 특화 에러 정의
internal static class DomainErrors
{
public static Error Empty(string value) =>
ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Currency)}.{nameof(Empty)}",
errorCurrentValue: value,
errorMessage: $"Currency code cannot be empty. Current value: '{value}'");
public static Error NotThreeLetters(string value) =>
ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Currency)}.{nameof(NotThreeLetters)}",
errorCurrentValue: value,
errorMessage: $"Currency code must be exactly 3 letters. Current value: '{value}'");
public static Error Unsupported(string value) =>
ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(Currency)}.{nameof(Unsupported)}",
errorCurrentValue: value,
errorMessage: $"Currency code is not supported. Current value: '{value}'");
}
}

복합 값 객체에서 다중 값 에러를 정의하는 패턴입니다.

public sealed class PriceRange : ComparableValueObject
{
public Price MinPrice { get; }
public Price MaxPrice { get; }
public static Fin<PriceRange> Create(decimal minPriceValue, decimal maxPriceValue, string currencyCode) =>
CreateFromValidation(
Validate(minPriceValue, maxPriceValue, currencyCode),
validValues => new PriceRange(validValues.MinPrice, validValues.MaxPrice));
public static Validation<Error, (Price MinPrice, Price MaxPrice)> Validate(
decimal minPriceValue, decimal maxPriceValue, string currencyCode) =>
from validMinPriceTuple in Price.Validate(minPriceValue, currencyCode)
from validMaxPriceTuple in Price.Validate(maxPriceValue, currencyCode)
from validPriceRange in ValidatePriceRange(
Price.CreateFromValidated(validMinPriceTuple),
Price.CreateFromValidated(validMaxPriceTuple))
select validPriceRange;
private static Validation<Error, (Price MinPrice, Price MaxPrice)> ValidatePriceRange(Price minPrice, Price maxPrice) =>
(decimal)minPrice.Amount > (decimal)maxPrice.Amount
? DomainErrors.MinExceedsMax(minPrice, maxPrice)
: (MinPrice: minPrice, MaxPrice: maxPrice);
// 내부 DomainErrors 클래스 - 가격 범위 검증 에러
internal static class DomainErrors
{
public static Error MinExceedsMax(Price minPrice, Price maxPrice) =>
ErrorCodeFactory.Create(
errorCode: $"{nameof(DomainErrors)}.{nameof(PriceRange)}.{nameof(MinExceedsMax)}",
errorCurrentValue: $"MinPrice: {minPrice}, MaxPrice: {maxPrice}",
errorMessage: $"Minimum price cannot exceed maximum price. Min: '{minPrice}', Max: '{maxPrice}'");
}
}

기존 Error.New 방식과 ErrorCodeFactory 방식의 차이를 요약합니다.

구분이전 방식 (Error.New)현재 방식 (ErrorCodeFactory)
에러 코드 구조단순한 문자열 메시지DomainErrors.클래스.이유 형식
값 정보 관리메시지에 하드코딩타입 안전한 별도 필드
디버깅 지원메시지 파싱 필요구조화된 정보 즉시 제공
모니터링 지원일관성 부족표준화된 형식으로 집계 가능
타입 안전성없음제네릭으로 보장

구조화된 에러 코드 시스템의 트레이드오프를 정리합니다.

장점단점
구조화된 에러 관리초기 설정 복잡성
타입 안전한 에러 정보코드 볼륨 증가
LanguageExt 완전 호환학습 곡선 존재
디버깅 및 모니터링 향상프레임워크 의존성

에러 메서드 이름은 검증 조건과 정확히 일치해야 합니다. 에러 코드만 봐도 무엇이 잘못되었는지 즉시 파악할 수 있어야 합니다.

에러 상황메서드 이름적용 클래스
빈 값EmptyCurrency, PostalCode, Street, City
3자리 영문자 아님NotThreeLettersCurrency
5자리 숫자 아님NotFiveDigitsPostalCode
좌표 범위 초과XOutOfRange, YOutOfRangeCoordinate
금액 범위 초과OutOfRangeMoneyAmount
0 값ZeroDenominator
지원 안 함UnsupportedCurrency
최솟값 > 최댓값MinExceedsMaxPriceRange
시작일 >= 종료일StartAfterEndDateRange

Q1: 기존 Error.New 방식 대비 어떤 장점이 있나요?

섹션 제목: “Q1: 기존 Error.New 방식 대비 어떤 장점이 있나요?”

A: 구조화된 에러 코드(DomainErrors.Denominator.Zero)를 통해 에러의 출처와 이유를 즉시 파악할 수 있고, 타입 안전한 값 필드로 모니터링 시스템에서 도메인별 집계가 가능합니다. 기존 방식은 메시지 문자열을 파싱해야 했습니다.

Q2: 내부 DomainErrors 클래스를 사용하는 이유는?

섹션 제목: “Q2: 내부 DomainErrors 클래스를 사용하는 이유는?”

A: 값 객체와 에러 정의를 같은 파일에 두어 응집도를 높입니다. 값 객체를 수정할 때 관련 에러도 함께 확인할 수 있고, 새 값 객체 생성 시 에러 정의도 자연스럽게 함께 작성합니다.

Q3: LanguageExt와의 호환성은 어떻게 보장되나요?

섹션 제목: “Q3: LanguageExt와의 호환성은 어떻게 보장되나요?”

A: ErrorCodeExpected, ErrorCodeExpected<T> 등이 모두 LanguageExt의 Error 클래스를 상속받아 구현됩니다. Match, Map, Bind 등의 함수형 연산자와 완전히 호환되므로, 기존 코드를 수정하지 않고도 새 에러 처리 시스템을 도입할 수 있습니다.


에러 코드 구조가 갖춰졌지만, 매번 ErrorCodeFactory.Create를 직접 호출하면 코드가 장황해집니다. 다음 장에서는 DomainError 헬퍼와 DomainErrorType을 도입하여 에러 생성을 간결하게 만듭니다.

14장: 에러 코드 Fluent