연습 - Null 안전성 전략 적용

완료됨

이전 단원에서는 코드에서 null 허용 여부 의도를 표현하는 방법을 알아보았습니다. 이 단원에서는 학습한 내용을 기존 C# 프로젝트에 적용합니다.

참고

이 모듈에서는 로컬 개발에 .NET CLI(명령줄 인터페이스) 및 Visual Studio Code를 사용합니다. 이 모듈을 완료하면 Visual Studio(Windows), Mac용 Visual Studio(macOS) 또는 Visual Studio Code(Windows, Linux 및 macOS)를 사용한 연속 개발을 사용하여 개념을 적용할 수 있습니다.

이 모듈에서는 .NET 6.0 SDK를 사용합니다. 기본 설정 터미널에서 다음 명령을 실행하여 .NET 6.0이 설치되어 있는지 확인합니다.

dotnet --list-sdks

그러면 다음과 같은 출력이 표시됩니다.

3.1.100 [C:\program files\dotnet\sdk]
5.0.100 [C:\program files\dotnet\sdk]
6.0.100 [C:\program files\dotnet\sdk]

6으로 시작하는 버전이 나열되어 있는지 확인합니다. 나열되는 버전이 없거나 명령을 찾을 수 없는 경우 최신 .NET 6.0 SDK를 설치합니다.

샘플 코드 검색 및 검사

  1. 명령 터미널에서 샘플 GitHub 리포지토리를 복제하고 복제된 디렉터리로 전환합니다.

    git clone https://github.com/microsoftdocs/mslearn-csharp-null-safety
    cd mslearn-csharp-null-safety
    
  2. Visual Studio Code에서 프로젝트 디렉터리를 엽니다.

    code .
    
  3. dotnet run 명령을 사용하여 샘플 프로젝트를 실행합니다.

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    

    그러면 NullReferenceException이 throw됩니다.

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
       at Program.<Main>$(String[] args) in .\src\ContosoPizza.Service\Program.cs:line 13
    

    스택 추적은 .\src\ContosoPizza.Service\Program.cs의 줄 13에서 예외가 발생했음을 나타냅니다. 줄 13에서는 pizza.Cheeses 속성에 대해 Add 메서드가 호출됩니다. pizza.Cheesesnull이므로 NullReferenceException이 throw됩니다.

    using ContosoPizza.Models;
    
    // Create a pizza
    Pizza pizza = new("Meat Lover's Special")
    {
        Size = PizzaSize.Medium,
        Crust = PizzaCrust.DeepDish,
        Sauce = PizzaSauce.Marinara,
        Price = 17.99m,
    };
    
    // Add cheeses
    pizza.Cheeses.Add(PizzaCheese.Mozzarella);
    pizza.Cheeses.Add(PizzaCheese.Parmesan);
    
    // Add toppings
    pizza.Toppings.Add(PizzaTopping.Sausage);
    pizza.Toppings.Add(PizzaTopping.Pepperoni);
    pizza.Toppings.Add(PizzaTopping.Bacon);
    pizza.Toppings.Add(PizzaTopping.Ham);
    pizza.Toppings.Add(PizzaTopping.Meatballs);
    
    Console.WriteLine(pizza);
    
    /*
        Expected output:
    
        The "Meat Lover's Special" is a deep dish pizza with marinara sauce.
        It's covered with a blend of mozzarella and parmesan cheese.
        It's layered with sausage, pepperoni, bacon, ham and meatballs.
        This medium size is $17.99. Delivery is $2.50 more, bringing your total $20.49!
    */
    

Null 허용 컨텍스트 사용

