EveryDay.DevUp

[PART2.클래스와 객체(5/7)] object 클래스 — 모든 타입의 조상 본문

C# 심화

[PART2.클래스와 객체(5/7)] object 클래스 — 모든 타입의 조상

EveryDay.DevUp 2026. 4. 5. 13:32

object 클래스 — 모든 타입의 조상

ToString / Equals / GetHashCode를 함께 재정의해야 하는 이유

문제 제기

커스텀 구조체를 만들어 Dictionary의 키로 사용했다. 같은 값을 가진 키로 검색하면 당연히 찾을 것이라 예상했다.

C#
var dict = new Dictionary<EnemyId, int>();
dict[new EnemyId("슬라임", 1)] = 100;

// 같은 값으로 검색
bool found = dict.ContainsKey(new EnemyId("슬라임", 1));
// found = ???

Equals만 재정의하고 GetHashCode를 재정의하지 않으면, 논리적으로 같은 키임에도 Dictionary에서 영원히 찾지 못하는 버그가 발생한다. 에러도 없고 예외도 없다 — 그냥 false가 반환될 뿐이다.

이 문제를 이해하려면 C#의 모든 타입이 상속하는 object 클래스와, 그 핵심 메서드들이 어떻게 맞물려 동작하는지 알아야 한다. 특히 Unity에서 커스텀 struct를 Dictionary 키로 쓰거나, 매 프레임 객체를 비교하는 코드가 있다면 성능과 정확성 모두에 직결되는 주제다.


개념 정의

object — 모든 타입의 공통 조상

C#에서 정의하는 모든 타입은 명시적으로 상속을 지정하지 않아도 System.Object(object의 정식 이름)를 자동으로 상속한다. 마치 모든 사람이 태어나면 자동으로 국적을 갖는 것처럼, 모든 타입은 태어나는 순간 object의 멤버를 물려받는다.

System.Object — 모든 타입의 뿌리
virtual — 가상 메서드 (Virtual Method) 파생 클래스에서 override로 재정의할 수 있는 메서드다. 호출 시 컴파일 타임 타입이 아니라 런타임 실제 타입의 구현이 실행된다.
예시: object obj = new Enemy(); obj.ToString(); Enemy가 ToString을 재정의했다면 Enemy.ToString()이 실행된다

object가 제공하는 핵심 메서드는 6가지다.

메서드 특성 기본 동작
ToString() virtual 타입의 전체 이름 반환 ("Enemy")
Equals(object) virtual 참조 비교 (같은 인스턴스인지)
GetHashCode() virtual 참조 기반 해시 값
ReferenceEquals(a, b) static 두 참조가 같은 객체를 가리키는지
GetType() non-virtual 런타임 타입 정보
MemberwiseClone() protected 얕은 복사

이 중 ToString, Equals, GetHashCode 세 메서드는 virtual이므로 재정의할 수 있고, 실전에서 가장 자주 재정의하는 대상이다.

C#
using System;

class Enemy
{
    public string Name;
    public int Hp;
    public Enemy(string name, int hp) { Name = name; Hp = hp; }
}

class EnemyWithOverride
{
    public string Name;
    public int Hp;
    public EnemyWithOverride(string name, int hp) { Name = name; Hp = hp; }

    public override string ToString() => $"{Name}(HP:{Hp})";

    public override bool Equals(object? obj)
    {
        return obj is EnemyWithOverride other && Name == other.Name && Hp == other.Hp;
    }

    public override int GetHashCode() => HashCode.Combine(Name, Hp);
}

