본문으로 건너뛰기

프레임워크 타입 선택 가이드

값 객체를 구현할 때 어떤 프레임워크 타입을 선택해야 하는지 안내하는 의사결정 가이드입니다.


여러 변형 중 하나인가? ─── Yes ──→ 상태 전이 필요? ─── Yes ──→ UnionValueObject<TSelf>
│ │
No No ──→ UnionValueObject
값이 하나인가? ─── Yes ──→ 비교 필요? ─── Yes ──→ ComparableSimpleValueObject<T>
│ │
No No ──→ SimpleValueObject<T>
열거형인가? ─── Yes ──→ SmartEnum + IValueObject
No
비교 필요? ─── Yes ──→ ComparableValueObject
No ──→ ValueObject

언제 사용?

  • 단일 값을 래핑할 때
  • 비교(정렬)가 필요 없을 때
  • 가장 일반적인 값 객체

예시

- Email (문자열)
- ProductCode (문자열)
- Password (해시된 문자열)
- UserId (GUID)

구현 예시

public sealed class Email : SimpleValueObject<string>
{
private Email(string value) : base(value) { }
public static Fin<Email> Create(string? value) =>
CreateFromValidation(
ValidationRules<Email>.NotNull(value)
.ThenNotEmpty()
.ThenMaxLength(255),
v => new Email(v));
}

언제 사용?

  • 단일 값을 래핑할 때
  • 정렬이나 비교가 필요할 때
  • 내부 값이 IComparable<T>일 때

예시

- Age (정수 - 나이 비교)
- Quantity (정수 - 수량 비교)
- Amount (decimal - 금액 비교)
- InterestRate (decimal - 이율 비교)
- DateOfBirth (DateOnly - 날짜 비교)

구현 예시

public sealed class Age : ComparableSimpleValueObject<int>
{
private Age(int value) : base(value) { }
public static Fin<Age> Create(int value)
{
if (value < 0 || value > 150)
return DomainError.For<Age, int>(new OutOfRange("0", "150"), value, "유효하지 않은 나이");
return new Age(value);
}
}

언제 사용?

  • 여러 속성을 가진 값 객체
  • 비교(정렬)가 필요 없을 때
  • 복합 값이 필요할 때

예시

- Address (도시, 거리, 우편번호)
- FullName (성, 이름)
- Coordinate (위도, 경도)
- DateTimeRange (시작, 종료)

구현 예시

public sealed class Address : ValueObject
{
public string City { get; }
public string Street { get; }
public string PostalCode { get; }
private Address(string city, string street, string postalCode)
{
City = city;
Street = street;
PostalCode = postalCode;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return City;
yield return Street;
yield return PostalCode;
}
}

4. ComparableValueObject (복합 비교 가능)

섹션 제목: “4. ComparableValueObject (복합 비교 가능)”

언제 사용?

  • 여러 속성을 가진 값 객체
  • 정렬이나 비교가 필요할 때
  • 복합 키로 정렬해야 할 때

예시

- Money (금액, 통화 - 동일 통화 내 비교)
- DateRange (시작일, 종료일 - 시작일 기준 정렬)
- ExchangeRate (기준통화, 견적통화, 환율)
- TimeSlot (시작시간, 종료시간)

구현 예시

public sealed class Money : ComparableValueObject
{
public decimal Amount { get; }
public string Currency { get; }
protected override IEnumerable<IComparable> GetComparableEqualityComponents()
{
yield return Currency;
yield return Amount;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}

5. SmartEnum + IValueObject (타입 안전 열거형)

섹션 제목: “5. SmartEnum + IValueObject (타입 안전 열거형)”

언제 사용?

