26. Nullable<T> 내부 구조와 boxing 특수 처리, lifted operator
난이도 중내 리뷰 · C#
해설 · C#
해설 — Nullable 내부 구조와 boxing 특수 처리, lifted operator
난이도: 중상
요약
Nullable<T>(T?)는 CLR 차원의 특별한 타입이 아니라 HasValue(bool)와 Value(T) 두 필드만 가진 평범한 값 형식(struct)이다. 그런데 boxing 규칙에서만큼은 CLR이 특별 취급을 한다 — HasValue == false인 Nullable<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).
- 산술 연산자(
단계별 내부 동작
object boxed1 = bonus1;—bonus1.HasValue == true이므로, CLR은Nullable<int>래퍼가 아니라int값 15가 그대로 박싱된 객체를 만든다. 따라서boxed1.GetType()은System.Int32를 출력한다.(int)boxed1으로의 언박싱도 평범한int언박싱과 동일하게 동작해15가 나온다.object boxedNull = bonus2;—bonus2.HasValue == false이므로, 박싱 결과는null참조가 된다. 따라서boxedNull == null은 true이고,boxedNull is int는 false다(참조 자체가 null이므로 어떤 타입 패턴에도 매치되지 않는다).bonus1 + bonus2— 들어올림 산술 연산자가 적용되어,bonus2가 null이므로 결과는null(sum.HasValue == false)이 된다. 출력은"bonus1 + bonus2 = null".bonus1 == bonus2— 들어올림 동등 비교.bonus1은 값이 있고bonus2는 null이므로 하나만 null인 경우이며 결과는 false.bonus2 == null은bonus2가 실제로HasValue == false이므로 true.bonus1 == bonus3(둘 다 값 15)은 언더라잉 값 15와 15를 비교해 true.bonus2.GetValueOrDefault()는HasValue가 false이면 예외 없이T의 기본값(int의 경우0)을 반환한다 — 결과는 0. 반면bonus2.Value는HasValue == false인 상태에서 접근하면InvalidOperationException("Nullable object must have a value.")을 던진다. 문제 코드의try/catch가 이 예외를 잡아"bonus2.Value 접근 시 예외 발생: InvalidOperationException"을 출력한다.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?패턴 매칭이 자연스럽게 동작하지 않는 문제가 생겼을 것이다. - "
boxedNull은HasValue=false인 특수 객체이니boxedNull.GetType()을 호출하면Nullable<int>가 나올 것"이라는 오해 —boxedNull은 진짜null참조이므로GetType()자체를 호출하면NullReferenceException이 난다(문제 코드에서는GetType()을 호출하지 않고== null만 확인함에 유의). - 순서 비교(
<,<=등)에서 null이 있으면 "false를 반환"하는 것과 동등비교(==)에서 "둘 다 null이면 true"인 것을 혼동하기 쉽다. 예를 들어bonus2 < bonus1도 false,bonus2 > bonus1도 false가 되어(즉 null은 어떤 방향으로도 "크거나 작다"고 판정되지 않음), 이는 부동소수점 NaN의 비교 동작과 유사한 함정이다. Value와GetValueOrDefault()를 혼용하다가, 값이 없을 것으로 예상하지 못한 코드 경로에서Value를 호출해InvalidOperationException이 나는 실무 버그가 흔하다.
면접 포인트
Nullable<T>의 실제 필드 구조(HasValue/Value)와, 이것이 왜 "특별한 CLR 타입"이 아니라 평범한 제네릭 값 형식인지 설명할 수 있는가.- boxing 시
HasValue에 따라 결과가null또는T로 분기되는 CLR 특수 규칙을 정확히 알고 있는가(이것이 "왜 필요한가"까지 — 즉object로 다룰 때int?와int를 구분할 필요가 없게 하기 위함이라는 설계 의도까지 설명할 수 있는가). - 들어올림 연산자에서 산술 연산(null 전파)과 순서 비교(false 반환), 동등 비교(둘 다 null이면 true)의 규칙 차이를 구분해 설명할 수 있는가.
Value와GetValueOrDefault()의 차이와, 실무에서 어떤 상황에 어떤 것을 써야 하는지 판단할 수 있는가.