C#LAB
C# 4.9 · 4부. 고급 문법

Nullable 참조 형식 실전

이미 있는 코드에 뒤늦게 들이는 방법입니다.

예상 학습 시간 20분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 고급
개념 설명

형식은 실행되지 않습니다

3.6에서 물음표로 null 을 형식에 적었습니다. 컴파일러가 그것을 보고 경고를 냅니다.

거기까지입니다. 만들어진 프로그램에는 그 표시가 남지 않고, null 인지 확인하는 코드도 생기지 않습니다. 바깥에서 들어오는 값은 형식을 지키지 않습니다.

그래서 이 단원은 두 가지입니다. 바깥과 만나는 자리를 어떻게 막을 것인가, 그리고 이미 있는 코드에 어떻게 뒤늦게 들일 것인가입니다.

최소 예제 · 편집 가능

적혀 있어도 들어옵니다

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
using System.Text.Json;

// Name 에는 물음표가 없습니다. null 이 아니라고 적은 것입니다.
// (형식 선언은 최상위 문보다 뒤에 와야 하므로 아래에 있습니다)

// 1) 값을 빠뜨린 JSON
var a = JsonSerializer.Deserialize<Person>("""{"Age":32}""")!;
Console.WriteLine(a.Name is null ? "null" : a.Name);

// 2) 대놓고 null 을 넣은 JSON
var b = JsonSerializer.Deserialize<Person>("""{"Name":null,"Age":32}""")!;
Console.WriteLine(b.Name is null ? "null" : b.Name);

// 3) 형식은 null 이 아니라고 하는데, 물으면
try { _ = a.Name.Length; }
catch (Exception ex) { Console.WriteLine(ex.GetType().Name); }

// 4) required 를 붙이면 들어오는 자리에서 막힙니다
try { _ = JsonSerializer.Deserialize<Strict>("""{"Age":32}"""); }
catch (Exception ex) { Console.WriteLine(ex.GetType().Name); }

class Person {
    public string Name { get; set; }
    public int Age { get; set; }
}

class Strict {
    public required string Name { get; set; }
    public int Age { get; set; }
}
출력
null null NullReferenceException JsonException

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

물음표를 붙이지 않았는데 null 이 들어 있습니다. 컴파일러는 Name 을 안전한 것으로 보고 경고를 내지 않으므로, 확인하지 않은 코드가 그대로 나갑니다.

마지막 줄이 답입니다. required 를 붙이면 만들어지는 자리에서 막힙니다. 안쪽까지 들어와 엉뚱한 곳에서 예외가 발생하는 것보다 낫습니다.

상세 사용법

바깥과 만나는 자리

형식을 지키지 않는 곳은 정해져 있습니다. 거기서만 막으면 안쪽은 믿을 수 있습니다.

어디서어떻게 막는가
JSON · 폼 · 쿼리required 를 붙이거나 받는 형식에 물음표를 적고 한 번 검사합니다
데이터베이스컬럼이 NULL 을 받으면 물음표를 적는 것이 사실입니다
리플렉션 · 역직렬화형식을 거치지 않고 담습니다. 위와 같습니다
물음표가 없는 옛 라이브러리표시가 없으므로 컴파일러가 아무 말도 하지 않습니다. 받는 쪽에서 검사합니다

표에 NULL 이 들어가는 컬럼을 물음표 없이 받지 마십시오. 형식에 거짓을 적는 것이고, 그 거짓을 믿고 적은 코드가 실행할 때 무너집니다. 물음표를 적으면 컴파일러가 검사하지 않은 자리를 전부 짚어 줍니다.

! 는 아무 일도 하지 않습니다

!경고만 지웁니다. 값은 그대로입니다.

string? maybe = null;
var forced = maybe!;   // 경고가 사라집니다

forced is null;        // True
forced.Length;         // NullReferenceException

그러니 !사람이 아는 것을 컴파일러가 모를 때만 정직합니다. 그런 자리는 생각보다 적습니다.

이럴 때정직한가
바로 위에서 검사했는데 못 알아볼 때대개 흐름을 알려 주는 특성으로 고칠 수 있습니다
테스트 자료를 만들 때됩니다. 틀리면 그 자리에서 드러납니다
경고가 시끄러워서아닙니다. 미룬 것이지 고친 것이 아닙니다
바깥에서 들어온 값아닙니다. 거기가 검사할 자리입니다

! 를 적을 때는 왜 아는지를 주석으로 함께 남기십시오. 나중에 그 근거가 사라져도 코드는 그대로 남습니다.

나중에 채우는 것

