C#LAB
C# 3.6 · 3부. 중급 문법

Nullable 참조 형식

값이 없을 수 있는 자리를 컴파일러가 함께 지키게 합니다.

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

null 일 수 있는지를 형식에 적습니다

참조 형식은 어느 것이든 null 이 될 수 있었습니다. string name 이라고 적어도 그 자리에 null 이 들어올 수 있고, 컴파일러는 아무 말도 하지 않았습니다. 1.10의 NullReferenceException 은 대개 그렇게 발생합니다.

string name = FindName(id);        // 못 찾으면 null 을 돌려줍니다
Console.WriteLine(name.Length);   // 여기서 NullReferenceException

이 코드에는 잘못된 곳이 보이지 않습니다. FindName 이 null 을 돌려줄 수 있다는 것이 선언 어디에도 적혀 있지 않기 때문입니다. 문서를 읽거나 구현부를 열어 보아야 알 수 있고, 대개는 예외가 발생하고 나서야 알게 됩니다.

nullable 참조 형식은 그것을 형식에 적게 합니다(C# 8.0). 1.3int? 와 같은 모양으로, 참조 형식에도 물음표를 붙입니다.

string  name;   // null 이 들어오지 않습니다
string? nick;   // null 일 수 있습니다

물음표가 붙은 것을 확인 없이 사용하면 컴파일러가 경고합니다. 다만 경고일 뿐 실행을 막지는 않습니다. 이 문법이 무엇을 해 주고 무엇은 해 주지 않는지가 이 단원의 절반입니다.

최소 예제 · 편집 가능

물음표가 붙은 값 다루기

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

Program.cs
string name = "홍길동";
string? nick = null;

// nick.Length 라고 바로 적으면 CS8602 경고가 납니다.
// 확인하고 나면 그 안에서는 경고가 없습니다.
if (nick != null)
    Console.WriteLine(nick.Length);
else
    Console.WriteLine("별명이 없습니다");

// 확인 대신 기본값을 정해도 됩니다(1.4 의 ??)
Console.WriteLine((nick ?? "(없음)").ToUpper());

// ?. 는 앞이 null 이면 건너뜁니다. 결과는 int? 가 됩니다.
int? len = nick?.Length;
Console.WriteLine(len is null ? "길이를 모릅니다" : $"길이 {len}");

// 물음표가 없는 name 은 확인 없이 사용합니다
Console.WriteLine($"{name} / {name.Length}");
출력
별명이 없습니다 (없음) 길이를 모릅니다 홍길동 / 3

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

경고가 하나도 없는 코드입니다. nick 을 사용하는 세 자리가 모두 null 을 먼저 처리했고, name 은 물음표가 없으니 확인할 것이 없습니다. 확인을 빠뜨린 자리만 컴파일러가 짚어 준다는 것이 이 문법의 용도입니다.

상세 사용법

켜져 있어야 동작합니다

이 검사는 프로젝트 설정으로 켭니다. 1.1에서 본 .csprojNullable 이 그 자리입니다.

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>

.NET 6 이후 dotnet new 로 만든 프로젝트에는 이 줄이 이미 들어 있습니다. 파일 하나만 다르게 하려면 코드 안에서 켜고 끕니다.

#nullable disable
// 아직 손대지 못한 옛 코드입니다. 경고가 나지 않습니다.

#nullable enable
// 여기서부터 다시 검사합니다.

이미 있는 코드에 이 검사를 켜면 경고가 수백 개 쏟아집니다. 한꺼번에 고치기 어려우면 프로젝트 전체를 켜 두고 #nullable disable 로 아직 손대지 못한 파일을 잠시 빼 두는 방식으로 옮겨 갑니다.

확인하고 나면 경고가 사라집니다

컴파일러는 코드의 흐름을 따라가며 각 자리에서 이 변수가 null 일 수 있는지를 다시 판단합니다. 그래서 같은 변수라도 확인한 뒤에는 경고하지 않습니다.

확인하지 않으면
string? nick = Find(id);

Console.WriteLine(nick.Length);
// CS8602: null 가능 참조에 대한
// 역참조입니다.
확인하고 나면
string? nick = Find(id);

if (nick is not null)
    Console.WriteLine(nick.Length);
// 경고 없음 — 이 안에서 nick 은
// null 이 아닙니다

!= null·is not null·??·?. 를 모두 인식하고, return 이나 throw 로 먼저 빠져나가는 것도 알아봅니다.

static int Length(string? text) {
    if (text is null) return 0;

    // 여기까지 왔다면 null 이 아닙니다. 경고가 없습니다.
    return text.Length;
}

다만 사이에 값이 바뀌면 다시 처음부터 봅니다. 확인한 뒤에 다른 값을 담았다면 그 자리부터는 또 null 일 수 있는 것으로 판단합니다.

자주 만나는 경고

번호는 다르지만 짚는 것은 하나입니다. null 일 수 있는 값이 null 이 아니어야 하는 자리에 갔다는 것입니다.

번호언제 나는가예시
CS8602null 가능 참조에 대한 역참조입니다nick.Length
CS8600가능한 null 값을 null 을 허용하지 않는 형식으로 변환string s = nick;
CS8604가능한 null 참조 인수입니다Take(nick)
CS8603가능한 null 참조 반환입니다return nick;
CS8625null 리터럴을 null 을 허용하지 않는 참조 형식으로Take(null)
CS8618생성자를 종료할 때 null 이 아닌 값을 포함해야 합니다채우지 않은 속성

고치는 방법도 둘 중 하나입니다. 정말 null 이 올 수 있다면 받는 쪽 형식에 물음표를 붙이고, 올 수 없다면 그것을 코드로 보여 주면 됩니다. 경고를 없애는 것이 목적이 아니라 어느 쪽인지 정하는 것이 목적입니다.

생성자를 마칠 때까지 채워야 합니다

CS8618 은 조금 다른 자리에서 납니다. 물음표 없는 속성을 선언해 놓고 객체를 만드는 동안 아무것도 담지 않으면 만들어진 직후에 그 속성이 null 이기 때문입니다.

class Member {
    public string Name { get; set; }   // CS8618
    // null을 허용하지 않는 속성 'Name'은(는) 생성자를 종료할 때
    // null이 아닌 값을 포함해야 합니다.
}

메우는 방법은 셋입니다. 2.3에서 본 것들입니다.

// ① 기본값을 정해 둡니다
public string Name { get; set; } = "";

// ② 생성자에서 받습니다
public string Name { get; }
public Member(string name) => Name = name;

// ③ 담지 않으면 못 만들게 합니다(C# 11.0)
public required string Name { get; set; }

// 정말 없을 수 있는 값이라면 물음표를 붙이는 것이 답입니다
public string? Nick { get; set; }

! 는 마지막 수단입니다

null 허용 연산자 ! 는 이 자리는 null 이 아니라고 컴파일러에게 알리는 표시입니다. 검사를 추가하는 것이 아니라 경고만 없앱니다.

string? nick = null;

Console.WriteLine(nick!.Length);   // 경고 없음

// 실행하면
// Unhandled exception. System.NullReferenceException:
// Object reference not set to an instance of an object.

경고는 사라졌지만 실행하면 1.10 에서 본 그 예외가 그대로 발생합니다. ! 를 붙였다는 것은 내가 책임진다는 뜻이고, 틀리면 컴파일러는 아무것도 해 주지 않습니다.

사용할 자리가 아주 없지는 않습니다. 컴파일러가 알 수 없는 것을 내가 확실히 아는 경우가 있습니다. 그럴 때도 ! 를 흩뿌리지 말고 왜 null 이 아닌지를 주석으로 남기는 편이 낫습니다. 경고가 나올 때마다 ! 를 붙이면 검사를 켜 둔 뜻이 없어집니다.

언제 null 이 아닌지 알려 주기

3.2TryGetValue 를 떠올려 보세요. 실패하면 out 변수가 null 이고 성공하면 null 이 아닙니다. 돌려주는 boolout 변수가 이어져 있다는 것은 컴파일러가 스스로 알 수 없습니다.

using System.Diagnostics.CodeAnalysis;

static bool TryFind(string key, [NotNullWhen(true)] out string? value) {
    value = key == "홍길동" ? "gildong" : null;
    return value != null;
}

if (TryFind("홍길동", out var found))
    Console.WriteLine(found.ToUpper());   // 경고 없음

[NotNullWhen(true)] 이 그 연결을 적어 둔 것입니다. true 가 나온 가지에서는 value 가 null 이 아니라고 컴파일러가 판단합니다. int.TryParseDictionary.TryGetValue 에 이미 이 표시가 붙어 있어서 지금까지 경고 없이 사용할 수 있었습니다.

값 형식의 물음표와는 다릅니다

모양은 같지만 속은 완전히 다릅니다. int? 는 실제로 다른 형식이고, string? 는 컴파일러에게만 보이는 표시입니다.

Console.WriteLine(typeof(int?));
// System.Nullable`1[System.Int32] — 실제로 있는 형식입니다

string? s = "가";
Console.WriteLine(s.GetType());
// System.String — 물음표는 남아 있지 않습니다

Console.WriteLine(typeof(string?));
// CS8639: typeof 연산자는 nullable 참조 형식에 사용할 수 없습니다.

따라오는 것이 셋 있습니다.

  • 실행 중에는 검사되지 않습니다. 물음표 없는 매개 변수에도 리플렉션이나 역직렬화를 거치면 null 이 들어올 수 있습니다.
  • 검사를 켜지 않은 코드에서 넘어오는 값은 모릅니다. 옛 라이브러리를 부르면 컴파일러가 경고도 보증도 하지 않습니다.
  • 바깥에서 들어오는 값은 여전히 직접 확인해야 합니다. 입력·설정 파일·데이터베이스에서 온 값이 그렇습니다.

int?실행할 때도 남아 있는 형식이라 HasValue 로 물어볼 수 있고, string?컴파일할 때만 사용되고 사라지는 표시입니다. 같은 물음표지만 하는 일이 다릅니다.

버전 배지

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

1.0
2.0
6.0
7.0
8.0
9.0
10.0
11.0
14.0
  • 2.0int? — nullable 값 형식
  • 6.0?. — null 이면 건너뛰는 연산자
  • 8.0nullable 참조 형식 · ! 연산자 · ??=
  • 9.0is not null 패턴
  • 11.0required — 담지 않으면 못 만들게
C# 8.0+

C# 8.0의 이 변화는 문법을 추가한 것이 아니라 이미 있던 형식에 뜻을 붙인 것입니다. 그전까지 string 은 null 일 수도 있는 문자열이었고, 8.0부터는 null 이 아닌 문자열이 됩니다. 같은 코드의 뜻이 달라지는 일이라 설정으로 켜고 끄게 만들었습니다. [NotNullWhen] 같은 표시는 언어가 아니라 .NET Core 3.0의 클래스 라이브러리에 들어 있습니다.

실습 문제

직접 해보기

1. 경고 셋 고치기 난이도 하

아래 코드에는 경고가 납니다. 각각 어느 줄에서 왜 나는지 말하고 고쳐보세요. ! 는 사용하지 않습니다.

static string FindNick(Member[] members, string name) {
    foreach (var m in members)
        if (m.Name == name) return m.Nick;

    return null;
}

class Member {
    public string Name { get; set; }
    public string? Nick { get; set; }
}
둘은 돌려주는 자리에서, 하나는 속성을 선언한 자리에서 납니다. 이 메서드는 못 찾으면 null 을 돌려주려고 만든 것이니 반환 형식이 사실을 적고 있는지 보세요.
// CS8603 : return m.Nick; — Nick 은 string? 인데 반환 형식이 string 입니다 // CS8603 : return null; — 같은 까닭입니다 // CS8618 : Name — 만들 때 담지 않으면 null 입니다 // 못 찾으면 null 을 돌려주는 것이 이 메서드의 뜻이므로, // 반환 형식에 물음표를 붙이는 것이 답입니다. static string? FindNick(Member[] members, string name) { foreach (var m in members) if (m.Name == name) return m.Nick; return null; } class Member { public required string Name { get; set; } // 이름은 반드시 있어야 합니다 public string? Nick { get; set; } // 별명은 없을 수 있습니다 } // 부르는 쪽은 이제 null 이 올 수 있다는 것을 알고 적습니다 Console.WriteLine(FindNick(members, name) ?? "(별명 없음)");
2. 없는 회원과 별명 없는 회원 구분하기 난이도 중

1번의 FindNick그런 회원이 없는 경우와 회원은 있는데 별명을 정하지 않은 경우가 모두 null 이라 구분되지 않습니다. [NotNullWhen(true)] 을 사용해 TryFind 를 만들고 둘을 나누어 출력해보세요.

찾은 것을 out Member? 로 돌려주고 성공 여부는 bool 로 돌려줍니다. [NotNullWhen(true)] 이 없으면 true 인 가지에서도 CS8602 가 납니다.
using System.Diagnostics.CodeAnalysis; Member[] members = [ new() { Name = "홍길동", Nick = "gildong" }, new() { Name = "김철수" } ]; foreach (var name in new[] { "홍길동", "김철수", "이영희" }) { if (!TryFind(members, name, out var m)) Console.WriteLine($"{name} : 그런 회원이 없습니다"); else if (m.Nick is null) Console.WriteLine($"{name} : 별명을 정하지 않았습니다"); else Console.WriteLine($"{name} : {m.Nick}"); } static bool TryFind(Member[] members, string name, [NotNullWhen(true)] out Member? found) { foreach (var m in members) if (m.Name == name) { found = m; return true; } found = null; return false; } // 홍길동 : gildong // 김철수 : 별명을 정하지 않았습니다 // 이영희 : 그런 회원이 없습니다 // m.Nick 을 적은 자리에 경고가 없습니다. [NotNullWhen(true)] 이 있으니 // else 로 넘어온 가지에서 m 은 null 이 아닙니다.
요약
  • 참조 형식에 물음표를 붙여 null 이 올 수 있는지를 형식에 적습니다(C# 8.0). 확인을 빠뜨린 자리를 컴파일러가 짚어 줍니다.
  • <Nullable>enable</Nullable> 로 켜고, #nullable disable 로 파일 단위로 잠시 뺄 수 있습니다.
  • 컴파일러가 흐름을 따라가며 판단하므로 한 번 확인한 뒤에는 경고하지 않습니다.
  • !경고만 없앨 뿐 검사를 추가하지 않습니다. 틀리면 NullReferenceException 이 그대로 발생합니다.
  • boolout 이 이어져 있으면 [NotNullWhen(true)] 로 알려 줍니다.
  • string?컴파일할 때만 사용되고 사라지는 표시입니다. 바깥에서 들어오는 값은 여전히 직접 확인해야 합니다.

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