EveryDay.DevUp

null이란 무엇인가 — null의 두 얼굴 본문

C# 심화

null이란 무엇인가 — null의 두 얼굴

EveryDay.DevUp 2026. 3. 30. 20:21

null이란 무엇인가 — null의 두 얼굴

값 타입의 null(Nullable<T>)과 참조 타입의 null은 같은 null이지만 완전히 다르게 동작한다. NullReferenceException의 근본 원인을 이해하고, C# 8.0의 nullable 참조 타입이 무엇을 해결하는지 알아본다.


문제 제기 — null은 왜 이렇게 자주 게임을 터트리는가

Unity로 게임을 개발하다 보면 반드시 한 번은 마주치는 에러가 있다.

NullReferenceException: Object reference not set to an instance of an object

씬 전환 후 갑자기 터진다. 몬스터가 죽은 직후에 터진다. 에디터에서는 멀쩡한데 빌드하면 터진다. 로그를 보면 스택 트레이스가 있지만, 정확히 null이 됐는지 이해하지 못한 채로 if (obj != null) 한 줄을 붙여 넘기는 경우가 많다.

그런데 Unity에는 함정이 하나 더 있다. Destroy(enemy)를 호출한 뒤에도 enemy가 C# 수준에서는 null이 아닌 상태로 남는다. ?? 연산자로 안전하게 처리했다고 생각했는데, 파괴된 객체가 그대로 통과하는 버그가 생긴다.

null을 단순히 "값이 없음"으로만 이해하면 이런 함정을 피하기 어렵다. null은 참조 타입에서는 메모리 주소 0이고, 값 타입에서는 HasValue가 false인 구조체다. 이 차이를 이해해야 Unity의 fake null 문제도, Nullable<T>의 boxing 함정도, C# 8.0의 nullable 참조 타입도 자연스럽게 이해된다.


개념 정의 — null은 메모리 주소 0이다

참조 타입의 null — 빈 주소록

책상 위에 주소록이 있다고 생각하자. 주소록에는 친구의 집 주소가 적혀 있다. 그 주소를 따라가면 친구를 만날 수 있다. 그런데 주소록 페이지가 완전히 비어있다면? 적힌 주소가 없으니 찾아갈 수 없다. 이것이 null이다.

C#에서 Enemy enemy를 선언하면, enemy는 주소록 페이지다. new Enemy()를 호출하면 힙(Heap, 객체가 동적으로 할당되는 메모리 영역)에 실제 Enemy 객체가 생성되고, 그 메모리 주소가 enemy 변수에 저장된다. enemy = null은 그 페이지를 지우는 것이다 — 이제 어떤 객체도 가리키지 않는다.

스택 (Stack)
C#
public class Enemy
{
    public string Name;
    public int Hp;
}

public class Program
{
    public static void Main()
    {
        // enemy 변수는 스택에 존재, 현재 null (어떤 객체도 가리키지 않음)
        Enemy enemy = null;

        // null 체크 후 접근
        if (enemy != null)
        {
            System.Console.WriteLine(enemy.Name);
        }

        // 새 인스턴스 생성 — 힙에 객체 생성, alive에 주소 저장
        Enemy alive = new Enemy();
        alive.Name = "Orc";
        System.Console.WriteLine(alive.Name);
    }
}

IL 코드

IL
.locals init (
    [0] class Enemy,    // enemy — 스택에 참조(주소)만 저장
    [1] class Enemy,    // alive — 스택에 참조(주소)만 저장
    [2] bool            // null 비교 임시 결과
)

IL_0001: ldnull          // null 값(주소 0x0000)을 스택에 올림
IL_0002: stloc.0         // enemy 변수에 null 저장

IL_0003: ldloc.0         // enemy 로드
IL_0004: ldnull          // null 로드
IL_0005: cgt.un          // enemy > null 비교 (주소가 0보다 크면 true)
IL_0007: stloc.2         // bool 결과 저장
IL_0009: brfalse.s IL_0019  // false(null)이면 if 블록 건너뜀