생성자가 직접 담지 않고 다른 메서드가 담으면 컴파일러가 알아보지 못합니다.

class Sample {
    private string _name;

    public Sample() => Init();
    private void Init() => _name = "값";
}

// warning CS8618: null을 허용하지 않는 속성 'Name'은(는) 생성자를 종료할 때
// null이 아닌 값을 포함해야 합니다.

[MemberNotNull]이 메서드를 지나면 채워져 있다고 알려 주면 경고가 사라집니다. ! 와 달리 거짓이면 그 메서드 안에서 경고가 납니다.

using System.Diagnostics.CodeAnalysis;

[MemberNotNull(nameof(_name))]
private void Init() => _name = "값";

// 경고가 사라집니다

비슷한 것이 몇 가지 더 있습니다. 3.6[NotNullWhen] 과 같은 묶음입니다.

특성무엇을 알려 주는가
[MemberNotNull]이 메서드를 지나면 그 멤버는 채워져 있습니다
[MemberNotNullWhen]돌려주는 값이 그것이면 채워져 있습니다
[NotNullWhen]TryParse 처럼 true 일 때만 값이 있습니다
[AllowNull]담을 때는 null 도 받지만 읽을 때는 아닙니다
[DisallowNull]읽으면 null 일 수 있지만 담을 때는 안 됩니다

제네릭에서는 물음표가 다릅니다

제약이 없는 T참조 형식일 수도 값 형식일 수도 있습니다. 그래서 T? 가 두 가지를 뜻합니다.

static T? Default<T>() => default;

Default<string>() is null;   // True   참조 형식이면 null
Default<int>();               // 0      값 형식이면 0 입니다. null 이 아닙니다

T? 를 돌려주고 null 인지 물으면 값 형식에서 틀립니다. 둘을 갈라야 한다면 제약을 적으십시오.

where T : class    // 참조 형식만. T? 는 null 일 수 있는 참조입니다
where T : struct   // 값 형식만. T? 는 Nullable<T> 입니다
where T : notnull  // null 이 올 수 없는 것만

이미 있는 코드에 들이기

프로젝트 전체를 한 번에 켜면 경고가 수백 개 쏟아지고 아무도 보지 않게 됩니다. 켜는 방식이 네 가지 있습니다.

<Nullable>물음표를 읽는가경고를 내는가
disable아니요. 물음표 자체가 CS8632아니요
annotations아니요
warnings아니요
enable

같은 코드를 네 번 빌드해 받은 번호입니다.

string? maybe = null;
Console.WriteLine(maybe.Length);   // 역참조
string never = null;             // null 을 담기

// disable      CS8632
// annotations  (없음)
// warnings     CS8602 · CS8632
// enable       CS8600 · CS8602

annotations 로 시작하는 것이 편합니다. 물음표를 적어 나가면서 경고에는 아직 쫓기지 않습니다. 다 적고 나면 enable 로 올립니다.

파일 단위로 나눌 수도 있습니다. 손대는 파일부터 켜 나가는 방식이 가장 널리 사용됩니다.

// 파일 맨 위에
#nullable enable

// 한 구간만 잠시 끄기
#nullable disable warnings
…
#nullable restore

다 옮기고 나면 경고를 오류로 올리십시오. 경고로 두면 다시 쌓입니다.

<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>

옮기는 차례

차례무엇을
1. 안쪽부터다른 것에 기대지 않는 형식부터. 밖에서 켜면 안쪽 경고가 밀려 올라옵니다
2. 사실대로 적기null 이 올 수 있으면 물음표를 붙입니다. 붙이지 않으려고 고치지 마십시오
3. 경계를 막기들어오는 자리에서 한 번 검사하면 안쪽 경고가 대부분 사라집니다
4. 오류로 올리기다 옮긴 프로젝트부터 WarningsAsErrors

2번이 가장 자주 어긋납니다. 경고를 없애려고 물음표를 떼거나 ! 를 붙이면, 형식에 적힌 것과 실제가 갈라집니다. 그 상태의 코드는 켜기 전보다 위험합니다. 읽는 사람이 형식을 믿기 때문입니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

2.0
7.0
8.0
9.0
11.0
14.0
  • 8.0Nullable 참조 형식 · NotNullWhen · AllowNull
  • 9.0MemberNotNull · MemberNotNullWhen
  • 11.0required
C# 8.0+

