| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- job
- Tween
- Custom Package
- 최적화
- Job 시스템
- Framework
- DotsTween
- 샘플
- ui
- 패스트캠퍼스후기
- 프레임워크
- 직장인공부
- AES
- 직장인자기계발
- unity
- 암호화
- Unity Editor
- RSA
- TextMeshPro
- 게임개발
- adfit
- 가이드
- Dots
- 오공완
- C#
- base64
- 패스트캠퍼스
- sha
- 2D Camera
- 환급챌린지
- Today
- Total
EveryDay.DevUp
[PART5.구조체와 레코드(4/4)] == vs Equals vs ReferenceEquals — 같아 보이는 세 비교, 완전히 다른 동작 본문
[PART5.구조체와 레코드(4/4)] == vs Equals vs ReferenceEquals — 같아 보이는 세 비교, 완전히 다른 동작
EveryDay.DevUp 2026. 4. 5. 20:02== vs Equals vs ReferenceEquals — 같아 보이는 세 비교, 완전히 다른 동작
"두 오브젝트가 같은지" 비교하는 코드를 쓸 때, ==과 Equals를 아무렇게나 골라 써도 될까? Unity에서 Destroy(obj) 후 obj == null은 true인데 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 변수에 담으면 메모리 주소를 비교한다. 이것이 ==의 가장 중요한 특성이다.
아래 코드로 확인해 보자.
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 타입이므로 참조 비교
}
}
// 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 분석 포인트:
ceqvscall op_Equality:Person비교와object비교에서는ceq명령어가 사용된다.ceq는 스택의 두 값을 단순히 비트 비교하는 명령어다. 참조 타입이면 메모리 주소를, 값 타입이면 값 자체를 비교한다.string의op_Equality:string변수끼리==하면 컴파일러가ceq대신String::op_Equality를 호출한다. 이 메서드 내부에서 문자열 내용을 비교하므로true가 반환된다.object업캐스팅의 함정: 같은string값이라도object변수에 담으면, 컴파일러는object타입의==를 적용한다.object는==를 오버로딩하지 않았으므로ceq(참조 비교)가 사용된다. Unity의Update()루프에서 컬렉션의 요소를object로 받아==비교하면, 의도와 다르게 항상false가 나올 수 있다.
내부 동작
== 연산자는 정적 디스패치(static dispatch)를 따른다. 런타임에 실제 타입이 뭔지는 상관없다. 컴파일 시점에 변수의 선언 타입을 보고, 해당 타입에 operator ==가 정의되어 있으면 그 메서드를 호출하고, 없으면 ceq로 참조 비교를 수행한다.
이 동작은 제네릭에서도 동일하게 적용된다.
where T : class— 제네릭 타입 제약 조건 제네릭 타입 매개변수T를 참조 타입으로 제한하는 제약 조건. 이 제약이 없으면T타입의 변수에==를 사용할 수 없다.
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로 업캐스팅하면 ==가 참조 비교로 바뀌는 것을 보았다. 그렇다면 런타임에 실제 타입에 맞는 비교를 하고 싶을 때는 어떻게 해야 할까? 그리고 struct를 Dictionary의 키로 쓸 때 왜 성능이 떨어지는 걸까?
개념 정의
Equals는 System.Object에 정의된 가상 메서드(virtual method)다. ==가 컴파일 타임 타입을 따르는 것과 달리, Equals는 런타임 타입을 따른다. 이것이 둘의 결정적 차이다.
가상 메서드 (Virtual method) 파생 클래스에서override로 재정의할 수 있는 메서드. 호출 시 변수의 선언 타입이 아닌 실제 런타임 타입의 구현이 실행된다. IL에서는callvirt로 호출된다.