이제 null 허용 컨텍스트를 사용하도록 설정하고 빌드에 미치는 영향을 검사합니다.

  1. src/ContosoPizza.Service/ContosoPizza.Service.csproj에서 강조 표시된 줄을 추가하고 변경 내용을 저장합니다.

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\ContosoPizza.Models\ContosoPizza.Models.csproj" />
      </ItemGroup>
    
    </Project>
    

    위의 변경은 전체 ContosoPizza.Service 프로젝트에 대해 null 허용 컨텍스트를 사용하도록 설정합니다.

  2. src/ContosoPizza.Models/ContosoPizza.Models.csproj에서 강조 표시된 줄을 추가하고 변경 내용을 저장합니다.

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    
    </Project>
    

    위의 변경은 전체 ContosoPizza.Models 프로젝트에 대해 null 허용 컨텍스트를 사용하도록 설정합니다.

  3. dotnet build 명령을 사용하여 샘플 솔루션을 빌드합니다.

    dotnet build
    

    빌드가 성공하지만 경고 2개가 발생합니다.

    dotnet build
    Microsoft (R) Build Engine version 17.0.0+c9eb9dd64 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
    
      Determining projects to restore...
      Restored .\src\ContosoPizza.Service\ContosoPizza.Service.csproj (in 477 ms).
      Restored .\src\ContosoPizza.Models\ContosoPizza.Models.csproj (in 475 ms).
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
      ContosoPizza.Models -> .\src\ContosoPizza.Models\bin\Debug\net6.0\ContosoPizza.Models.dll
      ContosoPizza.Service -> .\src\ContosoPizza.Service\bin\Debug\net6.0\ContosoPizza.Service.dll
    
    Build succeeded.
    
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
        2 Warning(s)
        0 Error(s)
    
    Time Elapsed 00:00:07.48
    
  4. dotnet build 명령을 다시 사용하여 샘플 솔루션을 빌드합니다.

    dotnet build
    

    이번에는 오류 또는 경고 없이 빌드가 성공합니다. 이전 빌드는 성공적으로 완료되었지만 경고가 발생했습니다. 소스가 변경되지 않았으므로 빌드 프로세스에서 컴파일러를 다시 실행하지 않습니다. 빌드에서 컴파일러를 실행하지 않으므로 경고가 발생하지 않습니다.

    dotnet build 전에 dotnet clean 명령을 사용하여 프로젝트의 모든 어셈블리를 강제로 다시 빌드할 수 있습니다.

  5. .csproj 파일에서 강조 표시된 줄을 추가하고 변경 내용을 저장합니다.

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\ContosoPizza.Models\ContosoPizza.Models.csproj" />
      </ItemGroup>
    
    </Project>
    
    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
    
    </Project>
    

    이전 변경 내용은 컴파일러에 경고가 발생할 때마다 빌드를 실패시키도록 지시합니다.

    <TreatWarningsAsErrors> 사용은 선택 사항입니다. 그러나 어떤 경고도 간과하지 않는 것이 좋습니다.

  6. dotnet build 명령을 사용하여 샘플 솔루션을 빌드합니다.

    dotnet build
    

    2개 오류가 발생하며 빌드가 실패합니다.

    dotnet build
    Microsoft (R) Build Engine version 17.0.0+c9eb9dd64 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
    
      Determining projects to restore...
      All projects are up-to-date for restore.
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    
    Build FAILED.
    
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
        0 Warning(s)
        2 Error(s)
    
    Time Elapsed 00:00:02.95
    

    경고를 오류로 처리할 때 앱은 더 이상 빌드되지 않습니다. 오류 수가 적고 신속하게 해결할 수 있으므로 이 상황에서는 실제로 바람직합니다. 두 오류(CS8618)를 통해 null을 허용하지 않음으로 선언된 속성이 아직 초기화되지 않았다는 것을 알 수 있습니다.

오류 수정