class Program
{
    static void Main()
    {
        var e1 = new Enemy("슬라임", 10);
        var e2 = new Enemy("슬라임", 10);

        // 기본 ToString — 타입 이름 반환
        Console.WriteLine(e1.ToString());

        // 기본 Equals — 참조 비교
        Console.WriteLine(e1.Equals(e2));      // false (다른 인스턴스)

        // ReferenceEquals — 항상 참조 비교
        Console.WriteLine(ReferenceEquals(e1, e2));  // false

        // GetType — 런타임 타입
        Console.WriteLine(e1.GetType().Name);  // Enemy

        // 재정의 버전
        var o1 = new EnemyWithOverride("슬라임", 10);
        var o2 = new EnemyWithOverride("슬라임", 10);
        Console.WriteLine(o1.ToString());       // 슬라임(HP:10)
        Console.WriteLine(o1.Equals(o2));       // true (값 비교)
        Console.WriteLine(o1.GetHashCode() == o2.GetHashCode());  // true
    }
}
IL
// Main 메서드 — 기본 동작 vs 재정의 동작
.method private hidebysig static void Main () cil managed
{
    // e1.ToString() — 기본 구현 (타입 이름 반환)
    IL_001b: ldloc.0
    IL_001c: callvirt instance string [System.Runtime]System.Object::ToString()  // 가상 디스패치
    IL_0021: call void [System.Console]System.Console::WriteLine(string)

    // e1.Equals(e2) — 기본 구현 (참조 비교)
    IL_0027: ldloc.0
    IL_0028: ldloc.1
    IL_0029: callvirt instance bool [System.Runtime]System.Object::Equals(object)  // 가상 디스패치
    IL_002e: call void [System.Console]System.Console::WriteLine(bool)

    // ReferenceEquals(e1, e2) — 포인터 비교
    IL_0034: ldloc.0
    IL_0035: ldloc.1
    IL_0036: ceq              // ← 단순 참조 주소 비교 (가장 빠름)

    // o1.ToString() — 재정의된 구현
    IL_0069: ldloc.2
    IL_006a: callvirt instance string [System.Runtime]System.Object::ToString()  // 같은 IL이지만
    // → 런타임에 EnemyWithOverride.ToString()이 호출됨 (가상 디스패치)

    // o1.Equals(o2) — 재정의된 구현
    IL_0075: ldloc.2
    IL_0076: ldloc.3
    IL_0077: callvirt instance bool [System.Runtime]System.Object::Equals(object)
    // → 런타임에 EnemyWithOverride.Equals()가 호출됨 (값 비교)
}
callvirt — 가상 메서드 호출 (Call Virtual) IL에서 가상 디스패치를 수행하는 명령어다. 런타임에 객체의 실제 타입을 확인하고 해당 타입의 메서드를 호출한다. call과 달리 null 체크도 포함한다.

IL에서 callvirt System.Object::Equals로 동일한 명령어를 사용하지만, 런타임에 실제 타입(Enemy vs EnemyWithOverride)의 구현이 호출된다. 이것이 virtual 메서드의 핵심이다.

ReferenceEqualsceq 한 줄로 컴파일된다 — 두 참조의 메모리 주소를 직접 비교하는 가장 빠른 연산이다. Equals==가 재정의되어 있어도, "정말 같은 인스턴스인지"를 확인할 때는 ReferenceEquals를 사용한다.


내부 동작

Dictionary와 해시 테이블의 버킷 메커니즘

EqualsGetHashCode를 함께 재정의해야 하는 이유를 이해하려면, Dictionary<TKey, TValue>의 내부 동작을 알아야 한다.

Dictionary 검색: GetHashCode → 버킷 → Equals

Dictionary가 키를 검색하는 과정은 두 단계다.

  1. GetHashCode()로 버킷 번호를 계산한다hashCode % buckets.Length로 키가 들어있을 버킷을 찾는다.
  2. 버킷 내에서 Equals()로 정확한 키를 찾는다 — 해시 충돌이 있을 수 있으므로, 버킷 안의 항목을 하나씩 Equals로 비교한다.

핵심은 해시 코드가 다르면 2단계로 진입조차 하지 않는다는 것이다. GetHashCode가 버킷을 좁히는 필터 역할을 하고, 그 안에서만 Equals가 호출된다.

값 타입의 Equals — 리플렉션과 boxing

boxing (박싱) 값 타입을 object 타입으로 변환할 때, 힙(heap)에 새 객체를 할당하고 값을 복사하는 과정이다. GC(Garbage Collector, 사용하지 않는 메모리를 자동 회수하는 런타임 구성요소) 압박의 원인이 된다.

struct에서 Equals를 재정의하지 않으면 System.ValueType.Equals가 호출된다. 이 기본 구현에는 두 가지 문제가 있다.

  1. 리플렉션(Reflection, 런타임에 타입 정보를 조회하는 기능) 기반 비교 — 참조 타입 필드가 있으면 모든 필드를 리플렉션으로 열거하여 비교한다. 매우 느리다.
  2. boxing 발생Equals(object obj) 시그니처가 object를 받으므로, 비교 대상이 힙에 박싱된다.
C#
using System;

// ❌ Before — Equals 재정의 없음, boxing 발생
struct PointBefore
{
    public int X;
    public int Y;
    public PointBefore(int x, int y) { X = x; Y = y; }
}

