← C# 강의 목록으로
모던 C#
모던 C# · 선수: 객체지향

20. Nullable 참조 타입

C# 8 부터 들어온 NRT(Nullable Reference Types) 는 'null 일 수 있음' 을 타입에 표시해 NullReferenceException 을 컴파일 타임에 잡아 줍니다.

C#.NET 8NullableNRT
소요 시간
약 1~1.5시간
난이도
📊 중급
선수 조건
🎯 객체지향까지 이수
결과물
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` 를 쓸 수 있습니다.

xml
<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?`nullableOKnull 체크 안 하면 경고

타입 정보를 보고 **"이 변수는 null 이 들어올 수 있는가"** 를 즉시 알 수 있게 만들자는 취지입니다.

3) null 안전 연산자들

이미 03단원에서 잠깐 봤지만, 여기서 한 번에 정리합니다.

csharp
string? name = null;

string display = name ?? "(없음)";    // null이면 기본값
name ??= "Alice";                     // null일 때만 대입
int? length = name?.Length;           // null이면 통째로 null
char? first = name?[0];               // 인덱싱도 가능

4) `!` — null-forgiving 연산자

"내가 책임지고 보장하니까, 컴파일러는 경고 꺼" 라는 표현입니다.

csharp
string s = Console.ReadLine()!;       // 입력이 null 이 아니라고 단언
Person p = null!;                     // 곧 초기화될 거라는 자리표시

남용하면 NRT 의 의미가 사라집니다. **정말 확실할 때만**, 그리고 가능한 작은 범위에 쓰세요.

5) `is null` vs `== null`

권장은 `is null`. 이유는 `==` 은 사용자 정의 오버로딩이 끼어들 수 있지만, `is` 는 언어 차원의 검사라 항상 동일하게 동작합니다.

csharp
if (x is null) ...
if (x is not null) ...

6) Nullable Attribute (라이브러리용)

`[NotNullWhen(true)]` 같은 attribute 로 컴파일러의 흐름 분석을 보강합니다. `Dictionary.TryGetValue` 가 `out` 매개변수의 null 여부를 보고하는 것도 같은 원리입니다.

csharp
bool TryParseName(string raw, [NotNullWhen(true)] out string? value);
// true 를 돌려주면 → value 는 null 이 아님을 컴파일러에 알린다

핵심 예제

예제 1 — `EnableNrt` : `string` vs `string?`

csharp
string nonNullable = "hello";
string? nullable = null;
// nonNullable = null;            // CS8600 경고
// Console.WriteLine(nullable.Length);   // CS8602 경고

if (nullable is not null)
{
    Console.WriteLine($"길이: {nullable.Length}");
}

**실행 결과**

text
non = hello
null = (없음)

**메모:** `.csproj` 의 `<Nullable>enable</Nullable>` 한 줄이 모든 차이를 만듭니다. 신규 .NET 8 프로젝트는 기본 켜짐.

예제 2 — `NullForgiving` : `!` 연산자

csharp
string name = Console.ReadLine()!;     // 입력이 null 아니라고 단언
public string Name { get; set; } = null!;  // 곧 채울 거니 경고 꺼 두기

**실행 결과(예시 입력 "지수")**

text
이름을 입력하세요: 지수
안녕, 지수!
Name = Alice

**메모:** `null!` 은 ORM 의 엔티티나 의존성 주입 컨테이너가 곧 채워 줄 필드에 자주 등장합니다. 일반 비즈니스 코드에서 남발하면 NRT 의 의미가 무력화됩니다.

예제 3 — `NullPattern` : `is null` 과 `??`/`??=`/`?.`

csharp
string? a = null;
if (a is null) Console.WriteLine("a 는 null");
string display = a ?? "(이름 없음)";
a ??= "기본값";
string? upper = b?.ToUpper();

**실행 결과**

text
a 는 null
b 는 hello
display = (이름 없음)
a = 기본값
upper = HELLO
first = h

**메모:** 이 네 가지 연산자(`is null`, `??`, `??=`, `?.`)만 잘 써도 NullReferenceException 의 95% 가 사라집니다.

예제 4 — `NullableAnnotation` : `[NotNullWhen(true)]`

csharp
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 로 추론됨
}

**실행 결과**

text
이름 길이: 5
실패: empty == null ? True

**메모:** 이 attribute 덕분에 호출자는 `if` 안에서 `name!` 을 쓸 필요가 없습니다. 라이브러리 작성 시 API 정확도를 크게 높여 줍니다.

예제 5 — `MaybeNullPattern` : "찾는" 메서드의 `T?` 반환

csharp
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} 는 성인");

**실행 결과**

text
Alice 나이: 30
Zoe 나이:   
Zoe 나이 (기본 -1): -1
Alice 는 성인

**메모:** "결과가 없을 수 있다"를 타입으로 분명히 드러내는 패턴입니다. 호출 쪽은 `?.`/`??`/패턴 매칭으로 다루면 깔끔합니다.

전체 예제 코드 (src/)

src/EnableNrt/EnableNrt.csproj

xml
<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

csharp
// .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

xml
<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

csharp
// "찾는 메서드" 는 결과가 없을 수 있다 → 반환 타입을 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

xml
<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

csharp
// 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

xml
<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

csharp
// `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

xml
<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

csharp
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;
}

자주 하는 실수

  1. 경고를 죽이려고 `!` 를 남발 — 정작 null 이 흘러 들어와도 못 잡습니다.
  2. `string` (non-nullable) 멤버를 생성자에서 초기화하지 않음 — CS8618 경고. `required` (07편) 나 `= ""` 로 해결.
  3. `is null` 대신 `Equals(null)`/`== null` 사용 — 동작은 같지만 일관성을 위해 `is null` 권장.
  4. NRT 가 꺼진 옛 라이브러리에서 받은 값을 그대로 non-nullable 변수에 대입 — 런타임에 null 일 수 있음. `?` 로 받아 검사.
  5. `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` 으로 표시한다.
  • 안전 연산자 `?.` 와 `??` 만 사용해 한 줄로 계산할 것.

예상 출력

text
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 }` 패턴 권장)

예상 출력

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

xml
<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

csharp
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

xml
<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

csharp
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;
    }
}

직접 해 보기

bash
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` 패턴으로 분기를 한 단계 우아하게 만듭니다.

예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