1. 소개
프로그래밍할 때 우리는 항상 뭔가를 비교하지. 숫자, 문자열, 객체 등등. 근데 C#에서 "객체의 동등성"이란 게 실제로 뭘 의미할까? 이게 생각보다 그렇게 단순하지 않아. 오늘은 객체가 어떻게 비교되는지 결정하는 두 가지 핵심 메서드, Equals() 와 GetHashCode() 를 파헤쳐볼 거야. 이 메서드들을 이해하는 건 네 프로그램이 제대로 동작하게 하려면 진짜 중요해, 특히 Dictionary 나 HashSet 같은 컬렉션 쓸 때!
C#에는 두 가지 주요 동등성 개념이 있어:
참조 동등성 (Reference Equality):이건 두 개의 참조 타입 변수가 메모리에서 같은 객체를 가리키고 있다는 뜻이야. 이건 참조 타입에서 == 연산자로 체크해.
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false (메모리에서 다른 객체)
Console.WriteLine(obj1 == obj3); // true (둘 다 같은 객체를 가리킴)
값 동등성 (Value Equality):
이건 두 개의 다른 객체(혹은 값 타입)가 필드/프로퍼티 값이 똑같다는 뜻이야. 우리가 객체를 비교할 때 보통 원하는 게 이거지. 이럴 때 Equals() 메서드를 써.
// 예를 들어 Point 클래스가 있다고 하자
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1과 p2는 다른 객체지만, 값이 같으니 "같다"고 하고 싶어
Console.WriteLine(p1.Equals(p2)); // ? Equals() 구현에 따라 다름
Console.WriteLine(p1.Equals(p3)); // ?
2. Equals() 메서드
Equals() 메서드는 C#의 모든 타입이 상속받는 System.Object에 정의되어 있어. 이 메서드의 주 목적은 두 객체가 값으로 같은지 판단하는 거야.
Equals()의 기본 동작
값 타입(struct, int, bool 등): 기본 Equals() 구현(System.ValueType에서 상속됨)은 모든 필드를 비트 단위로 비교해. 모든 필드가 같으면 객체도 같다고 봐. 이건 보통 우리가 기대하는 대로 동작해.
참조 타입(class, string, array 등): 기본 Equals() 구현( System.Object에서 상속됨)은 참조 동등성을 체크해. 즉, obj1.Equals(obj2)는 기본적으로 true를 반환하려면 obj1과 obj2가 메모리에서 같은 객체여야 해.class Person // 참조 타입
{
public string Name { get; set; }
public int Age { get; set; }
}
Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // 다른 객체지만 값은 같음
Console.WriteLine(person1.Equals(person2)); // false (기본적으로 참조만 비교함)
보다시피, 참조 타입의 기본 동작은 우리가 원하는 게 아닐 때가 많아! 우리는 "Alice"라는 이름과 30살이 같으면, 메모리에서 다른 객체라도 같다고 하고 싶잖아.
사용자 정의 클래스에서 Equals() 오버라이드하기
값 동등성을 보장하려면 직접 Equals() 메서드를 오버라이드해야 해.
Equals() 오버라이드 규칙(컨트랙트):
- 반사성: x.Equals(x)는 항상 true여야 해.
- 대칭성: x.Equals(y)가 true면, y.Equals(x)도 true여야 해.
- 추이성: x.Equals(y)와 y.Equals(z)가 둘 다 true면, x.Equals(z)도 true여야 해.
- 일관성: x.Equals(y)를 여러 번 호출해도, 객체가 바뀌지 않는 한 결과는 같아야 해.
- Null 호환: x.Equals(null)은 항상 false여야 해.
예시: Person 클래스에서 Equals() 오버라이드
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// Equals() 오버라이드
public override bool Equals(object? obj)
{
// 1. null 체크
if (obj == null) return false;
// 2. 타입 체크
if (obj.GetType() != this.GetType()) return false;
// 3. 타입 캐스팅
Person other = (Person)obj;
// 4. 필드 값 비교
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// 사용 예시:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true (이제 값으로 비교!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. GetHashCode() 메서드
GetHashCode() 메서드도 System.Object에 정의되어 있어. 이 메서드는 객체를 빠르고(가능하면) 유일하게 식별할 수 있는 정수값(해시 코드)을 반환해.
GetHashCode()는 왜 필요할까?
해시 코드는 해시 테이블 기반 컬렉션의 성능 최적화에 쓰여. 대표적으로:
- Dictionary<TKey, TValue> (키를 빠르게 찾으려고 해시 코드 사용)
- HashSet<T> (요소의 유일성 체크에 해시 코드 사용)
- Hashtable
HashSet에 객체를 추가하거나 Dictionary에서 키로 쓸 때, 컬렉션은 먼저 객체의 해시 코드를 계산해. 이걸로 바로 특정 "버킷"이나 같은 해시 코드를 가진 그룹으로 점프할 수 있어. 그 다음, 그 "버킷" 안에서 Equals()로 진짜 같은지 비교하지.
GetHashCode() 오버라이드 규칙(컨트랙트):
Equals()를 오버라이드하면, GetHashCode()도 꼭 오버라이드해야 해! 이건 C#에서 진짜 중요한 규칙이야.
- 일관성: Equals()가 두 객체에 대해 true를 반환하면, GetHashCode()도 같은 값을 반환해야 해. (반대는 아님. 다른 객체가 같은 해시 코드를 가질 수도 있는데, 이걸 "충돌"이라고 해.)
- 불변성: GetHashCode()는 비교에 쓰인 필드가 바뀌지 않는 한, 같은 객체에 대해 항상 같은 값을 반환해야 해.
- 속도: GetHashCode()는 빠르게 동작해야 하고, 무거운 연산을 하면 안 돼.
왜 이렇게 중요할까? Equals()만 오버라이드하고 GetHashCode()는 안 하면, 컬렉션(특히 해시 컬렉션)이 제대로 동작하지 않아:
- Dictionary가 네 키를 못 찾을 수 있어.
- HashSet이 중복을 허용해서, 같은 값인데도 여러 번 추가될 수 있어.
이유는, 기본 GetHashCode()는 객체 참조 기반 해시를 반환하기 때문이야(참조 타입의 경우). Equals()가 값 비교로 바뀌었는데, 해시 코드는 여전히 참조 기반이면, 값이 같아도 해시가 달라서 컬렉션이 "다른 객체"로 인식해버려.
Person 클래스에서 GetHashCode() 오버라이드
좋은 방법은 Equals()에서 비교하는 필드로 해시 코드를 만드는 거야. .NET에는 HashCode.Combine()이라는 편리한 정적 메서드가 있어.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != this.GetType()) return false;
Person other = (Person)obj;
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
// GetHashCode() 오버라이드
public override int GetHashCode()
{
// 필드 해시를 합칠 때 HashCode.Combine 사용
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// 컬렉션에서 사용 예시:
public class Program
{
public static void Main(string[] args)
{
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = new Person("Bob", 25);
HashSet
uniquePeople = new HashSet
(); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2는 p1과 값이 같으니 추가 안 됨 Console.WriteLine($"고유한 사람 수: {uniquePeople.Count}"); // 출력: 1 uniquePeople.Add(p3); Console.WriteLine($"고유한 사람 수: {uniquePeople.Count}"); // 출력: 2 } }
중요한 점: GetHashCode()에서 대소문자 구분 없이 비교하는 문자열 필드(StringComparison.OrdinalIgnoreCase를 Equals에서 쓴 경우)는 해시 계산할 때도 대소문자 구분 없이(예: Name.ToLowerInvariant()처럼 소문자로 변환해서) 해야 해. 안 그러면 Equals()는 true를 반환하는데(Alice == alice), GetHashCode()는 다른 값을 반환해서 컨트랙트가 깨져.
4. == 와 != 연산자 오버로딩
클래스(참조 타입)에서 == 연산자는 기본적으로 참조 동등성만 체크해. 이걸 값 동등성(Equals()처럼)으로 바꾸고 싶으면 오버로딩할 수 있어.
== 오버로딩 규칙:
- ==을 오버로딩하면, !=도 꼭 같이 오버로딩해야 해.
- 오버로딩한 ==은 Equals()와 같은 동작을 하도록 만드는 게 좋아.
- ==을 오버로딩할 때는 GetHashCode()와 Equals()도 꼭 오버라이드해야 해.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// 생성자, Equals, GetHashCode는 위와 동일
// == 연산자 오버로딩
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // left가 null인지 체크
{
return ReferenceEquals(right, null); // 둘 다 null이면 같음
}
return left.Equals(right); // 아니면 오버라이드한 Equals() 사용
}
// != 연산자 오버로딩(== 오버로딩하면 필수)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// 사용 예시:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true (이제 오버로딩된 == 사용)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record — 자동 값 동등성
C# 9부터 record 타입이 생겼어. 이건 참조 타입이지만, 자동으로 값 동등성을 구현해줘(Equals(), GetHashCode(), ToString(), ==/!= 연산자까지 모든 필드/프로퍼티 기준으로 오버라이드해줌). 그래서 record는 불변 데이터 객체에 딱이야.
public record PersonRecord(string Name, int Age);
// 사용 예시:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true (자동으로 값 비교!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record를 쓰면 참조 타입에서도 값 타입처럼 동작하게 쉽게 만들 수 있어.
6. 팁 & 권장사항
Equals()를 오버라이드하면, GetHashCode()도 꼭 같이 오버라이드해! 이 규칙을 안 지키면 해시 컬렉션에서 예측 불가한 문제가 생겨.
Equals()와 GetHashCode()는 같은 필드를 써야 해. "값이 같다"고 판단하는 필드로 해시도 만들어야 해.
변경 가능한(mutable) 타입은 조심해. Equals()와 GetHashCode()에 쓰인 필드가 객체 생성 후에 바뀔 수 있으면, 해시 코드도 바뀌게 돼. 이러면 해시 컬렉션에서 객체를 "잃어버릴" 수 있어(해시가 바뀌면 컬렉션이 그 객체를 못 찾음). 해시 컬렉션에서는 불변(immutable) 타입을 키나 요소로 쓰는 게 좋아.
불변 데이터 객체에는 record를 써봐. 값 동등성 구현이 훨씬 쉬워져.
==와 != 연산자는 참조 타입에서만, 필요할 때만 오버로딩해. 값 타입은 ==이 이미 값 비교를 해주니까. 오버로딩할 땐 Equals()와 동작이 일치하는지 꼭 확인해.
GO TO FULL VERSION