IL_000c: ldloc.0         // enemy 로드
IL_000d: ldfld string Enemy::Name   // enemy.Name 필드 로드

IL_0019: newobj instance void Enemy::.ctor()  // 힙에 Enemy 객체 생성
IL_001e: stloc.1         // alive에 주소 저장
IL_0020: ldstr "Orc"     // 문자열 리터럴 "Orc" — intern pool에서 로드
IL_0025: stfld string Enemy::Name   // alive.Name = "Orc"

IL 분석 포인트

ldnull — null은 특별한 상수값이다. ldnull은 리터럴 0을 메모리 주소로 스택에 올리는 명령어다. 참조 타입 변수는 내부적으로 힙 객체의 메모리 주소를 저장하며, null은 그 주소가 0임을 의미한다.

cgt.un — null 비교는 주소 대소 비교다. if (enemy != null)은 IL에서 cgt.un(unsigned greater than) 명령어로 컴파일된다. enemy의 주소가 0보다 크면(실제 객체를 가리키면) true다. CLR(Common Language Runtime, .NET 프로그램을 실행하는 런타임 엔진)은 주소 0을 null로 처리한다.

newobj vs ldnull — 힙 할당과 null의 대비. new Enemy()newobj 명령어로 힙에 메모리를 할당하고 주소를 반환한다. nullldnull로 주소 0을 저장할 뿐이다. newobj 한 번 = GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 추적 대상 객체 하나 생성이다. 참고로 ldstr "Orc" 명령어는 intern pool(동일한 문자열 리터럴을 프로그램 전체에서 하나의 인스턴스로 재사용하는 메모리 풀)에서 문자열을 가져오므로 새 힙 할당이 발생하지 않는다.

값 타입은 왜 null이 될 수 없는가 — 주소록이 아니라 명함이다

참조 타입이 "주소록"이라면, 값 타입(int, float, struct 등)은 명함 자체다. 명함에는 정보가 직접 인쇄되어 있다. "명함이 없다"라는 상태가 성립하지 않는다 — 명함이 있으면 반드시 내용이 있다.

int hp를 선언하면 스택에 4바이트 공간이 생기고, 거기에 값이 직접 저장된다. 별도의 힙 객체가 없으므로, "가리키는 객체가 없다"는 null의 의미 자체가 적용되지 않는다.

참조 타입 (class)

그런데 실무에서는 값 타입에도 "값이 없음"을 표현해야 하는 상황이 있다. 데이터베이스의 int 컬럼이 NULL일 수 있거나, 게임 설정에서 "아직 정해지지 않은 점수"를 표현해야 할 때다. 이 문제를 해결하기 위해 C# 2.0에서 Nullable<T> 구조체가 도입됐다.

Nullable<T>는 두 개의 필드만 가진 구조체다: bool hasValue(값이 있는지 여부)와 T value(실제 값). int?Nullable<int>의 문법적 단축 표기다.

T? — Nullable 값 타입 (Nullable<T>) 값 타입 뒤에 ?를 붙이면 null을 허용하는 Nullable<T> 구조체가 된다. 내부에 HasValue(값 존재 여부)와 Value(실제 값) 두 필드를 갖는다.
예시: int? score = null; score는 값이 없는 상태(HasValue=false). score = 100;으로 값을 넣으면 HasValue=true가 된다.
C#
using System;

public struct PlayerStats
{
    public int Hp;
    public float Speed;
}

public class Program
{
    public static void Main()
    {
        int hp = 0;
        PlayerStats stats = new PlayerStats();
        stats.Hp = 100;

        int? score = null;
        Console.WriteLine(score.HasValue);  // False

        score = 100;
        Console.WriteLine(score.HasValue);  // True
        Console.WriteLine(score.Value);     // 100
    }
}

IL 코드