Null 허용 여부와 관련된 경고/오류를 해결하기 위한 많은 전술이 있습니다. 일부 사례:

  • 생성자 매개 변수로 치즈 및 토핑의 null을 허용하지 않는 컬렉션이 필요합니다.
  • get/set 속성을 가로채서 null 검사를 추가합니다.
  • 해당 속성에 Null을 허용하려는 의도를 표현합니다.
  • 속성 이니셜라이저를 사용하여 기본(빈) 값 인라인으로 컬렉션을 초기화합니다.
  • 생성자에서 속성에 기본(빈) 값을 할당합니다.
  1. Pizza.Cheeses 속성의 오류를 해결하려면 Pizza.cs의 속성 정의를 수정하여 null 검사를 추가합니다. 정말로 치즈 없는 피자가 아니죠, 그렇죠?!

    namespace ContosoPizza.Models;
    
    public sealed record class Pizza([Required] string Name)
    {
        private ICollection<PizzaCheese>? _cheeses;
    
        public int Id { get; set; }
    
        [Range(0, 9999.99)]
        public decimal Price { get; set; }
    
        public PizzaSize Size { get; set; }
        public PizzaCrust Crust { get; set; }
        public PizzaSauce Sauce { get; set; }
    
        public ICollection<PizzaCheese> Cheeses
        {
            get => (_cheeses ??= new List<PizzaCheese>());
            set => _cheeses = value ?? throw new ArgumentNullException(nameof(value));
        }
    
        public ICollection<PizzaTopping>? Toppings { get; set; }
    
        public override string ToString() => this.ToDescriptiveString();
    }
    

    위의 코드에서

    • _cheeses라는 getset 속성 접근자를 가로채는 데 도움이 되는 새 지원 필드가 추가됩니다. 이 필드는 null 허용(?)으로 선언되고 초기화되지 않은 상태로 유지됩니다.
    • get 접근자는 null 병합 연산자(??)를 사용하는 식에 매핑됩니다. 이 식은 null이 아니라고 가정하여 _cheeses 필드를 반환합니다. 이 필드가 null인 경우 _cheeses를 반환하기 전에 _cheesesnew List<PizzaCheese>()에 할당합니다.
    • 또한 set 접근자는 식에 매핑되고 null 병합 연산자를 사용합니다. 소비자가 null 값을 할당하면 ArgumentNullException이 throw됩니다.
  2. 모든 피자에 토핑이 있는 것은 아니므로 Pizza.Toppings 속성에 null이 유효한 값이 될 수 있습니다. 이 경우 이 속성을 null 허용으로 표현하는 것이 좋습니다.

    1. Toppings이 null을 허용하도록 Pizza.cs의 속성 정의를 수정합니다.

      namespace ContosoPizza.Models;
      
      public sealed record class Pizza([Required] string Name)
      {
          private ICollection<PizzaCheese>? _cheeses;
      
          public int Id { get; set; }
      
          [Range(0, 9999.99)]
          public decimal Price { get; set; }
      
          public PizzaSize Size { get; set; }
          public PizzaCrust Crust { get; set; }
          public PizzaSauce Sauce { get; set; }
      
          public ICollection<PizzaCheese> Cheeses
          {
              get => (_cheeses ??= new List<PizzaCheese>());
              set => _cheeses = value ?? throw new ArgumentNullException(nameof(value));
          }
      
          public ICollection<PizzaTopping>? Toppings { get; set; }
      
          public override string ToString() => this.ToDescriptiveString();
      }
      

      이제 Toppings 속성이 null 허용으로 표시됩니다.

    2. 강조 표시된 줄을 ContosoPizza.Service\Program.cs에 추가합니다.

      using ContosoPizza.Models;
      
      // Create a pizza
      Pizza pizza = new("Meat Lover's Special")
      {
          Size = PizzaSize.Medium,
          Crust = PizzaCrust.DeepDish,
          Sauce = PizzaSauce.Marinara,
          Price = 17.99m,
      };
      
      // Add cheeses
      pizza.Cheeses.Add(PizzaCheese.Mozzarella);
      pizza.Cheeses.Add(PizzaCheese.Parmesan);
      
      // Add toppings
      pizza.Toppings ??= new List<PizzaTopping>();
      pizza.Toppings.Add(PizzaTopping.Sausage);
      pizza.Toppings.Add(PizzaTopping.Pepperoni);
      pizza.Toppings.Add(PizzaTopping.Bacon);
      pizza.Toppings.Add(PizzaTopping.Ham);
      pizza.Toppings.Add(PizzaTopping.Meatballs);
      
      Console.WriteLine(pizza);
      
      /*
          Expected output:
      
          The "Meat Lover's Special" is a deep dish pizza with marinara sauce.
          It's covered with a blend of mozzarella and parmesan cheese.
          It's layered with sausage, pepperoni, bacon, ham and meatballs.
          This medium size is $17.99. Delivery is $2.50 more, bringing your total $20.49!
      */
      

    위의 코드에서 null인 경우 Toppingsnew List<PizzaTopping>();에 할당하기 위해 null 병합 연산자가 사용됩니다.

완료된 솔루션 실행

  1. 모든 변경 내용을 저장한 다음 솔루션을 빌드합니다.

    dotnet build
    

    경고 또는 오류 없이 빌드가 완료됩니다.

  2. 앱을 실행합니다.

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    

    앱이 오류 없이 끝까지 실행되고 다음 출력이 표시됩니다.

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    The "Meat Lover's Special" is a deep dish pizza with marinara sauce.
    It's covered with a blend of mozzarella and parmesan cheese.
    It's layered with sausage, pepperoni, bacon, ham and meatballs.
    This medium size is $17.99. Delivery is $2.50 more, bringing your total $20.49!
    

요약

이 단원에서는 null 허용 컨텍스트를 사용하여 코드에서 발생할 수 있는 NullReferenceException을 식별하고 방지했습니다.