20. Nullable 참조 타입
C# 8 부터 들어온 NRT(Nullable Reference Types) 는 'null 일 수 있음' 을 타입에 표시해 NullReferenceException 을 컴파일 타임에 잡아 줍니다.
이 강의에서 배우는 것
- 1`string` 과 `string?` 의 차이를 안다
- 2`#nullable enable` / `<Nullable>enable</Nullable>` 의 효과를 이해한다
- 3`?.`, `??`, `??=` 로 null 을 안전하게 다룬다
- 4`!` (null-forgiving) 을 언제 쓰고 언제 피해야 하는지 안다
- 5`[NotNullWhen(true)]` 같은 attribute 의 큰 그림을 본다
소개
C# 8 부터 **참조 타입에도 `?` 를 붙여서 "null 가능 여부"를 타입으로 표현**할 수 있게 되었습니다. `.NET 8` 의 새 프로젝트는 기본적으로 NRT 가 켜져 있어, NullReferenceException 의 대부분이 컴파일 타임에 잡힙니다.
핵심 개념
1) `<Nullable>enable</Nullable>` — 프로젝트 단위 스위치
이 한 줄이 NRT 를 켜는 핵심입니다. 파일 단위로는 `#nullable enable` / `#nullable disable` 를 쓸 수 있습니다.
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>켜져 있으면:
- `string x = null;` → 경고 (`CS8600`)
- `string? x = null;` → 정상
- `string? x = ...; x.Length;` → 경고 (`CS8602`)
2) `string` vs `string?`
| 표기 | 의미 | null 대입 | 멤버 접근 시 경고 |
|---|---|---|---|
| `string` | non-nullable | 경고 | 없음 |
| `string?` | nullable | OK | null 체크 안 하면 경고 |
타입 정보를 보고 **"이 변수는 null 이 들어올 수 있는가"** 를 즉시 알 수 있게 만들자는 취지입니다.
3) null 안전 연산자들
이미 03단원에서 잠깐 봤지만, 여기서 한 번에 정리합니다.
string? name = null;
string display = name ?? "(없음)"; // null이면 기본값
name ??= "Alice"; // null일 때만 대입
int? length = name?.Length; // null이면 통째로 null
char? first = name?[0]; // 인덱싱도 가능4) `!` — null-forgiving 연산자
"내가 책임지고 보장하니까, 컴파일러는 경고 꺼" 라는 표현입니다.
string s = Console.ReadLine()!; // 입력이 null 이 아니라고 단언
Person p = null!; // 곧 초기화될 거라는 자리표시남용하면 NRT 의 의미가 사라집니다. **정말 확실할 때만**, 그리고 가능한 작은 범위에 쓰세요.
5) `is null` vs `== null`
권장은 `is null`. 이유는 `==` 은 사용자 정의 오버로딩이 끼어들 수 있지만, `is` 는 언어 차원의 검사라 항상 동일하게 동작합니다.
if (x is null) ...
if (x is not null) ...6) Nullable Attribute (라이브러리용)
`[NotNullWhen(true)]` 같은 attribute 로 컴파일러의 흐름 분석을 보강합니다. `Dictionary.TryGetValue` 가 `out` 매개변수의 null 여부를 보고하는 것도 같은 원리입니다.
bool TryParseName(string raw, [NotNullWhen(true)] out string? value);
// true 를 돌려주면 → value 는 null 이 아님을 컴파일러에 알린다핵심 예제
예제 1 — `EnableNrt` : `string` vs `string?`
string nonNullable = "hello";
string? nullable = null;
// nonNullable = null; // CS8600 경고
// Console.WriteLine(nullable.Length); // CS8602 경고
if (nullable is not null)
{
Console.WriteLine($"길이: {nullable.Length}");
}**실행 결과**
non = hello
null = (없음)**메모:** `.csproj` 의 `<Nullable>enable</Nullable>` 한 줄이 모든 차이를 만듭니다. 신규 .NET 8 프로젝트는 기본 켜짐.
예제 2 — `NullForgiving` : `!` 연산자
string name = Console.ReadLine()!; // 입력이 null 아니라고 단언
public string Name { get; set; } = null!; // 곧 채울 거니 경고 꺼 두기**실행 결과(예시 입력 "지수")**
이름을 입력하세요: 지수
안녕, 지수!
Name = Alice**메모:** `null!` 은 ORM 의 엔티티나 의존성 주입 컨테이너가 곧 채워 줄 필드에 자주 등장합니다. 일반 비즈니스 코드에서 남발하면 NRT 의 의미가 무력화됩니다.
예제 3 — `NullPattern` : `is null` 과 `??`/`??=`/`?.`
string? a = null;
if (a is null) Console.WriteLine("a 는 null");
string display = a ?? "(이름 없음)";
a ??= "기본값";
string? upper = b?.ToUpper();**실행 결과**
a 는 null
b 는 hello
display = (이름 없음)
a = 기본값
upper = HELLO
first = h**메모:** 이 네 가지 연산자(`is null`, `??`, `??=`, `?.`)만 잘 써도 NullReferenceException 의 95% 가 사라집니다.
예제 4 — `NullableAnnotation` : `[NotNullWhen(true)]`
static bool TryParseName(string raw, [NotNullWhen(true)] out string? value)
{
if (string.IsNullOrWhiteSpace(raw)) { value = null; return false; }
value = raw.Trim();
return true;
}
if (TryParseName("Alice", out string? name))
{
Console.WriteLine(name.Length); // 여기선 name 이 not null 로 추론됨
}**실행 결과**
이름 길이: 5
실패: empty == null ? True**메모:** 이 attribute 덕분에 호출자는 `if` 안에서 `name!` 을 쓸 필요가 없습니다. 라이브러리 작성 시 API 정확도를 크게 높여 줍니다.
예제 5 — `MaybeNullPattern` : "찾는" 메서드의 `T?` 반환
static Person? FindByName(IEnumerable<Person> people, string name)
{
foreach (Person p in people) if (p.Name == name) return p;
return null;
}
Person? found = FindByName(people, "Alice");
int age = found?.Age ?? -1;
if (found is { Age: > 18 } adult) Console.WriteLine($"{adult.Name} 는 성인");**실행 결과**
Alice 나이: 30
Zoe 나이:
Zoe 나이 (기본 -1): -1
Alice 는 성인**메모:** "결과가 없을 수 있다"를 타입으로 분명히 드러내는 패턴입니다. 호출 쪽은 `?.`/`??`/패턴 매칭으로 다루면 깔끔합니다.
전체 예제 코드 (src/)
src/EnableNrt/EnableNrt.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/EnableNrt/Program.cs
// .csproj 의 <Nullable>enable</Nullable> 덕분에 NRT 가 켜진 상태.
// 또는 파일 상단에 #nullable enable 을 써도 같은 효과.
string nonNullable = "hello";
string? nullable = null;
// 컴파일러 경고: nonNullable 에 null 대입 안 됨.
// nonNullable = null; // CS8600
// ? 가 붙은 변수는 사용 전에 null 인지 확인해야 한다.
// Console.WriteLine(nullable.Length); // CS8602: 가능한 null 역참조
if (nullable is not null)
{
Console.WriteLine($"길이: {nullable.Length}");
}
Console.WriteLine($"non = {nonNullable}");
Console.WriteLine($"null = {nullable ?? "(없음)"}");
src/MaybeNullPattern/MaybeNullPattern.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/MaybeNullPattern/Program.cs
// "찾는 메서드" 는 결과가 없을 수 있다 → 반환 타입을 Person? 로 명시.
// 호출 쪽은 ?. / ?? 로 안전하게 다룬다.
List<Person> people =
[
new("Alice", 30),
new("Bob", 25),
];
Person? found = FindByName(people, "Alice");
Person? missing = FindByName(people, "Zoe");
// 안전한 멤버 접근 — null 이면 통째로 null.
Console.WriteLine($"Alice 나이: {found?.Age}"); // 30
Console.WriteLine($"Zoe 나이: {missing?.Age}"); // (빈 값)
// 기본값 채우기.
int age = missing?.Age ?? -1;
Console.WriteLine($"Zoe 나이 (기본 -1): {age}");
// 패턴 매칭으로 분기.
if (found is { Age: > 18 } adult)
{
Console.WriteLine($"{adult.Name} 는 성인");
}
static Person? FindByName(IEnumerable<Person> people, string name)
{
foreach (Person p in people)
{
if (p.Name == name) return p;
}
return null;
}
internal sealed record Person(string Name, int Age);
src/NullForgiving/NullForgiving.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/NullForgiving/Program.cs
// null-forgiving 연산자 ! : "내가 보장하니까 컴파일러는 null 경고 꺼" 라는 표시.
// Console.ReadLine() 의 반환은 string? — EOF 시 null 일 수 있다.
// 콘솔 입력에서 null 이 안 나온다고 확신하면 ! 로 꺼 줄 수 있다.
Console.Write("이름을 입력하세요: ");
string name = Console.ReadLine()!; // ! 로 null 가능성 무시
Console.WriteLine($"안녕, {name}!");
// 다른 자주 쓰는 곳: 늦은 초기화 (예: 단위 테스트의 SetUp)
Person p = GetUser();
Console.WriteLine($"Name = {p.Name}");
// top-level statements 다음에 로컬 함수 → 그 뒤에 타입 선언 순서를 지킨다.
static Person GetUser()
{
return new Person { Name = "Alice" };
}
internal sealed class Person
{
// 생성 직후 곧장 채울 거지만, 컴파일러는 그걸 모른다 → null! 로 초기화 의도 표시.
public string Name { get; set; } = null!;
}
src/NullPattern/NullPattern.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/NullPattern/Program.cs
// `is null` vs `== null` — 권장은 `is null`.
// 이유: == 은 사용자 정의 오버로딩이 끼어들 수 있지만, is 는 언어 차원 검사라 항상 동일하게 동작.
string? a = null;
string? b = "hello";
if (a is null) Console.WriteLine("a 는 null");
if (b is not null) Console.WriteLine($"b 는 {b}");
// 결합 연산자
string display = a ?? "(이름 없음)"; // null 이면 대체값
Console.WriteLine($"display = {display}");
a ??= "기본값"; // null 일 때만 대입
Console.WriteLine($"a = {a}");
// 멤버 접근에서도 ?. 체이닝 가능
string? upper = b?.ToUpper();
Console.WriteLine($"upper = {upper}");
// 안전 인덱싱
char? first = b?[0];
Console.WriteLine($"first = {first}");
src/NullableAnnotation/NullableAnnotation.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/NullableAnnotation/Program.cs
using System.Diagnostics.CodeAnalysis;
// [NotNullWhen(true)] : "이 메서드가 true 를 돌려줄 땐 out 매개변수가 null 이 아닙니다" 를
// 컴파일러에게 알려주는 attribute. 분석기가 이걸 보고 흐름을 더 똑똑하게 추적한다.
if (TryParseName("Alice", out string? name))
{
// 여기 안에서는 name 이 not null 로 추론된다 → 경고 없이 Length 사용 OK.
Console.WriteLine($"이름 길이: {name.Length}");
}
if (!TryParseName("", out string? empty))
{
Console.WriteLine($"실패: empty == null ? {empty is null}");
}
// 비슷한 형제 attribute : [NotNull], [MaybeNull], [MemberNotNull] 등 — 라이브러리 작성 때 유용.
static bool TryParseName(string raw, [NotNullWhen(true)] out string? value)
{
if (string.IsNullOrWhiteSpace(raw))
{
value = null;
return false;
}
value = raw.Trim();
return true;
}
자주 하는 실수
- 경고를 죽이려고 `!` 를 남발 — 정작 null 이 흘러 들어와도 못 잡습니다.
- `string` (non-nullable) 멤버를 생성자에서 초기화하지 않음 — CS8618 경고. `required` (07편) 나 `= ""` 로 해결.
- `is null` 대신 `Equals(null)`/`== null` 사용 — 동작은 같지만 일관성을 위해 `is null` 권장.
- NRT 가 꺼진 옛 라이브러리에서 받은 값을 그대로 non-nullable 변수에 대입 — 런타임에 null 일 수 있음. `?` 로 받아 검사.
- `Dictionary.TryGetValue` 의 `out` 매개변수를 null 체크 없이 사용 — 컴파일러는 이미 알고 있으니, `if (dict.TryGetValue(...))` 분기 안에서 안전합니다.
정리
- NRT 는 "참조 타입의 null 가능성"을 타입 수준으로 표현하는 기능
- `<Nullable>enable</Nullable>` 또는 `#nullable enable` 로 켠다
- `?` 가 없는 참조 타입에는 null 을 넣지 않는다 (컴파일러가 막아 줌)
- `?.`, `??`, `??=`, `is null` 네 연산자로 null 을 우아하게 다룬다
- `!` 는 최후의 수단 — 정말 책임질 수 있을 때만
과제
**과제 - 20. Nullable 참조 타입**
문제 1 — null 가능 값 안전 조회
- 프로젝트 폴더: `Homework01/`
- 핵심 개념: `Dictionary<string, string?>`, `?.`, `??`
요구사항
- 다음과 같은 `Dictionary<string, string?>` 을 만든다.
```csharp Dictionary<string, string?> profile = new() { ["name"] = "Alice", ["email"] = null, ["city"] = "Seoul", }; ```
- 키 목록 `["name", "email", "city", "phone"]` 을 순회하며 각 키의 길이를 출력한다.
- 키가 없거나 값이 null 이면 길이는 `0` 으로 표시한다.
- 안전 연산자 `?.` 와 `??` 만 사용해 한 줄로 계산할 것.
예상 출력
name : 5
email : 0
city : 5
phone : 0힌트
- `dict.TryGetValue(key, out string? value)` 로 값 추출.
- 길이 = `value?.Length ?? 0`.
- 출력은 `key.PadRight(6)` 등으로 정렬하면 깔끔합니다.
문제 2 — `User?` 를 반환하는 리포지토리
- 프로젝트 폴더: `Homework02/`
- 핵심 개념: `T?` 반환, `is { ... }` 패턴, `??`
요구사항
- `record User(int Id, string Name, int Age);` 를 정의한다.
- `UserRepo` 클래스에 다음 메서드를 만든다.
- `User? FindById(int id)` — 없으면 null 반환.
- 메인에서 ID 1, 2, 99 를 조회하며 다음을 출력한다.
- 찾으면 `"#1 Alice (30세)"` 형태.
- 못 찾으면 `"#99 (찾을 수 없음)"` 형태.
- 추가로, 찾은 User 중 성인(Age >= 18)만 골라 "성인 회원: Alice, Bob" 형태로 한 줄 출력. (`is { Age: >= 18 }` 패턴 권장)
예상 출력
#1 Alice (30세)
#2 Bob (16세)
#99 (찾을 수 없음)
성인 회원: Alice힌트
- 데이터 예시:
```csharp new User(1, "Alice", 30), new User(2, "Bob", 16), ```
- 사용처에서 `User? u = repo.FindById(id);` → `if (u is null) ... else ...`.
- 성인만 모을 때 `List<User?>` 보다는 `List<User>` 가 깔끔합니다.
정답 확인
직접 풀어 본 후 [`answer/`](./answer/) 폴더의 정답과 비교해 보세요.
정답 (answer/)
homework/answer/Homework01/Homework01.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework01/Program.cs
Dictionary<string, string?> profile = new()
{
["name"] = "Alice",
["email"] = null,
["city"] = "Seoul",
};
string[] keys = ["name", "email", "city", "phone"];
foreach (string key in keys)
{
// 키가 없으면 value 는 null, 키가 있어도 값이 null 일 수 있다.
profile.TryGetValue(key, out string? value);
int length = value?.Length ?? 0; // 두 가지 null 케이스를 한 번에 처리
Console.WriteLine($"{key.PadRight(6)}: {length}");
}
homework/answer/Homework02/Homework02.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework02/Program.cs
UserRepo repo = new();
int[] queries = [1, 2, 99];
List<User> adults = [];
foreach (int id in queries)
{
User? u = repo.FindById(id);
if (u is null)
{
Console.WriteLine($"#{id} (찾을 수 없음)");
}
else
{
Console.WriteLine($"#{u.Id} {u.Name} ({u.Age}세)");
}
// 패턴 매칭으로 성인만 골라 모은다.
if (u is { Age: >= 18 })
{
adults.Add(u); // 분기 안에서는 u 가 not null 로 추론됨
}
}
Console.WriteLine("성인 회원: " + string.Join(", ", adults.Select(a => a.Name)));
internal sealed record User(int Id, string Name, int Age);
internal sealed class UserRepo
{
private readonly List<User> _users =
[
new(1, "Alice", 30),
new(2, "Bob", 16),
];
public User? FindById(int id)
{
foreach (User u in _users)
{
if (u.Id == id) return u;
}
return null;
}
}
직접 해 보기
cd src/EnableNrt && dotnet run
cd ../NullForgiving && dotnet run
cd ../NullPattern && dotnet run
cd ../NullableAnnotation && dotnet run
cd ../MaybeNullPattern && dotnet run다음 단원
[21_패턴_매칭](../21_패턴_매칭/) — `is`/`switch` 패턴으로 분기를 한 단계 우아하게 만듭니다.