반응형

[PART2.변수와 기본 데이터 타입(12/13)] default 리터럴과 값 타입 nullable (Nullable<T>)

값 타입의 "비어 있음"을 어떻게 표현할 것인가 / default의 진짜 의미 / Nullable<T>struct인데 null처럼 쓰이는 이유


1. 문제 제기 — "아직 값이 없음"을 어떻게 표현할 것인가

Unity에서 AI 적이 추격할 목표 위치를 필드로 들고 있다고 가정해 봅시다. 아직 목표를 정하지 않은 "대기 상태"는 어떻게 표현해야 할까요?

C#
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 키워드 — 타입별 0-비트 초깃값
C#
// 값 타입들의 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; x0이 대입됩니다. 컴파일러가 좌변 타입을 추론해 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으로 채워진 채 버려지고, "값 있음"이면 내부의 실제 값을 꺼내 씁니다.

Nullable<int> 내부 구조 — 값 없음 vs 값 있음
C#
// 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 hasValueinternal 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이 어떻게 달라지는지가 핵심입니다.

C#
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;
IL
.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 — newobjldarga

값이 있는 int?를 만들고, 조건부로 꺼내는 코드를 IL 수준에서 관찰합니다.

C#
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);
}
IL
.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 해설: 주목할 건 두 가지입니다.

  1. newobj ... Nullable<int32>::.ctor(!0)int? y = 42는 일반적인 참조 타입 객체 생성 같아 보이지만, 실제로는 struct Nullable<int>의 생성자를 호출해 스택 위에서 래퍼를 구성합니다. 힙 할당이 없습니다.
  2. ldarga.s v + call get_HasValue/get_Valuev가 struct이므로 값을 복사해 스택에 올리지 않고 주소를 로드(ldarga, load argument address) 해서 프로퍼티 메서드를 호출합니다. 덕분에 struct가 커도 복사 비용이 없습니다.

3.3 ?? 연산자와 리프팅 — 컴파일러의 변환 책임

C# 컴파일러는 ??·== null·산술 연산자 같은 구문을 Nullable<T> 멤버 호출로 번역합니다. 이를 리프팅(lifting) 이라고 부릅니다.

C#
public static int NullableCoalesce(int? v) => v ?? 999;
IL
.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; maybeScorenull이면 0, 아니면 내부 값이 대입됩니다.

IL 해설: v ?? 999는 분기·HasValue 체크 없이 GetValueOrDefault(999) 한 번의 call로 접힙니다. 컴파일러가 ??의 의미를 이미 Nullable<T> API 한 줄로 매핑해 놓았기 때문입니다. 이게 바로 "리프팅"입니다 — ??라는 참조 타입용 연산자가 값 타입 래퍼에 맞게 자동 변환되는 현상.


4. 실전 적용

4.1 Before/After — "원점" 대신 Vector3?로 "타겟 없음" 표현하기

첫 섹션에서 던진 문제를 실제로 고쳐봅니다.

Before (잘못된 패턴) — 특수값 Vector3.zero를 "없음"으로 사용

C#
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?로 "값 없음"을 명시

C#
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 체크 부분):

IL
// 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, 메모리를 자동으로 회수하는 런타임 구성요소)는 값 타입을 추적하지 않습니다.

C#
// 매 프레임 호출 — GC 할당 0
public class WeaponOverride : MonoBehaviour
{
    public float baseCooldown = 1.0f;
    public float? overrideCooldown; // null이면 기본, 값이면 오버라이드

    public float CurrentCooldown() => overrideCooldown ?? baseCooldown;
}
IL
// 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을 내려준 것"인지 구분해야 하는 경우가 많습니다.

C#
[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 내장 JsonUtilityNullable<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이 특별 취급합니다. 이 규칙을 모르면 디버깅할 때 크게 혼란스럽습니다.

C#
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

놀라운 점 두 가지입니다.

  1. HasValue=false인 Nullable을 박싱하면 진짜 null 참조가 된다Nullable<int> 박스가 만들어지지 않습니다.
  2. HasValue=true인 Nullable을 박싱하면 내부 T 값만 박싱된다o2.GetType()System.Int32이지 System.Nullable<Int32>가 아닙니다.

IL로 보면 이 특별 규칙이 더 명확합니다.

C#
public static object NullableBoxingNull()
{
    int? n = null;
    object o = n;
    return o;
}
IL
.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?를 담은 뒤 원복

C#
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 핫패스)

C#
// ❌ Nullable<int>를 자주 object로 업캐스트 (박싱 발생)
void DispatchEvent(object payload) { /* ... */ }
DispatchEvent((int?)42); // 박싱!

// ✅ 제네릭으로 박싱 회피
void DispatchEvent<T>(T payload) where T : struct { /* ... */ }
DispatchEvent<int?>(42);  // 박싱 없음

5.2 Value 직접 접근의 위험

ValueHasValue 체크 없이 바로 꺼내면 InvalidOperationException이 터집니다.

❌ 잘못된 패턴

C#
int? score = GetScoreFromServer();  // null일 수 있음
int display = score.Value;          // score가 null이면 런타임 예외!

✅ 올바른 패턴 — ??·GetValueOrDefault 사용

C#
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?를 쓰려면 제네릭 제약이 필요합니다. 제약이 없으면 컴파일 에러입니다.

❌ 잘못된 패턴

C#
// CS8627: 매개 변수 T는 Nullable로 만들 수 없음
public static T? SafeGet<T>(Dictionary<string, T> dict, string key)
{
    return dict.TryGetValue(key, out var v) ? v : default;
}

✅ 올바른 패턴 — 제약으로 의도 명시

C#
// 값 타입 전용 — 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 : defaultT?의 조합으로 두 경우를 모두 허용하는 API 설계도 가능하지만(Dictionary<TKey, TValue>.TryGetValue가 그 예), 신입 단계에서는 제약으로 의도를 명확히 하는 편이 실수를 줄일 수 있습니다.

5.4 default(int?) vs new int?() — 둘 다 null

미세한 문법 차이지만 의미는 같습니다.

C#
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#
// 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 이전)

C#
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+) — 타입 생략

C#
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
IL_0000: ldc.i4.0
IL_0001: ret

6.3 C# 8.0 (2019) — NRT(Nullable Reference Types) 도입

C# 8에서 참조 타입에도 string? 문법이 추가되었습니다. 하지만 이건 컴파일러 정적 분석용 어노테이션 일 뿐 런타임 타입은 그대로 string입니다.

C#
#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_HasValuenewobj 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 JsonUtilityNullable<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#의 이중 구조는 처음엔 번거로워 보이지만, 각 층위가 분명하면 버그는 오히려 줄어듭니다. defaultNullable<T>는 그 틈을 메우는 가장 기본적인 도구입니다.

반응형

+ Recent posts