IL
.locals init (
    [0] int32,                                              // hp — 스택에 직접 저장
    [1] valuetype PlayerStats,                              // stats — 스택에 직접 저장
    [2] valuetype System.Nullable`1<int32>                  // score — 스택에 구조체로 저장
)

// PlayerStats stats = new PlayerStats();
IL_0003: ldloca.s 1                  // stats 변수의 스택 주소를 올림
IL_0005: initobj PlayerStats         // 해당 주소의 메모리를 0으로 초기화 (힙 할당 없음!)

// stats.Hp = 100;
IL_000b: ldloca.s 1                  // stats 주소를 다시 올림
IL_000d: ldc.i4.s 100               // 정수 100
IL_000f: stfld int32 PlayerStats::Hp // 주소가 가리키는 struct의 Hp 필드에 100 저장

// int? score = null;
IL_0014: ldloca.s 2
IL_0016: initobj Nullable`1<int32>   // Nullable 구조체를 0으로 초기화 → HasValue=false

// score = 100;
IL_0029: ldloca.s 2
IL_002b: ldc.i4.s 100
IL_002d: call instance void Nullable`1<int32>::.ctor(!0)  // HasValue=true, Value=100

// score.HasValue
IL_001e: call instance bool Nullable`1<int32>::get_HasValue()

IL 분석 포인트

initobj — 힙 없이 스택을 직접 초기화한다. 값 타입의 newinitobj 명령어다. 힙에 아무것도 할당하지 않는다 — 스택의 메모리 슬롯을 0으로 채울 뿐이다. newobj(힙 할당)와 완전히 다른 명령어다. Unity Update 루프에서 struct를 매 프레임 사용해도 GC 부담이 없는 이유가 바로 여기에 있다.

Nullable<T>도 스택에 저장된다. .locals initvaluetype 접두사가 붙은 변수는 모두 스택에 직접 저장된다. int?bool hasValue + int value 두 필드를 가진 구조체이므로, initobj로 스택을 0으로 밀면 HasValue = false가 된다. 이것이 int? score = null의 실체다.


내부 동작 — CLR이 null을 처리하는 방식

NullReferenceException은 어떻게 발생하는가

null인 참조 변수의 멤버에 접근하려고 하면, CPU가 메모리 주소 0번지에 접근을 시도한다. 운영체제는 주소 0번지를 보호 영역으로 설정해 놓았기 때문에, 하드웨어 수준에서 접근 거부 신호(Access Violation)가 발생한다. CLR이 이 신호를 가로채 NullReferenceException으로 변환한다. null 역참조는 소프트웨어 예외이기 이전에 하드웨어 보호 메커니즘이 동작한 결과다.

C# 코드

여기서 중요한 것은 callvirt라는 IL 명령어다. C# 컴파일러는 인스턴스 메서드 호출을 callvirt로 컴파일하는데, 이 명령어는 호출 전에 대상 객체가 null인지 항상 확인한다.

C#
using System;

public class Monster
{
    public string Name;
    public int Hp;

    public void TakeDamage(int dmg)
    {
        Hp -= dmg;
    }
}

public class Program
{
    public static void Main()
    {
        Monster monster = null;

        // 가드 절(Guard Clause) — 함수 초입에서 null을 차단하고 즉시 반환
        if (monster == null)
        {
            Console.WriteLine("몬스터가 없습니다.");
            return;
        }

        monster.TakeDamage(10);
    }
}

IL 코드

IL
.locals init (
    [0] class Monster          // 스택에 참조(주소)만 저장
)

IL_0000: ldnull               // null을 스택에 올림
IL_0001: stloc.0              // monster = null

IL_0002: ldloc.0              // monster 로드
IL_0003: brtrue.s IL_0010     // null이 아니면 IL_0010으로 점프 (null이면 다음 진행)

// null 경로
IL_0005: ldstr "몬스터가 없습니다."
IL_000a: call void [System.Console]System.Console::WriteLine(string)
IL_000f: ret                  // 메서드 즉시 종료 (guard clause)

// non-null 경로
IL_0010: ldloc.0              // monster 로드
IL_0011: ldc.i4.s 10          // 정수 10
IL_0013: callvirt instance void Monster::TakeDamage(int32)  // null 체크 내장
IL_0018: ret

IL 분석 포인트

brtrue.s — null 비교는 분기 명령어 하나다. if (monster == null)brtrue.s(null이 아니면 점프)로 컴파일된다. 순수 스택 비교이므로 추가 힙 할당이나 메서드 호출이 없다.

callvirt — null 체크가 내장된 호출. monster.TakeDamage(10)callvirt로 컴파일된다. 이 명령어는 호출 대상이 null이면 NullReferenceException을 던진다. brtrue.s로 이미 null을 걸렀더라도 IL 수준에서 callvirt의 null 체크는 항상 존재한다.

가드 절(guard clause) 패턴. IL_000f: ret으로 null 경로가 즉시 종료된다. IL_0010 이후 코드는 monster가 null이 아닌 것이 보장된 상태에서만 실행된다.

Nullable<T>의 boxing — CLR의 특수 규칙

Nullable<T>object에 대입하면 boxing(값 타입을 힙에 감싸는 과정)이 발생한다. 이때 CLR은 특수 규칙을 적용한다.

C#
using System;

public class Program
{
    public static void Main()
    {
        // HasValue=false → null로 boxing됨
        int? nullableA = null;
        object boxedNull = nullableA;
        Console.WriteLine(boxedNull == null);  // True

        // HasValue=true → T 값만 boxing됨 (Nullable<T> 자체가 아님!)
        int? nullableB = 42;
        object boxedVal = nullableB;
        Console.WriteLine(boxedVal.GetType().Name);  // "Int32" (Nullable<Int32>가 아님)

        // ?? 연산자 — boxing 없이 GetValueOrDefault() 호출
        int? score = 100;
        int result = score ?? 0;
        Console.WriteLine(result);  // 100
    }
}

IL 코드

IL
// object boxedNull = nullableA;  (HasValue=false)
IL_0054: ldloc.3                            // nullableA 값을 스택에 복사
IL_0055: box valuetype Nullable`1<int32>    // boxing 명령어 실행
IL_005a: stloc.s 4                          // boxedNull에 저장 → 결과는 null

// object boxedVal = nullableB;  (HasValue=true)
IL_0070: ldloc.s 5                          // nullableB 값을 스택에 복사
IL_0072: box valuetype Nullable`1<int32>    // boxing 명령어 실행
IL_0077: stloc.s 6                          // boxedVal = Int32(42) 박싱 객체

// boxedVal.GetType().Name
IL_0079: ldloc.s 6
IL_007b: callvirt instance class Type Object::GetType()  // "Int32" 반환

// int result = score ?? 0;
IL_008b: ldloca.s 2
IL_008d: call instance !0 Nullable`1<int32>::GetValueOrDefault()  // boxing 없음!
IL_0092: stloc.s 7

IL 분석 포인트

box valuetype Nullable<int32> — boxing 시 CLR이 특별 처리한다. 같은 box 명령어인데 결과가 다르다:

  • HasValue == false: 힙 객체를 만들지 않고 null을 반환한다
  • HasValue == true: 내부의 int 값(42)만 꺼내서 Int32로 boxing한다 (Nullable<Int32>가 아님)

이 때문에 boxedVal.GetType().Name"Int32"를 반환한다. Unity에서 object 타입 파라미터로 int?를 넘길 때 이 특수 규칙에 주의해야 한다.

?? 연산자는 GetValueOrDefault()로 최적화된다. score ?? 0box 명령어 없이 GetValueOrDefault() 한 번으로 처리된다. 순수 스택 연산이므로 힙 할당이 전혀 없다.


실전 적용 — null을 안전하게 다루는 패턴

?.?? 연산자 — Before/After

?. — 널 조건 연산자 (Null-conditional operator) 왼쪽 피연산자가 null이면 전체 표현식이 null이 되고, null이 아니면 오른쪽 멤버에 접근한다. null 체크와 멤버 접근을 한 번에 처리한다.
예시: string name = enemy?.Name; enemy가 null이면 name에 null 대입, 아니면 enemy.Name 대입
?? — 널 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가 null이 아니면 그 값을 반환하고, null이면 오른쪽 피연산자 값을 반환한다. 기본값을 설정할 때 사용한다.
예시: string result = name ?? "Unknown"; name이 null이면 "Unknown" 대입

Unity 게임 오브젝트의 무기 정보를 읽는 상황을 보자.

❌ Before — 중첩 null 체크

C#
// null 체크가 중첩되면 코드 가독성이 급격히 떨어진다
string display;
if (player.Weapon != null)
    display = player.Weapon.WeaponName;
else
    display = "맨손";

✅ After — ?.?? 체이닝

C#
using System;

public class Weapon
{
    public string WeaponName;
    public int Damage;
}

public class Player
{
    public string Name;
    public Weapon Weapon;
}

public class Program
{
    public static void Main()
    {
        Player player = new Player();
        player.Name = "Hero";

        // ?. 연산자 — null이면 null 반환, 아니면 멤버 접근
        string weaponName = player.Weapon?.WeaponName;

        // ?. + ?? 체이닝 — null이면 "맨손"
        string display = player.Weapon?.WeaponName ?? "맨손";
        Console.WriteLine(display);  // "맨손"
    }
}

IL 코드

IL
// player.Weapon?.WeaponName
IL_0013: ldfld class Weapon Player::Weapon  // player.Weapon 로드
IL_0018: dup                                // 스택 복제 (null 체크용)
IL_0019: brtrue.s IL_001f                   // null이 아니면 IL_001f로 분기
IL_001b: pop                                // null이면 스택에서 제거
IL_001c: ldnull                             // null 반환값 준비
IL_001d: br.s IL_0024                       // 종료로 점프
IL_001f: ldfld string Weapon::WeaponName    // null 아니면 필드 접근

// player.Weapon?.WeaponName ?? "맨손"
IL_0037: dup                                // 결과를 복제
IL_0038: brtrue.s IL_0040                   // null 아니면 그대로 사용
IL_003a: pop                                // null이면 버림
IL_003b: ldstr "맨손"                        // 기본값 "맨손" 로드

IL 분석 포인트

?.dup + brtrue.s 분기문으로 변환된다. player.Weapon?.WeaponName 한 줄이 IL에서 6개 명령어의 분기 패턴이 된다. dup으로 참조를 복제하고, brtrue.s로 null 여부를 분기한다. 성능상 직접 if로 쓰는 것과 동일하다 — 코드 가독성만 개선된다.

??도 동일한 dup + brtrue.s 패턴이다. ?.??를 연쇄하면 brtrue.s 분기가 두 번 발생한다. 추가 힙 할당은 전혀 없는 순수 스택 연산이다.

??= 연산자 — 지연 초기화 패턴

??= — 널 병합 대입 연산자 (Null-coalescing assignment, C# 8.0) 왼쪽 변수가 null일 때만 오른쪽 값을 대입한다. null이 아니면 아무 일도 하지 않는다. 지연 초기화 패턴에 유용하다.
예시: items ??= new List<string>(); items가 null이면 새 리스트 생성, 아니면 기존 리스트 유지
C#
using System;

public class Program
{
    public static void Main()
    {
        // ??= (C# 8.0) — null이면 할당, 아니면 그대로
        System.Collections.Generic.List<string> items = null;
        items ??= new System.Collections.Generic.List<string>();
        items.Add("sword");
        Console.WriteLine(items.Count);  // 1
    }
}

IL 코드

IL
// items ??= new List<string>()
IL_004a: ldloc.3                                       // items 로드
IL_004b: brtrue.s IL_0053                              // null이 아니면 new 건너뜀
IL_004d: newobj instance void List`1<string>::.ctor()  // null일 때만 힙 할당
IL_0052: stloc.3                                       // items에 저장

IL 분석 포인트

??=brtrue.s 하나로 처리된다. items가 null이 아니면 newobj가 실행되지 않으므로 힙 할당이 발생하지 않는다. 레이캐스트 결과 버퍼나 오브젝트 풀처럼 지연 초기화가 필요한 컨테이너에 유용하다. 첫 접근 시점에만 newobj가 발생하므로 GC 압박이 예측 가능해진다.

nullable 참조 타입 (C# 8.0) — 컴파일 타임 null 검사

#nullable enable — nullable 참조 타입 활성화 (C# 8.0) 이 지시문을 선언하면 컴파일러가 참조 타입의 null 흐름을 정적으로 분석한다. string은 non-nullable(null 불가), string?은 nullable(null 허용)로 구분되며, null 가능성이 있는 변수를 검사 없이 사용하면 컴파일 경고가 발생한다.
예시: string? name = null; — null 허용 명시 string name = null; — 컴파일 경고 CS8600 발생

C# 8.0 이전까지 모든 참조 타입 변수는 null이 될 수 있었다. 컴파일러는 null 가능성을 전혀 추적하지 않았다. NRT(Nullable Reference Types, nullable 참조 타입)가 도입되면서 컴파일러가 null 흐름을 정적으로 분석해 경고를 발생시킨다.

C# 7 이전 (모든 참조 = nullable)
C#
#nullable enable
using System;

public class Enemy
{
    public string Name { get; set; } = "";     // non-nullable — 반드시 초기화 필요
    public string? Nickname { get; set; }       // nullable — null 허용 명시
}

public class Program
{
    public static void Main()
    {
        Enemy e = new Enemy();
        e.Nickname = null;  // ✅ nullable이므로 가능

        string? maybe = GetMaybeName();

        // ❌ 경고 없이 바로 접근하면 CS8602 경고 발생
        // Console.WriteLine(maybe.Length);

        // ✅ null 체크 후 접근 — 컴파일러가 non-null로 추론
        if (maybe != null)
        {
            Console.WriteLine(maybe.Length);
        }

        // ?? 패턴으로 기본값 지정
        string display = maybe ?? "이름 없음";
        Console.WriteLine(display);
    }

    static string? GetMaybeName() => null;  // null 반환 가능 명시
}

IL 코드

IL
// Enemy 클래스의 Nickname backing field
.field private string '<Nickname>k__BackingField'
.custom instance void NullableAttribute::.ctor(uint8) = (01 00 02 00 00)  // 02 = nullable

// if (maybe != null)
IL_0011: ldloc.0              // maybe 로드
IL_0012: brfalse.s IL_001f    // null이면 건너뜀

// if (maybe is not null) — C# 9 패턴
IL_001f: ldloc.0              // maybe 로드
IL_0020: brfalse.s IL_002d    // null이면 건너뜀 (동일한 IL!)

IL 분석 포인트

NRT는 IL 수준에서 NullableAttribute 어노테이션만 추가한다. string?string은 런타임에서 동일한 타입이다. 런타임 null 체크 코드를 추가하지 않으므로 성능에 전혀 영향을 주지 않는다. Unity 프로젝트에서 #nullable enable을 켜도 빌드 결과물 크기나 실행 속도에 영향이 없다.

!= nullis not null은 동일한 IL을 생성한다. 둘 다 brfalse.s 패턴으로 컴파일된다. 성능 차이가 없으므로 가독성 기준으로 선택하면 된다.

NRT는 런타임 동작을 바꾸지 않는다. 컴파일러 정적 분석 도구이며, !(null-forgiving) 연산자를 남용하면 런타임에 여전히 NullReferenceException이 발생한다. !는 "나는 이게 null이 아님을 보장한다"는 의미이므로, 확신이 없으면 쓰지 않는 것이 안전하다.

함정과 주의사항

Unity의 fake null — 가장 흔한 함정

Unity에서 C#을 쓸 때 가장 독특한 null 함정이 있다. UnityEngine.Object(모든 Unity 컴포넌트, 게임오브젝트의 기반 클래스)는 == 연산자를 오버로드한다. Destroy()로 파괴된 객체를 == null로 비교하면 true를 반환하지만, C# 힙 메모리에는 래퍼(Wrapper, C++ 네이티브 객체를 감싸는 C# 객체) 객체가 여전히 살아있다.

C# 힙 메모리
C#
// ❌ 잘못된 패턴 — Unity 오브젝트에 ?? 사용
using UnityEngine;

public class BulletManager : MonoBehaviour
{
    private Enemy _target;

    void Update()
    {
        // Destroy(_target) 호출 후:
        // ?? 연산자는 C# 수준 null만 확인 → 파괴된 객체가 통과됨
        Enemy safeTarget = _target ?? FindNewTarget();  // 버그!

        // ?. 연산자도 동일하게 위험
        _target?.TakeDamage(10);  // MissingReferenceException
    }
}
C#
// ✅ 올바른 패턴 — Unity 오브젝트에는 == null 사용
using UnityEngine;

public class BulletManager : MonoBehaviour
{
    private Enemy _target;

    void Update()
    {
        // Unity의 == 오버로드가 destroyed 상태를 null로 처리
        if (_target == null)
        {
            _target = FindNewTarget();
            if (_target == null) return;
        }

        _target.TakeDamage(10);  // 안전
    }
}

IL 분석: ?? 연산자는 dup + brtrue.s로 C# 참조가 null인지만 검사한다. Unity의 == 오버로드(op_Equality)를 호출하지 않는다. 반면 명시적 == nullcall bool UnityEngine.Object::op_Equality(...) 명령어를 생성하여 오버로드를 통과한다. 이것이 두 방식의 동작 차이 근거다.

핵심 규칙: MonoBehaviour, GameObject, Component를 상속한 모든 타입에는 ??, ?.를 사용하지 않는다. 반드시 == null 또는 != null을 사용한다.

Nullable<T>.Value 접근 함정

C#
using System;

public class Program
{
    public static void Main()
    {
        // ❌ 잘못된 패턴 — HasValue 확인 없이 Value 접근
        int? score = null;
        // int value = score.Value;  // InvalidOperationException!

        // ✅ 올바른 패턴 — ?? 또는 GetValueOrDefault 사용
        int value1 = score ?? 0;                   // null이면 0
        int value2 = score.GetValueOrDefault();    // null이면 default(int) = 0
        int value3 = score.GetValueOrDefault(99);  // null이면 99

        Console.WriteLine(value1);  // 0
        Console.WriteLine(value2);  // 0
        Console.WriteLine(value3);  // 99
    }
}

IL 분석: score.Value는 내부에서 HasValue를 확인하고, false이면 InvalidOperationException을 던진다. 반면 GetValueOrDefault()는 예외 없이 기본값을 반환한다. ??GetValueOrDefault()로 최적화되므로 예외 위험이 없다.

Inspector에서 할당하지 않은 직렬화 필드

C#
// ❌ 잘못된 패턴 — Inspector 할당 누락 시 NRE
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private AudioSource _jumpSound;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            _jumpSound.Play();  // NullReferenceException!
        }
    }
}
C#
// ✅ 올바른 패턴 — Awake에서 필수 참조 확인
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private AudioSource _jumpSound;

    void Awake()
    {
        Debug.Assert(_jumpSound != null,
            "jumpSound가 Inspector에 할당되지 않았습니다.", this);
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (_jumpSound != null)  // Unity 오브젝트이므로 == null 사용
                _jumpSound.Play();
        }
    }
}