// ✅ After — IEquatable<T> 구현으로 boxing 제거
struct PointAfter : IEquatable<PointAfter>
{
    public int X;
    public int Y;
    public PointAfter(int x, int y) { X = x; Y = y; }

    public bool Equals(PointAfter other)
    {
        return X == other.X && Y == other.Y;
    }

    public override bool Equals(object? obj)
    {
        return obj is PointAfter other && Equals(other);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(X, Y);
    }
}

class Program
{
    static void Main()
    {
        // ❌ Before — ValueType.Equals(object)가 호출됨
        var a1 = new PointBefore(1, 2);
        var a2 = new PointBefore(1, 2);
        bool eq1 = a1.Equals(a2);

        // ✅ After — IEquatable<T>.Equals(T)가 호출됨
        var b1 = new PointAfter(1, 2);
        var b2 = new PointAfter(1, 2);
        bool eq2 = b1.Equals(b2);

        Console.WriteLine($"{eq1}, {eq2}");
    }
}
IL
.method private hidebysig static void Main () cil managed
{
    // ❌ Before — a1.Equals(a2): boxing 발생!
    IL_0013: ldloca.s 0          // a1의 주소
    IL_0015: ldloc.1             // a2의 값
    IL_0016: box PointBefore     // ← a2를 힙에 박싱! (힙 할당 발생)
    IL_001b: constrained. PointBefore
    IL_0021: callvirt instance bool [System.Runtime]System.Object::Equals(object)
    //       → ValueType.Equals(object)가 호출됨 — 리플렉션으로 필드 비교

    // ✅ After — b1.Equals(b2): boxing 없음!
    IL_0039: ldloca.s 3          // b1의 주소
    IL_003b: ldloc.s 4           // b2의 값 (그대로 전달)
    IL_003d: call instance bool PointAfter::Equals(valuetype PointAfter)
    //       → IEquatable<T>.Equals(T)가 호출됨 — 직접 필드 비교, 힙 할당 없음
}
IEquatable<T> — 타입별 동등성 인터페이스 bool Equals(T other) 메서드를 정의하여, object 대신 구체적인 타입 T를 직접 받는다. 값 타입에서 이를 구현하면 boxing 없이 비교할 수 있다.
예시: struct Point : IEquatable<Point> Equals(Point other)가 호출되어 박싱 없이 값 비교

Before에서 box PointBefore 명령어가 a2를 힙에 복사한다. 매번 Equals를 호출할 때마다 힙 할당이 발생하는 것이다. After에서는 call PointAfter::Equals(valuetype PointAfter)로 값 타입 그대로 전달된다 — box 명령어가 없다.

Dictionary<TKey, TValue>의 내부 비교기(EqualityComparer<T>.Default)는 TIEquatable<T>를 구현하는지 검사하여, 구현되어 있으면 Equals(T)를 호출하고 그렇지 않으면 Equals(object)를 통해 박싱을 유발한다.

🎮 Unity 실전: Update()에서 매 프레임 struct를 비교하거나, Dictionary<커스텀Struct, T>를 사용하는 경우 IEquatable<T> 미구현은 매 프레임 GC 압박을 만든다. Unity의 Boehm GC는 세대별 수집이 없어서, 이런 소량의 반복 할당이 GC 스파이크(순간적인 프레임 드랍)를 유발할 수 있다.


실전 적용

struct의 올바른 동등성 구현 패턴

고성능 코드에서 struct의 동등성을 올바르게 구현하는 전체 패턴이다.

readonly struct — 읽기 전용 구조체 모든 필드가 불변임을 컴파일러 수준에서 강제한다. 필드를 변경하려는 코드가 있으면 컴파일 에러가 발생한다. Dictionary 키로 사용할 때 해시 코드가 변하지 않음을 보장한다.
예시: readonly struct TileId { public readonly int X; } 생성 후 X를 변경할 수 없음
== / != — 동등 연산자 struct는 ==!=를 기본 제공하지 않는다. 사용하려면 반드시 직접 오버로드해야 한다. Equals와 일관되게 동작하도록 Equals(T)에 위임하는 것이 권장된다.
C#
using System;
using System.Collections.Generic;

