Nullable 참조 형식

Nullable 참조 형식 (C#)

C#의 nullable 참조 형식은 "이 변수는 null일 수 있어요"라는 의도를 컴파일러가 알 수 있게 해 주는 기능이에요. 값이 null일 가능성이 있는 곳을 컴파일 타임에 짚어 주기 때문에, 런타임에 NullReferenceException이 터지기 전에 미리 막을 수 있죠. 이 글이 다루는 건 nullable 참조 형식이고, 값 형식(nullable value types)은 별도 문서에서 다뤄요.

출처: Microsoft Learn

본문

Nullable 참조 형식은 코드가 nullable aware context(nullable 인식 컨텍스트) 안에 있을 때 사용해요. 이런 맥락에서 nullable 참조 형식, null 정적 분석 경고, null-forgiving 연산자는 모두 선택적인 언어 기능이에요. 기본적으로는 전부 꺼져 있죠. nullable context를 프로젝트 수준에서는 빌드 설정으로, 코드 안에서는 pragma 지시문으로 제어할 수 있어요.

[!IMPORTANT] 프로젝트 템플릿은 새 프로젝트에 nullable context를 켜 두는 편이에요. 예전 템플릿으로 만든 프로젝트에는 이 설정이 들어 있지 않아서, 프로젝트 파일에서 켜거나 pragma를 쓰지 않으면 이 기능들은 꺼진 상태로 남아요.

nullable aware context 안에서는 이렇게 동작해요.

  • 참조 형식 T의 변수는 반드시 null이 아닌 값으로 초기화해야 하고, null이 될 수도 있는 값은 절대 대입할 수 없어요.
  • 참조 형식 T?의 변수는 null로 초기화하거나 null을 대입할 수 있지만, 역참조(dereference)하기 전에 반드시 null인지 확인해야 해요.
  • T? 타입의 변수 m에 null-forgiving 연산자를 적용하면, 즉 m!처럼 쓰면 그 변수는 null이 아닌 것으로 간주돼요.

컴파일러는 앞의 규칙들을 이용해서 nullable이 아닌 참조 형식 T와 nullable 참조 형식 T?를 구분해요. 그런데 여기서 헷갈리기 쉬운 게 있는데, T 타입의 변수와 T? 타입의 변수는 같은 .NET 형식이에요. 즉 런타임에서는 차이가 없다는 뜻이죠. 다음 예시는 nullable이 아닌 string과 nullable string을 선언한 다음, null-forgiving 연산자로 nullable이 아닌 string에 값을 대입해요.

:::code language="csharp" source="snippets/shared/NullableReferenceTypes.cs" id="SnippetCoreSyntax":::

여기서 notNullnullable 변수는 둘 다 xref:System.String 형식이에요. nullable이 아닌 형식과 nullable 형식이 같은 형식을 쓰기 때문에, 몇몇 위치에서는 nullable 참조 형식을 쓸 수 없어요. 일반적으로 nullable 참조 형식은 기본 클래스(base class)나 구현한 인터페이스로 쓸 수 없고, 객체 생성이나 형식 검사(type testing) 식에도 쓸 수 없으며, 멤버 접근 식의 형식으로도 쓸 수 없어요. 아래 예시들이 어떤 것들인지 보여줘요.

public MyClass : System.Object? // not allowed
{
}

var nullEmpty = System.String?.Empty; // Not allowed
var maybeObject = new object?(); // Not allowed
try
{
    if (thing is string? nullableString) // not allowed
        Console.WriteLine(nullableString);
} catch (Exception? e) // Not Allowed
{
    Console.WriteLine("error");
}

Nullable 참조와 정적 분석

이전 절의 예시들은 nullable 참조 형식의 본질을 보여줘요. nullable 참조 형식은 새로운 클래스 형식이 아니라, 기존 참조 형식에 붙는 주석(annotation) 이에요. 컴파일러는 이 주석을 이용해서 코드에서 잠재적인 null 참조 오류를 찾도록 도와줘요. nullable이 아닌 참조 형식과 nullable 참조 형식은 런타임에는 아무 차이가 없어요. 컴파일러는 nullable이 아닌 참조 형식에 어떤 런타임 검사도 추가하지 않아요. 이점은 전부 컴파일 타임 분석에서 나오죠. 컴파일러가 경고를 생성해서 코드 안의 잠재적인 null 오류를 찾고 고치도록 도와주는 구조예요. 여러분이 의도를 선언하면, 컴파일러는 코드가 그 의도를 어기는 순간 경고를 알려줘요.

[!IMPORTANT] nullable 참조 주석은 동작(behavior)을 바꾸지는 않아요. 그런데 다른 라이브러리는 리플렉션을 이용해서 nullable과 nullable이 아닌 참조 형식에 서로 다른 런타임 동작을 만들어 낼 수도 있어요. 특히 Entity Framework Core는 nullable 특성(attribute)을 읽어요. nullable 참조를 선택(optional) 값으로, nullable이 아닌 참조를 필수(required) 값으로 해석하죠.

nullable enabled context에서는 컴파일러가 nullable이든 아니든 모든 참조 형식 변수에 대해 정적 분석을 수행해요. 컴파일러는 각 참조 변수의 null-statenot-null 또는 maybe-null 중 하나로 추적해요. nullable이 아닌 참조의 기본 상태는 not-null이고, nullable 참조의 기본 상태는 maybe-null이에요.

nullable이 아닌 참조 형식은 null-statenot-null이기 때문에 항상 안전하게 역참조할 수 있어야 해요. 그 규칙을 강제하기 위해 컴파일러는 nullable이 아닌 참조 형식이 null이 아닌 값으로 초기화되지 않으면 경고를 발생시켜요. 지역 변수는 선언한 곳에서 반드시 대입해야 하고, 모든 필드는 필드 초기화자나 모든 생성자에서 not-null 값을 대입해야 해요. 컴파일러는 nullable이 아닌 참조에 maybe-null 상태인 참조를 대입하면 경고를 냅니다. 일반적으로 nullable이 아닌 참조는 not-null이라서, 그런 변수를 역참조할 때는 경고가 나오지 않아요.

[!NOTE] maybe-null 식을 nullable이 아닌 참조 형식에 대입하면 컴파일러가 경고를 생성해요. 그러면 그 변수는 not-null 식으로 대입되기 전까지 계속 경고가 나와요.

nullable 참조 형식에는 null을 초기화하거나 대입할 수 있어요. 그래서 정적 분석은 변수를 역참조하기 전에 그 변수가 not-null임을 알아내야 해요. nullable 참조가 maybe-null로 판정되면, 그걸 nullable이 아닌 참조 변수에 대입했을 때 컴파일러 경고가 나와요. 다음 클래스가 이런 경고의 예를 보여줘요.

:::code language="csharp" source="snippets/shared/NullableReferenceTypes.cs" id="SnippetClassWithNullable":::

아래 스니펫은 이 클래스를 사용할 때 컴파일러가 어디서 경고를 내는지 보여줘요.

:::code language="csharp" source="snippets/shared/NullableReferenceTypes.cs" id="SnippetLocalWarnings":::

앞의 예시들은 컴파일러의 정적 분석이 참조 변수의 null-state를 어떻게 판정하는지 보여줘요. 컴파일러는 null 검사와 대입에 대한 언어 규칙을 적용해서 분석을 진행해요. 그런데 컴파일러는 메서드나 프로퍼티의 의미론적 내용을 가정할 수 없어요. null 검사를 수행하는 메서드를 호출해도, 컴파일러는 그 메서드가 변수의 null-state에 영향을 준다는 걸 알 수 없거든요. API에 특성(attribute)을 추가하면 인자와 반환 값의 의미를 컴파일러에게 알려줄 수 있어요. .NET 라이브러리의 많은 공통 API에는 이런 특성이 이미 붙어 있어요. 예를 들어 컴파일러는 xref:System.String.IsNullOrEmpty*를 null 검사로 올바르게 해석해요. null-state 정적 분석에 적용되는 특성에 대해 더 자세히 알고 싶다면 Nullable attributes 문서를 참고하세요.

Nullable context

nullable context는 컴파일러가 nullable 참조 형식 주석을 어떻게 다루고, 정적 null 상태 분석 중 어떤 경고를 생성할지를 결정해요. nullable context에는 두 개의 플래그가 들어 있어요. annotation(주석) 설정과 warning(경고) 설정이에요.

기존 프로젝트에서는 annotation과 warning 설정이 둘 다 기본적으로 꺼져 있어요. .NET 6(C# 10)부터는 프로젝트에서 두 플래그가 모두 기본으로 켜져요. nullable context에 두 개의 별도 플래그를 두는 이유는 nullable 참조 형식이 도입되기 전에 만들어진 큰 프로젝트를 더 쉽게 마이그레이션하기 위해서예요.

작은 프로젝트라면 nullable 참조 형식을 켜고 경고를 고치면서 계속 진행할 수 있어요. 하지만 큰 프로젝트나 여러 프로젝트로 이뤄진 솔루션에서는 그 과정에서 경고가 아주 많이 생길 수 있죠. nullable 참조 형식을 사용하기 시작하면서 pragma로 파일 단위로 켤 수도 있어요. 기존 코드베이스에서 xref:System.NullReferenceException?displayProperty=nameWithType을 막아 주는 새 기능을 켜면 혼란이 생길 수 있어요.

  • 명시적으로 타입이 적힌 모든 참조 변수가 nullable이 아닌 참조 형식으로 해석돼요.
  • 제네릭의 class 제약(constraint)의 의미가 nullable이 아닌 참조 형식을 뜻하는 것으로 바뀌어요.
  • 이런 새 규칙 때문에 새로운 경고가 생성돼요.

nullable annotation context는 컴파일러의 동작을 결정해요. nullable context 설정은 네 가지 조합을 가져요.

  • 둘 다 비활성(disable): 코드는 nullable-oblivious(nullable을 모르는 상태)예요. 새로운 구문이 오류 대신 경고를 만들 뿐, disable은 nullable 참조 형식이 켜지기 전의 동작과 같아요.
    • nullable 경고가 비활성화돼요.
    • 모든 참조 형식 변수가 nullable 참조 형식이에요.
    • ? 접미사로 nullable 참조 형식을 선언하면 경고가 나와요.
    • null-forgiving 연산자 !는 쓸 수 있지만 효과는 없어요.
  • 둘 다 활성(enable): 컴파일러가 모든 null 참조 분석과 모든 언어 기능을 켜요.
    • 모든 새 nullable 경고가 활성화돼요.
    • ? 접미사로 nullable 참조 형식을 선언할 수 있어요.
    • ? 접미사가 없는 참조 형식 변수는 nullable이 아닌 참조 형식이에요.
    • null-forgiving 연산자는 null 역참조 가능성에 대한 경고를 억제해요.
  • warning 활성: 컴파일러가 모든 null 분석을 수행하고, 코드가 null을 역참조할 가능성이 있으면 경고를 내요.
    • 모든 새 nullable 경고가 활성화돼요.
    • ? 접미사로 nullable 참조 형식을 선언하면 경고가 나와요.
    • 모든 참조 형식 변수가 null일 수 있게 허용돼요. 다만 ? 접미사로 선언하지 않았다면 모든 메서드의 여는 중괄호에서 멤버의 null-statenot-null이에요.
    • null-forgiving 연산자 !를 쓸 수 있어요.
  • annotation 활성: 코드가 null을 역참조하거나 maybe-null 식을 nullable이 아닌 변수에 대입해도 컴파일러가 경고를 내지 않아요.
    • 모든 새 nullable 경고가 비활성화돼요.
    • ? 접미사로 nullable 참조 형식을 선언할 수 있어요.
    • ? 접미사가 없는 참조 형식 변수는 nullable이 아닌 참조 형식이에요.
    • null-forgiving 연산자 !는 쓸 수 있지만 효과는 없어요.

프로젝트의 nullable annotation context와 nullable warning context는 .csproj 파일에 <Nullable> 요소를 넣어서 설정할 수 있어요. 이 요소는 컴파일러가 형식의 null 가능성을 어떻게 해석하고 어떤 경고를 낼지 구성해요. 다음 표는 허용 가능한 값과 그 값이 지정하는 컨텍스트를 요약해요.

Context 역참조 경고 대입 경고 참조 형식 ? 접미사 ! 연산자
disable 비활성 비활성 전부 nullable 경고 생성 효과 없음
enable 활성 활성 ?로 선언하지 않으면 non-nullable nullable 형식 선언 가능한 null 대입 경고를 억제
warnings 활성 적용 안 됨 전부 nullable이지만 메서드 여는 중괄호에서 멤버는 not-null로 간주 경고 생성 가능한 null 대입 경고를 억제
annotations 비활성 비활성 ?로 선언하지 않으면 non-nullable nullable 형식 선언 효과 없음

disabled 컨텍스트로 컴파일된 코드의 참조 형식 변수는 nullable-oblivious예요. null 리터럴이나 maybe-null 변수를 nullable-oblivious 변수에 대입할 수 있어요. 다만 nullable-oblivious 변수의 기본 상태는 not-null이에요.

프로젝트에 가장 잘 맞는 설정을 고르면 돼요.

  • 진단이나 새 기능을 기준으로 업데이트하고 싶지 않은 레거시 프로젝트에서는 disable을 골라요.
  • warnings는 코드에서 xref:System.NullReferenceException?displayProperty=nameWithType이 발생할 수 있는 위치를 파악할 때 골라요. nullable이 아닌 참조 형식을 쓰도록 코드를 수정하기 전에 그 경고들을 해결할 수 있어요.
  • annotations는 경고를 켜기 전에 설계 의도를 표현하고 싶을 때 골라요.
  • enable은 null 참조 예외로부터 보호하고 싶은 새 프로젝트나 활발히 개발 중인 프로젝트에 골라요.

예시:

<Nullable>enable</Nullable>

같은 플래그를 소스 코드 어디에서나 지시문으로 설정할 수도 있어요. 이 지시문들은 큰 코드베이스를 마이그레이션할 때 가장 유용해요.

  • #nullable enable: annotation과 warning 플래그를 enable로 설정해요.
  • #nullable disable: annotation과 warning 플래그를 disable로 설정해요.
  • #nullable restore: annotation과 warning 플래그를 프로젝트 설정으로 복원해요.
  • #nullable disable warnings: warning 플래그를 disable로 설정해요.
  • #nullable enable warnings: warning 플래그를 enable로 설정해요.
  • #nullable restore warnings: warning 플래그를 프로젝트 설정으로 복원해요.
  • #nullable disable annotations: annotation 플래그를 disable로 설정해요.
  • #nullable enable annotations: annotation 플래그를 enable로 설정해요.
  • #nullable restore annotations: annotation 플래그를 프로젝트 설정으로 복원해요.

코드의 아무 줄에서나 다음 조합을 설정할 수 있어요.

Warning 플래그 Annotation 플래그 용도
프로젝트 기본값 프로젝트 기본값 기본값
enable disable 분석 경고 수정
enable 프로젝트 기본값 분석 경고 수정
프로젝트 기본값 enable 형식 주석 추가
enable enable 이미 마이그레이션된 코드
disable enable 경고 수정 전에 코드에 주석 달기
disable disable 마이그레이션된 프로젝트에 레거시 코드 추가
프로젝트 기본값 disable 드묾
disable 프로젝트 기본값 드묾

이 아홉 가지 조합으로 컴파일러가 코드에 내는 진단을 세밀하게 제어할 수 있어요. 아직 다루기 준비가 안 된 경고가 더 늘어나는 걸 보지 않으면서, 업데이트하는 영역에서 더 많은 기능을 켤 수 있어요.

[!IMPORTANT] 전역 nullable context는 생성된 코드 파일(generated code)에는 적용되지 않아요. 어느 전략을 쓰든, generated로 표시된 소스 파일에서는 nullable context가 disabled예요. 즉 컴파일러는 생성된 파일의 API에 주석을 달지 않고, 생성된 파일에 대해 nullable 경고도 내지 않아요. 파일은 다음 네 가지 방법 중 하나로 generated로 표시돼요.

  1. .editorconfig에서 그 파일에 적용되는 섹션에 generated_code = true를 지정해요.
  2. 파일 맨 위 주석에 <auto-generated> 또는 <auto-generated/>를 넣어요. 주석의 어느 줄에 있어도 되지만, 주석 블록이 파일의 첫 번째 요소여야 해요.
  3. 파일 이름을 TemporaryGeneratedFile_ 로 시작해요.
  4. 파일 이름을 .designer.cs, .generated.cs, .g.cs, .g.i.cs 로 끝내요.

생성기(Generator)는 #nullable 전처리기 지시문을 사용해서 선택적으로 참여할 수 있어요.

기본적으로 nullable annotation과 warning 플래그는 비활성(disabled) 이에요. 즉 기존 코드를 바꾸지 않고도 컴파일되고 어떤 새 경고도 생성되지 않아요. .NET 6부터 새 프로젝트는 모든 프로젝트 템플릿에 <Nullable>enable</Nullable> 요소를 포함해서 이 플래그들을 활성(enable) 상태로 설정해요.

이 옵션들은 기존 코드베이스를 nullable 참조 형식으로 업데이트하는 두 가지 서로 다른 전략을 제공해요.

nullable context 설정하기

nullable context는 두 가지 방법으로 제어할 수 있어요. 프로젝트 수준에서는 <Nullable>enable</Nullable> 프로젝트 설정을 추가하고, 단일 C# 소스 파일에서는 #nullable enable pragma를 추가해서 nullable context를 켜요. 자세한 내용은 nullable 전략 설정을 참고하세요. .NET 6 이전에는 새 프로젝트가 기본값인 <Nullable>disable</Nullable>을 사용했어요. .NET 6부터는 새 프로젝트가 프로젝트 파일에 <Nullable>enable</Nullable> 요소를 포함해요.

제네릭(Generics)

타입 매개변수 T를 그 nullable 대응물인 T?로 사용하면, 실제 타입 인자(type argument)가 ?를 어떻게 해석할지를 결정해요. 다음 제네릭 선언을 보세요.

public class Box<T>
{
    public T Contents { get; set; }
}

타입 매개변수는 참조 형식이나 값 형식이 될 수 있으므로, T?의 의미는 호출자가 제공하는 타입 인자에 따라 달라져요. T에 제약이 없을 때 T?가 무엇으로 해석되는지 규칙으로 정리하면 이래요.

  • 타입 인자가 nullable이 아닌 참조 형식인 경우. Box<string>에서 Tstring이고 T?string? — 해당 nullable 참조 형식이에요.
  • 타입 인자가 값 형식인 경우. Box<int>에서 Tint이고 T?int — 같은 값 형식이에요. 타입 매개변수에 struct 제약이 없으면 주석은 값 형식에 아무 효과가 없어요. struct 제약이 있으면 T?xref:System.Nullable%601(즉 int?)를 뜻해요.
  • 타입 인자가 이미 nullable인 경우. Box<string?>에서 Tstring?이고 T?도 여전히 string?이에요. "이중으로 nullable"인 형식은 생기지 않아요.

제약(constraint) 은 어떤 타입 인자가 허용되는지를 제한해요. 또한 컴파일러가 T를 어떻게 사용할 수 있는지 추론하게 해 줘요.

  • where T : class는 nullable이 아닌 참조 형식을 요구해요. Box<string>은 허용되고 Box<string?>은 경고가 나와요.
  • where T : class?는 nullable이든 nullable이 아니든 참조 형식을 허용해요. Box<string>Box<string?> 둘 다 허용돼요.
  • where T : struct는 nullable이 아닌 값 형식을 요구해요. Box<int>은 허용되고 Box<int?>는 안 돼요. 이 제약에서 제네릭 내부의 T?xref:System.Nullable%601을 뜻해요. Box<int>에서는 T?int?가 되죠.
  • where T : notnull는 nullable이 아닌 참조 형식이나 값 형식을 요구해요. Box<string>Box<int>는 허용되고 Box<string?>은 경고가 나와요.
  • where T : BaseTypeBaseType에서 파생된 nullable이 아닌 참조 형식을 요구해요. ?를 붙여(where T : BaseType?) nullable 파생 형식도 허용할 수 있어요.

제약은 컴파일러가 제네릭 타입 매개변수가 어떻게 사용되는지 추론하는 데 도움을 줘요.

:::code language="csharp" source="snippets/shared/NullableReferenceTypes.cs" id="Generics":::

C# 언어 사양

자세한 내용은 C# 언어 사양Nullable 참조 형식 절을 참고하세요.

더 알아보기