EveryDay.DevUp

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

카테고리 없음

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

EveryDay.DevUp 2026. 3. 24. 09:03

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

Unity 모바일 개발에서 가장 흔한 런타임 오류는 NullReferenceException이다. 이 예외를 제대로 이해하려면 null이 메모리 수준에서 무엇인지, C#과 Unity가 각각 null을 어떻게 다루는지 알아야 한다. null에는 두 얼굴이 있다 — 참조 타입의 "빈 주소"와, 값 타입에 억지로 null을 담기 위해 만든 Nullable<T> 구조체.


기초 개념 — null은 주소값 0이다

null이란 무엇인가

택배 추적 앱을 떠올려 보자. 배송지 주소칸이 비어 있으면 택배를 어디로 보내야 할지 알 수 없다. C#의 참조 타입 변수도 마찬가지다. 참조 타입 변수(class, string, array, interface)는 값 자체를 담지 않는다. 힙(Heap, 프로그램이 동적으로 메모리를 할당하는 영역) 어딘가에 있는 객체의 주소를 담는다. null은 그 주소칸에 0이 채워진 상태다.

운영체제는 주소 0번지를 의도적으로 사용 불가 영역으로 비워두기 때문에, 이 주소로 무언가를 하려는 순간 CLR(Common Language Runtime, .NET 프로그램을 실행하는 가상 머신)이 감지하고 예외를 던진다.

스택 (Stack)
C#
using System;

class Player { public string Name; }