이 기능은 한 번에 완성되지 않았습니다. 8.0이 물음표와 경고를 들여왔지만, 생성자 밖에서 채우는 코드나 들어오는 값을 다루기에는 모자랐습니다. 9.0의 [MemberNotNull] 과 11.0의 required 가 그 자리를 메웠습니다. 지금 옮기신다면 셋을 함께 사용할 수 있으니, 예전 글에서 ! 로 넘기라고 한 자리들은 다시 보십시오.

실습 문제

직접 해보기

1. 사실대로 적기 난이도 하

아래 형식은 데이터베이스에서 읽어 옵니다. memo 컬럼은 NULL 을 받고 나머지는 받지 않습니다. 경고가 나지 않으면서 사실과 맞도록 고쳐보세요.

class Member {
    public int Id { get; set; }
    public string Name { get; set; }
    public string Memo { get; set; }
}
둘 다 CS8618 이 납니다. 하나는 물음표를 붙이는 것이 사실이고, 다른 하나는 반드시 채워지는 것이니 required 가 맞습니다.
class Member { public int Id { get; set; } // NULL 을 받지 않는 컬럼입니다. 반드시 채워야 합니다. public required string Name { get; set; } // NULL 을 받는 컬럼입니다. 물음표가 사실입니다. public string? Memo { get; set; } } // 이렇게 하지 마십시오 — 경고는 사라지지만 거짓이 됩니다. // public string Memo { get; set; } = ""; 빈 문자열과 NULL 은 다릅니다 // public string Memo { get; set; } = null!; 형식이 거짓말을 합니다 // 빈 문자열로 채우는 것이 정말 맞는 경우도 있습니다. 그때는 // "메모가 없다" 와 "메모가 빈 문자열이다" 를 구분할 일이 없는지 // 먼저 확인하십시오. 구분해야 한다면 물음표가 맞습니다.
2. ! 를 걷어내기 난이도 중

아래 코드에는 ! 가 셋 있습니다. 각각 정직한지 판단하고, 아닌 것은 고쳐보세요.

class Cache {
    private Dictionary<string, string> _map;

    public Cache() => Load();
    private void Load() => _map = new();

    public bool Has(string key) => _map.ContainsKey(key);

    public string Get(string key) {
        _map.TryGetValue(key, out var v);
        return v!;                                   // 1
    }

    public string First(List<string>? keys)
        => Get(keys!.First());                          // 2

    public static Cache FromJson(string json)
        => JsonSerializer.Deserialize<Cache>(json)!;   // 3
}
셋 다 다른 문제입니다. 하나는 값이 없을 때를 정하지 않았고, 하나는 검사를 미뤘고, 하나는 바깥에서 온 값입니다. 그리고 _map 에도 경고가 하나 있습니다.
// 셋 다 정직하지 않습니다. // 1) TryGetValue 가 false 면 v 는 null 입니다. ! 는 그 사실을 지웠을 뿐이고 // 없는 키를 물으면 부르는 쪽에서 터집니다. 없을 때를 정해야 합니다. public string? Get(string key) => _map.TryGetValue(key, out var v) ? v : null; // 또는 없을 때를 부르는 쪽이 정하게 합니다 public string Get(string key, string fallback) => _map.TryGetValue(key, out var v) ? v : fallback; // 2) keys 가 null 일 수 있다고 적어 놓고 ! 로 지웠습니다. // 비어 있을 때도 First() 가 예외입니다. 둘 다 봅니다. public string? First(List<string>? keys) => keys is [var head, ..] ? Get(head) : null; // 3) 바깥에서 온 값입니다. "null" 이라는 JSON 하나로 null 이 됩니다. // 여기가 막을 자리입니다. public static Cache FromJson(string json) => JsonSerializer.Deserialize<Cache>(json) ?? throw new ArgumentException("내용이 비어 있습니다.", nameof(json)); // _map 은 CS8618 입니다. Load 가 담는 것을 알려 주면 됩니다. [MemberNotNull(nameof(_map))] private void Load() => _map = new();
요약
  • 물음표는 컴파일할 때만 있습니다. 실행 중에 확인하는 코드는 생기지 않습니다.
  • JSON·데이터베이스는 형식을 지키지 않습니다. 들어오는 자리에서 막으십시오.
  • required 를 붙이면 만들어지는 자리에서 걸립니다.
  • ! 는 값을 바꾸지 않고 경고만 지웁니다. 적을 때는 까닭을 함께 남기십시오.
  • 나중에 채우는 것은 [MemberNotNull] 입니다. 거짓이면 경고가 납니다.
  • 제약 없는 T? 는 값 형식에서 null 이 아닙니다.
  • 옮길 때는 annotations 부터. 경고를 없애려고 형식에 거짓을 적지 마십시오.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.