Unity

[Unity] UnityEngine.Object의 이중 구조와 == null의 진짜 의미

2026. 6. 11. 14:21 @CGNY

[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에 접근하면:

  1. C# 래퍼의 m_CachedPtr로 C++ 객체를 찾는다
  2. 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) 선호
    → ?. 를 남발하지 않는 습관이 안전한 편

참고 자료