[Unity] UnityEngine.Object의 이중 구조와 == null의 진짜 의미
[Unity] UnityEngine.Object의 이중 구조와 == null의 진짜 의미
들어가며
Unity에서 Destroy(go) 한 뒤 go == null을 체크하는 건 흔히 사용하는 패턴이다. 그런데 이런 코드를 보면 어떤가?
Destroy(go);
go == null // true
(object)go == null // false
같은 변수인데 비교 방식에 따라 결과가 다르다. 이게 왜 그런지 설명하려면 Unity 엔진의 내부 구조까지 내려가야 한다.
이 글에서는 다음을 단계적으로 정리한다:
- Unity가 왜 C++ + C# 이중 구조로 설계되었는가
- Unity Object와 순수 C# 객체는 무엇이 다른가
Destroy()가 정확히 무엇을 파괴하고, 무엇을 남기는가==,?.,??,is null,ReferenceEquals— 각각 무엇을 비교하는가- 실무에서 어떤 함정이 있고, 어떻게 피하는가
1. Unity 엔진은 왜 이중 구조인가
엔진 코어 = C++, 사용자 코드 = C#
Unity 엔진의 렌더링, 물리, 오디오, 메모리 관리는 전부 C++로 작성되어 있다.
사용자가 작성하는 게임 로직은 C#이다.
┌─ C++ 엔진 코어 ──────┐ ┌─ C# 사용자 코드 ──────┐
│ 렌더링 (OpenGL/DX) │ │ PlayerController │
│ 물리 (PhysX) │ │ EnemyAI │
│ 오디오 │ │ SkillSystem │
│ 메모리 관리 │ ←→ │ UIManager │
│ 씬 그래프 │ │ │
│ 에셋 로딩 │ │ │
└──────────────────────┘ └──────────────────────┘이 둘은 서로 다른 메모리 공간에 산다:
- C++ 객체 → 엔진이
new/delete로 직접 관리 - C# 객체 → CLR(Mono 또는 IL2CPP)의 GC(Garbage Collector)가 관리
이 둘을 연결하려면 래퍼(Wrapper)가 필요하다.
UE5와의 차이
UE5는 엔진도 C++, 사용자 코드도 C++이다. 같은 메모리 공간, 같은 포인터 체계. 래퍼가 필요 없다.
// UE5: 전부 C++. 포인터로 직접 접근. 래퍼 불필요.
class AActor : public UObject {
virtual void Tick(float DeltaTime) override;
};
Unity는 다르다. C++ 엔진과 C# 스크립트 사이에 다리가 필요하다.
왜 이렇게 설계했나
| 이유 | 설명 |
|---|---|
| 성능 | 렌더링/물리는 C++이 압도적으로 빠르다. 매 프레임 수만 개 Transform을 C#에서 계산하면 느리다. |
| 생산성 | C#은 C++보다 문법이 간결하고, 메모리 관리를 GC에 맡길 수 있어 생산성이 높다. |
| 크로스 플랫폼 | C# 코드를 IL로 컴파일하면 Mono(JIT) 또는 IL2CPP(AOT)를 통해 어떤 플랫폼에서든 실행 가능하다. |
그런데 — 모든 C# 객체가 이중 구조인 건 아니다
여기서 중요한 점이 있다. 이중 구조(C# 래퍼 + C++ 네이티브)는 UnityEngine.Object를 상속한 클래스에만 해당한다.
Unity 프로젝트에서 만드는 C# 클래스는 크게 두 종류로 나뉜다:
// ── Unity Object ──
// UnityEngine.Object를 상속. C++ 네이티브 쌍이 존재.
public class PlayerController : MonoBehaviour { } // MonoBehaviour 상속
public class SkillDataSO : ScriptableObject { } // ScriptableObject 상속
// + GameObject, Transform, Rigidbody, Texture, Mesh, Material, AudioClip...
// 전부 UnityEngine.Object의 자식.
// 엔진이 C++ 쪽에 대응하는 네이티브 객체를 만들어서 관리한다.
// ── 순수 C# 객체 ──
// UnityEngine.Object와 무관. C++ 쌍 없음.
public class PlayerData { // 아무것도 상속 안 함
public string Name;
public int Level;
}
public class SkillConfig { // 아무것도 상속 안 함
public float Damage;
public float Cooldown;
}
// + List<T>, Dictionary<K,V>, Action, string 등 C# 기본 타입들
// C# 힙에만 존재. 엔진이 이 객체의 존재를 모른다.
C++로 비유하면:
// Unity Object = 엔진 메모리 풀에서 관리하는 객체
auto* go = Engine::CreateGameObject(); // 엔진이 소유. 엔진이 delete 할 수 있음.
// → Destroy() 가능. == null 오버로드 있음.
// 순수 C# 객체 = 일반 힙 객체
auto* data = new PlayerData(); // 우리가 소유. GC가 수거.
// → Destroy() 없음. null이면 진짜 null. 포인터가 직관적으로 동작.
왜 이 구분이 중요한가? → 이 글 뒷부분에서 다루는 ?., ??, is null의 함정이 Unity Object에서만 발생하기 때문이다. 순수 C# 객체에서는 아무 문제 없다. 이 구분을 먼저 알고 가야 4장 이후가 이해된다.
판단법:
MonoBehaviour 상속? → Unity Object
ScriptableObject 상속? → Unity Object
Component 상속? → Unity Object
아무것도 상속 안 함? → 순수 C# 객체2. UnityEngine.Object의 내부 — m_CachedPtr
Unity 공식 소스 코드
Unity의 C# 소스는 공식으로 공개되어 있다:
Unity C# Reference Source: github.com/Unity-Technologies/UnityCsReference
파일 위치:Runtime/Export/Scripting/UnityEngineObject.bindings.cs
핵심 부분을 발췌하면:
// Unity 실제 코드 (UnityCsReference 기반, 간략화)
public partial class Object
{
IntPtr m_CachedPtr; // ← C++ 네이티브 객체를 가리키는 포인터
int m_InstanceID;
public static bool operator ==(Object x, Object y)
{
return CompareBaseObjects(x, y);
}
static bool CompareBaseObjects(Object lhs, Object rhs)
{
bool lhsIsNull = ((object)lhs) == null || lhs.m_CachedPtr == IntPtr.Zero;
bool rhsIsNull = ((object)rhs) == null || rhs.m_CachedPtr == IntPtr.Zero;
if (lhsIsNull && rhsIsNull) return true;
if (lhsIsNull || rhsIsNull) return false;
return lhs.m_InstanceID == rhs.m_InstanceID;
}
}
m_CachedPtr — 이것이 C++ 네이티브 객체를 가리키는 실제 필드다.
모든 Unity 객체는 쌍으로 존재한다
UnityEngine.Object를 상속하는 모든 클래스 — GameObject, MonoBehaviour, ScriptableObject, Texture, Mesh — 전부 이 구조다:
@startuml
skinparam defaultFontName Malgun Gothic
skinparam packageStyle rectangle
skinparam classAttributeIconSize 0
skinparam classFontSize 13
skinparam noteFontSize 12
skinparam backgroundColor #1E1E1E
skinparam defaultFontColor #D4D4D4
skinparam classBorderColor #569CD6
skinparam classBackgroundColor #2D2D2D
skinparam packageBorderColor #569CD6
skinparam packageBackgroundColor #252526
skinparam noteBorderColor #6A9955
skinparam noteBackgroundColor #2D2D2D
skinparam arrowColor #D4D4D4
title Unity Object 이중 구조 — C# 래퍼 + C++ 네이티브
package "C# 관리 영역 (GC)" #252526 {
class "UnityEngine.Object" as UObject {
- IntPtr m_CachedPtr
- int m_InstanceID
+ operator ==(Object, Object)
__
m_CachedPtr로 C++ 객체 참조
}
class "GameObject" as GO {
}
class "MonoBehaviour" as MB {
}
class "ScriptableObject" as SO {
}
}
package "C++ 엔진 영역 (엔진 관리)" #1E2D1E {
class "NativeGameObject" as NGO {
+ Transform
+ Components[]
+ Name, Active
}
class "NativeMonoBehaviour" as NMB {
+ Enabled
+ Script Data
}
class "NativeScriptableObject" as NSO {
+ 직렬화된 필드 데이터
+ .asset 파일 연결
}
}
UObject <|-- GO
UObject <|-- MB
UObject <|-- SO
GO -right-> NGO : m_CachedPtr
MB -right-> NMB : m_CachedPtr
SO -right-> NSO : m_CachedPtr
note bottom of UObject
모든 Unity 객체는
C# 래퍼 + C++ 네이티브
한 쌍으로 존재한다
end note
@enduml
C++로 이 구조를 표현하면:
// ── C++ 엔진 영역 ──
struct NativeGameObject {
int instanceID;
char name[64];
Transform transform;
std::vector<Component*> components;
// 렌더링, 물리 등 엔진 기능과 직접 연결
};
// ── C# 래퍼 역할 (C++로 표현) ──
class GameObject {
NativeGameObject* m_CachedPtr; // C++ 객체를 가리키는 포인터
int m_InstanceID;
};
C# 코드에서 go.name에 접근하면:
- C# 래퍼의
m_CachedPtr로 C++ 객체를 찾는다 - C++ 쪽에서
name을 읽어서 C#으로 반환한다
// C# 코드
Debug.Log(go.name);
// 내부 동작 (의사 코드)
// NativeGameObject* native = go.m_CachedPtr;
// return native->name;
3. Destroy의 정체 — 무엇을 파괴하고 무엇을 남기는가
Destroy가 하는 일
Destroy(go);
이 한 줄이 실행되면:
@startuml
skinparam defaultFontName Malgun Gothic
skinparam sequenceArrowThickness 2
skinparam backgroundColor #1E1E1E
skinparam defaultFontColor #D4D4D4
skinparam sequenceParticipantBorderColor #569CD6
skinparam sequenceParticipantBackgroundColor #2D2D2D
skinparam sequenceLifeLineBorderColor #569CD6
skinparam sequenceGroupBackgroundColor #252526
skinparam sequenceGroupBorderColor #569CD6
skinparam noteBorderColor #6A9955
skinparam noteBackgroundColor #2D2D2D
skinparam sequenceDividerBackgroundColor #333333
skinparam sequenceDividerBorderColor #569CD6
title Destroy() 실행 흐름
box "C# 영역 (GC 관리)" #252526
participant "go\n(C# 변수)" as Var
participant "C# 래퍼\n(힙 객체)" as Wrapper
end box
box "C++ 영역 (엔진 관리)" #1E2D1E
participant "C++ Native\nGameObject" as Native
end box
== 정상 상태 ==
Var -> Wrapper : 참조
Wrapper -> Native : m_CachedPtr = 0x1234
note over Var, Native
go.name -> C# 래퍼 -> m_CachedPtr -> C++ 객체 -> "Enemy"
**go == null -> false** (m_CachedPtr 유효)
end note
== Destroy(go) 호출 ==
Native -> Native : C++ 객체 delete (메모리 해제)
destroy Native
Wrapper --> Wrapper : m_CachedPtr = IntPtr.Zero
note over Wrapper
C# 래퍼는 안 건드림
GC가 나중에 수거
end note
== Destroy 후 상태 ==
Var -> Wrapper : 참조 (여전히 유효)
note over Var, Wrapper
**go == null -> true** (m_CachedPtr == 0)
**(object)go == null -> false** (래퍼는 힙에 있음)
**go?.Method() -> 실행됨** (래퍼 있으니 null 아님 판정)
end note
@enduml
Destroy가 하지 않는 것:
go = null대입 → 안 한다- C# 래퍼 객체 삭제 → 안 한다
- C# GC 강제 실행 → 안 한다
C++의 Dangling Pointer와 같은 문제
이 상태는 C++에서 delete 후 다른 포인터가 죽은 메모리를 가리키는 것과 비슷한 문제라고 볼 수 있다:
NativeObject* ptr1 = new NativeObject();
NativeObject* ptr2 = ptr1; // 같은 객체를 가리킴
delete ptr1; // 파괴
ptr1 = nullptr; // ptr1은 정리됨
// ptr2는? → 여전히 죽은 주소를 가리킴! (dangling pointer)
ptr2->DoSomething(); // 💥 정의되지 않은 동작
Unity에서도 동일한 상황이 발생한다:
GameObject ref1 = go;
GameObject ref2 = go; // 같은 객체의 두 번째 참조
Destroy(ref1);
// ref2는? → C# 래퍼를 통해 죽은 C++ 객체를 가리킴
ref2.name; // 💥 MissingReferenceException
Unity의 해결책: == 연산자 오버로드
C++에서 이 문제를 해결하는 표준 방법은 std::weak_ptr이다:
auto shared = std::make_shared<NativeObject>();
std::weak_ptr<NativeObject> ref1 = shared;
std::weak_ptr<NativeObject> ref2 = shared;
shared.reset(); // 파괴
ref1.expired(); // true — 어떤 weak_ptr에서든 감지 가능
ref2.expired(); // true
if (auto locked = ref1.lock()) {
locked->DoSomething(); // 살아있을 때만 접근
}
Unity는 weak_ptr.expired()와 동일한 역할을 == null 오버로드로 구현했다:
// Unity의 == null = weak_ptr.expired()
if (go != null) // m_CachedPtr != IntPtr.Zero 인가?
{
go.SetActive(true); // 살아있을 때만 접근. 안전.
}
대응 관계:
weak_ptr.expired() == Unity의 go == null
weak_ptr.lock() == Unity의 null 체크 후 접근
shared_ptr.reset() == Unity의 Destroy()
weak_ptr 자체 == C# 래퍼 (m_CachedPtr를 가진 객체)4. == null vs ?. vs ?? vs is null vs ReferenceEquals
Destroy 후에 사용할 수 있는 null 체크 방식은 여러 가지다. 결과가 전부 다르다.
@startuml
skinparam defaultFontName Malgun Gothic
skinparam backgroundColor #1E1E1E
skinparam defaultFontColor #D4D4D4
skinparam classBorderColor #569CD6
skinparam classBackgroundColor #2D2D2D
skinparam packageBorderColor #569CD6
skinparam packageBackgroundColor #252526
skinparam noteBorderColor #6A9955
skinparam noteBackgroundColor #2D2D2D
skinparam arrowColor #D4D4D4
skinparam objectBorderColor #569CD6
skinparam objectBackgroundColor #2D2D2D
title Destroy 후 null 체크 비교 — 무엇을 비교하는가
object "Destroy(go) 후 상태" as state {
C++ 객체 = 파괴됨
C# 래퍼 = 살아있음
m_CachedPtr = IntPtr.Zero
}
package "안전 — Unity 오버로드 사용" #1E2D1E {
object "go == null" as eq {
비교 대상 = m_CachedPtr
결과 = true
이유 = "C++ 죽었음을 감지"
}
object "go != null" as neq {
비교 대상 = m_CachedPtr
결과 = false
이유 = "C++ 죽었음을 감지"
}
}
package "위험 — Unity 오버로드 우회" #2D1E1E {
object "go?.Method()" as nc {
비교 대상 = C# 래퍼 존재 여부
결과 = 실행됨
이유 = "래퍼 있으니 null 아님 판정"
}
object "go ?? other" as coal {
비교 대상 = C# 래퍼 존재 여부
결과 = go 선택
이유 = "래퍼 있으니 null 아님 판정"
}
object "go is null" as isnull {
비교 대상 = C# 래퍼 존재 여부
결과 = false
이유 = "래퍼 있으니 null 아님 판정"
}
object "ReferenceEquals(go, null)" as refeq {
비교 대상 = C# 래퍼 존재 여부
결과 = false
이유 = "래퍼 있으니 null 아님 판정"
}
}
state -down-> eq
state -down-> neq
state -down-> nc
state -down-> coal
state -down-> isnull
state -down-> refeq
note bottom of coal
Unity Object의 null 체크는
== / != 만 사용하는 게 안전하다고 생각한다
end note
@enduml
실험 코드
GameObject go = new GameObject("Test");
Destroy(go);
// 다음 프레임 (C++ 객체 파괴 완료 후)
| 체크 방식 | 무엇을 비교하는가 | 결과 | Unity 오버로드 사용 |
|---|---|---|---|
go == null |
m_CachedPtr == 0 인가 | true | O |
go != null |
m_CachedPtr != 0 인가 | false | O |
(object)go == null |
C# 래퍼가 힙에 있는가 | false | X |
go?.Method() |
C# 래퍼가 힙에 있는가 | 실행됨 💥 | X |
go ?? other |
C# 래퍼가 힙에 있는가 | go 선택 💥 | X |
go is null |
C# 래퍼가 힙에 있는가 | false | X |
ReferenceEquals(go, null) |
C# 래퍼가 힙에 있는가 | false | X |
go == null/go != null이 안전한 방식이라고 생각한다. 나머지는 Unity 오버로드를 우회하기 때문에 파괴된 객체를 감지하지 못하는 것으로 보인다.
C++로 정리하면:
// 안전 — 내부 포인터 체크
if (wrapper->m_CachedPtr != nullptr) // → go == null / go != null
// 위험 — 래퍼 자체만 체크
if (wrapper != nullptr) // → ?., ??, is null, ReferenceEquals
아래에서 각각을 자세히 설명한다.
4-1. ?. (Null-Conditional Operator)
C# 6.0에서 추가. 왼쪽이 null이면 뒤를 실행하지 않고 null을 반환한다.
// ?. 없이
string name = null;
if (go != null)
{
name = go.name;
}
// ?. 사용
string name = go?.name; // go가 null이면 name = null, 아니면 go.name
C++에는 ?.가 없다. 삼항 연산자가 대체한다:
// C++ 등가
const char* name = (go != nullptr) ? go->name : nullptr;
// 체이닝
// C#: go?.transform?.parent?.name
// C++: (go && go->transform && go->transform->parent)
// ? go->transform->parent->name : nullptr;
순수 C# 객체에서는 문제 없다
먼저 짚고 넘어갈 점 — 순수 C# 객체에서 ?.는 아무 문제 없이 적극 사용하면 된다:
// 순수 C# 객체 — ?. 적극 사용 OK
PlayerData data = null;
string name = data?.Name; // ✅ null 반환. 안전.
List<int> list = null;
int? count = list?.Count; // ✅ null 반환. 안전.
Action callback = null;
callback?.Invoke(); // ✅ 호출 안 됨. 안전.
순수 C# 객체는 C++ 네이티브 쌍이 없다. null이면 진짜 null이고, null이 아니면 진짜 살아있다. C++의 std::optional이나 일반 포인터처럼 직관적으로 동작한다.
// C++ 비유: 순수 C# 객체 = 일반 포인터
PlayerData* data = nullptr;
if (data != nullptr) data->name; // 단순 포인터 체크. 문제없음.
// ?. 가 이것과 동일하게 동작
Unity Object에서 주의가 필요한 이유
문제는 UnityEngine.Object를 상속한 객체(GameObject, MonoBehaviour, ScriptableObject 등)에서만 발생한다:
Destroy(go);
// 다음 프레임
// ── if문 + Unity == (안전) ──
if (go != null) // Unity 오버로드 호출
{ // m_CachedPtr 체크 → 0 → false → 진입 안 함
go.SetActive(true); // ✅ 여기 안 들어옴
}
// ── ?. 연산자 (주의 필요) ──
go?.SetActive(true); // 💥 MissingReferenceException 가능
?.는 Unity의 == 오버로드를 사용하지 않는다. C# 언어 스펙에서 ?.는 순수 C# null 체크를 수행한다:
// go?.SetActive(true) 가 내부적으로 하는 일:
if ((object)go != null) // ← Unity 오버로드 우회!
{ // C# 래퍼가 힙에 있으니 null 아님 → 진입
go.SetActive(true); // → C++ 객체에 접근 → 죽었음 → 💥
}
Destroy 후 메모리:
go (C# 변수)
└→ C# 래퍼 (힙에 있음) ← ?.는 이것만 본다. "있네? 실행하자"
└→ m_CachedPtr = 0 ← if(go != null)은 이걸 본다. "0이네? 안 하자"C++로 비유하면:
struct Wrapper {
NativeObject* m_Ptr;
};
Wrapper* go = new Wrapper();
go->m_Ptr = new NativeObject();
// Destroy
delete go->m_Ptr;
go->m_Ptr = nullptr;
// ?. 에 해당 — 래퍼 자체만 체크
if (go != nullptr) { // true — go 래퍼는 살아있음
go->m_Ptr->DoSomething(); // 💥 nullptr 역참조!
}
// Unity == 에 해당 — 내부 포인터 체크
if (go->m_Ptr != nullptr) { // false — 내부가 죽었음
go->m_Ptr->DoSomething(); // ✅ 여기 안 들어옴
}
실제로는 얼마나 자주 터지나?
솔직히 대부분의 경우 go?.transform, go?.SetActive() 같은 코드는 잘 돌아간다. Destroy()한 객체를 다음 프레임 이후에도 계속 참조하는 코드 자체가 많지 않기 때문이다.
하지만 비동기 로딩, 이벤트 시스템, Addressables 같이 시간차가 생기는 구조에서는 종종 나온다:
// 비동기에서 터지는 케이스
async void LoadAndActivate()
{
await UniTask.Delay(1000); // 1초 대기
go?.SetActive(true); // 💥 1초 사이에 Destroy 됐을 수 있음
}
// 이벤트 콜백에서 터지는 케이스
someEvent += () => {
go?.SetActive(true); // 💥 이벤트 발생 시점에 이미 Destroy 됐을 수 있음
};
그래서 결론적으로 ?.가 무조건 위험한 건 아니지만, Destroy된 객체를 나중에 건드릴 가능성이 있는 코드에서는 명시적 체크가 안전하다고 생각한다.
정리: 언제 ?. 를 쓰고 언제 피하나
| 대상 | ?. 사용 | 이유 |
|---|---|---|
| 순수 C# 객체 (PlayerData, List, Action 등) | 적극 사용 | C++ 네이티브 쌍 없음. null이면 진짜 null. |
| Unity Object — 즉시 사용, 수명 확실 | 주의하며 사용 가능 | Destroy 안 된 게 확실하면 대부분 동작 |
| Unity Object — 비동기, 이벤트, Addressables | if(obj != null) 선호 |
시간차로 Destroy될 가능성 있음 |
일부 프로젝트에서는 MonoBehaviour, GameObject, Transform, Component, ScriptableObject에 대해 ?./?? 사용 금지 룰을 두는 곳도 있다고 한다.
4-2. ?? (Null-Coalescing Operator)
C# 2.0에서 추가. 왼쪽이 null이면 오른쪽을 반환한다.
// ?? 없이
GameObject target;
if (go != null)
target = go;
else
target = defaultGo;
// ?? 사용
GameObject target = go ?? defaultGo;
C++에는 ??가 없다:
// C++ 등가
GameObject* target = (go != nullptr) ? go : defaultGo;
Unity에서 위험한 이유 — ?.와 완전히 같은 문제:
Destroy(go);
// ❌ 위험
GameObject target = go ?? defaultGo;
// 내부: (object)go == null ? defaultGo : go
// 래퍼가 힙에 있으니 → go 선택 → 파괴된 객체!
target.SetActive(true); // 💥 MissingReferenceException
// ✅ 안전
GameObject target = (go != null) ? go : defaultGo;
// Unity 오버로드 사용 → m_CachedPtr 체크 → 0 → defaultGo 선택
??= (null 병합 할당)도 동일하게 위험하다:
// ❌ 위험
_cached ??= GetComponent<Rigidbody>();
// _cached가 Destroy됐는데 래퍼 있으니 재할당 안 함
// ✅ 안전
if (_cached == null)
_cached = GetComponent<Rigidbody>();
4-3. is null (패턴 매칭)
C# 7.0에서 추가. 오버로드된 ==를 호출하지 않고 순수 참조 비교를 한다.
if (go == null) { } // Unity 오버로드 호출 → m_CachedPtr 체크
if (go is null) { } // 오버로드 무시 → C# 래퍼 존재 여부만 체크
is null이 존재하는 이유: 일반 C# 프로그래밍에서는 == 오버로드에 비용이 있을 수 있다. is null은 오버로드를 건너뛰고 가장 빠른 null 체크를 한다. 일반 C# 앱에서는 성능 이점이 있다.
// C++ 비유
class MyClass {
// == 오버로드가 있는 클래스
bool operator==(nullptr_t) const {
return m_Ptr == nullptr && m_State == DESTROYED; // 복잡한 로직
}
};
MyClass* obj = ...;
// is null → 오버로드 무시, 포인터만 비교
if (obj == nullptr) { } // 단순 주소 비교. 가장 빠름.
// == null → 오버로드 호출
if (*obj == nullptr) { } // operator== 실행. 로직 포함.
그런데 Unity에서는 이 성능 이점이 오히려 문제가 될 수 있다:
Destroy(go);
if (go is null)
Debug.Log("null이다");
else
Debug.Log("null 아니다"); // ← 이게 출력됨!
// is null은 래퍼가 힙에 있는지만 봄 → 있다 → null 아님
4-4. ReferenceEquals
System.Object의 static 메서드. 절대로 오버로드되지 않는 순수 참조 비교.
ReferenceEquals(go, null); // go 변수가 가리키는 C# 객체가 null인가?
ReferenceEquals(go1, go2); // go1과 go2가 같은 C# 객체인가?
// C++ 등가 — 주소 비교 그 이상도 이하도 아님
(go == nullptr) // ReferenceEquals(go, null)
(go1 == go2) // ReferenceEquals(go1, go2)
Unity에서의 동작:
Destroy(go);
go == null // true — m_CachedPtr 체크 (Unity)
ReferenceEquals(go, null) // false — 래퍼가 힙에 있으니까
// ReferenceEquals가 유용한 경우:
// "이 두 변수가 정말 같은 C# 래퍼를 가리키는가?"
GameObject a = go;
GameObject b = go;
ReferenceEquals(a, b); // true — 같은 래퍼 객체
5. 실무에서 주의할 패턴
핵심 원칙: 순수 C# 객체는
?./??적극 사용. Unity Object는 상황에 따라if(obj != null)선호.
순수 C# 객체 — ?. 적극 사용
// ✅ 전부 안전. 적극 사용.
PlayerData data = null;
string name = data?.Name; // null 반환
List<Enemy> enemies = null;
int? count = enemies?.Count; // null 반환
Action onComplete = null;
onComplete?.Invoke(); // 호출 안 됨
Config config = LoadConfig();
int timeout = config?.network?.timeout ?? 30; // 체이닝도 OK
순수 C# 객체는 C++ 네이티브 쌍이 없다. null이면 진짜 null이니까 ?.가 의도대로 동작한다.
Unity Object — 비동기/이벤트에서 주의
// ⚠️ 비동기에서 위험 — Destroy될 시간이 있음
async void LoadAndActivate()
{
await UniTask.Delay(1000); // 1초 대기
go?.SetActive(true); // 💥 1초 사이에 Destroy 됐을 수 있음
}
// ✅ 명시적 체크
async void LoadAndActivate()
{
await UniTask.Delay(1000);
if (go != null) // Unity 오버로드
go.SetActive(true);
}
// ⚠️ 이벤트 콜백에서 주의
someEvent.AddListener(() => {
go?.SetActive(true); // 💥 이벤트 시점에 이미 Destroy 됐을 수 있음
});
// ✅ 명시적 체크
someEvent.AddListener(() => {
if (this == null) return; // Unity 오버로드로 체크
if (go != null)
go.SetActive(true);
});
?? / ??= 도 동일한 주의
// ⚠️ Unity Object에 ?? 사용 시 주의
var target = mainTarget ?? fallbackTarget;
// mainTarget이 Destroy됐는데 래퍼 있으니 mainTarget 선택될 수 있음
// ✅ 명시적 체크
var target = (mainTarget != null) ? mainTarget : fallbackTarget;
// ⚠️ ??= 캐싱 주의
_rigidbody ??= GetComponent<Rigidbody>();
// Destroy 후에도 래퍼 있어서 재할당 안 될 수 있음
// ✅ 명시적 체크
if (_rigidbody == null)
_rigidbody = GetComponent<Rigidbody>();
LINQ에서 null 필터링
// ⚠️ is not null → Unity 오버로드 우회
enemies.Where(e => e is not null) // Destroy된 적이 필터링 안 됨
// ✅ Unity 오버로드 사용
enemies.Where(e => e != null)
6. 왜 이런 설계를 선택했나 — 트레이드오프
다른 선택지는 없었나?
선택지 1: Destroy 시 모든 C# 참조를 null로 바꾼다
→ 불가능.
→ C#에서 다른 변수의 값을 강제로 바꿀 수 없다.
→ C++에서도 delete ptr1 했다고 ptr2가 자동으로 nullptr이 되지 않는다.
선택지 2: C++ 객체를 쓰지 않고 전부 C#으로 만든다
→ 성능 손해.
→ 렌더링/물리를 C#으로 하면 느리다.
→ 수만 개 오브젝트를 전부 GC에 맡기면 GC pause가 발생한다.
선택지 3: 현재 방식 (래퍼 + == 오버로드)
→ ?.와 ??에서 함정이 생기지만
→ 성능 + 메모리 관리 + 안전한 null 체크의 균형점C++에서의 동일한 트레이드오프:
raw pointer → 빠르지만 dangling 위험
shared_ptr → 안전하지만 reference counting 오버헤드
weak_ptr → 안전 + 파괴 감지 가능 (Unity가 이 모델을 채택)
Unity가 선택한 것
weak_ptr모델 +==오버로드로expired()체크를 자동화
개발자가 go == null만 쓰면 안전하게 파괴 감지가 된다. 다만 C# 언어 차원의 ?., ??, is null은 이 오버로드를 우회하기 때문에 함정이 생긴다. 이건 C# 언어와 Unity 엔진의 설계 철학이 다르기 때문에 발생하는 구조적 불일치가 아닐까 생각한다.
정리
Unity 객체 = C# 래퍼 + C++ 네이티브 (한 쌍)
Destroy() → C++ 네이티브만 파괴. C# 래퍼는 GC가 나중에 수거.
→ C++의 dangling pointer와 비슷한 상태.
go == null → m_CachedPtr 체크 (C++ 객체가 살아있는가?) → ✅ 안전
?. ?? is null ReferenceEquals
→ C# 래퍼 존재 여부만 체크 → Destroy된 객체 감지 못할 수 있음실무 규칙:
순수 C# 객체 (PlayerData, List, Action 등)
→ ?., ?? 적극 사용. 아무 문제 없음.
Unity Object (GameObject, MonoBehaviour, ScriptableObject 등)
→ 수명이 확실한 곳: ?. 사용 가능
→ 비동기, 이벤트, Addressables: if(obj != null) 선호
→ ?. 를 남발하지 않는 습관이 안전한 편참고 자료
- Unity C# Reference Source (GitHub) —
UnityEngineObject.bindings.cs에서m_CachedPtr과CompareBaseObjects확인 가능 - Unity Blog — Custom == operator, should we keep it? — Unity 공식 블로그에서 이 설계 결정에 대한 논의
'Unity' 카테고리의 다른 글
| [Unity] 코루틴은 프레임, yield 타이밍, 그리고 DLL 분석하기 (0) | 2026.07.13 |
|---|---|
| [Unity] 코루틴과 IEnumerator, Heap (0) | 2026.07.03 |
| [Unity] 직렬화 시스템 — [SerializeField], .asset, .meta가 하는 일 (0) | 2026.06.17 |
| [Unity] C# 컴파일 과정과 IL, Mono, IL2CPP, asmdef 이해하기 (0) | 2026.06.10 |
| [Unity] Render서버를 통한 Addressable 로드 (0) | 2025.12.03 |