EveryDay.DevUp

[PART5.구조체와 레코드(4/4)] == vs Equals vs ReferenceEquals — 같아 보이는 세 비교, 완전히 다른 동작 본문

C# 심화

[PART5.구조체와 레코드(4/4)] == vs Equals vs ReferenceEquals — 같아 보이는 세 비교, 완전히 다른 동작

EveryDay.DevUp 2026. 4. 5. 20:02

== vs Equals vs ReferenceEquals — 같아 보이는 세 비교, 완전히 다른 동작

"두 오브젝트가 같은지" 비교하는 코드를 쓸 때, ==Equals를 아무렇게나 골라 써도 될까? Unity에서 Destroy(obj)obj == nulltrue인데 ReferenceEquals(obj, null)false를 반환하는 이유는 뭘까? 이 글은 C# 비교 연산의 세 축 — ==, Equals, ReferenceEquals — 이 컴파일러와 런타임에서 어떻게 다르게 처리되는지 IL 수준까지 파고들어 설명한다.


== 연산자

문제 제기

Unity 프로젝트에서 아이템 비교 로직을 만들었다. Person 클래스의 두 인스턴스를 ==로 비교했는데, 이름이 같아도 항상 false가 나온다. 반면 string 두 개는 ==로 비교하면 내용이 같을 때 true가 나온다. 같은 == 연산자인데 왜 결과가 다를까?

개념 정의

일상에서 "같다"는 두 가지 의미가 있다. 첫째, 같은 물건이다 — 내 책상 위의 리모컨과 네가 들고 있는 리모컨이 물리적으로 동일한 하나의 리모컨인가? 둘째, 같은 종류/값이다 — 둘 다 같은 모델, 같은 버튼 배열의 리모컨인가?

== 연산자는 컴파일 타임의 정적 타입에 따라 이 두 가지 중 어느 것을 수행할지가 결정된다.

타입 분류 == 기본 동작 비유
참조 타입 (class) 참조 비교 — 같은 인스턴스인가? 같은 리모컨인가?
값 타입 (struct, int) 값 비교 — 내용이 같은가? 같은 모델인가?
string (참조 타입이지만 예외) 값 비교 — 문자열 내용이 같은가? 같은 글자가 적혀 있는가?
== — 동등 연산자 (Equality operator) 두 피연산자의 동등성을 비교하는 정적 연산자. 피연산자의 컴파일 타임 타입에 따라 참조 비교 또는 값 비교를 수행하며, operator ==를 오버로딩하면 동작을 재정의할 수 있다.
예시: if (a == b) — a와 b의 타입에 따라 참조 또는 값을 비교
== 연산자의 동작 — 컴파일 타임 타입이 결정한다

핵심은 이것이다. ==변수의 선언 타입(컴파일 타임 타입)을 보고 비교 방법을 결정한다. string 변수끼리 ==하면 문자열 내용을 비교하지만, 같은 문자열이라도 object 변수에 담으면 메모리 주소를 비교한다. 이것이 ==의 가장 중요한 특성이다.

아래 코드로 확인해 보자.

C#
public class Person
{
    public string Name { get; set; }
}

public class Example1
{
    // 참조 타입 == (참조 비교)
    public static bool ComparePersons()
    {
        Person p1 = new Person { Name = "Alice" };
        Person p2 = new Person { Name = "Alice" };
        return p1 == p2; // false — 서로 다른 인스턴스
    }

    // string == (값 비교 오버로딩)
    public static bool CompareStrings()
    {
        string s1 = "hello";
        string s2 = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
        return s1 == s2; // true — 내용이 같음
    }

    // object로 업캐스팅 후 == (참조 비교로 전환)
    public static bool CompareAsObject()
    {
        string s1 = "hello";
        string s2 = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
        object o1 = s1;
        object o2 = s2;
        return o1 == o2; // false — object 타입이므로 참조 비교
    }
}
IL
// ComparePersons — 참조 타입 기본 ==
IL_0025: ldloc.0          // p1을 스택에 로드
IL_0026: ldloc.1          // p2를 스택에 로드
IL_0027: ceq              // 두 참조의 메모리 주소를 직접 비교 → false

// CompareStrings — string의 오버로딩된 ==
IL_001e: ldloc.0          // s1을 스택에 로드
IL_001f: ldloc.1          // s2를 스택에 로드
IL_0020: call bool [System.Runtime]System.String::op_Equality(string, string)
                          // string.op_Equality 호출 → 문자열 내용 비교 → true