Equals의 기본 동작은 타입에 따라 다르다:
Object.Equals(참조 타입 기본): 내부적으로ReferenceEquals와 동일한 참조 비교를 수행한다.ValueType.Equals(값 타입 기본): 리플렉션(Reflection, 런타임에 타입 정보를 조회하는 메커니즘)을 사용하여 모든 필드를 하나씩 비교한다. 정확하지만 극도로 느리다.- 오버라이드된
Equals: 개발자가override로 재정의한 비교 로직을 실행한다.
같은 문자열을 object 변수에 담아도 Equals는 런타임에 string임을 알고 문자열 내용을 비교한다.
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) 직접 호출
}
}
// 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 분석 포인트:
callvirtvsceq:==가ceq(정적 비교)를 사용하는 반면,Equals는callvirt(가상 메서드 호출)를 사용한다.callvirt는 런타임에 객체의 메서드 테이블을 조회하여 실제 타입의Equals를 호출한다. 이것이object변수에서도 올바른 비교가 되는 이유다.Int32.Equals(int32)— 박싱 없는 직접 호출:int는IEquatable<int>를 구현하고 있어서,a.Equals(b)호출 시Equals(object)대신Equals(int)가 호출된다.ldloca.s로 주소를 직접 전달하므로 박싱(Boxing, 값 타입을 힙에 포장하는 과정)이 발생하지 않는다.
내부 동작
struct의 기본 Equals — 성능 함정
커스텀 구조체가 Equals를 오버라이드하지 않으면 ValueType.Equals가 호출된다. 이 메서드는 내부적으로 리플렉션을 사용하여 모든 필드를 비교한다. 매 호출마다 타입 정보를 조회하고 필드를 순회하므로, 직접 필드를 비교하는 것보다 수십 배 느리다.
리플렉션 (Reflection) 런타임에 타입의 메타데이터(필드, 메서드, 속성 등)를 조회하고 조작하는 메커니즘. 유연하지만 성능 비용이 크다.
박싱 (Boxing) 값 타입을 object 타입으로 변환할 때, 스택의 값을 힙에 복사하고 참조를 만드는 과정. 힙 할당이 발생하므로 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 부담이 생긴다.

실전 적용
Before: IEquatable<T> 미구현 struct
// 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 — 하지만 내부에서 박싱 + 리플렉션 발생
}
}
// 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>
// 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 — 박싱 없이 직접 필드 비교
}
}
// 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 명령어가 완전히 사라졌다. call로 Equals(PositionAfter)를 직접 호출하며, 내부에서는 ldfld로 필드를 읽고 ceq로 비교한다. 리플렉션도, 힙 할당도 없다.
Unity에서 Dictionary나 HashSet의 키로 struct를 사용한다면, 반드시 IEquatable<T> + GetHashCode + operator == + operator != 4종 세트를 구현해야 한다. 미구현 시 매 조회마다 박싱이 발생하여 GC Alloc이 쌓인다.
정리
Equals는 런타임 타입에 따라 비교 방법이 결정된다 (가상 메서드 디스패치)object변수에서도 실제 타입의Equals가 호출되므로,==와 달리 정확한 값 비교가 가능하다- struct의 기본
ValueType.Equals는 리플렉션 + 박싱 — 성능 치명적 - struct에는 반드시
IEquatable<T>를 구현하여 박싱을 방지해야 한다
ReferenceEquals
문제 제기
어떤 타입이든, ==가 어떻게 오버로딩되었든, Equals가 어떻게 재정의되었든 — 순수하게 "이 두 변수가 메모리에서 같은 인스턴스를 가리키는가?"만 확인하고 싶을 때가 있다. == 오버로딩이 무거운 로직을 실행할 수도 있는데, 단순히 동일 객체인지만 빠르게 확인하려면?
개념 정의
ReferenceEquals는 System.Object의 정적 메서드로, 두 변수가 메모리상 동일한 인스턴스를 가리키는지만 확인한다. 오버라이드할 수 없으므로 어떤 타입에서든 항상 순수한 참조 비교를 보장한다.
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 매개변수에 전달되면서 각각 별도로 박싱되어, 서로 다른 힙 객체가 되기 때문이다.
public class ValueTypeReferenceEquals
{
public static bool CompareInts()
{
int a = 42;
int b = 42;
return object.ReferenceEquals(a, b); // 항상 false!
}
}
// 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 재정의 내부에서 빠른 자기 자신 체크:
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. 오버로딩된 ==를 우회하여 순수 참조 비교:
// == 오버로딩이 무거운 비교를 수행할 때, 같은 인스턴스인지만 빠르게 확인
if (ReferenceEquals(monster1, monster2))
{
// 동일 인스턴스 — 추가 비교 불필요
}
정리
ReferenceEquals는 오버라이드 불가능한 정적 메서드 — 항상 순수 참조 비교- 값 타입에 사용하면 이중 박싱으로 항상
false— 사용 금지 Equals재정의 시 자기 자신 체크, 또는 오버로딩된==우회에 활용
함정과 주의사항
Unity의 == 오버로딩 — 가장 위험한 함정
Unity에서 UnityEngine.Object를 상속하는 모든 타입(GameObject, MonoBehaviour, Transform 등)은 == 연산자가 특별하게 오버로딩되어 있다. Destroy(obj) 후에도 C# 래퍼 객체는 GC가 수거하기 전까지 메모리에 남아있지만, Unity의 ==는 네이티브 엔진 객체가 파괴되었는지까지 확인하여 null처럼 취급한다.

