HashSet<T>

HashSet<T>

요즘 많이 사용하고 있는 컬렉션으로 활용 가치에 대해 공유하고자 합니다.

문제 상황


record Dispatch(string Title, DateOnly From, DateOnly To);

class Assignments
{
   // ... 
   
   public List<Dispatch> Dispatches{ get; init;} = [];
}

“파견(Dispatch)” 이라는 도메인 문제는 "기간의 중복이 불가하다"는 요구 사항이 있습니다.

List<T> 는 이러한 요구 사항을 만족하기에는 부족합니다. 만약 위 코드를 그대로 사용한다면, 아래와 같은 부산스런 코드가 프로젝트 사방에 흩뿌려질 것입니다.

void Add(Dispatch dispach, Assignments on)
{
   var overlapped = on.Dispatches
      .Any(d => !(dispatch.To < d.From || d.To < dispatch.From));

   if (!overlapped)
   {
      on.Dispatches.Add(dispatch);
   }
}

해결

이 문제를 해결하기 위해서 선택할 수 있는 수단은:

  1. 별도 자료 구조 정의.
  2. 기본 자료 구조 파생

입니다. 이 글에서는 두번째를 선택해서 HashSet<T> 을 파생합니다.

class DispatchSet : HashSet<Dispatch>
{
   public DispatchSet() : base(
      EqualityComparer<Dispatch>.Create(
      (x, y) => { 
          // 겹치면 true, 아니면 false 
      }, 
      d => 0 // 항상 같은 해시코드 반환
      )) 
   { }
}

부모의 생성자에 "기간이 겹치면 true 를 반환"하는 IEqualityComparer<Dispatch> 를 매개 변수로 넘기도록 정의했습니다.

이 비교기는 Equals(x, y)의 호출이 강제되는 구조로 일반적인 해시셋( O(1) ) 보다는 성능 비효율적( O(n) )이긴 하지만, 이는 “기간이 겹치지 않아야 한다” 는 도메인 요구 사항을 지키기 위한 비용입니다.

주석의 구현을 매끄럽게 하기 위해, Dispatch 의 “데이터 응집” 문제를 먼저 해결합니다.

// record Dispatch(string Title, DateOnly From, DateOnly To);

record Dispatch(string Title, DateSpan Span);

record DateSpan(DateOnly From, DateOnly To)
{
   // From <= To 를 강제.
   public DateOnly To { get; init; } = From > To ? From : To;
}

static class DateSpanDefaults
{
   public static bool Overlapped(this DateSpan a, DateSpan b) =>
      !(b.To < a.From || a.To < b.From);
}

이 밑작업을 바탕으로 DispatchSet을 완성합니다.

class DispatchSet() 
   : HashSet<Dispatch>(EqualityComparer<Dispatch>.Create(
      (x, y) => { 
         if (ReferenceEquals(x, y)) return true;
         if (x == null || y == null) return false;

         // 겹치면 true, 아니면 false 
         return x.Span.Overlapped(y.Span);
      }, d => 0));

이 컬렉션을 채택한 Assignments 입니다.

class Assignments
{
   // ... 
   // public List<Dispatch> Dispatches { get; } = [];   
   // public HashSet<Dispatch> Dispatches { get; } = [];   
   public DispatchSet Dispatches { get; } = [];
}

소비 코드를 보자면,

void Add(Dispatch dispach, Assignments on)
{
   // var overlapped = on.Dispatches
   //   .Any(d => !(dispatch.To < d.From || d.To < dispatch.From));

   // if (!overlapped)
   // {
   //    on.Dispatches.Add(dispatch);
   // }

   if (on.Dispatches.Add(dispatch) is false)
   {
      MessageBox.Alert("파견의 기간은 중복될 수 없습니다.")
   }
}
void AddRange(IEnumerable<Dispatch> dispaches, Assignments on)
{
   var addeds = new List<Dispatch>();
   var iterations = 0;
   foreach(var d in dispatches)
   {
      if (on.Dispatches.Add(d)) addeds.Add(d);
      iterations ++;
   }
  
   if (addeds.Count != iterations)
   {
      var overlaps = iterations - addeds.Count;
      var ok = MessageBox.Confirm(
         $"총 {iterations} 개 중에, 기간이 중복된 " +
         $"{overlaps} 개를 빼고 저장할까요?")
      if (!ok) RemoveRange(addeds, on);
   }
}
var d1 = new Dispatch("부산 현장", new(new(2026, 1,1), new(2026, 1,10)));
var d2 = new Dispatch("광주 현장" , new(new(2026, 1,12), new(2026, 1,20)));
var d3 = new Dispatch("제주도 현장", new(new(2026, 1,15), new(2026, 1,25)));

DispatchSet  dispatches = [d1, d2, d3];
if (dispatches.Count != 3)
{
   foreach(var d in new[] {d1, d2, d3}.Except(dispatches))
      Console.WriteLine($"날짜가 겹쳐, {d.Title}은 제외되었습니다.");
}

// 결과
날짜가 겹쳐, 제주도 현장은 제외되었습니다.

코딩 효율성

사실 HashSet<T>(과 List<T>)는 파생을 염두해 둔 객체가 아니기 때문에 일반적으로는 권고되지 않는 방식입니다.

하지만, DispatchSetHashSet<T>의 인프라를 아무런 수정 없이 그대로 사용하고 있어, 이런 권고 사항을 위배한다고 볼 수 없고, 수정이 없기에 EF Core 의 트랙커, System.Text.Json 직렬화 도구 등, 닷넷 기반 인프라 호환성도 자연스럽게 유지되는 장점이 있습니다.

자료구조를 별도로 정의하는 경우, 이러한 호환성 문제까지 고려해야 해서, 전반적인 코딩 효율성이 좋다고는 할 수 없습니다. (토큰도 많이 들겠죠.)

정리하자면

DispatchSet 은 기간의 중복을 허용하지 않아야 하는 도메인 요구 사항을 충족하기 위해 정의되었습니다.

이 요구 사항을 좀 더 일반화해 보면, "중복을 허용하지 않는 컬렉션"에 대한 수요라고 할 수 있는데, 많은 도메인 문제에서 공통적으로 자주 발생하는 편입니다.

이러한 수요는 아래와 같은 단출한 코드 패턴으로 쉽게 해결이 가능합니다.

public class FarSet() 
    : HashSet<Far>(IEqualityComparer<Far>);
8개의 좋아요