// CompareAsObject — object 타입으로 업캐스팅 후 ==
IL_0022: ldloc.2          // o1 (object 타입)을 스택에 로드
IL_0023: ldloc.3          // o2 (object 타입)을 스택에 로드
IL_0024: ceq              // ceq — string인데도 참조 비교! → false

IL 분석 포인트:

  1. ceq vs call op_Equality: Person 비교와 object 비교에서는 ceq 명령어가 사용된다. ceq는 스택의 두 값을 단순히 비트 비교하는 명령어다. 참조 타입이면 메모리 주소를, 값 타입이면 값 자체를 비교한다.
  2. stringop_Equality: string 변수끼리 ==하면 컴파일러가 ceq 대신 String::op_Equality를 호출한다. 이 메서드 내부에서 문자열 내용을 비교하므로 true가 반환된다.
  3. object 업캐스팅의 함정: 같은 string 값이라도 object 변수에 담으면, 컴파일러는 object 타입의 ==를 적용한다. object==를 오버로딩하지 않았으므로 ceq(참조 비교)가 사용된다. Unity의 Update() 루프에서 컬렉션의 요소를 object로 받아 == 비교하면, 의도와 다르게 항상 false가 나올 수 있다.

내부 동작

== 연산자는 정적 디스패치(static dispatch)를 따른다. 런타임에 실제 타입이 뭔지는 상관없다. 컴파일 시점에 변수의 선언 타입을 보고, 해당 타입에 operator ==가 정의되어 있으면 그 메서드를 호출하고, 없으면 ceq로 참조 비교를 수행한다.

이 동작은 제네릭에서도 동일하게 적용된다.

where T : class — 제네릭 타입 제약 조건 제네릭 타입 매개변수 T를 참조 타입으로 제한하는 제약 조건. 이 제약이 없으면 T 타입의 변수에 ==를 사용할 수 없다.
C#
public class GenericComparer
{
    // 제약 없는 T — == 사용 불가
    // public static bool Compare<T>(T a, T b) => a == b; // 컴파일 에러!

    // where T : class 제약 — == 사용 가능 (참조 비교)
    public static bool CompareRef<T>(T a, T b) where T : class
    {
        return a == b; // 항상 참조 비교 — T가 string이어도 ceq
    }

    // 안전한 방법 — EqualityComparer<T>.Default.Equals
    public static bool CompareSafe<T>(T a, T b)
    {
        return EqualityComparer<T>.Default.Equals(a, b);
    }
}

where T : class 제약이 있어도 ==ceq(참조 비교)로 컴파일된다. T가 런타임에 string이어도 string.op_Equality는 호출되지 않는다. 제네릭 코드에서 값 비교가 필요하면 EqualityComparer<T>.Default.Equals()를 사용해야 한다.

정리

  • ==컴파일 타임 타입이 비교 방법을 결정한다
  • 참조 타입은 기본적으로 참조 비교(ceq), string은 값 비교로 오버로딩됨
  • object로 업캐스팅하면 오버로딩된 ==가 무시되어 참조 비교로 전환된다
  • 제네릭에서 ==는 항상 참조 비교 — 값 비교가 필요하면 EqualityComparer<T>.Default.Equals() 사용

Equals 메서드

문제 제기

앞서 object로 업캐스팅하면 ==가 참조 비교로 바뀌는 것을 보았다. 그렇다면 런타임에 실제 타입에 맞는 비교를 하고 싶을 때는 어떻게 해야 할까? 그리고 structDictionary의 키로 쓸 때 왜 성능이 떨어지는 걸까?

개념 정의

EqualsSystem.Object에 정의된 가상 메서드(virtual method)다. ==가 컴파일 타임 타입을 따르는 것과 달리, Equals런타임 타입을 따른다. 이것이 둘의 결정적 차이다.

가상 메서드 (Virtual method) 파생 클래스에서 override로 재정의할 수 있는 메서드. 호출 시 변수의 선언 타입이 아닌 실제 런타임 타입의 구현이 실행된다. IL에서는 callvirt로 호출된다.
== vs Equals — 컴파일 타임 vs 런타임 디스패치