class Program
{
    static void Main()
    {
        Player player = new Player { Name = "Hero" }; // 힙 주소를 담음
        Player enemy  = null;                         // 주소칸에 0

        Console.WriteLine(player.Name); // "Hero"
        Console.WriteLine(enemy.Name);  // NullReferenceException!
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    .locals init (
        [0] class Player,   // 스택에 주소칸(참조) 확보
        [1] class Player
    )
    IL_0001: newobj instance void Player::.ctor()   // 힙에 Player 객체 생성
    IL_0006: dup
    IL_0007: ldstr "Hero"
    IL_000c: stfld string Player::Name              // Name 필드 초기화
    IL_0011: stloc.0                                // player 변수에 주소 저장
    IL_0012: ldnull                                 // 스택에 0(null)을 올림
    IL_0013: stloc.1                                // enemy = null
    IL_0014: ldloc.0
    IL_0015: callvirt instance string Player::get_Name() // null 체크 포함
    IL_001a: call void System.Console::WriteLine(string)
    IL_001f: ldloc.1
    IL_0020: callvirt instance string Player::get_Name() // enemy가 null → NRE 발생
}

callvirt 명령어는 메서드를 호출하기 전에 암묵적으로 this가 null인지 확인한다. ldnull → stloc으로 저장된 enemy를 callvirt로 호출하는 순간, CLR이 주소값이 0임을 감지하고 NullReferenceException을 던진다.

값 타입은 왜 null이 안 되는가

int, float, bool, struct 같은 값 타입(Value Type)은 힙이 아닌 스택이나 객체 내부에 값 자체를 직접 저장한다. int 변수를 선언하면 4바이트가 정수값으로 채워지는데, "아무것도 없음"을 나타낼 여분의 비트 패턴이 없다. int의 모든 비트 조합(0~4,294,967,295)이 이미 유효한 값이기 때문이다.

C#
int score = 0;        // 유효한 값
// int score = null;  // 컴파일 에러 — CS0037: null을 int에 할당할 수 없음

동작 원리 — CLR이 callvirt로 null을 잡아내는 방법

NullReferenceException이 발생하는 원리

NullReferenceException이 발생하는 원리는 CPU와 OS 수준까지 내려간다.

C# 컴파일러는 인스턴스 메서드를 호출할 때 가상 메서드든 비가상 메서드든 거의 항상 callvirt를 사용한다. callvirt는 실행 전에 this 포인터(호출 대상 객체의 주소)가 null인지 확인한다. null이면 CLR이 NullReferenceException을 발생시킨다.

callvirt 명령어의 null 체크 흐름
C#
using System;

class NullCheck
{
    static void Main()
    {
        string name = null;
        try
        {
            int len = name.Length; // callvirt → null 체크 → NRE
        }
        catch (NullReferenceException)
        {
            Console.WriteLine("NRE 발생");
        }
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    .locals init (
        [0] string,      // name
        [1] int32        // len
    )
    IL_0001: ldnull                                                   // 스택에 null(0x0) 적재
    IL_0002: stloc.0                                                  // name = null
    .try
    {
        IL_0004: ldloc.0                                              // name을 스택에 올림
        IL_0005: callvirt instance int32 System.String::get_Length()  // null 검사 후 호출
        IL_000a: stloc.1
        IL_000c: leave.s IL_001e
    }
    catch System.NullReferenceException
    {
        IL_000e: pop
        IL_0010: ldstr "NRE 발생"                                     // 예외 처리 경로
        IL_0015: call void System.Console::WriteLine(string)
        IL_001c: leave.s IL_001e
    }
    IL_001e: ret
}

callvirtget_Length()를 호출하기 전에 name의 주소가 0인지 확인한다. ldnull → stloc.0으로 저장된 값이 0이므로 CLR이 NullReferenceException을 던지고 catch 블록으로 흐름이 이동한다.

참고 C# 컴파일러가 비가상 메서드에도 callvirt를 쓰는 이유: call을 쓰면 null 체크 없이 메서드 포인터로 직접 점프하여 프로세스가 예측 불가 방식으로 충돌할 수 있다. callvirt는 null 체크를 강제함으로써 안전하고 예측 가능한 예외 발생을 보장한다.

특징 — Nullable<T>와 null 연산자 삼총사

Nullable<T> — 값 타입에 "없음"을 추가하는 래퍼

값 타입은 기본적으로 null을 가질 수 없다. 하지만 데이터베이스의 빈 정수 컬럼처럼 "값이 없는 상태"를 표현해야 할 때가 있다. C#은 이를 위해 Nullable<T> 구조체를 제공한다. int?Nullable<int>의 축약형이다.

Nullable<T> Boxing 동작
C#
using System;

class Program
{
    static void Main()
    {
        // HasValue=true → 내부 T 값만 boxing
        int? hasValue = 42;
        object boxed = hasValue;
        Console.WriteLine(boxed);             // "42"

        // HasValue=false → null 참조를 boxing
        int? noValue = null;
        object boxedNull = noValue;
        Console.WriteLine(boxedNull == null); // True
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    .locals init (
        [0] valuetype System.Nullable`1<int32>,  // int?
        [1] object,
        [2] valuetype System.Nullable`1<int32>,
        [3] object
    )
    IL_0001: ldloca.s 0
    IL_0003: ldc.i4.s 42
    IL_0005: call instance void System.Nullable`1<int32>::.ctor(!0)  // HasValue=true, Value=42
    IL_000a: ldloc.0
    IL_000b: box valuetype System.Nullable`1<int32>  // CLR 특별 처리: HasValue=true → int(42)만 boxing
    IL_0010: stloc.1                                 // boxed = int 박스
    IL_0011: ldloc.1
    IL_0012: call void System.Console::WriteLine(object)
    IL_0018: ldloca.s 2
    IL_001a: initobj valuetype System.Nullable`1<int32>  // HasValue=false 상태로 0 초기화
    IL_0020: ldloc.2
    IL_0021: box valuetype System.Nullable`1<int32>  // CLR 특별 처리: HasValue=false → null 반환
    IL_0026: stloc.3                                 // boxedNull = null
    IL_0027: ldloc.3
    IL_0028: ldnull
    IL_0029: ceq                                     // null인지 비교 → true
    IL_002b: call void System.Console::WriteLine(bool)
    IL_0031: ret
}

box valuetype Nullable<int32> 명령어 하나지만 CLR이 런타임에 HasValue를 확인해 두 가지 다른 동작을 수행한다. HasValue=true이면 래퍼 구조체가 아닌 내부의 T 값(42)만 박싱하고, HasValue=false이면 순수한 null 참조를 반환한다.

null 연산자 삼총사 — ??, ?., ??=

세 연산자는 모두 null을 안전하게 처리하기 위한 도구지만, 각각 다른 문제를 해결한다.

세 연산자의 역할
C#
using System;
using System.Collections.Generic;

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

class Program
{
    static List<string> _log;

