← 문제로

26. Nullable<T> 내부 구조와 boxing 특수 처리, lifted operator

난이도 중
내 리뷰 · C#
해설 · C#

해설 — Nullable 내부 구조와 boxing 특수 처리, lifted operator

난이도: 중상

요약

Nullable<T>(T?)는 CLR 차원의 특별한 타입이 아니라 HasValue(bool)와 Value(T) 두 필드만 가진 평범한 값 형식(struct)이다. 그런데 boxing 규칙에서만큼은 CLR이 특별 취급을 한다 — HasValue == falseNullable<T>를 박싱하면 결과는 "HasValue가 false인 박스 객체"가 아니라 진짜 null 참조가 되고, HasValue == true인 경우에는 Nullable<T> 껍데기가 아니라 언더라잉 타입 T 자체가 박싱된 객체가 된다. 비교/산술 연산자는 컴파일러가 자동으로 "들어올림(lifted)" 버전을 생성해, 피연산자 중 하나라도 null이면 산술 결과는 null, 동등 비교는 규칙에 따라 true/false를 반환하도록 만든다.

핵심 개념

  • Nullable<T>의 실제 필드: private readonly bool hasValue; internal T value; (실제 필드명은 버전마다 다르나 개념적으로 HasValue/Value 프로퍼티가 이 두 필드를 노출한다.) T?는 컴파일러 문법 설탕이며 런타임에서는 System.Nullable<T> 제네릭 값 형식이다.
  • boxing의 특수 규칙(CLR/C# 스펙): object o = nullableInstance;처럼 Nullable<T>를 박싱하는 코드가 있으면, 컴파일러가 아니라 CLR이 실행 시점에 분기한다.
    • HasValue == true이면, 박싱 결과는 Nullable<T>가 아니라 T가 박싱된 객체다. 즉 boxed1.GetType()typeof(Nullable<int>)이 아니라 **typeof(int)**를 반환한다.
    • HasValue == false이면, 박싱 결과는 null 참조 그 자체다. "HasValue가 false인 박스 객체"라는 것은 존재하지 않는다.
  • 들어올림 연산자(lifted operator): int?에 대해 +, ==, < 등의 연산자를 쓰면, 컴파일러가 내부적으로 언더라잉 타입(int)의 연산자를 "들어올려(lift)" 다음 규칙을 적용한 버전으로 바꿔 사용한다.
    • 산술 연산자(+, -, * 등): 피연산자 중 하나라도 null이면 결과는 null.
    • 동등 비교(==, !=): 둘 다 null이면 ==는 true. 하나만 null이면 ==는 false. 둘 다 값이 있으면 언더라잉 값끼리 비교.
    • 순서 비교(<, > 등): 피연산자 중 하나라도 null이면 결과는 false(산술과 달리 null이 전파되는 게 아니라 바로 false).

단계별 내부 동작

  1. object boxed1 = bonus1;bonus1.HasValue == true이므로, CLR은 Nullable<int> 래퍼가 아니라 int 값 15가 그대로 박싱된 객체를 만든다. 따라서 boxed1.GetType()System.Int32를 출력한다. (int)boxed1으로의 언박싱도 평범한 int 언박싱과 동일하게 동작해 15가 나온다.
  2. object boxedNull = bonus2;bonus2.HasValue == false이므로, 박싱 결과는 null 참조가 된다. 따라서 boxedNull == nulltrue이고, boxedNull is intfalse다(참조 자체가 null이므로 어떤 타입 패턴에도 매치되지 않는다).
  3. bonus1 + bonus2 — 들어올림 산술 연산자가 적용되어, bonus2가 null이므로 결과는 null(sum.HasValue == false)이 된다. 출력은 "bonus1 + bonus2 = null".
  4. bonus1 == bonus2 — 들어올림 동등 비교. bonus1은 값이 있고 bonus2는 null이므로 하나만 null인 경우이며 결과는 false. bonus2 == nullbonus2가 실제로 HasValue == false이므로 true. bonus1 == bonus3(둘 다 값 15)은 언더라잉 값 15와 15를 비교해 true.
  5. bonus2.GetValueOrDefault()HasValue가 false이면 예외 없이 T의 기본값(int의 경우 0)을 반환한다 — 결과는 0. 반면 bonus2.ValueHasValue == false인 상태에서 접근하면 InvalidOperationException("Nullable object must have a value.")을 던진다. 문제 코드의 try/catch가 이 예외를 잡아 "bonus2.Value 접근 시 예외 발생: InvalidOperationException"을 출력한다.
  6. typeof(int?)는 **System.Nullable\1[System.Int32]**로 출력된다(typeof는 실제 CLR 타입을 다루므로 boxing의 특수 규칙과 무관하게 Nullable자체의 타입 정보를 정확히 반환한다).Nullable.GetUnderlyingType(typeof(int?))은 언더라잉 타입인 **System.Int32`**를 반환한다(이는 리플렉션으로 "이 타입이 Nullable이면 그 내부 타입이 무엇인지" 조회하는 표준적인 방법이다).

흔한 오해·함정

  • "Nullable<T>를 박싱하면 항상 Nullable<T> 박스가 생긴다"는 오해 — 실제로는 값이 있으면 T로, 값이 없으면 null로 박싱되는 CLR 차원의 특수 규칙이 있다. 이 규칙이 없다면 object로 받은 박싱된 정수가 원래 int였는지 int?였는지 구분이 안 되는 문제, 그리고 is int / is int? 패턴 매칭이 자연스럽게 동작하지 않는 문제가 생겼을 것이다.
  • "boxedNullHasValue=false인 특수 객체이니 boxedNull.GetType()을 호출하면 Nullable<int>가 나올 것"이라는 오해 — boxedNull은 진짜 null 참조이므로 GetType() 자체를 호출하면 NullReferenceException이 난다(문제 코드에서는 GetType()을 호출하지 않고 == null만 확인함에 유의).
  • 순서 비교(<, <= 등)에서 null이 있으면 "false를 반환"하는 것과 동등비교(==)에서 "둘 다 null이면 true"인 것을 혼동하기 쉽다. 예를 들어 bonus2 < bonus1도 false, bonus2 > bonus1도 false가 되어(즉 null은 어떤 방향으로도 "크거나 작다"고 판정되지 않음), 이는 부동소수점 NaN의 비교 동작과 유사한 함정이다.
  • ValueGetValueOrDefault()를 혼용하다가, 값이 없을 것으로 예상하지 못한 코드 경로에서 Value를 호출해 InvalidOperationException이 나는 실무 버그가 흔하다.

면접 포인트

  • Nullable<T>의 실제 필드 구조(HasValue/Value)와, 이것이 왜 "특별한 CLR 타입"이 아니라 평범한 제네릭 값 형식인지 설명할 수 있는가.
  • boxing 시 HasValue에 따라 결과가 null 또는 T로 분기되는 CLR 특수 규칙을 정확히 알고 있는가(이것이 "왜 필요한가"까지 — 즉 object로 다룰 때 int?int를 구분할 필요가 없게 하기 위함이라는 설계 의도까지 설명할 수 있는가).
  • 들어올림 연산자에서 산술 연산(null 전파)과 순서 비교(false 반환), 동등 비교(둘 다 null이면 true)의 규칙 차이를 구분해 설명할 수 있는가.
  • ValueGetValueOrDefault()의 차이와, 실무에서 어떤 상황에 어떤 것을 써야 하는지 판단할 수 있는가.