C# 컴파일러에서 해석하는 null 상태 정적 분석용 특성

null 허용 사용 컨텍스트에서 컴파일러는 코드의 정적 분석을 수행하여 모든 참조 형식 변수의 null 상태를 확인합니다.

  • null이 아님: 정적 분석을 통해 변수에 null이 아닌 값을 갖는지 확인합니다.
  • null일 수 있음: 정적 분석에서 변수에 null이 아닌 값이 할당되었는지 확인할 수 없습니다.

이러한 상태를 사용하면 null 값을 역참조하고 System.NullReferenceException을 throw할 때 컴파일러에서 경고를 제공할 수 있습니다. 이러한 특성은 인수 및 반환 값의 상태에 따라 인수의, 반환 값 및 개체 멤버의 null 상태에 대한 의미 체계 정보를 컴파일러에 제공합니다. 이 의미 체계 정보를 사용하여 API에 올바른 주석을 추가했을 때 컴파일러는 보다 정확한 경고를 제공합니다.

이 문서에서는 각 null 허용 참조 형식 특성 및 사용 방법에 대해 간단하게 설명합니다.

예제를 확인해보겠습니다. 라이브러리에 리소스 문자열을 검색하는 다음 API가 있다고 가정합니다. 이 메서드는 원래 nullable oblivious 컨텍스트에서 컴파일되었습니다.

bool TryGetMessage(string key, out string message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message != null;
}

앞의 예제는 .NET의 친숙한 Try* 패턴을 따릅니다. 이 API에 대한 두 개의 참조 매개 변수 keymessage가 있습니다. 이 API에는 해당 매개 변수의 null 상태에 관련된 다음 규칙이 있습니다.

  • 호출자는 nullkey의 인수로 전달하지 않아야 합니다.
  • 호출자는 값이 null인 변수를 message의 인수로 전달할 수 있습니다.
  • TryGetMessage 메서드가 true를 반환하면 message 값은 null이 아닙니다. 반환 값이 false,인 경우 message 값이 null입니다.

key에 대한 규칙은 간결하게 표현될 수 있습니다. key는 null을 허용하지 않는 참조 형식이어야 합니다. message 매개 변수는 더 복잡합니다. 이 매개 변수는 null인 변수를 인수로 허용하지만, 성공 시 out 인수가 null이 아님을 보장합니다. 이 시나리오의 경우 기대치를 설명하는 더 다양한 어휘가 필요합니다. 아래에 설명된 NotNullWhen 특성은 message 매개 변수에 사용되는 인수의 null 상태를 설명합니다.

참고 항목

이 특성을 추가하면 API 규칙에 대한 자세한 정보가 컴파일러에 제공됩니다. 호출 코드가 null 허용 사용 가능 컨텍스트에서 컴파일되는 경우 컴파일러는 해당 규칙을 위반할 때 호출자에게 경고를 표시합니다. 이러한 특성은 구현에 대한 더 많은 검사를 사용하지 않습니다.

Attribute 범주 의미
AllowNull 사전 조건 null을 허용하지 않는 매개 변수, 필드 또는 속성은 null일 수 있습니다.
DisallowNull 사전 조건 null 허용 매개 변수, 필드 또는 속성은 null일 수 없습니다.
MaybeNull 사후 조건 null을 허용하지 않는 매개 변수, 필드, 속성 또는 반환 값은 null일 수 있습니다.
NotNull 사후 조건 null 허용 매개 변수, 필드, 속성 또는 반환 값은 null일 수 없습니다.
MaybeNullWhen 조건부 사후 조건 메서드가 지정된 bool 값을 반환하는 경우 null을 허용하지 않는 인수는 null일 수 있습니다.
NotNullWhen 조건부 사후 조건 메서드가 지정된 bool 값을 반환하는 경우 null 허용 인수는 null이 아닙니다.
NotNullIfNotNull 조건부 사후 조건 지정된 매개 변수의 인수가 null이 아닌 경우 반환 값, 속성 또는 인수는 null이 아닙니다.
MemberNotNull 메서드 및 속성 도우미 메서드 메서드가 반환하는 경우 나열된 멤버는 null이 아닙니다.
MemberNotNullWhen 메서드 및 속성 도우미 메서드 메서드가 지정된 bool 값을 반환하는 경우 나열된 멤버는 null이 아닙니다.
DoesNotReturn 접근할 수 없는 코드 메서드 또는 속성이 값을 반환하지 않습니다. 즉, 항상 예외를 throw합니다.
DoesNotReturnIf 접근할 수 없는 코드 연결된 bool 매개 변수에 지정된 값이 있는 경우 이 메서드 또는 속성은 값을 반환하지 않습니다.

