null은 "값 없음"을 뜻합니다. 참조 형식(클래스, 문자열, 배열)은 언제나 null을 담을 수 있었습니다. 값 형식(int, decimal, bool, DateTime, 구조체)은 ?로 nullable로 만들지 않는 한 담을 수 없습니다. 그러면 C#은 빠질 수 있는 값을 다루기 위한 작은 연산자 모음을 줍니다: ??, ??=, ?..
nullable 값 형식: int?
값 형식에 ?를 붙이면 null이 허용됩니다. int?는 값과 그 값이 있는지 알려 주는 플래그를 담는 구조체 Nullable<int>의 축약형입니다:
출력:
False
True
True
5.50
0
3
Rated 4 stars
4
nullable 값 형식의 전형적인 출처는 선택적 폼 필드, 쿼리 매개변수, 데이터베이스의 NULL 열입니다. DateTime? ShippedAt을 가진 Order는 값이 null인 동안 아직 배송되지 않은 것입니다. 대안인 -1이나 DateTime.MinValue 같은 마법의 값은 진짜 데이터처럼 보여서 언젠가는 진짜처럼 쓰이게 됩니다.
int?는 변환이 실패할 수 있으므로 명시적으로만 int로 변환됩니다. 이것이 nullable 값 형식이 던지는 유일한 예외로 이어집니다.
"Nullable object must have a value"
값이 없을 때 .Value를 읽거나 null인 int?를 int로 캐스트하면 InvalidOperationException이 발생합니다:
출력:
InvalidOperationException: Nullable object must have a value.
0
0
not stocked
stock is int count는 패턴입니다. 값이 있는지 검사하고 한 번에 count로 풀어내며, HasValue와 Value를 함께 쓰는 것보다 읽기 좋습니다. 패턴 매칭 페이지에 더 많은 형태가 있습니다.
null과의 산술 연산과 비교
nullable 값 형식에 대한 연산자는 리프트됩니다. 어느 피연산자든 null이면 결과가 null입니다. 순서 비교(<, >, <=, >=)는 예외로, null이 아니라 false를 반환합니다. (다른 예외는 &와 |를 쓰는 bool?으로, null & false는 false입니다.) 예를 들어:
출력:
True
[]
False
False
True
True
a > 5와 a <= 5가 모두 거짓이므로, a가 null이면 !(a > 5)는 a <= 5와 같지 않습니다. if (!(age >= 18)) Deny(); 같은 코드는 빠진 나이를 미성년으로 취급하며, 의도한 것일 수도 아닐 수도 있습니다. 먼저 보통 ??로 null이 무엇을 뜻할지 정한 뒤 비교하세요.
null 병합 연산자 ??
a ?? b는 a가 null이 아니면 a로, 아니면 b로 평가됩니다. nullable 값 형식과 참조 형식 모두에 동작하며, 왼쪽에서 오른쪽으로 연결됩니다:
출력:
dark
16px
20
ANONYMOUS
오른쪽은 필요할 때만 평가되므로, 캐시에 값이 있으면 cache ?? LoadFromDatabase()는 데이터베이스에 가지 않습니다. ??는 throw 식도 받아서 간결한 보호 조건을 만듭니다(throw 참고):
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
??=(C# 8)
x ??= y는 x가 null일 때만 y를 x에 대입합니다. 지연 초기화의 관용구입니다:
private List<string> tags;
public void AddTag(string tag)
{
tags ??= new List<string>(); // C# 8 and later
tags.Add(tag);
}
C# 8 이전에는 tags = tags ?? new List<string>();이나 if (tags == null) tags = new List<string>();이라고 씁니다.
null 조건부 연산자 ?.
a?.Member는 a가 null이 아닐 때만 Member를 평가하고, 아니면 식 전체가 null입니다. null 검사의 연쇄를 식 하나로 바꿔 줍니다:
출력:
Braga
(no city)
(no customer)
0
True
handler: saved
알아 둘 세부 사항:
- 연산자는 체인의 나머지를 단락 평가합니다.
nobody?.Address?.City에서nobody가 null이면 오른쪽의 어떤 것도 실행되지 않습니다. - 멤버가 값 형식을 반환하면 결과가 nullable이 됩니다.
Orders?.Count는int?이며, 그래서 다시int가 되려면?? 0이 필요합니다. - C# 14 이전에는
?.가 대입의 대상이 될 수 없습니다. 이전 버전에서customer?.Name = "x"는 컴파일되지 않습니다. handler?.Invoke(...)는 이벤트를 발생시키거나 선택적 콜백을 호출하는 표준 방법입니다.
?.는 null이 정당한 상태인 곳에서 쓰세요. 예외를 피하려고 여기저기 뿌리면 예상치 못한 null을 만든 버그가 숨겨지고, 프로그램은 문제가 있는 곳에서 실패하는 대신 빠진 데이터로 계속 진행합니다.
null 검사하기
if (customer == null) { ... } // classic
if (customer is null) { ... } // C# 7 constant pattern
if (customer != null) { ... }
if (customer is not null) { ... } // C# 9
if (customer is { } c) { ... } // C# 8 property pattern: not null, and named
is null은 항상 진짜 null 검사를 하지만, ==는 타입에 연산자 오버로드가 있으면 그것을 호출하므로 잘못 작성된 오버로드가 x == null을 거짓말하게 만들 수 있습니다. null이면 안 되는 인수는 일찍 실패시키세요:
public void Ship(Order order)
{
ArgumentNullException.ThrowIfNull(order); // .NET 6+
// ...
}
nullable 참조 형식(C# 8)
값 형식에 ?를 붙이면 타입이 바뀝니다. C# 8부터 참조 형식에 ?를 붙이면 컴파일러가 검사하는 것만 바뀝니다. 기능을 켜면(프로젝트 파일의 <Nullable>enable</Nullable>, .NET 6부터 새 프로젝트의 기본값) string은 "절대 null이면 안 됨", string?는 "null일 수 있음"을 뜻하고, 코드가 맞지 않으면 컴파일러가 경고합니다:
#nullable enable
public class User
{
public string Email { get; set; } // warning CS8618: Non-nullable property 'Email'
// must contain a non-null value when exiting constructor.
public string? Nickname { get; set; } // allowed to be null
public int NicknameLength() => Nickname.Length;
// warning CS8602: Dereference of a possibly null reference.
public int SafeLength() => Nickname?.Length ?? 0; // no warning
}
string? input = Console.ReadLine();
string name = input; // warning CS8600: Converting null literal or possible
// null value to non-nullable type.
string checkedName = input ?? "guest"; // no warning
int len = input!.Length; // ! tells the compiler "trust me": no warning
컴파일러는 메서드 안에서 검사를 따라갑니다. if (input != null) 뒤에서 input은 null이 아닌 것으로 취급됩니다. !(null 허용) 연산자는 분석보다 내가 더 잘 알 때 경고를 없앱니다. 하나하나가 컴파일러가 검증할 수 없는 주장이므로 드물게 쓰세요.
대부분의 혼란을 풀어 주는 두 가지 사실이 있습니다. 첫째, 이것은 경고일 뿐입니다. 프로그램은 똑같이 실행되며, 주석이 없는 코드에서 왔다면 string도 실행 시점에 여전히 null을 담을 수 있습니다. 둘째, string?는 Nullable<string>이 아닙니다. HasValue가 없습니다.
흔한 실수
- 확인 없이
.Value쓰기. null이면InvalidOperationException이 발생합니다.??,GetValueOrDefault(),is int n을 쓰세요. !(x > 5)가x <= 5라고 가정하기.x가 null이면 아닙니다. null과의 모든 순서 비교는 거짓입니다.- 모든 null을
?.로 덮기. 증상을 잠재우고 실패를 원인에서 더 멀리 옮깁니다. - nullable 참조 경고를 잡음으로 취급하기. 경고 하나하나가
NullReferenceException을 던질 수 있는 줄을 표시합니다. 흐름을 고치거나?로 의도를 밝히세요. - null 대신 마법의 값 쓰기. "알 수 없음"을 뜻하는
-1,0,DateTime.MinValue는 진짜 데이터처럼 보입니다.int?와DateTime?를 쓰세요.
자주 묻는 질문
C#에서 int?는 무슨 뜻인가요?
int?는 Nullable<int>의 축약형으로, null도 될 수 있는 int입니다. int, decimal, bool, DateTime 같은 값 형식은 보통 항상 값을 가지는데, ?를 붙이면 빠진 폼 필드나 데이터베이스의 NULL 열에 필요한 "값 없음" 상태를 갖게 됩니다. HasValue나 != null로 확인하고 Value나 GetValueOrDefault()로 읽으세요.
C#에서 ??는 무엇을 하나요?
a ?? b는 null 병합 연산자입니다. a가 null이 아니면 a로, 아니면 b로 평가됩니다. string name = input ?? "guest";는 식 하나로 대체값을 줍니다. 연결할 수 있고(a ?? b ?? c는 처음으로 null이 아닌 값을 취함), b는 필요할 때만 평가됩니다. ??=(C# 8)는 변수가 null일 때만 오른쪽을 대입합니다.
C#에서 ?.는 무엇을 하나요?
a?.B는 null 조건부 연산자입니다. a가 null이면 식 전체가 null이고 B는 평가되지 않으므로 NullReferenceException이 없습니다. 연결할 수 있고(order?.Customer?.Address?.City), 메서드 호출(logger?.Log(...)), 이벤트(Changed?.Invoke(...)), 인덱서(items?[0])에도 동작합니다. 기본값은 ??와 결합하세요: order?.Customer?.Name ?? "unknown".
"Nullable object must have a value"의 원인은 무엇인가요?
null인 nullable 값 형식의 .Value를 읽거나, 이를 null이 될 수 없는 타입으로 캐스트하면((int)maybe) 이 메시지와 함께 InvalidOperationException이 발생합니다. 먼저 HasValue를 확인하거나, GetValueOrDefault(), ?? fallback, 또는 패턴을 쓰세요: if (maybe is int n) { ... }.
C#에서 null은 어떻게 검사하나요?
x == null과 x is null 모두 동작합니다. is null(C# 7)은 ==를 오버로딩한 타입의 영향을 받지 않으므로 많은 코드베이스가 이를 선호합니다. 부정은 x != null, C# 9에서는 x is not null입니다. null 인수를 거부하려면 ArgumentNullException.ThrowIfNull(x)(.NET 6), 예전 코드에서는 _ = x ?? throw new ArgumentNullException(nameof(x));를 쓰세요.
C#의 nullable 참조 형식이란 무엇인가요?
<Nullable>enable</Nullable>로 켜는 C# 8 컴파일러 기능입니다(새 .NET 6+ 프로젝트의 기본값). 켜면 string은 "절대 null이 아님", string?는 "null일 수 있음"을 뜻하고, null을 역참조할 수 있을 때(CS8602)나 null을 null이 될 수 없는 변수에 대입할 때(CS8600) 컴파일러가 경고합니다. 경고만 바꿀 뿐 실행 시점 동작은 바꾸지 않습니다.