컬렉션 키 비교나 동등성 판단에서 참조 동일성 대신 내용(구조)을 비교하면 버그를 크게 줄일 수 있습니다. C#/.NET은 배열, 튜플, 레코드 등에서 구조적 비교를 지원하며, EqualityComparer
1. 구조적 비교란?
구조적 비교는 객체의 참조가 아닌 내부 구성 요소 값을 기준으로 동등성/정렬을 판단하는 방식입니다. 기본 배열 비교는 참조 비교이며, 튜플/레코드/ValueTuple은 기본적으로 구조적 비교를 지원합니다.
using System;
using System.Collections;
var a1 = new[] { 1, 2, 3 };
var a2 = new[] { 1, 2, 3 };
Console.WriteLine(Equals(a1, a2)); // False: 참조 비교
// 구조적 비교(배열 요소 비교)
Console.WriteLine(StructuralComparisons.StructuralEqualityComparer.Equals(a1, a2)); // True
// 튜플(System.Tuple) 구조적 비교
var t1 = Tuple.Create(1, "a");
var t2 = Tuple.Create(1, "a");
Console.WriteLine(StructuralComparisons.StructuralEqualityComparer.Equals(t1, t2)); // True
// ValueTuple은 기본 Equals가 구조적 비교입니다.
var vt1 = (1, "a");
var vt2 = (1, "a");
Console.WriteLine(vt1.Equals(vt2)); // True
2. 배열/튜플 구조적 비교 실전 포인트
배열은 기본적으로 참조 비교이므로, 컬렉션 키에 쓰면 의도치 않은 동작이 발생합니다. 반면 Tuple/ValueTuple/record는 이미 값 기반 비교를 지원하므로 그대로 키로 써도 괜찮습니다(불변 데이터일 때 권장).
using System;
using System.Collections.Generic;
// ValueTuple은 기본 구조적 동등성 지원
var map1 = new Dictionary<(int x, int y), string> { [(1, 2)] = "P" };
Console.WriteLine(map1.ContainsKey((1, 2))); // True
// 배열은 기본 EqualityComparer로는 참조 비교라 실패
var map2 = new Dictionary<int[], string> { [new[] { 1, 2 }] = "P" };
Console.WriteLine(map2.ContainsKey(new[] { 1, 2 })); // False (의도와 다름)
3. 배열을 Dictionary/HashSet 키로 쓰기(커스텀 EqualityComparer)
배열 키가 필요하면 EqualityComparer<T[]>를 구현하세요. .NET 6+에서는 EqualityComparer<T>.Create로 람다 기반 생성도 가능합니다.
using System;
using System.Collections;
using System.Collections.Generic;
sealed class IntArrayStructuralComparer : EqualityComparer<int[]>
{
public override bool Equals(int[]? x, int[]? y)
=> StructuralComparisons.StructuralEqualityComparer.Equals(x, y);
public override int GetHashCode(int[] obj)
=> StructuralComparisons.StructuralEqualityComparer.GetHashCode(obj);
}
var dict = new Dictionary<int[], string>(new IntArrayStructuralComparer())
{
[new[] { 1, 2 }] = "A"
};
Console.WriteLine(dict.ContainsKey(new[] { 1, 2 })); // True
// .NET 6+: 람다로 간단히 생성
using System;
using System.Collections;
using System.Collections.Generic;
var arrayComparer = EqualityComparer<int[]>.Create(
(x, y) => StructuralComparisons.StructuralEqualityComparer.Equals(x, y),
obj => StructuralComparisons.StructuralEqualityComparer.GetHashCode(obj)
);
var set = new HashSet<int[]>(arrayComparer) { new[] { 1, 2 } };
Console.WriteLine(set.Contains(new[] { 1, 2 })); // True
4. 사용자 타입 EqualityComparer 커스터마이징
도메인에서 "ID만 같으면 같다" 같은 규칙이 많습니다. 두 가지 접근이 있습니다.
- 타입 내부에 값 동등성 구현: IEquatable<T> 구현 또는 record 사용
- 외부 비교자 제공: EqualityComparer<T>를 상속하거나 Create로 주입
// record는 기본으로 구조적 동등성과 해시를 제공합니다.
public record Money(string Currency, decimal Amount);
var m1 = new Money("USD", 10m);
var m2 = new Money("USD", 10m);
Console.WriteLine(m1 == m2); // True
using System;
using System.Collections.Generic;
public sealed class Person
{
public string Id { get; }
public string Name { get; }
public Person(string id, string name) { Id = id; Name = name; }
}
sealed class PersonIdComparer : EqualityComparer<Person>
{
public override bool Equals(Person? x, Person? y)
=> string.Equals(x?.Id, y?.Id, StringComparison.Ordinal);
public override int GetHashCode(Person obj)
=> StringComparer.Ordinal.GetHashCode(obj.Id);
}
var people = new HashSet<Person>(new PersonIdComparer())
{
new("u1", "Kim")
};
people.Add(new("u1", "Lee"));
Console.WriteLine(people.Count); // 1 (ID 기준 동등)
// .NET 6+: 람다 기반 비교자
var ciEmailComparer = EqualityComparer<string>.Create(
(x, y) => StringComparer.OrdinalIgnoreCase.Equals(x, y),
s => StringComparer.OrdinalIgnoreCase.GetHashCode(s!)
);
var emails = new HashSet<string>(ciEmailComparer) { "dev@site.com" };
emails.Add("DEV@SITE.COM");
Console.WriteLine(emails.Count); // 1
5. 정렬 비교자(StructuralComparer) 활용
정렬이 필요하면 StructuralComparisons.StructuralComparer를 사용해 튜플/배열을 사전식(lexicographic) 정렬할 수 있습니다.
using System;
using System.Collections;
using System.Collections.Generic;
var list = new List<Tuple<int, int>>
{
Tuple.Create(1, 2),
Tuple.Create(1, 1),
Tuple.Create(0, 5)
};
list.Sort(Comparer<Tuple<int,int>>.Create(
(x, y) => StructuralComparisons.StructuralComparer.Compare(x, y))
);
foreach (var t in list)
Console.WriteLine(t); // (0,5) -> (1,1) -> (1,2)
6. 베스트 프랙티스와 주의사항
- 키는 불변이어야 합니다: 키로 쓴 배열/객체의 요소가 바뀌면 해시가 달라져 조회가 실패합니다.
- GetHashCode는 Equals와 일관되어야 합니다: 같으면 해시도 같아야 합니다.
- 가능하면 ValueTuple/record 사용: 기본으로 값 동등성 제공, 보일러플레이트 감소합니다.
- 비교자 재사용: static readonly로 캐싱해 할당/박싱 비용을 줄입니다.
- 성능: 큰 배열 비교가 잦다면 길이/첫 요소 등 빠른 가드 체크를 먼저 적용하거나 Span 기반 최적화를 검토합니다.
핵심은 "무엇을 같다고 볼지"를 코드에 명확히 선언하는 것입니다. 구조적 비교와 EqualityComparer<T> 커스터마이징을 통해 의도를 컬렉션과 알고리즘에 정확히 반영하세요.
'C#' 카테고리의 다른 글
| C# ThreadLocal<T>로 스레드별 데이터 관리 (0) | 2026.05.26 |
|---|---|
| C# 암시적/명시적 변환 연산자(implicit/explicit) 구현하기 (0) | 2026.05.25 |
| C# 인터페이스의 기본 구현(Default Interface Implementation) 활용 (0) | 2026.05.23 |
| C# 디버깅 심화: Conditional Attribute와 DebuggerDisplay (0) | 2026.05.22 |
| C# nullable 값 형식(Nullable<T>) 고급 사용법 (0) | 2026.05.22 |