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);
}
}
해결
이 문제를 해결하기 위해서 선택할 수 있는 수단은:
- 별도 자료 구조 정의.
- 기본 자료 구조 파생
입니다. 이 글에서는 두번째를 선택해서 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>)는 파생을 염두해 둔 객체가 아니기 때문에 일반적으로는 권고되지 않는 방식입니다.
하지만, DispatchSet 은 HashSet<T>의 인프라를 아무런 수정 없이 그대로 사용하고 있어, 이런 권고 사항을 위배한다고 볼 수 없고, 수정이 없기에 EF Core 의 트랙커, System.Text.Json 직렬화 도구 등, 닷넷 기반 인프라 호환성도 자연스럽게 유지되는 장점이 있습니다.
자료구조를 별도로 정의하는 경우, 이러한 호환성 문제까지 고려해야 해서, 전반적인 코딩 효율성이 좋다고는 할 수 없습니다. (토큰도 많이 들겠죠.)
정리하자면
DispatchSet 은 기간의 중복을 허용하지 않아야 하는 도메인 요구 사항을 충족하기 위해 정의되었습니다.
이 요구 사항을 좀 더 일반화해 보면, "중복을 허용하지 않는 컬렉션"에 대한 수요라고 할 수 있는데, 많은 도메인 문제에서 공통적으로 자주 발생하는 편입니다.
이러한 수요는 아래와 같은 단출한 코드 패턴으로 쉽게 해결이 가능합니다.
public class FarSet()
: HashSet<Far>(IEqualityComparer<Far>);