Equals의 기본 동작은 타입에 따라 다르다:

  • Object.Equals (참조 타입 기본): 내부적으로 ReferenceEquals와 동일한 참조 비교를 수행한다.
  • ValueType.Equals (값 타입 기본): 리플렉션(Reflection, 런타임에 타입 정보를 조회하는 메커니즘)을 사용하여 모든 필드를 하나씩 비교한다. 정확하지만 극도로 느리다.
  • 오버라이드된 Equals: 개발자가 override로 재정의한 비교 로직을 실행한다.

같은 문자열을 object 변수에 담아도 Equals는 런타임에 string임을 알고 문자열 내용을 비교한다.

C#
public class Example2
{
    // object.Equals — 런타임 타입의 Equals 호출
    public static bool EqualsOnObject()
    {
        string s1 = "hello";
        object o1 = s1;
        string s2 = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
        object o2 = s2;
        return o1.Equals(o2); // true — 런타임에 string.Equals 호출
    }

    // 값 타입의 기본 Equals (int는 IEquatable<int> 구현)
    public static bool IntEquals()
    {
        int a = 42;
        int b = 42;
        return a.Equals(b); // true — Int32.Equals(int) 직접 호출
    }
}
IL
// EqualsOnObject — 가상 메서드 디스패치
IL_0022: ldloc.1          // o1 (object 타입, 실제로는 string)
IL_0023: ldloc.3          // o2 (object 타입, 실제로는 string)
IL_0024: callvirt instance bool [System.Runtime]System.Object::Equals(object)
                          // callvirt — 런타임에 실제 타입(string)의 Equals 호출 → true

// IntEquals — 값 타입의 IEquatable<T> 호출
IL_0007: ldloca.s 0       // a의 주소를 로드 (값 타입이므로 주소 전달)
IL_0009: ldloc.1          // b의 값을 로드
IL_000a: call instance bool [System.Runtime]System.Int32::Equals(int32)
                          // Int32.Equals(int) 직접 호출 — 박싱 없음

IL 분석 포인트:

  1. callvirt vs ceq: ==ceq(정적 비교)를 사용하는 반면, Equalscallvirt(가상 메서드 호출)를 사용한다. callvirt는 런타임에 객체의 메서드 테이블을 조회하여 실제 타입의 Equals를 호출한다. 이것이 object 변수에서도 올바른 비교가 되는 이유다.
  2. Int32.Equals(int32) — 박싱 없는 직접 호출: intIEquatable<int>를 구현하고 있어서, a.Equals(b) 호출 시 Equals(object) 대신 Equals(int)가 호출된다. ldloca.s로 주소를 직접 전달하므로 박싱(Boxing, 값 타입을 힙에 포장하는 과정)이 발생하지 않는다.

내부 동작

struct의 기본 Equals — 성능 함정

커스텀 구조체가 Equals를 오버라이드하지 않으면 ValueType.Equals가 호출된다. 이 메서드는 내부적으로 리플렉션을 사용하여 모든 필드를 비교한다. 매 호출마다 타입 정보를 조회하고 필드를 순회하므로, 직접 필드를 비교하는 것보다 수십 배 느리다.

리플렉션 (Reflection) 런타임에 타입의 메타데이터(필드, 메서드, 속성 등)를 조회하고 조작하는 메커니즘. 유연하지만 성능 비용이 크다.
박싱 (Boxing) 값 타입을 object 타입으로 변환할 때, 스택의 값을 힙에 복사하고 참조를 만드는 과정. 힙 할당이 발생하므로 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 부담이 생긴다.
struct Equals — Before vs After (IEquatable<T>)

실전 적용

Before: IEquatable<T> 미구현 struct

C#
// IEquatable<T> 없이 struct를 정의
public struct PositionBefore
{
    public int X;
    public int Y;
}

public class BeforeExample
{
    public static bool BeforeCompare()
    {
        PositionBefore a = new PositionBefore { X = 10, Y = 20 };
        PositionBefore b = new PositionBefore { X = 10, Y = 20 };
        return a.Equals(b); // true — 하지만 내부에서 박싱 + 리플렉션 발생
    }
}
IL
// BeforeCompare — 기본 ValueType.Equals 호출
IL_0039: ldloca.s 0       // a의 주소를 로드
IL_003b: ldloc.1          // b의 값을 로드
IL_003c: box PositionBefore  // ★ b를 object로 박싱 — 힙에 24바이트 할당!
IL_0041: constrained. PositionBefore
IL_0047: callvirt instance bool [System.Runtime]System.Object::Equals(object)
                          // ValueType.Equals 호출 — 리플렉션으로 X, Y 필드 비교

