[PART2.변수와 기본 데이터 타입(12/13)] default 리터럴과 값 타입 nullable (Nullable<T>)
값 타입의 "비어 있음"을 어떻게 표현할 것인가 / default의 진짜 의미 / Nullable<T>가 struct인데 null처럼 쓰이는 이유
1. 문제 제기 — "아직 값이 없음"을 어떻게 표현할 것인가
Unity에서 AI 적이 추격할 목표 위치를 필드로 들고 있다고 가정해 봅시다. 아직 목표를 정하지 않은 "대기 상태"는 어떻게 표현해야 할까요?
public class EnemyAI : MonoBehaviour
{
// 아직 추격할 타겟이 없으면 이 값은 무엇이어야 할까?
private Vector3 targetPosition;
void Update()
{
// 대기 상태를 (0, 0, 0)으로 표현하면?
if (targetPosition == Vector3.zero) { /* 대기 */ }
else { /* 추격 */ }
}
}
Vector3는 구조체(struct)입니다. class가 아니라서 null을 대입할 수 없습니다. 그래서 많은 신입 개발자가 Vector3.zero나 -1 같은 특수값(magic number) 을 "값 없음"의 신호로 쓰다가 다음과 같은 버그에 걸립니다.
- 원점 근처에 있는 적이 "대기 상태"로 오인되어 멈춥니다.
- 체력이
-1로 들어오면 "데이터 없음"인지 "음수 체력"인지 구분할 수 없습니다. - 서버 JSON에서
lastLoginDay필드가 누락된 것과lastLoginDay = 0인 것이 같게 처리됩니다.
값 타입은 원래 null을 가질 수 없는데, 실무에서는 "0"과 "값 없음"은 분명히 다른 의미인 경우가 많습니다. 이 틈을 메우기 위해 C#은 두 가지 도구를 제공합니다. default 리터럴(모든 타입의 0-비트 초기값을 꺼내는 방법)과 Nullable<T>(값 타입에 "비어 있음"을 얹는 구조체 래퍼).
이 글이 끝나면 다음을 구분해서 쓸 수 있게 됩니다.
- 언제
default를 쓰고 언제Nullable<T>를 쓰는가 int? x = null이 실제로는 어떤 메모리 구조인가Nullable<T>가struct인데 왜null과 비교가 되는가 (박싱 특별 규칙)Value·HasValue·GetValueOrDefault()중 어떤 것을 언제 쓰는가
2. 개념 정의
2.1 default — 모든 타입의 "0-비트" 초깃값
default는 어떤 타입이든 "메모리를 모두 0으로 채웠을 때의 상태" 를 꺼내는 키워드입니다. 값 타입에는 0·false·'\0'·(0,0,0) 같은 "빈 기본값"을, 참조 타입에는 null을 돌려줍니다.
비유하자면, 새 공책을 펼쳤을 때의 빈 페이지입니다. 공책이 숫자 공책이면 빈 칸마다 0이 찍혀 있고, 체크박스 공책이면 체크가 안 되어 있고, 주소록 공책이면 연락처 칸이 비어 있습니다. default는 이 "갓 찍어낸 공책 상태"를 타입별로 꺼내는 명령입니다.

