도메인 요구 사항
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];
}
}
호환성
보통 record는 System.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() 호출이 필요한데, 이는
- 컬렉션 파라미터로 첫 번째 배열 생성,
- 이 배열로 두 번째 배열을 생성
하는 구조가 되어 생성 시 비효율이 있습니다.
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 키워드를 통해 "할 일 없는 파견"을 막는 것을 구현했습니다.
모쪼록 "공무원들의 할 일 없는 해외 출장"을 막는데도 도움이 되기를 바랍니다. ^^