    static void Main()
    {
        Player player = null;

        // ?. — null이면 전체 체인 단락
        int? damage = player?.Weapon?.Damage; // player가 null → damage = null(HasValue=false)
        Console.WriteLine(damage.HasValue);   // False

        // ?? — null이면 대체값
        string name = null;
        string display = name ?? "Guest";
        Console.WriteLine(display);           // "Guest"

        // ??= — null일 때만 할당 (지연 초기화)
        _log ??= new List<string>();
        _log.Add("initialized");
        Console.WriteLine(_log.Count);        // 1
    }
}
IL
// ?. 연산자의 IL 분기 구조
.locals init (
    [0] string,                              // name
    [1] valuetype System.Nullable`1<int32>,  // 결과는 항상 int?
    [2] string,
    [3] valuetype System.Nullable`1<int32>   // 임시 버퍼
)
IL_0001: ldnull
IL_0002: stloc.0                             // name = null
// ?. 분기
IL_0003: ldloc.0
IL_0004: brtrue.s IL_0011   // null이 아니면 Length 호출로 점프
// null 경로: Nullable<int>(HasValue=false) 반환
IL_0006: ldloca.s 3
IL_0008: initobj valuetype System.Nullable`1<int32>
IL_000e: ldloc.3
IL_000f: br.s IL_001c       // 결과 저장 위치로 점프
// not-null 경로: Length 호출 후 Nullable<int>로 래핑
IL_0011: ldloc.0
IL_0012: call instance int32 System.String::get_Length()  // callvirt 아닌 call!
IL_0017: newobj instance void System.Nullable`1<int32>::.ctor(!0) // int를 int?로 래핑
IL_001c: stloc.1            // length에 저장 (두 경로가 합류)
// ?? 분기
IL_001d: ldloc.0            // name 로드
IL_001e: dup
IL_001f: brtrue.s IL_0027   // null이 아니면 그대로 사용
IL_0021: pop                // null이면 스택에서 제거
IL_0022: ldstr "Guest"      // 대체값 로드
IL_0027: stloc.2            // result = "Guest" 또는 name

?.은 IL 수준에서 brtrue.s 분기로 구현된다. null이면 initobj(HasValue=false Nullable)를, null이 아니면 실제 메서드를 호출하고 newobjNullable<T>에 래핑한다. 두 경로가 같은 stloc 주소(IL_001c)에서 합류한다.

💡 참고 ?.call(비가상 디스패치)을 사용한다. brtrue.s로 이미 null이 아님을 확인했으므로 callvirt의 추가 null 체크가 불필요하다.

장단점 — null을 언제 쓰고 언제 피해야 하는가

장점

"없음"을 타입 시스템으로 표현한다

Unity에서 씬에 스폰되지 않은 적, 로드되지 않은 리소스, 선택되지 않은 아이템을 null로 표현하면 직관적이다.

C#
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public GameObject CurrentTarget; // 타겟 없음 = null

    void Update()
    {
        if (CurrentTarget == null)  // Unity 권장 방식
        {
            FindNewTarget();
            return;
        }
        MoveToward(CurrentTarget.transform.position);
    }

    void FindNewTarget()         { /* 생략 */ }
    void MoveToward(Vector3 pos) { /* 생략 */ }
}

IL 레벨에서 CurrentTarget == nullldnull → ceq 명령어로 변환된다. 힙 할당 없이 주소 비교만 수행한다.

Nullable<T>로 값 타입에도 "없음" 상태를 안전하게 추가한다

데이터베이스 쿼리 결과, 선택적 설정값 등에서 "아직 설정되지 않음"과 "기본값 0"을 구분해야 할 때 유용하다.

C#
using System;

struct PlayerStats
{
    public int?   BestScore; // null = 아직 플레이 안 함, 0 = 0점
    public float? LastPing;  // null = 네트워크 미연결
}

class Program
{
    static void Main()
    {
        PlayerStats stats = new PlayerStats();
        string display = stats.BestScore.HasValue
            ? stats.BestScore.Value.ToString()
            : "기록 없음";
        Console.WriteLine(display); // "기록 없음"
    }
}

stats.BestScore.HasValueldfld bool Nullable<int>::hasValue IL 명령어로 변환된다. bool 필드 하나를 읽는 것이므로 힙 할당이 없다.

단점

null은 호출 스택 어디에서도 터질 수 있다

null이 메서드 인자로 전달되거나 반환값으로 흘러갈 때, 예측 불가 지점에서 NullReferenceException이 터진다.

C#
using UnityEngine;

public class WeaponSystem : MonoBehaviour
{
    // ❌ null이 전파되어 예측 불가 지점에서 터짐
    void BadPattern()
    {
        var weapon = GetComponent<Weapon>();  // 없으면 null 반환
        int damage = weapon.BaseDamage;       // weapon이 null이면 NRE
        Debug.Log($"Damage: {damage}");
    }