readonly struct TileId : IEquatable<TileId>
{
    public readonly int X;
    public readonly int Y;

    public TileId(int x, int y) { X = x; Y = y; }

    // 1. IEquatable<T>.Equals — boxing 없는 값 비교 (핵심)
    public bool Equals(TileId other) => X == other.X && Y == other.Y;

    // 2. object.Equals 재정의 — Equals(T)에 위임
    public override bool Equals(object? obj) => obj is TileId other && Equals(other);

    // 3. GetHashCode 재정의 — Equals와 반드시 일관되어야 함
    public override int GetHashCode() => HashCode.Combine(X, Y);

    // 4. == / != 연산자 — Equals(T)에 위임
    public static bool operator ==(TileId left, TileId right) => left.Equals(right);
    public static bool operator !=(TileId left, TileId right) => !left.Equals(right);

    // 5. ToString 재정의 — 디버깅 편의
    public override string ToString() => $"({X}, {Y})";
}

class Program
{
    static void Main()
    {
        var dict = new Dictionary<TileId, string>();
        var key1 = new TileId(3, 5);
        dict[key1] = "풀밭";

        // 같은 값의 새 인스턴스로 검색
        var key2 = new TileId(3, 5);
        Console.WriteLine(dict.ContainsKey(key2));  // true ← GetHashCode 일치 + Equals true
        Console.WriteLine(key1 == key2);             // true ← 연산자 오버로드
        Console.WriteLine(key1.ToString());          // (3, 5)
    }
}
IL
// Dictionary.ContainsKey 호출 — IEquatable<TileId>가 있어 boxing 없음
IL_0026: ldloc.0
IL_0027: ldloc.2
IL_0028: callvirt instance bool class Dictionary`2<valuetype TileId, string>::ContainsKey(!0)
// Dictionary 내부에서 EqualityComparer<TileId>.Default가
// GenericEqualityComparer<TileId>를 사용 → TileId.Equals(TileId) 직접 호출

이 패턴에서 5가지를 모두 구현하는 이유는 일관성 때문이다. Equals는 true인데 ==는 false인 상황이 발생하면, HashSet, LINQ의 Distinct(), GroupBy() 등 내부적으로 Equals를 쓰는 API와 개발자가 직접 쓰는 ==의 결과가 달라진다.

HashCode.Combine — 해시 코드 조합 유틸리티 여러 필드의 해시 값을 균등하게 분포된 단일 해시로 결합한다. C# 8 / .NET Core 2.1에서 도입되었다.
예시: HashCode.Combine(X, Y) X와 Y의 해시를 조합하여 하나의 int 반환

클래스의 값 동등성이 필요한 경우

게임에서 동일 이름·동일 레벨의 아이템을 같은 것으로 취급하고 싶다면, 클래스에서도 EqualsGetHashCode를 함께 재정의한다.

C#
using System;

class ItemData
{
    public string Id { get; }
    public int Level { get; }

    public ItemData(string id, int level) { Id = id; Level = level; }

    public override bool Equals(object? obj)
    {
        return obj is ItemData other && Id == other.Id && Level == other.Level;
    }

    public override int GetHashCode() => HashCode.Combine(Id, Level);

    public override string ToString() => $"{Id}(Lv.{Level})";
}

class Program
{
    static void Main()
    {
        var item1 = new ItemData("sword", 5);
        var item2 = new ItemData("sword", 5);

        Console.WriteLine(item1.Equals(item2));     // true (값 비교)
        Console.WriteLine(item1 == item2);           // false! (== 는 여전히 참조 비교)
        Console.WriteLine(ReferenceEquals(item1, item2));  // false
    }
}

클래스에서 Equals를 재정의해도 ==는 여전히 참조 비교다. 클래스에서 ==까지 값 비교로 만들려면 ==/!= 연산자도 오버로드해야 한다. 하지만 이렇게 하면 ReferenceEquals와의 혼란이 생기므로, 클래스에서 값 동등성이 필요하다면 record를 사용하는 것이 C# 9 이후 권장 패턴이다.


함정과 주의사항

함정 1: Equals만 재정의하고 GetHashCode는 빠뜨리기

C#
using System;
using System.Collections.Generic;

// ❌ GetHashCode 재정의 누락
class EnemyIdBad
{
    public string Name;
    public int Id;
    public EnemyIdBad(string name, int id) { Name = name; Id = id; }

    public override bool Equals(object? obj)
    {
        return obj is EnemyIdBad other && Name == other.Name && Id == other.Id;
    }
    // GetHashCode 재정의 안 함! → 기본 구현 = 참조 기반 해시
}

// ✅ GetHashCode도 함께 재정의
class EnemyIdGood
{
    public string Name;
    public int Id;
    public EnemyIdGood(string name, int id) { Name = name; Id = id; }

    public override bool Equals(object? obj)
    {
        return obj is EnemyIdGood other && Name == other.Name && Id == other.Id;
    }

    public override int GetHashCode() => HashCode.Combine(Name, Id);
}

class Program
{
    static void Main()
    {
        // ❌ Bad — Dictionary에서 검색 실패
        var badDict = new Dictionary<EnemyIdBad, int>();
        badDict[new EnemyIdBad("슬라임", 1)] = 100;
        bool found1 = badDict.ContainsKey(new EnemyIdBad("슬라임", 1));
        Console.WriteLine($"Bad: {found1}");   // false! Equals는 true지만 해시가 다름

        // ✅ Good — 정상 검색
        var goodDict = new Dictionary<EnemyIdGood, int>();
        goodDict[new EnemyIdGood("슬라임", 1)] = 100;
        bool found2 = goodDict.ContainsKey(new EnemyIdGood("슬라임", 1));
        Console.WriteLine($"Good: {found2}");  // true
    }
}

EnemyIdBadEqualstrue를 반환하지만, GetHashCode가 기본 구현(참조 기반)이므로 두 인스턴스의 해시 코드가 다르다. Dictionary는 해시 코드로 버킷을 찾으므로, 엉뚱한 버킷을 뒤지다 false를 반환한다. 컴파일러는 이 상황에서 CS0659 경고를 발생시키지만, 경고를 무시하면 런타임에 소리 없이 버그가 발생한다.

함정 2: 가변 객체를 Dictionary 키로 사용

C#
using System;
using System.Collections.Generic;

class MutableKey
{
    public int Value;
    public MutableKey(int v) { Value = v; }

    public override bool Equals(object? obj) => obj is MutableKey other && Value == other.Value;
    public override int GetHashCode() => Value.GetHashCode();
}

class Program
{
    static void Main()
    {
        var key = new MutableKey(1);
        var dict = new Dictionary<MutableKey, string>();
        dict[key] = "원본";

        // 키 객체의 상태를 변경!
        key.Value = 999;

        // 같은 참조로 검색해도 못 찾음
        bool found = dict.ContainsKey(key);
        Console.WriteLine(found);  // false! 해시 코드가 바뀌어서 다른 버킷을 탐색
    }
}

GetHashCode가 가변 필드 Value를 기반으로 하므로, Value를 바꾸면 해시 코드가 달라진다. Dictionary에 넣을 때의 해시 코드로 버킷이 결정되었으므로, 변경 후에는 엉뚱한 버킷을 뒤지게 된다. Dictionary 키로 사용하는 객체의 동등성 결정 필드는 불변이어야 한다. struct의 경우 readonly struct로 선언하면 이 문제를 원천 차단할 수 있다.

함정 3: Unity의 == null과 Destroyed 객체

🎮 Unity의 UnityEngine.Object== 연산자를 오버로드하여, Destroy된 오브젝트를 null과 동등하게 취급한다. 이 때문에 C#의 일반적인 null 체크 규칙과 다르게 동작한다.

C#
using UnityEngine;

public class ObjectNullTrap : MonoBehaviour
{
    void Example()
    {
        var go = new GameObject("Target");
        Destroy(go);

        // Unity의 == 오버로드 — Destroyed 감지
        Debug.Log(go == null);             // true (Destroyed 상태)

        // C#의 is null — 실제 C# null만 체크, 오버로드 무시
        Debug.Log(go is null);             // false! (C# 객체는 아직 존재)

        // ReferenceEquals — 실제 C# null만 체크
        Debug.Log(ReferenceEquals(go, null));  // false!

        // ⚠️ Unity 오브젝트 소멸 확인은 반드시 == null 사용
    }
}

Unity 오브젝트를 Dictionary 키로 사용할 때도 주의가 필요하다. Destroy 후에도 Dictionary에 "좀비 키"가 남아 있을 수 있다. 이를 피하려면 GetInstanceID()가 반환하는 int를 키로 사용하는 것이 안전하다.


C# 버전별 변화

C# 9 (2020) — record 도입

C# 9 이전에는 값 동등성을 원하는 클래스마다 Equals, GetHashCode, ==, !=, IEquatable<T> 구현을 모두 수동으로 작성해야 했다. record는 이 보일러플레이트를 컴파일러가 자동 생성한다.

C#
using System;

// ❌ C# 8 이전 — 모든 것을 수동 구현
class EnemyDataOld
{
    public string Name { get; }
    public int Hp { get; }
    public EnemyDataOld(string name, int hp) { Name = name; Hp = hp; }

    public override bool Equals(object? obj)
        => obj is EnemyDataOld o && Name == o.Name && Hp == o.Hp;
    public override int GetHashCode() => HashCode.Combine(Name, Hp);
    // + ==, !=, IEquatable<T>, ToString 까지...
}

// ✅ C# 9 — record 한 줄로 끝
record EnemyData(string Name, int Hp);

class Program
{
    static void Main()
    {
        var r1 = new EnemyData("슬라임", 10);
        var r2 = new EnemyData("슬라임", 10);

        Console.WriteLine(r1.Equals(r2));   // true (자동 값 비교)
        Console.WriteLine(r1 == r2);         // true (자동 == 생성)
        Console.WriteLine(r1.GetHashCode() == r2.GetHashCode());  // true
        Console.WriteLine(r1);               // EnemyData { Name = 슬라임, Hp = 10 }
    }
}
IL
// record가 자동 구현하는 것들 (컴파일러가 생성한 IL)
.class private auto ansi beforefieldinit EnemyRecord
    extends [System.Runtime]System.Object
    implements class [System.Runtime]System.IEquatable`1<class EnemyRecord>  // ← 자동 IEquatable
{
    .field private initonly string '<Name>k__BackingField'  // ← init-only (불변)
    .field private initonly int32 '<Hp>k__BackingField'

    // 컴파일러가 자동 생성한 메서드들:
    // - Equals(EnemyRecord) — IEquatable<T> 구현
    // - Equals(object) — object.Equals 재정의
    // - GetHashCode() — 속성 기반 해시
    // - op_Equality / op_Inequality — ==, != 연산자
    // - ToString() — "타입명 { 속성 = 값, ... }" 형식
    // - PrintMembers() — ToString 내부 도우미
    // - get_EqualityContract() — 타입 동일성 검사용
}