  • 제한된 값 집합
  • 각 값에 행위나 속성이 있을 때
  • 상태 전이 로직이 필요할 때

예시

- OrderStatus (대기, 확정, 배송중, 완료)
- PaymentMethod (카드, 현금, 계좌이체)
- UserRole (관리자, 사용자, 게스트)
- TransactionType (입금, 출금, 이체)

구현 예시

public sealed class OrderStatus : SmartEnum<OrderStatus, string>, IValueObject
{
public static readonly OrderStatus Pending = new("PENDING", "대기중", canCancel: true);
public static readonly OrderStatus Shipped = new("SHIPPED", "배송중", canCancel: false);
public string DisplayName { get; }
public bool CanCancel { get; }
private OrderStatus(string value, string displayName, bool canCancel)
: base(displayName, value)
{
DisplayName = displayName;
CanCancel = canCancel;
}
}

6. UnionValueObject (순수 데이터 Union)

섹션 제목: “6. UnionValueObject (순수 데이터 Union)”

언제 사용?

  • 여러 변형(케이스) 중 정확히 하나일 때
  • 패턴 매칭으로 빠짐없는 분기 처리가 필요할 때
  • 닫힌 타입 계층이 필요할 때
  • 상태 전이가 없는 순수 데이터 유니온

예시

- Shape (Circle | Rectangle | Triangle)
- PaymentMethod (CreditCard | BankTransfer | Cash)
- Result (Success | Failure)

구현 예시

public abstract record Shape : UnionValueObject
{
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Triangle(double Base, double Height) : Shape;
public double Area => Match(
circle: c => Math.PI * c.Radius * c.Radius,
rectangle: r => r.Width * r.Height,
triangle: t => 0.5 * t.Base * t.Height);
}

7. UnionValueObject<TSelf> (상태 전이 Union)

섹션 제목: “7. UnionValueObject<TSelf> (상태 전이 Union)”

언제 사용?

  • 여러 변형 중 정확히 하나이면서 상태 전이가 필요할 때
  • TransitionFrom으로 유효한 전이만 허용하고 잘못된 전이는 Fin<T> 실패로 처리할 때
  • CRTP로 DomainError에 정확한 타입 정보를 전달해야 할 때

예시

- OrderStatus (Pending → Confirmed → Shipped → Delivered)
- PaymentState (Initiated → Authorized → Captured → Refunded)
- ApprovalStatus (Draft → Submitted → Approved | Rejected)

구현 예시

public abstract record OrderStatus : UnionValueObject<OrderStatus>
{
public sealed record Pending(string OrderId) : OrderStatus;
public sealed record Confirmed(string OrderId, DateTime ConfirmedAt) : OrderStatus;
private OrderStatus() { }
public Fin<Confirmed> Confirm(DateTime confirmedAt) =>
TransitionFrom<Pending, Confirmed>(
p => new Confirmed(p.OrderId, confirmedAt));
}

각 타입이 지원하는 기능을 한눈에 비교합니다.

특성SimpleValueObjectComparableSimpleValueObjectComparableValueSmartEnumUnionValueObjUnionValueObj<TSelf>
단일 값
복합 값
DU (변형 중 하나)
비교 가능
정렬 가능
열거형
상태 전이

// ❌ 이메일은 정렬할 필요 없음
public sealed class Email : ComparableSimpleValueObject<string> { }
// ✅ 단순 값 객체 사용
public sealed class Email : SimpleValueObject<string> { }
// ❌ 불필요하게 복잡함
public sealed class ProductCode : ValueObject
{
public string Value { get; }
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
}
}
// ✅ 단순하게
public sealed class ProductCode : SimpleValueObject<string> { }
// ❌ 일반 enum의 한계
public enum OrderStatus { Pending, Shipped }
// ✅ 행위를 가진 SmartEnum
public sealed class OrderStatus : SmartEnum<OrderStatus, string>, IValueObject
{
public bool CanCancel { get; }
public Fin<OrderStatus> TransitionTo(OrderStatus next) { ... }
}

값 객체 구현 시 다음을 확인하세요:

  • 타입이 sealed로 선언되었는가?
  • public 생성자 대신 팩토리 메서드(Create)를 사용하는가?
  • 검증 로직이 Create 메서드에 있는가?
  • DomainErrors 내부 클래스가 있는가?
  • 불변성이 보장되는가?
  • 필요한 경우 암시적 변환 연산자가 있는가?
  • ToString()이 적절히 오버라이드되었는가?

용어집을 확인합니다.

C. 용어집