앞의 설명은 각 특성이 수행하는 작업에 대한 빠른 참조입니다. 다음 섹션에서는 이러한 특성의 동작과 의미를 보다 자세히 설명합니다.

전제 조건: AllowNullDisallowNull

읽기/쓰기 속성에는 적절한 기본값이 있으므로 null을 반환하지 않는 읽기/쓰기 속성을 살펴봅니다. 호출자는 속성을 해당 기본값으로 설정할 때 set 접근자에 null을 전달합니다. 예를 들어 대화방에서 화면 이름을 요청하는 메시지 시스템을 살펴봅니다. 아무것도 제공되지 않으면 시스템은 임의 이름을 생성합니다.

public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName;

null 허용 인식 불가능 컨텍스트에서 이전 코드를 컴파일하면 정상적으로 작동합니다. null 허용 참조 형식을 사용하도록 설정하면 ScreenName 속성이 null을 허용하지 않는 참조가 됩니다. get 접근자의 경우도 마찬가지입니다. 이 접근자는 null을 반환하지 않습니다. 호출자는 null에 대해 반환된 속성을 검사할 필요가 없습니다. 그러나 지금 속성을 null로 설정하면 경고가 생성됩니다. 이 형식의 코드를 지원하려면 다음 코드와 같이 속성에 System.Diagnostics.CodeAnalysis.AllowNullAttribute 특성을 추가합니다.

[AllowNull]
public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName = GenerateRandomScreenName();

이 특성과 이 문서에 설명된 다른 특성을 사용하려면 System.Diagnostics.CodeAnalysis에 대한 using 지시문을 추가해야 할 수 있습니다. 이 특성은 set 접근자가 아니라 속성에 적용됩니다. AllowNull 특성은 사전 조건을 지정하며 인수에만 적용됩니다. get 접근자에는 반환 값이 있지만 매개 변수가 없습니다. 따라서 AllowNull 특성은 set 접근자에만 적용됩니다.

앞의 예제에서는 인수에 대한 AllowNull 특성을 추가할 때 검색할 항목을 보여 줍니다.

  1. 해당 변수에 대한 일반 계약은 null이 아니어야 하므로 null을 허용하지 않는 참조 형식이 필요합니다.
  2. 가장 일반적으로 사용되는 것은 아니지만 호출자가 null을 인수로 전달하는 시나리오가 있습니다.

일반적으로 속성이나 in, outref 인수의 경우 이 특성이 필요합니다. AllowNull 특성은 일반적으로 변수가 null이 아닌 경우에 가장 적합하지만, null을 전제 조건으로 허용해야 합니다.

DisallowNull 사용에 대한 시나리오와 비교해 보세요. 이 특성을 사용하여 null 허용 참조 형식의 인수가 null이 아니도록 지정합니다. 기본값이 null이지만 클라이언트에서 null이 아닌 값으로만 설정할 수 있는 속성을 살펴봅니다. 다음 코드를 생각해 봅시다.

public string ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string _comment;

이전 코드는 ReviewCommentnull일 수 있지만 null로 설정될 수 없는 의도를 표현하는 가장 좋은 방법입니다. 이 코드가 null 허용 인식이 되면 System.Diagnostics.CodeAnalysis.DisallowNullAttribute를 사용하여 호출자에게 이 개념을 보다 명확하게 표현할 수 있습니다.

[DisallowNull]
public string? ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string? _comment;

