| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |
- 오공완
- 암호화
- adfit
- 직장인자기계발
- TextMeshPro
- Job 시스템
- 패스트캠퍼스
- ui
- 패스트캠퍼스후기
- Framework
- job
- Tween
- 최적화
- 직장인공부
- AES
- C#
- RSA
- Unity Editor
- 환급챌린지
- base64
- unity
- Dots
- 가이드
- Custom Package
- DotsTween
- 샘플
- sha
- 2D Camera
- 게임개발
- 프레임워크
- Today
- Total
EveryDay.DevUp
null이란 무엇인가 — null의 두 얼굴 본문
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 프로그램을 실행하는 가상 머신)이 감지하고 예외를 던진다.

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!
}
}
.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)이 이미 유효한 값이기 때문이다.
int score = 0; // 유효한 값
// int score = null; // 컴파일 에러 — CS0037: null을 int에 할당할 수 없음
동작 원리 — CLR이 callvirt로 null을 잡아내는 방법
NullReferenceException이 발생하는 원리
NullReferenceException이 발생하는 원리는 CPU와 OS 수준까지 내려간다.
C# 컴파일러는 인스턴스 메서드를 호출할 때 가상 메서드든 비가상 메서드든 거의 항상 callvirt를 사용한다. callvirt는 실행 전에 this 포인터(호출 대상 객체의 주소)가 null인지 확인한다. null이면 CLR이 NullReferenceException을 발생시킨다.

using System;
class NullCheck
{
static void Main()
{
string name = null;
try
{
int len = name.Length; // callvirt → null 체크 → NRE
}
catch (NullReferenceException)
{
Console.WriteLine("NRE 발생");
}
}
}
.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
}
callvirt가 get_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>의 축약형이다.

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
}
}
.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을 안전하게 처리하기 위한 도구지만, 각각 다른 문제를 해결한다.

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 분기 구조
.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이 아니면 실제 메서드를 호출하고 newobj로 Nullable<T>에 래핑한다. 두 경로가 같은 stloc 주소(IL_001c)에서 합류한다.
💡 참고?.이call(비가상 디스패치)을 사용한다.brtrue.s로 이미 null이 아님을 확인했으므로callvirt의 추가 null 체크가 불필요하다.
장단점 — null을 언제 쓰고 언제 피해야 하는가
장점
"없음"을 타입 시스템으로 표현한다
Unity에서 씬에 스폰되지 않은 적, 로드되지 않은 리소스, 선택되지 않은 아이템을 null로 표현하면 직관적이다.
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 == null은 ldnull → ceq 명령어로 변환된다. 힙 할당 없이 주소 비교만 수행한다.
Nullable<T>로 값 타입에도 "없음" 상태를 안전하게 추가한다
데이터베이스 쿼리 결과, 선택적 설정값 등에서 "아직 설정되지 않음"과 "기본값 0"을 구분해야 할 때 유용하다.
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.HasValue는 ldfld bool Nullable<int>::hasValue IL 명령어로 변환된다. bool 필드 하나를 읽는 것이므로 힙 할당이 없다.
단점
null은 호출 스택 어디에서도 터질 수 있다
null이 메서드 인자로 전달되거나 반환값으로 흘러갈 때, 예측 불가 지점에서 NullReferenceException이 터진다.
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, 메모리를 자동으로 회수하는 런타임 구성요소) 할당이 발생한다.
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 할당이 발생한다. GoodLog는 ldfld bool Nullable<int>::hasValue와 ldfld int Nullable<int>::value로 스택에서 직접 읽으므로 힙 할당이 없다.
주로 실수하는 부분
Unity에서 ?.와 ??를 UnityEngine.Object에 사용한다.
Unity의 모든 컴포넌트와 게임오브젝트는 UnityEngine.Object를 상속한다. Unity는 이 클래스의 == 연산자를 오버로딩하여 C++ 네이티브 엔진 객체가 파괴된 경우에도 null을 반환하도록 만들었다.
Destroy(go)를 호출하면 C++ 측 네이티브 객체는 즉시 파괴되지만, C# 관리 래퍼 객체는 GC가 수거할 때까지 메모리에 남는다. Unity는 이 상태를 Fake Null이라 부른다.

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?.name은 brtrue.s로 C# 참조가 CLR 메모리에서 null인지만 검사한다. Unity의 == 오버로딩을 전혀 호출하지 않는다. 반면 go == null은 call 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 플래그를 만들어야 했다.
// 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 방어 코드를 한 줄로 줄였다.
// 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 안전성을 경고로 알려준다.
#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 Null —
Destroy()후 C# 래퍼는 살아있으므로?./??가 오동작한다.UnityEngine.Object파생 클래스에는 반드시== null을 사용한다. - NRT (C# 8) — 컴파일 타임 null 안전성 분석. Unity 2021.2부터 지원.
자가 점검 체크리스트
- [ ]
NullReferenceException이 발생하는 코드를 보면callvirt와 연결 지어 생각할 수 있는가? - [ ]
int?가Nullable<int>의 축약형임을 알고, boxing 시 특수 처리를 이해하는가? - [ ] Unity에서
?.와??를 컴포넌트에 쓰지 말아야 하는 이유를 설명할 수 있는가? - [ ] Fake Null 상태를 올바르게 감지하는 코드를 작성할 수 있는가?
- [ ] NRT
#nullable enable이 무엇을 바꾸는지(컴파일 타임 경고)와 무엇을 바꾸지 않는지(런타임 동작)를 구분하는가?