box PositionBefore — 비교할 때마다 힙에 새 객체를 만든다. Unity의 Update() 루프에서 Dictionary<PositionBefore, TileData>를 조회하면 매 프레임 Equals 호출마다 박싱이 발생하여 GC 스파이크의 원인이 된다.

After: IEquatable<T> 구현 struct

IEquatable<T> — 타입 안전 동등성 인터페이스 Equals(T other) 메서드를 정의하는 인터페이스. 값 타입에서 구현하면 박싱 없이 타입 안전한 동등성 비교가 가능하다.
예시: public struct Position : IEquatable<Position>
C#
// IEquatable<T>를 구현한 struct
public struct PositionAfter : IEquatable<PositionAfter>
{
    public int X;
    public int Y;

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

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

    public override int GetHashCode() => HashCode.Combine(X, Y);

    public static bool operator ==(PositionAfter left, PositionAfter right) => left.Equals(right);
    public static bool operator !=(PositionAfter left, PositionAfter right) => !left.Equals(right);
}

public class AfterExample
{
    public static bool AfterCompare()
    {
        PositionAfter a = new PositionAfter { X = 10, Y = 20 };
        PositionAfter b = new PositionAfter { X = 10, Y = 20 };
        return a.Equals(b); // true — 박싱 없이 직접 필드 비교
    }
}
IL
// AfterCompare — IEquatable<T>.Equals 직접 호출
IL_0039: ldloca.s 0       // a의 주소를 로드
IL_003b: ldloc.1          // b의 값을 로드 (box 없음!)
IL_003c: call instance bool PositionAfter::Equals(valuetype PositionAfter)
                          // PositionAfter.Equals(PositionAfter) 직접 호출 — 박싱 없음

핵심 차이: box 명령어가 완전히 사라졌다. callEquals(PositionAfter)를 직접 호출하며, 내부에서는 ldfld로 필드를 읽고 ceq로 비교한다. 리플렉션도, 힙 할당도 없다.

Unity에서 DictionaryHashSet의 키로 struct를 사용한다면, 반드시 IEquatable<T> + GetHashCode + operator == + operator != 4종 세트를 구현해야 한다. 미구현 시 매 조회마다 박싱이 발생하여 GC Alloc이 쌓인다.

정리

  • Equals런타임 타입에 따라 비교 방법이 결정된다 (가상 메서드 디스패치)
  • object 변수에서도 실제 타입의 Equals가 호출되므로, ==와 달리 정확한 값 비교가 가능하다
  • struct의 기본 ValueType.Equals리플렉션 + 박싱 — 성능 치명적
  • struct에는 반드시 IEquatable<T>를 구현하여 박싱을 방지해야 한다

ReferenceEquals

문제 제기

어떤 타입이든, ==가 어떻게 오버로딩되었든, Equals가 어떻게 재정의되었든 — 순수하게 "이 두 변수가 메모리에서 같은 인스턴스를 가리키는가?"만 확인하고 싶을 때가 있다. == 오버로딩이 무거운 로직을 실행할 수도 있는데, 단순히 동일 객체인지만 빠르게 확인하려면?

개념 정의

ReferenceEqualsSystem.Object정적 메서드로, 두 변수가 메모리상 동일한 인스턴스를 가리키는지만 확인한다. 오버라이드할 수 없으므로 어떤 타입에서든 항상 순수한 참조 비교를 보장한다.

C#
public class Example3
{
    public static void ReferenceEqualsDemo()
    {
        string s1 = "hello";
        string s2 = "hello"; // 문자열 인터닝으로 s1과 같은 인스턴스
        string s3 = new string(new char[] { 'h', 'e', 'l', 'l', 'o' }); // 새 인스턴스

        Console.WriteLine(ReferenceEquals(s1, s2)); // true — 같은 인스턴스 (인터닝)
        Console.WriteLine(ReferenceEquals(s1, s3)); // false — 다른 인스턴스
        Console.WriteLine(s1 == s3);                // true — 값 비교 (op_Equality)
    }
}
문자열 인터닝 (String Interning) 컴파일러가 동일한 문자열 리터럴을 메모리에 하나만 보관하고, 여러 변수가 같은 인스턴스를 공유하게 하는 최적화. "hello"를 두 번 쓰면 실제로는 하나의 string 객체만 생성된다.