null 허용 컨텍스트에서 ReviewCommentget 접근자는 null의 기본값을 반환할 수 있습니다. 컴파일러는 액세스하기 전에 검사해야 한다는 경고를 표시합니다. 또한 null일 수 있는 경우에도 호출자가 명시적으로 null로 설정하지 않아야 한다는 경고를 호출자에게 표시합니다. 또한 DisallowNull 특성은 전제 조건을 지정하며 get 접근자에는 영향을 주지 않습니다. 다음에 대해 이 특성을 관찰할 때 DisallowNull 특성을 사용합니다.

  1. 변수는 처음 인스턴스화될 때 주로 핵심 시나리오에서 null일 수 있습니다.
  2. 변수를 명시적으로 null로 설정하면 안 됩니다.

이 상황은 원래 ‘null 인식 불가능’이었던 코드에서 일반적입니다. 개체 속성은 두 개의 개별 초기화 작업에서 설정될 수 있습니다. 일부 속성은 일부 비동기 작업이 완료된 후에만 설정될 수 있습니다.

AllowNullDisallowNull 특성을 사용하여 변수에 대한 전제 조건이 해당 변수의 null 허용 주석과 일치하지 않을 수 있도록 지정할 수 있습니다. 이 특성은 API의 특징에 대한 자세한 정보를 제공합니다. 이 추가 정보는 호출자가 API를 올바르게 사용하는 데 도움이 됩니다. 다음 특성을 사용하여 전제 조건을 지정해야 합니다.

  • AllowNull: null을 허용하지 않는 인수는 null일 수 있습니다.
  • DisallowNull: null 허용 인수는 null이 아니어야 합니다.

사후 조건: MaybeNullNotNull

다음 시그니처를 사용하는 메서드가 있다고 가정합니다.

public Customer FindCustomer(string lastName, string firstName)

검색한 이름을 찾을 수 없는 경우 null을 반환하도록 이 메서드를 작성했을 수 있습니다. null은 분명히 레코드를 찾을 수 없음을 나타냅니다. 이 예제에서는 반환 형식을 Customer에서 Customer?로 변경할 수 있습니다. 반환 값을 null 허용 참조 형식으로 선언하면 이 API의 의도가 분명하게 지정됩니다.

public Customer? FindCustomer(string lastName, string firstName)

제네릭 null 허용 여부에 설명된 이유 때문에 해당 기술이 API와 일치하는 정적 분석을 생성하지 못할 수 있습니다. 비슷한 패턴을 따르는 제네릭 메서드가 있을 수 있습니다.

public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

검색한 항목을 찾을 수 없는 경우 메서드는 null을 반환합니다. 메서드는 메서드 반환에 MaybeNull 주석을 추가하여 항목을 찾을 수 없을 때 null을 반환한다는 것을 명확히 할 수 있습니다.