IL 분석: Unity 전용 패턴이므로 IL 레벨에서 특이사항 없음. 핵심은 Debug.Assert가 개발 빌드에서만 실행되고, Release 빌드에서는 제거된다는 점이다.


C# 버전별 변화

C# 1.0 — 참조 타입만 null 가능

C#
// 값 타입은 null 불가, 참조 타입만 null
string name = null;      // 가능
// int hp = null;        // ❌ 컴파일 에러

if (name != null)
    System.Console.WriteLine(name.Length);

C# 2.0 — Nullable<T>와 ?? 도입

C#
// 값 타입에 nullable 지원 추가
int? hp = null;          // Nullable<int> — HasValue=false
hp = 100;                // HasValue=true, Value=100

// ?? (null 병합 연산자)
string display = name ?? "이름 없음";

IL Before (C# 1.0 — 직접 null 체크)

IL
// if (name != null) Console.WriteLine(name.Length);
IL_0000: ldloc.0         // name 로드
IL_0001: ldnull
IL_0002: cgt.un          // != null 비교
IL_0004: brfalse.s IL_end
IL_0006: ldloc.0
IL_0007: callvirt instance int32 System.String::get_Length()

IL After (C# 2.0 — ?? 사용)

IL
// string display = name ?? "이름 없음";
IL_0000: ldloc.0         // name 로드
IL_0001: dup             // 복제 (null 체크용)
IL_0002: brtrue.s IL_0008  // null 아니면 그대로 사용
IL_0004: pop
IL_0005: ldstr "이름 없음"  // null이면 기본값
IL_0008: stloc.1

?? 연산자 덕분에 null 처리가 한 줄로 줄어든다. IL 수준에서는 dup + brtrue.s 패턴으로 성능 차이는 없다.

C# 6.0 — ?. (null 조건 연산자) 도입

C#
// ?. 연산자로 null 체크와 멤버 접근을 한 번에
int? length = name?.Length;
string upper = name?.ToUpper() ?? "UNKNOWN";

?.가 IL에서 dup + brtrue.s 분기로 변환되는 것은 앞서 확인했다.

C# 8.0 — nullable 참조 타입(NRT)과 ??= 도입

C#
#nullable enable

string nonNull = "Hello";   // null 대입 시 컴파일 경고
string? mayNull = null;     // null 허용 명시

List<string>? items = null;
items ??= new List<string>();  // null이면 새 인스턴스 할당

NRT는 NullableAttribute 어노테이션만 추가하며 런타임 동작을 바꾸지 않는다. ??=brtrue.s 단순 분기로 처리된다.

C# 9.0 — is not null 패턴

is not null — null 아님 패턴 (C# 9.0) 패턴 매칭 구문으로 변수가 null이 아닌지 검사한다. != null과 동일한 IL을 생성하지만 가독성이 더 좋다. 단, Unity 오브젝트에서는 == 오버로드를 우회하므로 사용하지 않는다.
예시: if (enemy is not null) enemy.TakeDamage(10);
C#
// is not null — 가독성 개선
if (enemy is not null)
    enemy.TakeDamage(10);

IL에서 != nullis not null은 동일한 brfalse.s 패턴을 생성한다. 성능 차이 없이 가독성만 개선된다. 단, Unity 오브젝트에서 is not null??와 마찬가지로 == 오버로드를 우회하므로 사용하지 않는다.


정리

핵심 체크리스트

  • [ ] 참조 타입의 null은 주소 0이다 — 힙 객체를 가리키지 않는 스택 변수. ldnull로 주소 0을 저장하고, newobj로 힙에 객체를 생성해 유효한 주소를 저장한다.
  • [ ] 값 타입은 null이 될 수 없다 — 스택에 값 자체가 저장되므로 "없음"의 개념이 없다. initobj는 힙을 건드리지 않고 스택을 0으로 초기화한다.
  • [ ] Nullable<T>는 구조체다HasValue + Value 두 필드. 스택에 저장되며, object에 대입 시 boxing이 발생한다. boxing 결과가 Nullable<T>가 아닌 T임에 주의한다.
  • [ ] NullReferenceException은 하드웨어 보호 메커니즘이다 — CPU가 주소 0에 접근할 때 발생하고, CLR이 예외로 변환한다. callvirt는 호출 전 null 체크를 내장한다.
  • [ ] Unity의 == 오버로드를 신뢰한다Destroy()== nulltrue를 반환한다. ??, ?., is not null은 이 오버로드를 우회하므로 Unity 오브젝트에 사용하지 않는다.
  • [ ] #nullable enable은 컴파일 타임 도구다 — 런타임 동작을 바꾸지 않는다. IL에 NullableAttribute만 추가될 뿐이다. ! 연산자 남용은 NRE를 런타임으로 미룰 뿐이다.
  • [ ] 핫패스에서는 캐싱 후 한 번만 null 체크GetComponent를 Awake에서 캐싱하고, Update에서 반복 호출을 피한다.
  • [ ] ??GetValueOrDefault()로 최적화된다Nullable<T>??를 사용하면 boxing 없이 스택에서 처리된다. 안전하고 빠르다.