ReferenceEquals의 가장 중요한 특성은 값 타입에 사용하면 항상 false를 반환한다는 점이다. 값 타입 인자는 object 매개변수에 전달되면서 각각 별도로 박싱되어, 서로 다른 힙 객체가 되기 때문이다.

C#
public class ValueTypeReferenceEquals
{
    public static bool CompareInts()
    {
        int a = 42;
        int b = 42;
        return object.ReferenceEquals(a, b); // 항상 false!
    }
}
IL
// ReferenceEqualsOnValueType — 값 타입 → 이중 박싱
IL_0007: ldloc.0          // a (int, 값: 42)
IL_0008: box [System.Runtime]System.Int32  // ★ a를 박싱 — 힙에 새 object 생성
IL_000d: ldloc.1          // b (int, 값: 42)
IL_000e: box [System.Runtime]System.Int32  // ★ b를 박싱 — 힙에 또 다른 object 생성
IL_0013: ceq              // 두 박싱된 object의 주소 비교 → 항상 false

IL 분석 포인트:

box가 두 번 호출된다. 같은 값 42지만 힙에 서로 다른 위치에 복사되므로, ceq는 항상 false를 반환한다. 값 타입에 ReferenceEquals를 사용하면 쓸모없는 박싱만 2회 발생시키는 셈이다. 컴파일러도 CA2013 경고를 발생시켜 이를 알려준다.

실전 적용

ReferenceEquals는 주로 두 가지 상황에서 사용한다:

1. Equals 재정의 내부에서 빠른 자기 자신 체크:

C#
public class Monster
{
    public int Id { get; set; }

    public override bool Equals(object obj)
    {
        // 같은 인스턴스면 바로 true — 필드 비교 생략
        if (ReferenceEquals(this, obj)) return true;
        if (obj is not Monster other) return false;
        return Id == other.Id;
    }

    public override int GetHashCode() => Id.GetHashCode();
}

2. 오버로딩된 ==를 우회하여 순수 참조 비교:

C#
// == 오버로딩이 무거운 비교를 수행할 때, 같은 인스턴스인지만 빠르게 확인
if (ReferenceEquals(monster1, monster2))
{
    // 동일 인스턴스 — 추가 비교 불필요
}

정리

  • ReferenceEquals오버라이드 불가능한 정적 메서드 — 항상 순수 참조 비교
  • 값 타입에 사용하면 이중 박싱으로 항상 false — 사용 금지
  • Equals 재정의 시 자기 자신 체크, 또는 오버로딩된 == 우회에 활용

함정과 주의사항

Unity의 == 오버로딩 — 가장 위험한 함정

Unity에서 UnityEngine.Object를 상속하는 모든 타입(GameObject, MonoBehaviour, Transform 등)은 == 연산자가 특별하게 오버로딩되어 있다. Destroy(obj) 후에도 C# 래퍼 객체는 GC가 수거하기 전까지 메모리에 남아있지만, Unity의 ==는 네이티브 엔진 객체가 파괴되었는지까지 확인하여 null처럼 취급한다.

Unity Destroy 후 null 체크 — == vs ReferenceEquals vs is null
?. — null 조건부 연산자 (Null-conditional operator) 왼쪽 피연산자가 null이 아닐 때만 오른쪽 멤버에 접근한다. null이면 전체 표현식이 null을 반환한다. Unity 객체에서는 오버로딩된 ==를 호출하지 않으므로 주의가 필요하다.
예시: string name = player?.Name; — player가 null이면 name도 null
C#
// ❌ 잘못된 패턴 — Unity에서 파괴된 객체를 감지하지 못함
public class WrongNullCheck : MonoBehaviour
{
    private GameObject target;

    void Start()
    {
        target = new GameObject("Target");
        Destroy(target);
    }

    void Update()
    {
        // ❌ ReferenceEquals — 파괴된 객체를 감지 못함
        if (!ReferenceEquals(target, null))
        {
            target.SetActive(true); // MissingReferenceException!
        }

        // ❌ is null — 역시 오버로딩 무시
        if (target is not null)
        {
            target.SetActive(true); // MissingReferenceException!
        }

        // ❌ ?. — null 조건부 연산자도 오버로딩 무시
        target?.SetActive(true); // MissingReferenceException!
    }
}
C#
// ✅ 올바른 패턴 — Unity의 오버로딩된 == 사용
public class CorrectNullCheck : MonoBehaviour
{
    private GameObject target;

