객체의 등가 비교
C# 에서 두 객체가 같은 지 판별하는 방법에 관해 알아 봅니다.
언어 기본
C#에서 두 객체의 등가 비교의 기본 행위는 아래와 같습니다.
- 참조형 객체: 참조가 같으면 같다고 판별 (참조 비교)
- 값형 객체: 상태가 같으면 같다고 판별 (값 비교)
List<int> r = [ 1, 2, 3 ];
var r2 = list;
var isSame = r.Equals(r2); // true;
r.Add(4);
isSame = r.Equals(r2); // true;
var v = new MyStruct();
var v2 = v;
isSame = v.Equals(v2); // true;
v.P = 3;
isSame = v.Equals(v2); // false;
우선, 값자료형의 경우, 변수에 대한 할당은 객체의 복사를 유발하기 때문에
// ...
var v2 = v; // 인스턴스 카피 = 새로운 생성
참조를 비교하는 것이 불가능합니다.
ref 키워드는 예외로 하겠습니다.
struct 의 Equals 는 기본적으로 박싱을 하기 때문에, 성능을 위해서는 반드시 재정의하는 것이 좋습니다.
이 글에서 주로 살펴볼 참조형 객체는, 참조값을 비교한다라는 잘 알려진 상식을 다시 한번 되새길 필요가 있습니다.
이 원칙 대로 BCL과 주요 라이브러리들의 객체들이 설계되었고, 사용자인 우리도 그 원칙을 전제한 채 사용하고 있습니다.
이에, 우리가 설계하는 객체 또한 사용자들에게 혼란을 주지 않기 위해 이 원칙을 준수할 필요가 있습니다.
그러나, 때로는 이 원칙을 벗어날 필요도 있습니다.
등가 재정의
참조형 객체의 등가 비교를 재정의하는 가장 일반적은 방법은
object.Equals를 재정의IEquatable<T>의 구현
입니다. 아래는 예시입니다.
class Person : IEquatable<Person>
{
public string Name { get; set; }
public DateTimeOffset DoB { get; set; }
public bool Equals(Person? p)
{
if (ReferenceEquals(this, o)) return true;
return Name.Equals(p?.Name) && DoB.Equals(p?.DoB);
}
public override bool Equals(object? o) => o is Person p && Equals(p);
// GetHashCode 재정의
}
재정의된 Equals 는 참조가 같다면 true, 다르다면 지정된 필드를 검사하여 두 객체가 같은 지를 판별합니다.
record 활용
값의 의미가 명확한 객체라면 record 를 사용하는 것을 적극 고려해 보시기 바랍니다.
record는 값 비교를 위한 기반 인프라를 언어 차원에서 제공하기 때문에, 안전하고 편리하게 "값 객체"를 선언할 수 있습니다.
public record Point(int X, int Y);
하나의 키워드 뒤에 숨어 있는 코드들은 C#의 원칙은 지키면서,
var p = new Point(1, 1);
var q = p;
var isSame = p.Equals(q); // true;
값을 기반한 등가 비교라는 목적을 쉽게 달성해줍니다.
문법 자체가 값 객체로 다룰 것을 강제하기 때문에,
// 불가능
// p.X = 2;
p = p with { X = 2 }; // 명시적인 복사 생성
isSame = p.Equals(q); // false
사용자는 이 객체의 선언을 보 지 않고도 값 객체임을 명확히 인지할 수 있다는 점이 장점 중에 하나입니다.
레코드가 아닌 객체의 사용 코드와 비교하면 이 장점이 선명히 드러납니다.
var v = new MyStruct();
var v2 = v;
v.P = 3;
// MyStruct 가 값 객체인지 참조 객체인지에 따라 결과는 다름.
// 결과를 명확히 인지하려면 선언을 봐야 함.
var isSame = v.Equals(v2); // false or true
그런데, Equals를 재정의할 때는 보통 정확성을 위해 가능한 한 모든 필드를 비교하곤 합니다. 이는 record도 마찬가지입니다.
때로는 이러한 일률적 전체 상태 비교가 과한, 다시 말하면, 특정 부분 필드의 비교만으로도 충분한 경우가 있는 것이죠. 이런 경우에도, 기계적인 전체 필드 비교 수행은 성능 손실이라 할 수 있습니다.
IEqualityComparer<T>
이 객체는 형식 T에 대한 "등가 비교기"를 나타내는데, Linq 및 많은 라이브러리가 이 객체를 지원합니다.
우리는 이 객체를 이용하여 복수의 비교기를 정의할 수 있습니다.
이는 IEquatable<T>이 가진 일률성이란 한계를 벗어날 수 있음을 의미합니다.
// Entity class
class Person
{
public int Id { get; private set; }
public string Name { get; set; }
public DateTimeOffset DoB { get; set; }
}
이 객체를 위한 비교기의 예시입니다.
public static class PersonComparers
{
public static IEqualityComparer<Person> ForId =>
EqualityComparer<Person>.Create(
(p1, p2) => p1?.Id == p2?.Id,
p => p.GetHashCode());
public static IEqualityComparer<Person> ForName =>
EqualityComparer<Person>.Create(
(p1, p2) => p1?.Name == p2?.Name,
p => p.GetHashCode()));
public static IEqualityComparer<Person> ForNameDoB =>
EqualityComparer<Person>.Create(
(p1, p2) => p1?.Name == p2?.Name && p1?.DoB == p2?.DoB,
p => p.GetHashCode());
}
사용 예,
using static PersonComparers;
Person? selectedPerson;
void OnPersonNameModified(Person cloned)
{
if (ForName.Equals(selectedPerson, cloned))
{
_ = MessageBox.AlertNoChange();
return;
}
UpdateSelected(cloned);
}
void OnPersonNameDoBModified(Person cloned)
{
if (ForNameDoB.Equals(selectedPerson, cloned))
{
_ = MessageBox.AlertNoChange();
return;
}
UpdateSelected(cloned);
}
컬렉션 비교
참조형 객체에 대한 참조 비교는 컬렉션 객체도 예외는 아닙니다.
C# 이 처음 만들어질 당시에 성능을 우선 시 했기에, 컬렉션 객체라도 요소 비교를 하지 않고, (당시의 Java와 달리) 참조 비교를 하도록 설계되었다고 합니다.
그런데, 컬렉션은 이 원칙을 벗어 나, 요소를 기준으로 비교할 경우가 의외로 많습니다.
요소를 기준으로 컬렉션을 비교하는 방법은 크게 두 가지입니다.
- 순서 일치: 두 컬렉션이 동일한 요소를 같은 순서 대로 보유한 경우만 같다고 판별.
- 내용 일치: 두 컬렉션이 동일한 요소를 보유하면 같다고 판별.
- 기타 등등
public static class CollectionComparers
{
// 순서 일치 비교
public IEqualityComparer<ICollection<T>> Sequential<T>() =>
EqualityComparer<ICollection<T>>.Create(
(c1, c2) => {
if (ReferenceEquals(c1, c2)) return true;
if (c1 == null || c2 == null) return false;
return c1.SequentialEquals(c2);
},
c => c.GetHashCode());
// 내용 일치 비교
public IEqualityComparer<ICollection<T>> Content<T>() =>
EqualityComparer<ICollection<T>>.Create(
(c1, c2) => {
if (ReferenceEquals(c1, c2)) return true;
if (c1 == null || c2 == null) return false;
return c1.Count == c2.Count && !c1.Except(c2).Any();
},
c => c.GetHashCode());
}
두 비교기는 요소가 일반 객체인 경우, 참조값으로, record는 값으로 비교를 합니다.
여기에, 커스텀 요소 비교기를 지원하도록 오버로드를 추가해 봅니다.
public static class CollectionComparers
{
// ...
public IEqualityComparer<ICollection<T>> Sequential<T>(
IEqualityComparer<T> comparer) =>
EqualityComparer<ICollection<T>>.Create(
(c1, c2) => {
if (ReferenceEquals(c1, c2)) return true;
if (c1 == null || c2 == null) return false;
// SequentialEquals 은 IEqualityComparer 를 지원
return c1.SequentialEquals(c2, comparer);
},
c => c.GetHashCode());
public IEqualityComparer<ICollection<T>> Content<T>(
IEqualityComparer<T> comparer) =>
EqualityComparer<ICollection<T>>.Create(
(c1, c2) => {
if (ReferenceEquals(c1, c2)) return true;
if (c1 == null || c2 == null) return false;
// Except 도 IEqualityComparer 를 지원
return c1.Count == c2.Count
&& !c1.Except(c2, comparer).Any();
},
c => c.GetHashCode());
}
사용 예시는,
// Entity class
class Person
{
// ...
public List<DateTime> Anniversaries {get;} = [];
}
- 값 자료형 요소
Person? selectedPerson;
void OnAnniverariesSelected(List<DateTime> dates)
{
var snapshot = selectedPerson?.Anniversaries.ToList();
// 기념일 목록은 순서가 중요하지 않다.
if (CollectionComparers.Content().Equals(dates, snapshot))
{
_ = MessageBox.AlertNoChange();
return;
}
UpdateSelected(dates);
}
- 참조 자료형 요소 + 커스텀 비교기
void OnEntriesSelected(List<Person> selecteds)
{
var snapShot = Entries.People.ToList();
// Person 목록도 순서가 중요하지 않고, 요소는 Id 필드만 비교해도 된다.
var comparer = CollectionComparers.Content(PersonComparers.ForId);
if (comparer.Equals(selecteds, snapshot))
{
_ = MessageBox.AlertNoChange();
return;
}
UpdateEntries(selecteds);
}
ValueComparer<T>
이 객체는 EF Core 의 ChangeTracker가 엔티티의 상태의 변화를 확인하는데 사용하는 객체입니다.
이 객체를 별도로 제공하지 않으면 ChangeTracker 는 기본 비교 행태, 다시 말하면 컬렉션을 포함한 참조형은 참조 비교를 합니다.
앞서 Person 객체의 예를 들자면,
// Entity class
class Person
{
// string 으로 변환함.
public List<DateTime> Anniversaries {get;} = [];
}
Anniversaries 는 컬렉션 형식이지만 값 속성으로 네비게이션 속성이 아닙니다.
ChangeTracker가 이 속성을 처리하는 과정을 잠깐 살펴 보자면,
// ChangeTracker 가 p 에 대한 스냅샷(속성들의 값 복사본)을 남김.
var p = context.People.First(x => x.Id = idValue);
// 컬렉션 조작
p.Anniversaries.Add(new (1,1,1));
// ChangeTracker 는 p.Anniversaries에 대해 참조 비교를 수행
// 참조값에 변화가 없어 변경을 인지하지 못 함.
_ = context.SaveChanges();
주석에서 표현한 것과 같은 상태 변경 인지 실패를 막기 위해서는, ValueComparer<T> 를 제공하는 것이 좋습니다.
여기에서 고려해야 할 부분은, 앞 예제에서 살펴 본대로, 기념일 목록은 요소의 순서가 중요하지 않기 때문에 이 비교기는 내용 일치 비교를 수행해야 합니다. 이와 반대로, 순서 일치를 적용한다면, 불필요한 DB 조작이 일어날 수도 있습니다.
내용 일치 비교를 하는 ValueComparer<ICollection<T>>을 제너릭으로 정의하고,
static class CollectionValueComparers
{
public static ValueComparer<ICollection<T>> ForContent<T>() =>
new(
equalsExpression: (c1, c2) => ContentEqual(c1, c2),
hashCodeExpression: c => c.GetHashCode()),
snapshotExpression: c => c.ToList());
static bool ContentEqual<T>(ICollection<T>? a, ICollection<T>? b)
{
if (ReferenceEquals(a, b)) return true;
if (a == null || b == null) return false;
return a.Count == b.Count && !a.Except(b).Any();
}
}
이 속성에 대해 적용합니다.
builder.Property(p => p.Anniversaries)
.Metadata.SetValueComparer(CollectionValueComparers.ForContent<DateTime>());
마치며
객체의 등가 비교는 예상과 달리 훨씬 까다로운 주제입니다.
이 글은 주제에 비해 매우 소소하며, 반드시 함께 다뤄야 하는 아래의 소주제를 언급조차 하지 않고 있습니다.
- 도메인 세분화를 컨텍스트 한정 비교
- 컬렉션 내용 일치 비교 시의 성능 문제 (+
IComparer<T> 의 제공) Hash계열의 컬렉션에 대한 미고려- 가변 객체 비교 시 주의점
이러한 복잡성을 등한시하는 경우, “왜 안되지?” 혹은 “왜 되지?” 등의 의문의 늪에 빠지기 쉽습니다.
더 큰 문제는 아래와 같은 코드를 남발한다는 점입니다.
if (p.Id == id && p.Name == name && // ...
var sameNames = people.Where(p => p.Id == id && p.Name == name && // ...
이 코드는 쓰는 사람은 쉽게 쓰지만, 읽는 사람은 코드를 일일이 읽어야 그 의도를 파악할 수 있는 스파게티 코드입니다.
더군다나, 같은 의도의 코드가 반복적으로 쓰이면서도 그 형태가 일관적이지도 않다면,
// 어떤 곳은
if (p.Id == id && p.Name == name && // ...
// 다른 곳은
if (p.Name == name && p.Id == id && // ...
리펙토링은 머나먼 정글이 됩니다.
record 와 IEqualityComparer<T> 는 이러한 까다로움을 일관적이고, 우아하게 처리하는데 도움을 줍니다.