// 값 타입들의 default
int a = default; // 0
bool b = default; // false
char c = default; // '\0' (null 문자)
DateTime d = default; // DateTime.MinValue (1/1/0001)
// 사용자 struct의 default
public struct Point { public int X; public int Y; }
Point p = default; // X=0, Y=0 (모든 필드가 0)
// 참조 타입의 default
string s = default; // null
List<int> list = default; // null
// Nullable의 default
int? n = default; // null (HasValue=false)
default— 기본값 리터럴 (default literal) 어떤 타입의 "모든 비트가 0인 상태"를 반환합니다. 값 타입은 타입별 0-기본값(0·false·'\0'·구조체의 0-필드), 참조 타입과Nullable<T>는null이 됩니다.
예시:int x = default;x에0이 대입됩니다. 컴파일러가 좌변 타입을 추론해default(int)로 해석합니다 (C# 7.1+).
쉬운 설명: default는 "이 타입의 빈 상태"를 말합니다. 숫자면 0, 참·거짓이면 거짓, 참조면 null. 제네릭 코드에서 "지금 T가 뭔지 몰라도 빈 값 하나 줘"라고 할 때 가장 많이 씁니다.
기술 정의: default(T)는 CLR(Common Language Runtime, .NET 런타임)이 해당 타입의 크기만큼 메모리를 0으로 초기화한 상태의 값을 반환합니다. default 리터럴은 C# 7.1부터 타깃 타입 추론이 가능해져 default(T)의 T를 생략할 수 있게 된 문법 축약입니다.
2.2 Nullable<T> — 값 타입에 "비어 있음"을 얹는 래퍼 구조체
Nullable<T>는 값 타입을 안에 품고 "값이 있는지 없는지" 플래그(bool)를 하나 더 붙여 놓은 작은 구조체입니다. int?는 Nullable<int>의 문법 축약일 뿐, 내부는 struct 그대로입니다.
비유하자면 택배 상자입니다. 안에 내용물(T value)이 들어 있는지 여부를 겉면 스티커(bool hasValue)에 표시해 둡니다. 스티커가 "비었음"이면 내부 내용물은 0으로 채워진 채 버려지고, "값 있음"이면 내부의 실제 값을 꺼내 씁니다.

// int?는 Nullable<int>의 축약 표기
int? a = null; // hasValue=false, value=0
int? b = 42; // hasValue=true, value=42
// .NET 내부 Nullable<T> 구조 (개념)
public struct Nullable<T> where T : struct
{
private readonly bool hasValue;
internal readonly T value;
public bool HasValue => hasValue;
public T Value => hasValue ? value : throw new InvalidOperationException();
public T GetValueOrDefault() => value;
public T GetValueOrDefault(T defaultValue) => hasValue ? value : defaultValue;
}
쉬운 설명: 값 타입에 "비었음 스티커"를 붙인 작은 상자입니다. struct라서 힙 할당이 없습니다.
기술 정의: Nullable<T>는 where T : struct 제약을 가진 제네릭 구조체로, private bool hasValue와 internal T value 두 필드를 갖습니다. C# 컴파일러가 T? 문법과 ??·== null·리프팅 연산자(lifted operator, 값 타입 연산자를 Nullable<T>에 맞게 자동 변환한 것) 등을 Nullable<T>의 멤버 호출로 변환합니다.
2.3 구분 — default vs Nullable<T> vs NRT(C# 8)
| 구분 | 값 타입의 기본값 | "비어 있음" 표현 | 런타임 타입 |
|---|---|---|---|
default(int) |
0 |
0일 뿐, 비어 있음 아님 | int |
default(int?) |
null |
비어 있음 | Nullable<int> (hasValue=false) |
default(string) |
null |
자동으로 비어 있음 | string (null 참조) |
string? (C# 8 NRT) |
null |
컴파일러 경고만 추가 | string (런타임 동일) |
핵심 차이: int?는 런타임에 실존하는 Nullable<int> 구조체입니다. 반면 string?(C# 8 nullable 참조 타입, 이하 NRT)은 런타임에는 그냥 string이고, 컴파일러가 정적 분석용 어노테이션을 달아줄 뿐입니다. 같은 물음표지만 층위가 완전히 다릅니다.
3. 내부 동작
3.1 default의 IL — initobj 혹은 상수 로드로 접힘
default가 실제로 어떻게 컴파일되는지 보겠습니다. 원시 타입과 struct에 대해 IL이 어떻게 달라지는지가 핵심입니다.
public struct Point { public int X; public int Y; }
public static int DefaultInt() => default;
public static bool DefaultBool() => default;
public static char DefaultChar() => default;
public static Point DefaultStruct() => default;
.method public hidebysig static
int32 DefaultInt () cil managed
{
IL_0000: ldc.i4.0 // 정수 0을 스택에 푸시 — 상수로 접힘
IL_0001: ret
}
.method public hidebysig static
bool DefaultBool () cil managed
{
IL_0000: ldc.i4.0 // bool도 int32와 같은 0 상수
IL_0001: ret
}
.method public hidebysig static
char DefaultChar () cil managed
{
IL_0000: ldc.i4.0 // char도 0으로 접힘 ('\0')
IL_0001: ret
}
.method public hidebysig static
valuetype Point DefaultStruct () cil managed
{
.locals init (
[0] valuetype Point
)
IL_0000: ldloca.s 0 // 로컬 슬롯 0의 주소를 스택에
IL_0002: initobj Point // 그 주소의 메모리를 Point 크기만큼 0으로 채움
IL_0008: ldloc.0 // 초기화된 Point 값을 스택에
IL_0009: ret
}
IL 해설: 원시 타입의 default는 JIT이 상수 ldc.i4.0 하나로 접어버립니다. struct는 값이 여러 필드로 구성되므로 ldloca(로컬 주소 로드) + initobj(그 영역을 타입 크기만큼 0으로 초기화) 조합을 사용합니다. initobj가 바로 "해당 타입의 메모리를 0으로 채운다"는 default의 의미를 그대로 구현한 명령어입니다.
3.2 Nullable<T> 생성·접근의 IL — newobj와 ldarga
값이 있는 int?를 만들고, 조건부로 꺼내는 코드를 IL 수준에서 관찰합니다.
public static int? NullableCreate()
{
int? y = 42;
return y;
}
public static int NullableAccess(int? v)
{
if (v.HasValue) return v.Value;
return v.GetValueOrDefault(-1);
}
.method public hidebysig static
valuetype [System.Runtime]System.Nullable`1<int32> NullableCreate () cil managed
{
IL_0000: ldc.i4.s 42
IL_0002: newobj instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)
// ↑ Nullable<int>의 생성자로 42를 감싼다 (hasValue=true로 설정됨)
IL_0007: ret
}
.method public hidebysig static
int32 NullableAccess (
valuetype [System.Runtime]System.Nullable`1<int32> v
) cil managed
{
IL_0000: ldarga.s v // v의 "주소"를 스택에 — 복사 방지
IL_0002: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
IL_0007: brfalse.s IL_0011 // HasValue가 false면 GetValueOrDefault로 점프
IL_0009: ldarga.s v
IL_000b: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::get_Value()
IL_0010: ret // HasValue==true: 내부 value 리턴
IL_0011: ldarga.s v
IL_0013: ldc.i4.m1 // -1 상수 푸시
IL_0014: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault(!0)
IL_0019: ret // HasValue==false: -1 리턴
}
IL 해설: 주목할 건 두 가지입니다.
newobj ... Nullable<int32>::.ctor(!0)—int? y = 42는 일반적인 참조 타입 객체 생성 같아 보이지만, 실제로는struct Nullable<int>의 생성자를 호출해 스택 위에서 래퍼를 구성합니다. 힙 할당이 없습니다.ldarga.s v+call get_HasValue/get_Value—v가 struct이므로 값을 복사해 스택에 올리지 않고 주소를 로드(ldarga, load argument address) 해서 프로퍼티 메서드를 호출합니다. 덕분에 struct가 커도 복사 비용이 없습니다.
3.3 ?? 연산자와 리프팅 — 컴파일러의 변환 책임
C# 컴파일러는 ??·== null·산술 연산자 같은 구문을 Nullable<T> 멤버 호출로 번역합니다. 이를 리프팅(lifting) 이라고 부릅니다.
public static int NullableCoalesce(int? v) => v ?? 999;
.method public hidebysig static
int32 NullableCoalesce (
valuetype [System.Runtime]System.Nullable`1<int32> v
) cil managed
{
IL_0000: ldarga.s v
IL_0002: ldc.i4 999
IL_0007: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault(!0)
// ↑ v ?? 999는 v.GetValueOrDefault(999) 한 줄로 접힘
IL_000c: ret
}
??— 널 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가null이 아니면 그 값을,null이면 오른쪽 피연산자 값을 반환합니다.Nullable<T>에서는GetValueOrDefault(오른쪽)호출로 변환됩니다.
예시:int score = maybeScore ?? 0;maybeScore가null이면0, 아니면 내부 값이 대입됩니다.
IL 해설: v ?? 999는 분기·HasValue 체크 없이 GetValueOrDefault(999) 한 번의 call로 접힙니다. 컴파일러가 ??의 의미를 이미 Nullable<T> API 한 줄로 매핑해 놓았기 때문입니다. 이게 바로 "리프팅"입니다 — ??라는 참조 타입용 연산자가 값 타입 래퍼에 맞게 자동 변환되는 현상.
4. 실전 적용
4.1 Before/After — "원점" 대신 Vector3?로 "타겟 없음" 표현하기
첫 섹션에서 던진 문제를 실제로 고쳐봅니다.
Before (잘못된 패턴) — 특수값 Vector3.zero를 "없음"으로 사용
public class EnemyAI_Bad : MonoBehaviour
{
private Vector3 targetPosition; // 대기 상태 = Vector3.zero
void Update()
{
if (targetPosition == Vector3.zero) return; // 원점 근처의 적도 "대기"로 오인!
var dir = (targetPosition - transform.position).normalized;
transform.position += dir * 5f * Time.deltaTime;
}
public void SetTarget(Vector3 t) => targetPosition = t;
public void Clear() => targetPosition = Vector3.zero; // 의미가 모호하다
}
After (올바른 패턴) — Vector3?로 "값 없음"을 명시
public class EnemyAI_Good : MonoBehaviour
{
private Vector3? targetPosition; // null = 대기, 값 = 추격
void Update()
{
if (!targetPosition.HasValue) return; // 명시적 "없음" 체크
var dir = (targetPosition.Value - transform.position).normalized;
transform.position += dir * 5f * Time.deltaTime;
if (Vector3.Distance(transform.position, targetPosition.Value) < 0.1f)
targetPosition = null; // "타겟 소실"을 명확히 표현
}
public void SetTarget(Vector3 t) => targetPosition = t;
public void Clear() => targetPosition = null;
}
After의 핵심 코드 IL 확인 (HasValue 체크 부분):
// if (!targetPosition.HasValue) return;
ldarg.0
ldflda valuetype [System.Runtime]System.Nullable`1<valuetype [UnityEngine]UnityEngine.Vector3> EnemyAI_Good::targetPosition
// ↑ 필드의 주소를 로드 (ldflda = load field address) — 복사 없이 접근
call instance bool valuetype [System.Runtime]System.Nullable`1<valuetype [UnityEngine]UnityEngine.Vector3>::get_HasValue()
brtrue.s CONTINUE
ret
ldflda— 필드 주소 로드 (load field address) 인스턴스 필드의 주소를 스택에 올립니다. struct의 필드/프로퍼티에 접근할 때 복사를 피하기 위해 사용합니다.Vector3?처럼 16바이트 이상인 struct에서 성능상 중요합니다.
해설: Vector3?는 Vector3(12바이트)에 hasValue 플래그 + 정렬 패딩이 더해져 16바이트짜리 struct가 됩니다. ldflda로 필드 주소만 건네 HasValue 체크를 수행하므로 값 복사가 일어나지 않습니다. 이 패턴은 Update() 핫패스에서도 GC 할당 0으로 안전합니다.
4.2 Unity 핫패스에서의 GC 안전성
Nullable<T>는 struct이므로 힙 할당이 없고 GC 스파이크를 유발하지 않습니다. GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)는 값 타입을 추적하지 않습니다.
// 매 프레임 호출 — GC 할당 0
public class WeaponOverride : MonoBehaviour
{
public float baseCooldown = 1.0f;
public float? overrideCooldown; // null이면 기본, 값이면 오버라이드
public float CurrentCooldown() => overrideCooldown ?? baseCooldown;
}
// CurrentCooldown() — IL에 newobj·box 없음
.method public hidebysig
instance float32 CurrentCooldown () cil managed
{
IL_0000: ldarg.0
IL_0001: ldflda valuetype [System.Runtime]System.Nullable`1<float32> WeaponOverride::overrideCooldown
IL_0006: ldarg.0
IL_0007: ldfld float32 WeaponOverride::baseCooldown
IL_000c: call instance !0 valuetype [System.Runtime]System.Nullable`1<float32>::GetValueOrDefault(!0)
IL_0011: ret
}
해설: IL에 newobj·box·call 메서드 할당이 없습니다. overrideCooldown ?? baseCooldown은 필드 주소 로드 + GetValueOrDefault 호출 한 번으로 끝납니다. Unity Profiler의 GC Alloc 열이 0으로 유지됩니다.
주의:Nullable<T>자체는 GC와 무관하지만,object·인터페이스로 업캐스팅하면 박싱이 일어나 힙 할당이 발생합니다. 이 함정은 다음 섹션에서 다룹니다.
4.3 서버 JSON 파싱 — "필드 누락"과 "값 0" 구분
모바일 게임에서 서버 응답을 파싱할 때 특정 필드가 "서버가 안 내려준 것"인지 "0을 내려준 것"인지 구분해야 하는 경우가 많습니다.
[Serializable]
public class PlayerData
{
public string name;
public int level;
public int? lastLoginDay; // 신규 유저면 null
public float? giftClaimedAt; // 받은 적 없으면 null, 받았으면 타임스탬프
}
// 사용
void HandleLogin(PlayerData d)
{
if (!d.lastLoginDay.HasValue)
ShowWelcome(d.name);
else
ShowDailyReward(d.lastLoginDay.Value);
// ?? 로 기본값 합치기
float claimTime = d.giftClaimedAt ?? 0f;
}
주의: Unity 내장JsonUtility는Nullable<T>직렬화·역직렬화를 공식 지원하지 않습니다. 공식 문서(관찰 사실: [Unity Manual — JsonUtility](https://docs.unity3d.com/ScriptReference/JsonUtility.html))는 지원 타입 목록에Nullable<T>를 포함하지 않습니다. 실측 결과 직렬화가 기대한 대로 동작하지 않는 경우가 많으므로,int?같은 필드가 필요하면Newtonsoft.Json(Unity 공식 패키지com.unity.nuget.newtonsoft-json등)을 사용하거나,bool hasLastLoginDay+int lastLoginDay쌍으로 수동 분리하는 것이 안전합니다.
5. 함정과 주의사항
5.1 박싱 특별 규칙 — int? n = null; object o = n;이 왜 o == null인가
Nullable<T>는 박싱 시 CLR이 특별 취급합니다. 이 규칙을 모르면 디버깅할 때 크게 혼란스럽습니다.
int? a = null;
object o1 = a; // o1은... null? Nullable<int> 인스턴스?
int? b = 42;
object o2 = b; // o2는 Nullable<int>? int?
Console.WriteLine(o2.GetType()); // 뭐가 찍힐까?
실측 결과 (이 글을 쓰면서 실제로 실행한 결과):
o1 == null? True
o1 is null? True
o2.GetType() System.Int32
o2 is int? True
o2 is int? (is Nullable) True
놀라운 점 두 가지입니다.
HasValue=false인 Nullable을 박싱하면 진짜null참조가 된다 —Nullable<int>박스가 만들어지지 않습니다.HasValue=true인 Nullable을 박싱하면 내부T값만 박싱된다 —o2.GetType()이System.Int32이지System.Nullable<Int32>가 아닙니다.
IL로 보면 이 특별 규칙이 더 명확합니다.
public static object NullableBoxingNull()
{
int? n = null;
object o = n;
return o;
}
.method public hidebysig static
object NullableBoxingNull () cil managed
{
.locals init (
[0] valuetype [System.Runtime]System.Nullable`1<int32>
)
IL_0000: ldloca.s 0
IL_0002: initobj valuetype [System.Runtime]System.Nullable`1<int32>
// ↑ n은 default 상태(hasValue=false)로 초기화
IL_0008: ldloc.0
IL_0009: box valuetype [System.Runtime]System.Nullable`1<int32>
// ↑ IL에는 box 명령어가 찍혀 있지만...
IL_000e: ret
// ↑ CLR이 런타임에 box Nullable<T>를 특별 처리:
// hasValue=false면 null 참조 반환, true면 내부 T만 박싱
}
IL 해설: IL 수준에서 box Nullable<int>는 일반 박싱 명령과 동일합니다. 차이는 CLR에 있습니다. ECMA-335 스펙(III.4.1 box 명령 정의)은 Nullable<T>에 대해 예외 규칙을 명시합니다 — HasValue=false면 null 참조를 반환, HasValue=true면 기저 타입 T만 박싱. 이 덕분에 int?의 박스를 null 체크해도, GetType()으로 타입 확인해도 자연스러운 참조 타입처럼 동작합니다.
❌ 함정 패턴 — List<object>에 int?를 담은 뒤 원복
int? original = 42;
object boxed = original; // int로 박싱됨 (Nullable<int>가 아님)
int? restored = (int?)boxed; // unbox 과정에서 다시 Nullable로 래핑됨
Debug.Log(restored.HasValue); // True, 42
// 실수 포인트: boxed의 런타임 타입은 int이지만, (int?) 캐스트가 안전하게 복원해줌
✅ 안전한 패턴 — 애초에 박싱 피하기 (Unity 핫패스)
// ❌ Nullable<int>를 자주 object로 업캐스트 (박싱 발생)
void DispatchEvent(object payload) { /* ... */ }
DispatchEvent((int?)42); // 박싱!
// ✅ 제네릭으로 박싱 회피
void DispatchEvent<T>(T payload) where T : struct { /* ... */ }
DispatchEvent<int?>(42); // 박싱 없음
5.2 Value 직접 접근의 위험
Value를 HasValue 체크 없이 바로 꺼내면 InvalidOperationException이 터집니다.
❌ 잘못된 패턴
int? score = GetScoreFromServer(); // null일 수 있음
int display = score.Value; // score가 null이면 런타임 예외!
✅ 올바른 패턴 — ??·GetValueOrDefault 사용
int? score = GetScoreFromServer();
// 방법 1: ?? 연산자 (가장 권장)
int display = score ?? 0;
// 방법 2: GetValueOrDefault
int display2 = score.GetValueOrDefault(0);
// 방법 3: 명시적 체크
int display3 = score.HasValue ? score.Value : 0;
왜 방법 1이 가장 빠른가 — IL이 GetValueOrDefault(0) 한 번의 call로 접히기 때문입니다. 방법 3은 HasValue + Value 두 번의 메서드 호출이 발생합니다.
5.3 제네릭 메서드에서 T?의 함정 — where T : struct 제약
T?를 쓰려면 제네릭 제약이 필요합니다. 제약이 없으면 컴파일 에러입니다.
❌ 잘못된 패턴
// CS8627: 매개 변수 T는 Nullable로 만들 수 없음
public static T? SafeGet<T>(Dictionary<string, T> dict, string key)
{
return dict.TryGetValue(key, out var v) ? v : default;
}
✅ 올바른 패턴 — 제약으로 의도 명시
// 값 타입 전용 — Nullable<T>가 된다
public static T? SafeGetStruct<T>(Dictionary<string, T> dict, string key)
where T : struct
{
return dict.TryGetValue(key, out var v) ? v : (T?)null;
}
// 참조 타입 전용 — NRT 의미 (런타임엔 그냥 T)
public static T? SafeGetClass<T>(Dictionary<string, T> dict, string key)
where T : class
{
return dict.TryGetValue(key, out var v) ? v : null;
}
해설: T?가 where T : struct일 때는 실제 Nullable<T> 구조체가 되고, where T : class일 때는 NRT(참조 타입 nullable 어노테이션) 가 됩니다. 런타임 동작이 완전히 다릅니다. C# 9부터 where T : default와 T?의 조합으로 두 경우를 모두 허용하는 API 설계도 가능하지만(Dictionary<TKey, TValue>.TryGetValue가 그 예), 신입 단계에서는 제약으로 의도를 명확히 하는 편이 실수를 줄일 수 있습니다.
5.4 default(int?) vs new int?() — 둘 다 null
미세한 문법 차이지만 의미는 같습니다.
int? a = default; // null (hasValue=false)
int? b = default(int?); // null
int? c = new int?(); // null — 매개변수 없는 struct 생성자 호출
int? d = null; // null — 컴파일러가 Nullable<int>로 변환
int? e = new Nullable<int>(); // null
모두 같은 결과이지만, 가독성상 null 리터럴을 쓰는 것이 가장 권장됩니다.
6. C# 버전별 변화
6.1 C# 2.0 (2005) — Nullable<T>와 T? 문법 도입
C# 2.0에서 제네릭과 함께 Nullable<T>가 처음 추가되었고, 동시에 int? 문법 축약이 지원되었습니다. 그 이전에는 값 타입의 "없음"을 표현할 방법이 없어 특수값(-1, Int32.MinValue)을 써야 했습니다.
// C# 1.x 시대 (Nullable 없음)
int lastLogin = -1; // "없음"을 의미하는 특수값
// C# 2.0 이후
int? lastLogin = null; // 명시적 "없음"
6.2 C# 7.1 (2017) — Target-typed default 리터럴
C# 7.0까지는 default(T)처럼 타입을 반드시 명시해야 했습니다. C# 7.1부터 컴파일러가 좌변·반환 타입에서 추론할 수 있으면 default 리터럴 하나로 축약할 수 있게 되었습니다.
Before (C# 7.0 이전)
int a = default(int);
List<Player> list = default(List<Player>);
TimeSpan timeout = default(TimeSpan);
void Fn(CancellationToken ct = default(CancellationToken)) { }
public T? FindOrDefault<T>(Predicate<T> p) where T : struct
{
foreach (var x in items) if (p(x)) return x;
return default(T?);
}
After (C# 7.1+) — 타입 생략
int a = default;
List<Player> list = default;
TimeSpan timeout = default;
void Fn(CancellationToken ct = default) { }
public T? FindOrDefault<T>(Predicate<T> p) where T : struct
{
foreach (var x in items) if (p(x)) return x;
return default;
}
두 경우의 IL은 완전히 동일합니다. 순수한 문법 축약입니다.
// 둘 다 동일한 IL
IL_0000: ldc.i4.0
IL_0001: ret
6.3 C# 8.0 (2019) — NRT(Nullable Reference Types) 도입
C# 8에서 참조 타입에도 string? 문법이 추가되었습니다. 하지만 이건 컴파일러 정적 분석용 어노테이션 일 뿐 런타임 타입은 그대로 string입니다.
#nullable enable
string name = null; // 경고 CS8600: null 리터럴을 non-null 참조에 대입
string? maybe = null; // OK — null 가능을 명시
void Greet(string? who)
{
// who.Length; // 경고: null일 수 있음
if (who is null) return;
Console.WriteLine(who.Length); // 여기선 안전
}
런타임 관점 비교:
| 구분 | 런타임 타입 | 메모리 | 런타임 체크 |
|---|---|---|---|
int? (값 타입) |
Nullable<int> |
struct (8바이트) | hasValue 플래그 |
string? (NRT, C# 8) |
System.String |
string과 동일 |
런타임 체크 없음 — 컴파일러 경고뿐 |
핵심: 같은 ? 문법이지만 값 타입의 ?는 런타임 구조이고, 참조 타입의 ?는 컴파일러 어노테이션입니다.
6.4 .NET 6 이후 — Nullable<T> 박싱 관련 JIT 최적화 강화
.NET 버전이 올라가면서 Nullable<T>의 박싱·HasValue 체크·GetValueOrDefault 호출 경로가 JIT 수준에서 인라이닝됩니다. 대부분의 경우 개발자가 특별히 신경 쓸 필요는 없지만, 핫패스에서 반복되는 ??·HasValue는 현대 런타임에서는 거의 공짜 수준이라는 점을 알고 있으면 됩니다.
참고: 정확한 최적화 기준(인라이닝 임계값·JIT 버전별 동작)은 프로파일링으로 확인해야 합니다. 이 문단은 .NET 6+·IL2CPP 경향성에 대한 일반적 관찰이며, 프로젝트 성능이 문제라면 Unity Profiler·실측 벤치마크로 검증해야 합니다.
7. 정리
이 글에서 다룬 핵심을 하나로 압축하면:
default는 "이 타입의 0-비트 상태"를,Nullable<T>는 값 타입에 "비어 있음"을 얹은 struct 래퍼다. 둘 다 힙 할당 없이 동작하지만, 박싱되는 순간 CLR의 특별 규칙이 개입한다.
체크리스트
- [ ]
default(int)=0,default(bool)=false,default(string)=null,default(int?)=null 네 가지를 구분해서 답할 수 있다 - [ ] C# 7.1+ target-typed
default문법을 이해하고 IL이 동일함을 안다 - [ ]
Nullable<T>는 struct이며hasValue:bool+value:T두 필드로 구성된다는 점을 설명할 수 있다 - [ ]
int?와Nullable<int>가 같은 것임을 이해한다 - [ ]
Value·HasValue·GetValueOrDefault()·??의 차이와 권장 순서(??>GetValueOrDefault()>HasValue+Value)를 판단할 수 있다 - [ ] IL 수준에서
ldarga+call get_HasValue와newobj Nullable<T>::.ctor패턴이 보이면 Nullable 접근임을 알아본다 - [ ] 박싱 특별 규칙 —
int? n = null; object o = n;이o == null이 되는 이유를 설명할 수 있다 - [ ]
HasValue=true인 Nullable 박싱 시GetType()이Nullable<T>가 아니라T를 반환한다는 점을 안다 - [ ] C# 8 NRT(
string?)와 값 타입 nullable(int?)이 런타임 층위가 완전히 다르다는 것을 구분한다 - [ ] Unity 핫패스에서
Vector3?·float?는 GC 안전하지만object업캐스트 시 박싱이 발생함을 인지한다 - [ ] Unity
JsonUtility는Nullable<T>직렬화를 공식 지원하지 않으므로Newtonsoft.Json또는 플래그+값 분리 패턴을 쓴다
실전 판단 기준
| 상황 | 선택 |
|---|---|
| "값이 없을 수도 있다" 를 값 타입으로 표현 | int?·Vector3? 등 Nullable<T> |
| 제네릭에서 "빈 값"이 필요 | default 또는 default(T) |
| null일 때 기본값 주기 | ?? 기본값 (가장 빠르고 읽기 쉬움) |
| 반드시 값이 있는 경우만 꺼내기 | HasValue 체크 후 Value |
| "없을 수 있는" 참조 타입 표현 (C# 8+) | string? + #nullable enable |
| Unity JSON 역직렬화 | Newtonsoft.Json 또는 bool has + T value 분리 |
값 타입과 참조 타입이 "있음/없음"을 다르게 표현해야 한다는 C#의 이중 구조는 처음엔 번거로워 보이지만, 각 층위가 분명하면 버그는 오히려 줄어듭니다. default와 Nullable<T>는 그 틈을 메우는 가장 기본적인 도구입니다.