    // ✅ 경계에서 null을 바로 차단
    void GoodPattern()
    {
        var weapon = GetComponent<Weapon>();
        if (weapon == null)
        {
            Debug.LogWarning("Weapon 컴포넌트 없음");
            return;
        }
        Debug.Log($"Damage: {weapon.BaseDamage}");
    }
}

Nullable<T> boxing은 핫패스에서 GC 부담이 된다

Update 루프 같은 핫패스(Hot path, 매 프레임 실행되는 코드 경로)에서 Nullable<T>object로 boxing하면 매 프레임 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 할당이 발생한다.

C#
using System;
using System.Collections.Generic;

class HotPathExample
{
    // ❌ 매 호출마다 boxing — GC 부담
    static void BadLog(object value)   { Console.WriteLine(value); }

    // ✅ 값 타입을 그대로 처리 — boxing 없음
    static void GoodLog(int? value)
    {
        if (value.HasValue) Console.WriteLine(value.Value);
        else                Console.WriteLine("null");
    }

    static void Main()
    {
        int? score = 100;
        BadLog(score);  // box valuetype Nullable<int> → 힙 할당
        GoodLog(score); // HasValue 필드 직접 접근 → 힙 할당 없음
    }
}

BadLog(score) 호출 시 IL에 box valuetype Nullable<int32> 명령어가 삽입되어 매 호출마다 힙 객체가 생성된다. Update 루프에서 호출되면 초당 60번 GC 할당이 발생한다. GoodLogldfld bool Nullable<int>::hasValueldfld int Nullable<int>::value로 스택에서 직접 읽으므로 힙 할당이 없다.


주로 실수하는 부분

Unity에서 ?.??UnityEngine.Object에 사용한다.

Unity의 모든 컴포넌트와 게임오브젝트는 UnityEngine.Object를 상속한다. Unity는 이 클래스의 == 연산자를 오버로딩하여 C++ 네이티브 엔진 객체가 파괴된 경우에도 null을 반환하도록 만들었다.

Destroy(go)를 호출하면 C++ 측 네이티브 객체는 즉시 파괴되지만, C# 관리 래퍼 객체는 GC가 수거할 때까지 메모리에 남는다. Unity는 이 상태를 Fake Null이라 부른다.

Unity Fake Null — Destroy(go) 이후 메모리 상태
C#
using UnityEngine;

public class FakeNullDemo : MonoBehaviour
{
    void BadPattern()
    {
        GameObject go = gameObject;
        Destroy(go);

        // ❌ ?. 연산자는 C# 참조가 진짜 null인지만 확인
        //    C# 래퍼가 아직 살아있으므로 null이 아닌 것으로 판단
        //    → C++ 네이티브 객체가 없으니 MissingReferenceException 발생
        string name = go?.name;

        // ❌ ?? 연산자도 동일하게 오동작
        GameObject safe = go ?? this.gameObject; // go가 Fake Null → safe = go (파괴된 객체!)
    }