?.— null 조건부 연산자 (Null-conditional operator) 왼쪽 피연산자가 null이 아닐 때만 오른쪽 멤버에 접근한다. null이면 전체 표현식이 null을 반환한다. Unity 객체에서는 오버로딩된==를 호출하지 않으므로 주의가 필요하다.
예시:string name = player?.Name;— player가 null이면 name도 null
// ❌ 잘못된 패턴 — 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!
}
}
// ✅ 올바른 패턴 — 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
// ❌ null 참조에서 인스턴스 Equals 호출
object obj = null;
bool result = obj.Equals("hello"); // NullReferenceException!
// ✅ 정적 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를 함께 재정의하지 않으면, Dictionary와 HashSet에서 예기치 않은 동작이 발생한다. 두 객체가 Equals로 같다고 판정되더라도 GetHashCode가 다른 값을 반환하면, Dictionary에서 같은 키로 인식하지 못한다.
// ❌ 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!
// ✅ 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도 재정의한다. 두 객체가 Equals로 true를 반환하면 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# 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# 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
}
}
// 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 분석 포인트:
beq.s— 빠른 경로: 컴파일러가 생성한op_Equality는 먼저 두 참조가 같은지 확인한다. 같은 인스턴스면 필드 비교 없이 바로true를 반환한다.EqualityComparer<T>.Default: 각 필드를EqualityComparer<T>.Default.Equals()로 비교한다. 이 방식은 null 안전하며, 각 타입에 맞는 최적의 비교자를 자동 선택한다.EqualityContract:record는 상속 계층에서의 타입 동등성을 보장하기 위해EqualityContract프로퍼티를 먼저 비교한다. 같은 타입이어야 같은 객체로 취급한다.
C# 10.0 — record struct
C# 10.0에서는 record struct가 추가되어, 값 타입에서도 동일한 자동 생성 혜택을 받을 수 있게 되었다.
// 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,?.,??금지. - [ ] 커스텀
struct를Dictionary/HashSet키로 쓸 때는IEquatable<T>+GetHashCode+==+!=4종 세트를 반드시 구현한다. - [ ]
Equals를 재정의하면 반드시GetHashCode도 함께 재정의한다. - [ ]
object변수에서 값 비교가 필요하면==대신Equals를 사용한다. - [ ]
ReferenceEquals는 값 타입에 쓰지 않는다 — 이중 박싱으로 항상false. - [ ] 제네릭에서 비교할 때는
EqualityComparer<T>.Default.Equals()를 사용한다. - [ ] C# 9.0 이상이라면 값 동등성이 필요한 클래스에
record사용을 고려한다.
'C# 심화' 카테고리의 다른 글
| [PART6.문자열(2/3)] string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.05 |
|---|---|
| [PART6.문자열(1/3)] string은 왜 불변인가 (0) | 2026.04.05 |
| [PART5.구조체와 레코드(3/4)] record — 불변 데이터를 다루는 새로운 방법 (0) | 2026.04.05 |
| [PART5.구조체와 레코드(2/4)] struct 4형제 — struct, record struct, readonly struct, ref struct (0) | 2026.04.05 |
| [PART5.구조체와 레코드(1/4)] struct vs class — 무엇을 언제 선택하는가 (0) | 2026.04.05 |