recordIEquatable<T>를 자동 구현하고, initonly 필드로 불변성을 보장하며, 모든 동등성 관련 코드를 생성한다. 불변 데이터 타입에 값 동등성이 필요하면 record가 정답이다.

C# 10 (2021) — record struct

C# 10에서 record struct가 추가되어, 값 타입에서도 record의 편의성을 얻을 수 있게 되었다.

C#
// ✅ 값 타입 + 자동 동등성 + 불변 — 가장 이상적인 Dictionary 키
readonly record struct TileCoord(int X, int Y);
선택지 힙 할당 자동 동등성 불변 Dictionary 키에 안전
class + 수동 Equals ✅ 발생 ❌ 수동 ❌ 보장 안 됨 ⚠️ 주의 필요
struct + IEquatable ❌ 없음 ❌ 수동 ❌ readonly면 가능 ✅ readonly면 안전
record class ✅ 발생 ✅ 자동 ✅ init-only ✅ 안전
readonly record struct ❌ 없음 ✅ 자동 ✅ readonly ✅ 가장 안전

정리

  • Equals를 재정의하면 GetHashCode도 반드시 재정의한다. Dictionary/HashSet은 GetHashCode로 버킷을 먼저 찾고 Equals로 확인한다. 해시가 다르면 Equals에 도달조차 하지 않는다.
  • struct에서는 IEquatable<T>를 반드시 구현한다. 기본 ValueType.Equalsbox 명령어로 힙 할당을 유발한다. IEquatable<T>는 boxing 없이 직접 비교한다.
  • Dictionary 키로 쓰는 객체의 해시 결정 필드는 불변이어야 한다. 키를 넣은 후 상태를 변경하면 해시 코드가 달라져 검색이 실패한다. readonly struct로 불변을 강제한다.
  • == 연산자와 Equals는 일관되게 동작해야 한다. struct는 ==가 기본 제공되지 않으므로 직접 오버로드한다. 클래스에서 값 동등성이 필요하면 record를 사용한다.
  • Unity 오브젝트의 ==는 Destroyed 감지가 포함된다. is null, ReferenceEquals, ??는 Unity 오버로드를 우회하므로, Unity 오브젝트의 소멸 확인은 반드시 == null을 사용한다.
  • C# 9의 record는 동등성 보일러플레이트를 완전히 자동화한다. 불변 데이터 타입에는 record(클래스) 또는 readonly record struct(값 타입)를 사용한다.