    void Start()
    {
        target = new GameObject("Target");
        Destroy(target);
    }

    void Update()
    {
        // ✅ Unity의 == 사용 — 파괴된 객체도 null로 판정
        if (target != null)
        {
            target.SetActive(true); // 정상 작동 — 여기에 도달하지 않음
        }
    }
}

Unity에서 UnityEngine.Object 파생 타입의 null 체크는 반드시 ==를 사용해야 한다. is null, is not null, ?., ??는 C# 언어 수준의 참조 null만 체크하므로 파괴된 네이티브 객체를 감지하지 못한다.

Equals에 null을 전달하면 NullReferenceException

C#
// ❌ null 참조에서 인스턴스 Equals 호출
object obj = null;
bool result = obj.Equals("hello"); // NullReferenceException!
C#
// ✅ 정적 object.Equals 사용 — null 안전
object obj = null;
bool result = object.Equals(obj, "hello"); // false — NullReferenceException 없음

object.Equals(a, b) 정적 메서드는 양쪽이 null인지 먼저 검사한 뒤 a.Equals(b)를 호출하는 null 안전 래퍼(wrapper)다. null이 가능한 변수를 비교할 때는 정적 object.Equals를 사용한다.

Equals 재정의 시 GetHashCode를 빼먹으면

Equals를 재정의하면서 GetHashCode를 함께 재정의하지 않으면, DictionaryHashSet에서 예기치 않은 동작이 발생한다. 두 객체가 Equals로 같다고 판정되더라도 GetHashCode가 다른 값을 반환하면, Dictionary에서 같은 키로 인식하지 못한다.

C#
// ❌ GetHashCode를 재정의하지 않은 경우
public class ItemBad
{
    public int Id { get; set; }

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

    // GetHashCode 미재정의 — 컴파일러 경고 CS0659 발생
}

// Dictionary에서 문제 발생
var dict = new Dictionary<ItemBad, string>();
var key1 = new ItemBad { Id = 1 };
dict[key1] = "Sword";

var key2 = new ItemBad { Id = 1 };
// key1.Equals(key2)는 true지만, GetHashCode가 다르므로 키를 찾지 못한다
bool found = dict.ContainsKey(key2); // false!
C#
// ✅ Equals + GetHashCode를 함께 재정의
public class ItemGood
{
    public int Id { get; set; }

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

    public override int GetHashCode() => Id.GetHashCode();
}

규칙: Equals를 재정의하면 반드시 GetHashCode도 재정의한다. 두 객체가 Equalstrue를 반환하면 GetHashCode도 같은 값을 반환해야 한다. 이것은 Dictionary, HashSet 등 해시 기반 컬렉션의 정상 동작을 위한 필수 조건이다.


C# 버전별 변화

C# 9.0 — record 타입의 등장

C# 9.0 이전에는 클래스에 값 동등성을 구현하려면 Equals, GetHashCode, operator ==, operator !=를 모두 수동으로 작성해야 했다. C# 9.0에서 도입된 record 타입은 이 모든 보일러플레이트를 컴파일러가 자동으로 생성해준다.