    void GoodPattern()
    {
        GameObject go = gameObject;
        Destroy(go);

        // ✅ Unity의 == 오버로딩을 사용 — Fake Null도 올바르게 감지
        if (go == null)
        {
            Debug.Log("go가 파괴됨");
            return;
        }
        string name = go.name;
    }
}

IL 레벨에서 go?.namebrtrue.s로 C# 참조가 CLR 메모리에서 null인지만 검사한다. Unity의 == 오버로딩을 전혀 호출하지 않는다. 반면 go == nullcall static bool UnityEngine.Object::op_Equality(Object, Object)를 통해 Unity의 오버로딩된 연산자를 호출한다.

연산자 Unity == 오버로딩 호출 Fake Null 감지 권장 여부
== null ✅ 호출 ✅ 감지 ✅ 권장
?. ❌ 미호출 ❌ 미감지 ❌ 금지
?? ❌ 미호출 ❌ 미감지 ❌ 금지
??= ❌ 미호출 ❌ 미감지 ❌ 금지
⚠️ 주의 순수 C# 클래스(MonoBehaviour가 아닌)에는 ?.??를 자유롭게 사용할 수 있다. 제약은 UnityEngine.Object 파생 클래스에만 적용된다.

C# 버전별 개선점

C# 2.0 — Nullable<T> 도입

이전에는 값 타입에 null을 담으려면 매직 값(-1, int.MinValue 등)을 사용하거나 별도의 bool 플래그를 만들어야 했다.

C#
// Before — C# 1.x: 매직 값 패턴 (모호함)
int bestScore = -1; // -1을 "기록 없음"의 의미로 사용

// After — C# 2.0: Nullable<T>
int? bestScore = null; // "기록 없음"이 타입 시스템에 표현됨
if (bestScore.HasValue)
    Console.WriteLine($"최고 점수: {bestScore.Value}");

C# 6.0 — null 조건 연산자 (?.) 및 null 병합 대입 기반 완성

긴 null 방어 코드를 한 줄로 줄였다.

C#
// Before — C# 5 이하: 방어 코드 중첩
string guildName = null;
if (player != null && player.Guild != null)
    guildName = player.Guild.Name;

// After — C# 6: ?. 체이닝
string guildName = player?.Guild?.Name; // null이면 null 전파

C# 8.0 — Nullable Reference Types (NRT)

NRT(Nullable Reference Types)를 활성화(#nullable enable)하면 컴파일러가 코드 흐름을 정적으로 분석해 null 안전성을 경고로 알려준다.

C#
#nullable enable

// Before: null 허용 여부 불분명
string GetPlayerName() { return null; }

// After: 타입 선언에 의도 명시
string  GetSafeName()     { return "Guest"; }  // non-nullable
string? GetOptionalName() { return null; }     // nullable

void UseNames()
{
    string? b = GetOptionalName();
    Console.WriteLine(b.Length);    // ⚠️ CS8602 경고 — null 역참조 가능성
    if (b != null)
        Console.WriteLine(b.Length); // 안전 — 컴파일러가 not-null 상태 추적
}

NRT는 런타임 동작을 바꾸지 않는다. IL 수준에서는 기존 코드와 동일하며, 컴파일러가 정적 분석 정보를 어트리뷰트로 메타데이터에 기록할 뿐이다. Unity 2021.2부터 NRT를 지원한다.


핵심 정리

  • null = 주소 0 — 참조 타입 변수의 주소칸이 비어있는 상태. 역참조 시 CLR이 callvirt에서 감지해 NullReferenceException을 던진다.
  • Nullable<T> = 값 타입에 null을 추가하는 구조체HasValue + Value 필드로 구성. boxing 시 CLR이 특별 처리해 HasValue=false이면 순수 null, HasValue=true이면 T 값만 박싱한다.
  • ?? · ?. · ??= — null을 안전하게 처리하는 연산자. IL 수준에서 brtrue.s 분기 명령어로 구현된다.
  • Unity Fake NullDestroy() 후 C# 래퍼는 살아있으므로 ?./??가 오동작한다. UnityEngine.Object 파생 클래스에는 반드시 == null을 사용한다.
  • NRT (C# 8) — 컴파일 타임 null 안전성 분석. Unity 2021.2부터 지원.

자가 점검 체크리스트

  • [ ] NullReferenceException이 발생하는 코드를 보면 callvirt와 연결 지어 생각할 수 있는가?
  • [ ] int?Nullable<int>의 축약형임을 알고, boxing 시 특수 처리를 이해하는가?
  • [ ] Unity에서 ?.??를 컴포넌트에 쓰지 말아야 하는 이유를 설명할 수 있는가?
  • [ ] Fake Null 상태를 올바르게 감지하는 코드를 작성할 수 있는가?
  • [ ] NRT #nullable enable이 무엇을 바꾸는지(컴파일 타임 경고)와 무엇을 바꾸지 않는지(런타임 동작)를 구분하는가?