[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

위의 코드는 반환 값이 실제로 null일 수 있음을 호출자에게 알립니다. 또한 형식이 null을 허용하지 않는 경우에도 메서드가 null 식을 반환할 수 있음을 컴파일러에 알립니다. 해당 형식 매개 변수 T의 인스턴스를 반환하는 제네릭 메서드가 있는 경우 NotNull 특성을 사용하여 null이 반환되지 않도록 표현할 수 있습니다.

형식이 null 허용 참조 형식인 경우에도 반환 값이나 인수가 null이 아니도록 지정할 수도 있습니다. 다음 메서드는 첫 번째 인수가 null인 경우 throw하는 도우미 메서드입니다.

public static void ThrowWhenNull(object value, string valueExpression = "")
{
    if (value is null) throw new ArgumentNullException(nameof(value), valueExpression);
}

다음과 같이 이 루틴을 호출할 수 있습니다.

public static void LogMessage(string? message)
{
    ThrowWhenNull(message, $"{nameof(message)} must not be null");

    Console.WriteLine(message.Length);
}

null 참조 형식을 사용하도록 설정한 후 이전 코드가 경고 없이 컴파일되는지 확인하려고 합니다. 메서드가 값을 반환하는 경우 value 매개 변수는 null이 아님이 보장됩니다. 그러나 null 참조를 사용하여 ThrowWhenNull를 호출할 수 있습니다. value를 null 허용 참조 형식으로 만들고 NotNull 사후 조건을 매개 변수 선언에 추가할 수 있습니다.

public static void ThrowWhenNull([NotNull] object? value, string valueExpression = "")
{
    _ = value ?? throw new ArgumentNullException(nameof(value), valueExpression);
    // other logic elided

위의 코드는 기존 계약을 명확하게 표현합니다. 호출자는 null 값과 함께 변수를 전달할 수 있지만, 메서드가 예외를 throw하지 않고 반환하는 경우 인수가 null이 아님이 보장됩니다.

다음 특성을 사용하여 무조건 사후 조건을 지정합니다.

  • MaybeNull: null을 허용하지 않는 반환 값은 null일 수 있습니다.
  • NotNull: null 허용 반환 값은 null이 아닙니다.

조건부 사후 조건: NotNullWhen, MaybeNullWhenNotNullIfNotNull

string 메서드 String.IsNullOrEmpty(String)에 대해 잘 알고 있을 수 있습니다. 인수가 null이거나 빈 문자열인 경우 이 메서드는 true를 반환합니다. 이는 null 검사 형식입니다. 메서드가 false를 반환하는 경우 호출자는 인수를 null 검사할 필요가 없습니다. 메서드를 이 null 허용 인식처럼 만들려면 인수를 null 허용 참조 형식으로 설정하고 NotNullWhen 특성을 추가합니다.

bool IsNullOrEmpty([NotNullWhen(false)] string? value)

이를 통해 반환 값이 false인 코드는 null 검사가 필요하지 않음을 컴파일러에 알립니다. 특성을 추가하여 IsNullOrEmpty가 필요한 null 검사를 수행한다는 것을 컴파일러의 정적 분석에 알립니다. false를 반환하는 경우 인수는 null이 아닙니다.

string? userInput = GetUserInput();
if (!string.IsNullOrEmpty(userInput))
{
    int messageLength = userInput.Length; // no null check needed.
}
// null check needed on userInput here.

참고 항목

앞의 예는 C# 11 이상에서만 유효합니다. C# 11부터 nameof은 메서드에 적용된 특성에 사용될 때 매개 변수 및 형식 매개 변수 이름을 참조할 수 있습니다. C# 10 이하에서는 nameof 식 대신 문자열 리터럴을 사용해야 합니다.

.NET Core 3.0의 경우 위에 표시된 대로 String.IsNullOrEmpty(String) 메서드가 주석으로 처리됩니다. null 값에 대한 개체의 상태를 확인하는 비슷한 메서드가 코드베이스에 있을 수 있습니다. 컴파일러는 사용자 지정 null 검사 메서드를 인식하지 않으며 주석을 직접 추가해야 합니다. 특성을 추가하면 컴파일러의 정적 분석은 테스트된 변수가 null 검사된 시기를 알 수 있습니다.

또한 이 특성은 Try* 패턴에 사용됩니다. refout 인수에 대한 사후 조건은 반환 값을 통해 전달됩니다. 앞서 표시된 다음 메서드를 사용하는 것이 좋습니다(null 허용 사용 안 함 컨텍스트).

bool TryGetMessage(string key, out string message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message != null;
}

이전 메서드는 일반적인 .NET 관용구를 따릅니다. 반환 값은 message가 발견된 값으로 설정되었는지 또는 메시지가 없는 경우 기본값으로 설정되었는지 여부를 나타냅니다. 메서드가 true를 반환하는 경우 message의 값은 null이 아닙니다. 이 값을 반환하지 않는 경우 메서드는 message를 null로 설정합니다.

null 허용 사용 컨텍스트에서는 NotNullWhen 특성을 사용하여 해당 관용구를 전달할 수 있습니다. null 허용 참조 형식의 매개 변수에 주석을 추가하는 경우 messagestring?으로 만들고 특성을 추가합니다.

bool TryGetMessage(string key, [NotNullWhen(true)] out string? message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message is not null;
}

이전 예제에서 TryGetMessagetrue를 반환하는 경우 message 값은 null이 아닌 것으로 알려져 있습니다. 동일한 방식으로 코드베이스에서 비슷한 메서드를 주석으로 처리해야 합니다. 인수는 null과 동일할 수 있으며 메서드가 true를 반환하는 경우 null이 아닌 것으로 알려져 있습니다.

한 가지 마지막 특성이 필요할 수도 있습니다. 경우에 따라 반환 값의 null 상태는 하나 이상의 인수의 null 상태에 따라 달라집니다. 해당 메서드는 특정 인수가 null이 아닐 때마다 null이 아닌 값을 반환합니다. 해당 메서드를 주석으로 올바르게 처리하려면 NotNullIfNotNull 특성을 사용합니다. 다음 태그를 살펴봅니다.

string GetTopLevelDomainFromFullUrl(string url)

url 인수가 null이 아니면 출력은 null이 아닙니다. null 허용 참조를 사용하도록 설정한 후에는 API에서 null 인수를 허용할 수 있는 경우 더 많은 주석을 추가해야 합니다. 다음 코드와 같이 반환 형식에 주석을 추가할 수 있습니다.

string? GetTopLevelDomainFromFullUrl(string? url)

또한 시그니처가 작동하지만 호출자가 추가 null 검사를 강제로 구현하는 경우가 많습니다. 계약은 인수 urlnull인 경우에만 반환 값이 null이라는 것입니다. 해당 계약을 표현하려면 다음 코드와 같이 이 메서드를 주석으로 처리합니다.

[return: NotNullIfNotNull(nameof(url))]
string? GetTopLevelDomainFromFullUrl(string? url)

이전 예에서는 매개 변수 url에 대해 nameof 연산자를 사용했습니다. 해당 기능은 C# 11에서 사용할 수 있습니다. C# 11 이전에는 매개 변수 이름을 문자열로 입력해야 합니다. 반환 값 및 인수 중 하나가 null일 수 있음을 나타내는 ?를 사용하여 두 항목을 모두 주석으로 처리했습니다. 특성은 url 인수가 null이 아닌 경우 반환 값이 null이 아님을 명확하게 설명합니다.

해당 특성을 사용하여 조건부 사후 조건을 지정합니다.

  • MaybeNullWhen: 메서드가 지정된 bool 값을 반환하는 경우 null을 허용하지 않는 인수는 null일 수 있습니다.
  • NotNullWhen: 메서드가 지정된 bool 값을 반환하는 경우 null 허용 인수는 null이 아닙니다.
  • NotNullIfNotNull: 지정된 매개 변수의 인수가 null이 아닌 경우 반환 값은 null이 아닙니다.

도우미 메서드: MemberNotNullMemberNotNullWhen

이러한 특성은 생성자에서 공용 코드를 도우미 메서드로 리팩터링할 때 의도를 지정합니다. C# 컴파일러는 생성자 및 필드 이니셜라이저를 분석하여 각 생성자를 반환하기 전에 null을 허용하지 않는 모든 참조 필드가 초기화되도록 합니다. 그러나 C# 컴파일러가 모든 도우미 메서드에서 필드 할당을 추적하지는 않습니다. 컴파일러는 필드가 생성자에서 직접 초기화되지 않고 도우미 메서드에서 초기화되는 경우 CS8618 경고를 생성합니다. 메서드 선언에 MemberNotNullAttribute를 추가하고 메서드에서 Null이 아닌 값으로 초기화되는 필드를 지정합니다. 예를 들어 다음 예를 고려해 보겠습니다.

public class Container
{
    private string _uniqueIdentifier; // must be initialized.
    private string? _optionalMessage;

    public Container()
    {
        Helper();
    }

    public Container(string message)
    {
        Helper();
        _optionalMessage = message;
    }

    [MemberNotNull(nameof(_uniqueIdentifier))]
    private void Helper()
    {
        _uniqueIdentifier = DateTime.Now.Ticks.ToString();
    }
}

MemberNotNull 특성 생성자에 대한 인수로 여러 필드 이름을 지정할 수 있습니다.

MemberNotNullWhenAttribute에는 bool 인수가 있습니다. 도우미 메서드가 도우미 메서드에서 필드를 초기화했는지 여부를 나타내는 bool을 반환하는 경우 MemberNotNullWhen을 사용합니다.

호출한 메서드가 throw될 때 null 허용 분석 중지

일반적으로 예외 도우미 또는 기타 유틸리티 메서드와 같은 일부 메서드는 항상 예외를 throw하여 종료됩니다. 또는 도우미는 부울 인수 값을 기반으로 예외를 throw할 수 있습니다.

첫 번째 경우에 DoesNotReturnAttribute 특성을 메서드 선언에 추가할 수 있습니다. 컴파일러의 null 상태 분석은 DoesNotReturn 주석이 추가된 메서드의 호출 이후의 메서드에서 코드를 검사하지 않습니다. 이 메서드를 살펴봅니다.

[DoesNotReturn]
private void FailFast()
{
    throw new InvalidOperationException();
}

public void SetState(object containedField)
{
    if (containedField is null)
    {
        FailFast();
    }

    // containedField can't be null:
    _field = containedField;
}

FailFast를 호출한 후에는 컴파일러에서 경고를 생성하지 않습니다.

두 번째 경우에는 메서드의 부울 매개 변수에 System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute 특성을 추가합니다. 이전 예제를 다음과 같이 수정할 수 있습니다.

private void FailFastIf([DoesNotReturnIf(true)] bool isNull)
{
    if (isNull)
    {
        throw new InvalidOperationException();
    }
}

public void SetFieldState(object? containedField)
{
    FailFastIf(containedField == null);
    // No warning: containedField can't be null here:
    _field = containedField;
}

인수의 값이 DoesNotReturnIf 생성자의 값과 일치하는 경우 컴파일러는 해당 메서드 이후에 null 상태 분석을 수행하지 않습니다.

요약

null 허용 참조 형식을 추가하면 null일 수 있는 변수의 API 기대치를 설명하는 초기 어휘가 제공됩니다. 특성은 변수의 null 상태를 전제 조건 및 사후 조건으로 설명하는 다양한 어휘를 제공합니다. 해당 특성은 기대치를 명확하게 설명하며 API를 사용하는 개발자에게 더 나은 환경을 제공합니다.

null 허용 컨텍스트의 라이브러리를 업데이트할 때 해당 특성을 추가하여 API 사용자가 올바르게 사용하도록 안내합니다. 해당 특성을 통해 인수 및 반환 값의 null 상태를 완벽하게 설명할 수 있습니다.

  • AllowNull: null을 허용하지 않는 필드, 매개 변수 또는 속성이 null일 수 있습니다.
  • DisallowNull: null 허용 필드, 매개 변수 또는 속성이 null일 수 없습니다.
  • MaybeNull: null을 허용하지 않는 필드, 매개 변수, 속성 또는 반환 값이 null일 수 있습니다.
  • NotNull: null 허용 필드, 매개 변수, 속성 또는 반환 값이 null일 수 없습니다.
  • MaybeNullWhen: 메서드가 지정된 bool 값을 반환하는 경우 null을 허용하지 않는 인수는 null일 수 있습니다.
  • NotNullWhen: 메서드가 지정된 bool 값을 반환하는 경우 null 허용 인수는 null이 아닙니다.
  • NotNullIfNotNull: 지정된 매개 변수의 인수가 null이 아닌 경우 매개 변수, 속성 또는 반환 값이 null이 아닙니다.
  • DoesNotReturn: 메서드 또는 속성이 값을 반환하지 않습니다. 즉, 항상 예외를 throw합니다.
  • DoesNotReturnIf: 연결된 bool 매개 변수에 지정된 값이 있는 경우 이 메서드 또는 속성은 값을 반환하지 않습니다.