비어있지 않음을 보장하는 컬렉션

도메인 요구 사항

record Work;
record Dispatch(string Title, DateSpan Span, Work[] Works);

파견은 일이 있을 때 보냅니다.
해야 하는 일도 없는데, 파견을 보내는 것은 말이 안되겠죠?

var d = new Dispatch("제주도 현장", sevenDays, []); // 생성되면 안됨. 

따라서,

Dispatch.Works 에는 최소 1 개이상의 요소가 있어야 한다

는 도메인 요구사항이 발생합니다.

비어있지 않음을 보장하는 컬렉션

요소가 1 개 이상임을 보장하는 컬렉션을 직접 구현하는 것은 어렵지 않습니다.

그러나, 이 객체는 도메인 모델로, 닷넷의 인프라와 충돌을 일으키면 효용이 매우 떨어집니다.

자료 구조 vs 패턴

문제는 그런 호환성까지 보장하도록 구현하는 건 매우 어렵다는 점입니다. (AI도 쓰레기 코드를 만들어 줄 뿐입니다.)

따라서, 굳이 어려운 자료 구조를 정의하느라 애를 먹기 보다는, 유사한 효과를 내는 패턴을 선택합니다.

해결

생성만 막으면 되니까, 생성 시, 무조건 하나 이상의 할일을 제공하도록 강제합니다.

record Dispatch
{
   // 기본값으로 생성되는 것을 막음.
   protected Dispatch() { };

   public string Title { get; init; } = "";
   public DateSpan Span {get; init; }
   public Work[] Works { get; init; } = [];

   public static Dispatch Create(
      string title, 
      DateSpan span, 
      Work first, 
      params Work[] more) => new()
      {
         Title = string.IsNullOrWhiteSpace(title) 
            ? span.ToString() : title, 
         Span = span, 
         Works = [first, .. more];
      }
}

호환성

보통 recordSystem.Text.Json 도구와 호환이 잘 되지만, Dispatch는 기본 생성자를 감췄으므로 호환이 안 됩니다.

그런데, 이 호환성 부족은 (당장 해결해야 하는) 문제가 아닙니다.
왜냐하면, "직렬화"는 도메인 소비 레이어에서나 중요하지, 도메인 레이어에서는 전혀 중요하지 않기 때문입니다.

필요한 곳에서 이 모델을 위한 직렬화 관련 코드가 추가될 것인데, 이는 “도메인 규칙” 충족을 위해 치러야 하는 (적정한) 비용입니다.

만약, record까지 썼는데, 기왕이면 호환이 되었으면 하는 아쉬움에 사로잡혀 도메인 레이어에서 그 호환성을 억지로 맞추려 한다면 두고 두고 문제가 발생합니다.

  • 기본 생성자를 공개(public) => 도메인 규칙이 깨짐
  • 생성자에 [JsonConstructor] 부여 => 도메인과 도구가 강하게 결합

사실 이러한 해법은 AI 가 자주 제시(생성)하는 방식이라 주의가 필요합니다.

System.Text.Json 은 런타임과 함께 배포되기는 하지만 이는 편의를 위한 것일 뿐, 도메인 관점에서는 외부 의존성으로 분류됩니다. (직렬화 도구는 인프라)

참고로, 이 모델과 EF Core 는 호환 문제가 전혀 없는데, 이는 EF Core가 원래 DDD(와 CQRS)를 지원하도록 설계되어 있기 때문입니다.

언어에 기능(예를 들면 record)이 추가될 때 마다 이에 대한 지원도 추가되기 때문에, 도메인 모델을 어떻게 설계하더라도 EF Core와 문제가 있는 경우는 거의 없습니다.

소비 코드

뭔가 해결이 된 것 같으니, 소비를 살펴 보겠습니다.

// string t, DateSpan ds, Work w 

var d1 = Dispatch.Create(t, ds, w);
var d2 = Dispatch.Create(t, ds, w, w2);

문제 없이 도메인 요구 사항이 충족된 것 같습니다.

소비 코드를 몇 개 더 보겠습니다.

var d1 = Dispatch.Create(t, ds, w, moreArray);

// 컴파일 에러
// var d2 = Dispatch.Create(t, ds, w, moreList); 

var d2 = Dispatch.Create(t, ds, w, moreList.ToArray());
 
// 컴파일 에러
// var d3 = Dispatch.Create(t, ds, w, moreEnumerable); 

var d3 = Dispatch.Create(t, ds, w, moreEnumerable.ToArray()); 

현재 구현에서는 파라미터가 배열이 아닌 경우, ToArray() 호출이 필요한데, 이는

  1. 컬렉션 파라미터로 첫 번째 배열 생성,
  2. 이 배열로 두 번째 배열을 생성

하는 구조가 되어 생성 시 비효율이 있습니다.

params

C# 13에 params 에 대한 대대적인 업데이트가 있었습니다.

https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13#params-collections

요지는 기존에 배열만 가능했던 params 의 형식을 모든 컬렉션/시퀀스로 확대한 것입니다.

언어의 신기능을 채택하여 코드를 바꿔봅니다.

record Dispatch
{
// ...
      Work first, 
      // params Work[] more) => new()
      params IEnumerable<Work> more) => new()      
// ...

소비 코드

var d1 = Dispatch.Create(t, ds, w);
var d2 = Dispatch.Create(t, ds, w, w2);
var d3 = Dispatch.Create(t, ds, w, moreArray);
var d4 = Dispatch.Create(t, ds, w, moreList);
var d5 = Dispatch.Create(t, ds, w, moreEnumerable); 
var d6 = Dispatch.Create(t, ds, w, moreSet); 

추가적으로 params IEnumerable<Work> 도 좋지만, 컬렉션을 위한 추상을 쓰는 게 성능상 유리하므로, 오버로드를 추가합니다.

record Dispatch
{
// ...
  // 오버 로드 추가
  public static Dispatch Create(
     string title, 
     DateSpan span, 
     Work first, 
     params IReadOnlyList<Work> more) => new()
     {
// ...
var d1 = Dispatch.Create(t, ds, w); // IReadOnlyList
var d2 = Dispatch.Create(t, ds, w, w2); // IReadOnlyList
var d3 = Dispatch.Create(t, ds, w, moreArray); // IReadOnlyList
var d4 = Dispatch.Create(t, ds, w, moreList); // IReadOnlyList
var d5 = Dispatch.Create(t, ds, w, moreEnumerable); // IEnumerable
var d6 = Dispatch.Create(t, ds, w, moreSet); // IEnumerable

마치며

params 키워드를 통해 "할 일 없는 파견"을 막는 것을 구현했습니다.

모쪼록 "공무원들의 할 일 없는 해외 출장"을 막는데도 도움이 되기를 바랍니다. ^^

6 Likes

제목만 보고 어떤식으로 구현했을지 궁금하면서 들어왔는데,
Work first, prarmas Work[] more
아이디어가 좋은 것 같습니다.

1 Like