본문 바로가기

C#

C# 구조적 비교와 EqualityComparer<T> 커스터마이징

컬렉션 키 비교나 동등성 판단에서 참조 동일성 대신 내용(구조)을 비교하면 버그를 크게 줄일 수 있습니다. 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> 커스터마이징을 통해 의도를 컬렉션과 알고리즘에 정확히 반영하세요.