record — 레코드 타입 (C# 9.0+) 값 동등성을 기본으로 제공하는 참조 타입. 컴파일러가 Equals, GetHashCode, ==, !=, ToString을 모든 속성 기반으로 자동 생성한다.
예시: public record WeaponData(string Name, int Damage);

Before: 수동 구현 (C# 8.0 이하)

C#
// C# 8.0 이하 — 클래스에 값 동등성을 직접 구현
public class WeaponDataClass : IEquatable<WeaponDataClass>
{
    public string Name { get; }
    public int Damage { get; }

    public WeaponDataClass(string name, int damage)
    {
        Name = name;
        Damage = damage;
    }

    public bool Equals(WeaponDataClass other)
    {
        if (other is null) return false;
        return Name == other.Name && Damage == other.Damage;
    }

    public override bool Equals(object obj) => Equals(obj as WeaponDataClass);
    public override int GetHashCode() => HashCode.Combine(Name, Damage);

    public static bool operator ==(WeaponDataClass left, WeaponDataClass right)
        => left is null ? right is null : left.Equals(right);
    public static bool operator !=(WeaponDataClass left, WeaponDataClass right)
        => !(left == right);
}

After: record 한 줄 (C# 9.0+)

C#
// C# 9.0 — record 한 줄로 동일한 기능
public record WeaponData(string Name, int Damage);

public class RecordExample
{
    public static bool RecordCompare()
    {
        var w1 = new WeaponData("Sword", 50);
        var w2 = new WeaponData("Sword", 50);
        return w1 == w2; // true — 컴파일러가 생성한 op_Equality
    }
}
IL
// record의 == — 컴파일러가 생성한 op_Equality
IL_001b: ldloc.0          // w1
IL_001c: ldloc.1          // w2
IL_001d: call bool WeaponData::op_Equality(class WeaponData, class WeaponData)
                          // 컴파일러가 자동 생성한 op_Equality 호출

// op_Equality 내부
IL_0000: ldarg.0          // left
IL_0001: ldarg.1          // right
IL_0002: beq.s IL_0013    // 같은 참조면 바로 true (빠른 경로)
IL_0007: ldarg.0
IL_0008: ldarg.1
IL_0009: callvirt instance bool WeaponData::Equals(class WeaponData)
                          // 다른 참조면 Equals 호출 → 모든 속성 비교

// Equals 내부 (컴파일러 자동 생성)
// EqualityContract 비교 → Name 비교 → Damage 비교
IL_001a: call class EqualityComparer`1<!0> class EqualityComparer`1<string>::get_Default()
IL_0020: ldfld string WeaponData::'<Name>k__BackingField'
IL_0026: ldfld string WeaponData::'<Name>k__BackingField'
IL_002b: callvirt instance bool class EqualityComparer`1<string>::Equals(!0, !0)

IL 분석 포인트:

  1. beq.s — 빠른 경로: 컴파일러가 생성한 op_Equality는 먼저 두 참조가 같은지 확인한다. 같은 인스턴스면 필드 비교 없이 바로 true를 반환한다.
  2. EqualityComparer<T>.Default: 각 필드를 EqualityComparer<T>.Default.Equals()로 비교한다. 이 방식은 null 안전하며, 각 타입에 맞는 최적의 비교자를 자동 선택한다.
  3. EqualityContract: record는 상속 계층에서의 타입 동등성을 보장하기 위해 EqualityContract 프로퍼티를 먼저 비교한다. 같은 타입이어야 같은 객체로 취급한다.

C# 10.0 — record struct

C# 10.0에서는 record struct가 추가되어, 값 타입에서도 동일한 자동 생성 혜택을 받을 수 있게 되었다.

C#
// C# 10.0 — record struct (값 타입 + 자동 동등성)
public record struct TilePosition(int X, int Y);

var t1 = new TilePosition(3, 5);
var t2 = new TilePosition(3, 5);
Console.WriteLine(t1 == t2); // true — 박싱 없이 값 비교

record struct는 일반 struct와 달리 IEquatable<T>, Equals, GetHashCode, ==, !=를 모두 자동 생성하므로, 리플렉션 기반의 ValueType.Equals 대신 직접 필드 비교를 수행한다.


정리

세 비교 방법의 핵심 차이를 다시 정리하면:

  == Equals ReferenceEquals
종류 정적 연산자 가상 메서드 정적 메서드
결정 시점 컴파일 타임 런타임 - (항상 참조 비교)
재정의 operator == 오버로딩 override Equals 불가능
기본 동작 (참조 타입) 참조 비교 참조 비교 참조 비교
기본 동작 (값 타입) 값 비교 리플렉션 비교 (느림) 박싱 → 항상 false

Unity 신입 개발자를 위한 체크리스트:

  • [ ] UnityEngine.Object 파생 타입의 null 체크는 ==만 사용한다. is null, ?., ?? 금지.
  • [ ] 커스텀 structDictionary/HashSet 키로 쓸 때는 IEquatable<T> + GetHashCode + == + != 4종 세트를 반드시 구현한다.
  • [ ] Equals를 재정의하면 반드시 GetHashCode도 함께 재정의한다.
  • [ ] object 변수에서 값 비교가 필요하면 == 대신 Equals를 사용한다.
  • [ ] ReferenceEquals는 값 타입에 쓰지 않는다 — 이중 박싱으로 항상 false.
  • [ ] 제네릭에서 비교할 때는 EqualityComparer<T>.Default.Equals()를 사용한다.
  • [ ] C# 9.0 이상이라면 값 동등성이 필요한 클래스에 record 사용을 고려한다.