.NET의 옵션 패턴

옵션 패턴은 클래스를 사용하여 관련 설정 그룹에 대한 강력한 형식의 액세스를 제공합니다. 구성 설정이 시나리오에 따라 별도 클래스로 격리된 경우 앱은 두 가지 중요한 소프트웨어 엔지니어링 원칙을 따릅니다.

옵션은 구성 데이터의 유효성을 검사하는 메커니즘도 제공합니다. 자세한 내용은 옵션 유효성 검사 섹션을 참조하세요.

계층적 구성 바인딩

관련 구성 값을 읽는 기본 방법은 옵션 패턴를 사용하는 것입니다. 옵션 패턴은 제네릭 형식 매개 변수 TOptionsclass로 제한되는 IOptions<TOptions> 인터페이스를 통해 사용할 수 있습니다. IOptions<TOptions>는 나중에 종속성 주입을 통해 제공할 수 있습니다. 자세한 내용은 .NET에서 종속성 주입을 참조하세요.

예를 들어, appsettings.json 파일에서 강조 표시된 구성 값을 읽으려면 다음을 수행합니다.

{
    "SecretKey": "Secret key value",
    "TransientFaultHandlingOptions": {
        "Enabled": true,
        "AutoRetryDelay": "00:00:07"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    }
}

다음 TransientFaultHandlingOptions 클래스를 만듭니다.

public sealed class TransientFaultHandlingOptions
{
    public bool Enabled { get; set; }
    public TimeSpan AutoRetryDelay { get; set; }
}

옵션 패턴을 사용하는 경우 옵션 클래스는

  • 매개 변수가 없는 공용 생성자를 사용하는 비추상이어야 합니다.
  • 바인딩할 공용 읽기/쓰기 속성을 포함합니다(필드는 바인딩되지 않음).

다음 코드는 Program.cs C# 파일의 일부이며 다음과 같습니다.

  • ConfigurationBinder.Bind를 호출하여 TransientFaultHandlingOptions 클래스를 "TransientFaultHandlingOptions" 섹션에 바인딩합니다.
  • 구성 데이터를 표시합니다.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using ConsoleJson.Example;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Configuration.Sources.Clear();

IHostEnvironment env = builder.Environment;

builder.Configuration
    .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
    .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, true);

TransientFaultHandlingOptions options = new();
builder.Configuration.GetSection(nameof(TransientFaultHandlingOptions))
    .Bind(options);

Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");

using IHost host = builder.Build();

// Application code should start here.

await host.RunAsync();

// <Output>
// Sample output:

이전 코드에서 JSON 구성 파일에는 TransientFaultHandlingOptions 인스턴스에 바인딩된 "TransientFaultHandlingOptions" 섹션이 있습니다. 이렇게 하면 C# 개체 속성이 구성의 해당 값으로 수화됩니다.

ConfigurationBinder.Get<T>는 지정된 형식을 바인딩하고 반환합니다. ConfigurationBinder.Get<T>ConfigurationBinder.Bind를 사용하는 것보다 편리할 수 있습니다. 다음 코드에서는 ConfigurationBinder.Get<T>TransientFaultHandlingOptions 클래스를 함께 사용하는 방법을 보여 줍니다.

var options =
    builder.Configuration.GetSection(nameof(TransientFaultHandlingOptions))
        .Get<TransientFaultHandlingOptions>();

Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");

앞의 코드에서 ConfigurationBinder.Get<T>는 기본 구성에서 채워진 속성 값을 사용하여 TransientFaultHandlingOptions 개체의 인스턴스를 획득하는 데 사용됩니다.

Important

ConfigurationBinder 클래스는 .Bind(object instance).Get<T>()와 같이 class로 제한되지 않는 여러 API를 노출합니다. 옵션 인터페이스를 사용하는 경우 앞서 언급한 옵션 클래스 제약 조건을 준수해야 합니다.

옵션 패턴을 사용하는 경우 한 가지 대체 방법은 "TransientFaultHandlingOptions" 섹션을 바인딩하고 종속성 주입 서비스 컨테이너에 추가하는 것입니다. 다음 코드에서 TransientFaultHandlingOptionsConfigure를 통해 서비스 컨테이너에 추가되고 구성에 바인딩됩니다.

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.Configure<TransientFaultHandlingOptions>(
    builder.Configuration.GetSection(
        key: nameof(TransientFaultHandlingOptions)));

앞의 예에서 builderHostApplicationBuilder의 인스턴스입니다.

key 매개 변수는 검색할 구성 섹션의 이름입니다. 이를 표현하는 형식의 이름과 일치할 필요가 ‘없습니다’. 예를 들어 "FaultHandling"라는 섹션이 TransientFaultHandlingOptions 클래스에 의해 표현될 수 있습니다. 이 인스턴스에서는 "FaultHandling"GetSection 함수에 대신 전달합니다. nameof 연산자는 명명된 섹션이 해당하는 형식과 일치하는 경우 편의를 위해 사용됩니다.

위의 코드를 사용하여 다음 코드는 위치 옵션을 읽습니다.

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class ExampleService(IOptions<TransientFaultHandlingOptions> options)
{
    private readonly TransientFaultHandlingOptions _options = options.Value;

    public void DisplayValues()
    {
        Console.WriteLine($"TransientFaultHandlingOptions.Enabled={_options.Enabled}");
        Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={_options.AutoRetryDelay}");
    }
}

위 코드에서는 앱을 시작한 후의 JSON 구성 파일의 변경 사항을 읽지 않습니다. 앱이 시작된 후 변경 내용을 읽으려면 IOptionsSnapshot 또는 IOptionsMonitor를 사용하여 변경 내용이 발생할 때마다 모니터링하고 그에 따라 대응합니다.

옵션 인터페이스

IOptions<TOptions>:

IOptionsSnapshot<TOptions>:

IOptionsMonitor<TOptions>:

IOptionsFactory<TOptions>는 새로운 옵션 인스턴스를 만듭니다. 단일 Create 메서드가 있습니다. 기본 구현에서는 등록된 모든 IConfigureOptions<TOptions>IPostConfigureOptions<TOptions>를 사용하며 먼저 구성을 모두 실행한 다음, 사후 구성을 수행합니다. IConfigureNamedOptions<TOptions>IConfigureOptions<TOptions>를 구별하며 적절한 인터페이스만 호출합니다.

IOptionsMonitorCache<TOptions>IOptionsMonitor<TOptions>에서 TOptions 인스턴스를 캐시하는 데 사용됩니다. IOptionsMonitorCache<TOptions>는 모니터의 옵션 인스턴스를 무효화하므로 값이 다시 계산됩니다(TryRemove). TryAdd를 사용하여 값을 수동으로 도입할 수 있습니다. 모든 명명된 인스턴스를 필요에 따라 다시 만들어야 하는 경우 Clear 메서드가 사용됩니다.

IOptionsChangeTokenSource<TOptions>는 기본 TOptions 인스턴스에 대한 변경 내용을 추적하는 IChangeToken을 가져오는 데 사용됩니다. 변경 토큰 기본 요소에 대한 자세한 내용은 변경 알림을 참조하세요.

옵션 인터페이스의 이점

제네릭 래퍼 형식을 사용하면 옵션의 수명을 DI 컨테이너에서 분리할 수 있습니다. IOptions<TOptions>.Value 인터페이스는 일반 제약 조건을 포함하여 옵션 형식에 대한 추상화 계층을 제공합니다. 이 기능은 다음과 같은 이점을 제공합니다.

  • T 구성 인스턴스의 평가는 IOptions<TOptions>.Value가 주입될 때가 아니라 액세스될 때로 지연됩니다. 이는 T 옵션을 다양한 위치에서 사용하고 T에 대한 변경 없이 수명 의미 체계를 선택할 수 있기 때문에 중요합니다.
  • T 형식의 옵션을 등록하는 경우 T 형식을 명시적으로 등록할 필요가 없습니다. 이는 간단한 기본값을 사용하여 라이브러리를 작성하고 호출자가 특정 수명을 갖는 옵션을 DI 컨테이너에 등록하도록 강제하지 않으려는 경우에 편리합니다.
  • API의 관점에서 보면 T 형식에 제약 조건을 사용할 수 있습니다(이 경우 T가 참조 형식으로 제한됨).

IOptionsSnapshot을 사용하여 업데이트된 데이터 읽기

IOptionsSnapshot<TOptions>을 사용하면, 옵션은 액세스될 때 요청당 한 번씩 계산되고 요청의 수명 동안 캐시됩니다. 업데이트된 구성 값 읽기를 지원하는 구성 공급자를 사용하는 경우 앱을 시작한 후 구성 변경 내용을 읽습니다.

IOptionsMonitorIOptionsSnapshot의 차이점은 다음과 같습니다.

  • IOptionsMonitor는 언제든지 현재 옵션 값을 검색하는 싱글톤 서비스로, 싱글톤 종속성에서 특히 유용합니다.
  • IOptionsSnapshot범위가 지정된 서비스이며 IOptionsSnapshot<T> 개체가 생성될 때 옵션의 스냅샷을 제공합니다. 옵션 스냅숏은 임시 및 범위가 지정된 종속성과 함께 사용하도록 설계되었습니다.

다음 코드에서는 IOptionsSnapshot<TOptions> 메서드를 사용합니다.

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class ScopedService(IOptionsSnapshot<TransientFaultHandlingOptions> options)
{
    private readonly TransientFaultHandlingOptions _options = options.Value;

    public void DisplayValues()
    {
        Console.WriteLine($"TransientFaultHandlingOptions.Enabled={_options.Enabled}");
        Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={_options.AutoRetryDelay}");
    }
}

다음 코드에서는 TransientFaultHandlingOptions가 바인딩되는 구성 인스턴스를 등록합니다.

builder.Services
    .Configure<TransientFaultHandlingOptions>(
        configurationRoot.GetSection(
            nameof(TransientFaultHandlingOptions)));

앞의 코드에서 Configure<TOptions> 메서드는 TOptions가 바인딩할 구성 인스턴스를 등록하고 구성이 변경될 때 옵션을 업데이트하는 데 사용됩니다.

IOptionsMonitor

옵션 모니터를 사용하려면 구성 섹션에서 동일한 방식으로 옵션 개체를 구성해야 합니다.

builder.Services
    .Configure<TransientFaultHandlingOptions>(
        configurationRoot.GetSection(
            nameof(TransientFaultHandlingOptions)));

다음 예제에서는 IOptionsMonitor<TOptions>를 사용합니다.

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class MonitorService(IOptionsMonitor<TransientFaultHandlingOptions> monitor)
{
    public void DisplayValues()
    {
        TransientFaultHandlingOptions options = monitor.CurrentValue;

        Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
        Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");
    }
}

위 코드에서는 앱을 시작한 후 JSON 구성 파일 변경 사항을 읽습니다.

Docker 컨테이너나 네트워크 공유 같은 일부 파일 시스템은 변경 알림을 안정적으로 전송할 수 없습니다. 이러한 환경에서 IOptionsMonitor<TOptions> 인터페이스를 사용하는 경우 DOTNET_USE_POLLING_FILE_WATCHER 환경 변수를 1 또는 true로 설정하여 파일 시스템에서 변경 내용을 폴링합니다. 변경 내용이 폴링되는 간격은 4초마다이며 구성할 수 없습니다.

Docker 컨테이너에 대한 자세한 내용은 .NET 앱 컨테이너화를 참조하세요.

명명된 옵션은 IConfigureNamedOptions 사용을 지원

명명된 옵션:

  • 여러 구성 섹션이 동일한 속성에 바인딩되는 경우에 유용합니다.
  • 대/소문자를 구분합니다.

다음 appsettings.json 파일을 고려하세요.

{
  "Features": {
    "Personalize": {
      "Enabled": true,
      "ApiKey": "aGEgaGEgeW91IHRob3VnaHQgdGhhdCB3YXMgcmVhbGx5IHNvbWV0aGluZw=="
    },
    "WeatherStation": {
      "Enabled": true,
      "ApiKey": "QXJlIHlvdSBhdHRlbXB0aW5nIHRvIGhhY2sgdXM/"
    }
  }
}

Features:PersonalizeFeatures:WeatherStation를 바인딩하는 두 개의 클래스를 만드는 대신 각 섹션에 다음 클래스가 사용됩니다.

public class Features
{
    public const string Personalize = nameof(Personalize);
    public const string WeatherStation = nameof(WeatherStation);

    public bool Enabled { get; set; }
    public string ApiKey { get; set; }
}

다음 코드는 명명된 옵션을 구성합니다.

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Omitted for brevity...

builder.Services.Configure<Features>(
    Features.Personalize,
    builder.Configuration.GetSection("Features:Personalize"));

builder.Services.Configure<Features>(
    Features.WeatherStation,
    builder.Configuration.GetSection("Features:WeatherStation"));

다음 코드는 명명된 옵션을 표시합니다.

public class sealed Service
{
    private readonly Features _personalizeFeature;
    private readonly Features _weatherStationFeature;

    public Service(IOptionsSnapshot<Features> namedOptionsAccessor)
    {
        _personalizeFeature = namedOptionsAccessor.Get(Features.Personalize);
        _weatherStationFeature = namedOptionsAccessor.Get(Features.WeatherStation);
    }
}

모든 옵션은 명명된 인스턴스입니다. IConfigureOptions<TOptions> 인스턴스는 string.EmptyOptions.DefaultName 인스턴스를 대상으로 지정한 것처럼 처리됩니다. 또한 IConfigureNamedOptions<TOptions>IConfigureOptions<TOptions>를 구현합니다. IOptionsFactory<TOptions>의 기본 구현에는 각 옵션을 적절하게 사용하기 위한 논리가 있습니다. null 명명된 옵션은 특정 명명된 인스턴스 대신 모든 명명된 인스턴스를 대상으로 지정하는 데 사용됩니다. ConfigureAllPostConfigureAll에서 이 규칙을 사용합니다.

OptionsBuilder API

OptionsBuilder<TOptions>TOptions 인스턴스를 구성하는 데 사용됩니다. OptionsBuilderAddOptions<TOptions>(string optionsName) 호출에 대한 단일 매개 변수이므로 모든 후속 호출에 나타나는 대신 명명된 옵션 생성을 간소화합니다. 옵션 유효성 검사 및 서비스 종속성을 허용하는 ConfigureOptions 오버로드는 OptionsBuilder를 통해서만 사용할 수 있습니다.

OptionsBuilder옵션 유효성 검사 섹션에서 사용됩니다.

DI 서비스를 사용하여 옵션 구성

다음 두 가지 방법으로 옵션을 구성하는 동안 종속성 주입에서 서비스에 액세스할 수 있습니다.

  • 구성 대리자를 OptionsBuilder<TOptions>Configure에 전달합니다. OptionsBuilder<TOptions>는 최대 5개의 서비스를 사용하여 옵션을 구성할 수 있는 Configure의 오버로드를 제공합니다.

    builder.Services
        .AddOptions<MyOptions>("optionalName")
        .Configure<ExampleService, ScopedService, MonitorService>(
            (options, es, ss, ms) =>
                options.Property = DoSomethingWith(es, ss, ms));
    
  • IConfigureOptions<TOptions> 또는 IConfigureNamedOptions<TOptions>를 구현하는 형식을 만들고 해당 형식을 서비스로 등록합니다.

서비스를 만드는 것이 더 복잡하기 때문에 구성 대리자를 Configure에 전달하는 것이 좋습니다. 형식을 만드는 작업은 Configure를 호출할 때 프레임워크에서 수행하는 작업에 해당합니다. Configure을 호출하면 지정된 일반 서비스 유형을 허용하는 생성자가 있는 일시적인 일반 IConfigureNamedOptions<TOptions>가 등록됩니다.

옵션 유효성 검사

옵션 유효성 검사를 통해 옵션 값의 유효성을 검사할 수 있습니다.

다음 appsettings.json 파일을 고려하세요.

{
  "MyCustomSettingsSection": {
    "SiteTitle": "Amazing docs from Awesome people!",
    "Scale": 10,
    "VerbosityLevel": 32
  }
}

다음 클래스는 "MyCustomSettingsSection" 구성 섹션에 바인딩되고 몇 가지 DataAnnotations 규칙을 적용합니다.

using System.ComponentModel.DataAnnotations;

namespace ConsoleJson.Example;

public sealed class SettingsOptions
{
    public const string ConfigurationSectionName = "MyCustomSettingsSection";

    [Required]
    [RegularExpression(@"^[a-zA-Z''-'\s]{1,40}$")]
    public required string SiteTitle { get; set; }

    [Required]
    [Range(0, 1_000,
        ErrorMessage = "Value for {0} must be between {1} and {2}.")]
    public required int Scale { get; set; }

    [Required]
    public required int VerbosityLevel { get; set; }
}

위의 SettingsOptions 클래스에서 ConfigurationSectionName 속성에는 바인딩할 구성 섹션의 이름이 포함됩니다. 이 시나리오에서 옵션 개체는 구성 섹션의 이름을 제공합니다.

구성 섹션 이름은 바인딩되는 구성 개체와 관련이 없습니다. 즉, "FooBarOptions"라는 구성 섹션은 ZedOptions라는 옵션 개체에 바인딩할 수 있습니다. 일반적으로 이름을 동일하게 지정할 수 있지만 반드시 그럴 필요가 ‘없으며’ 실제로 이름 충돌이 발생할 수 있습니다.

코드는 다음과 같습니다.

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations();

ValidateDataAnnotations 확장 메서드는 Microsoft.Extensions.Options.DataAnnotations NuGet 패키지에서 정의됩니다.

다음 코드는 구성 값을 표시하거나 유효성 검사 오류를 보고합니다.

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class ValidationService
{
    private readonly ILogger<ValidationService> _logger;
    private readonly IOptions<SettingsOptions> _config;

    public ValidationService(
        ILogger<ValidationService> logger,
        IOptions<SettingsOptions> config)
    {
        _config = config;
        _logger = logger;

        try
        {
            SettingsOptions options = _config.Value;
        }
        catch (OptionsValidationException ex)
        {
            foreach (string failure in ex.Failures)
            {
                _logger.LogError("Validation error: {FailureMessage}", failure);
            }
        }
    }
}

다음 코드는 대리자를 사용하여 보다 복잡한 유효성 검사 규칙을 적용합니다.

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.Scale != 0)
        {
            return config.VerbosityLevel > config.Scale;
        }

        return true;
    }, "VerbosityLevel must be > than Scale.");

유효성 검사는 런타임 시 발생하지만 대신 ValidateOnStart에 대한 호출을 연결하여 시작 시 발생하도록 구성할 수 있습니다.

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.Scale != 0)
        {
            return config.VerbosityLevel > config.Scale;
        }

        return true;
    }, "VerbosityLevel must be > than Scale.")
    .ValidateOnStart();

.NET 8부터 시작 시 특정 옵션 형식에 대한 유효성 검사를 사용하도록 설정하는 대체 API인 AddOptionsWithValidateOnStart<TOptions>(IServiceCollection, String)을 사용할 수 있습니다.

builder.Services
    .AddOptionsWithValidateOnStart<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.Scale != 0)
        {
            return config.VerbosityLevel > config.Scale;
        }

        return true;
    }, "VerbosityLevel must be > than Scale.");

복잡한 유효성 검사의 경우 IValidateOptions

다음 클래스는 IValidateOptions<TOptions>를 구현합니다.

using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

sealed partial class ValidateSettingsOptions(
    IConfiguration config)
    : IValidateOptions<SettingsOptions>
{
    public SettingsOptions? Settings { get; private set; } =
        config.GetSection(SettingsOptions.ConfigurationSectionName)
              .Get<SettingsOptions>();

    public ValidateOptionsResult Validate(string? name, SettingsOptions options)
    {
        StringBuilder? failure = null;
    
        if (!ValidationRegex().IsMatch(options.SiteTitle))
        {
            (failure ??= new()).AppendLine($"{options.SiteTitle} doesn't match RegEx");
        }

        if (options.Scale is < 0 or > 1_000)
        {
            (failure ??= new()).AppendLine($"{options.Scale} isn't within Range 0 - 1000");
        }

        if (Settings is { Scale: 0 } && Settings.VerbosityLevel <= Settings.Scale)
        {
            (failure ??= new()).AppendLine("VerbosityLevel must be > than Scale.");
        }

        return failure is not null
            ? ValidateOptionsResult.Fail(failure.ToString())
            : ValidateOptionsResult.Success;
    }

    [GeneratedRegex("^[a-zA-Z''-'\\s]{1,40}$")]
    private static partial Regex ValidationRegex();
}

IValidateOptions를 사용하면 유효성 검사 코드를 클래스로 이동할 수 있습니다.

참고 항목

이 예제 코드는 Microsoft.Extensions.Configuration.Json NuGet 패키지를 사용합니다.

이전 코드를 사용하면 다음 코드로 서비스를 구성할 때 유효성 검사가 사용하도록 설정됩니다.

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Omitted for brevity...

builder.Services.Configure<SettingsOptions>(
    builder.Configuration.GetSection(
        SettingsOptions.ConfigurationSectionName));

builder.Services.TryAddEnumerable(
    ServiceDescriptor.Singleton
        <IValidateOptions<SettingsOptions>, ValidateSettingsOptions>());

옵션 사후 구성

IPostConfigureOptions<TOptions>를 사용하여 사후 구성을 설정합니다. 사후 구성은 모든 IConfigureOptions<TOptions> 구성이 발생한 후에 실행되며, 구성을 재정의해야 하는 시나리오에서 유용할 수 있습니다.

builder.Services.PostConfigure<CustomOptions>(customOptions =>
{
    customOptions.Option1 = "post_configured_option1_value";
});

PostConfigure를 사용하여 명명된 옵션을 사후 구성할 수 있습니다.

builder.Services.PostConfigure<CustomOptions>("named_options_1", customOptions =>
{
    customOptions.Option1 = "post_configured_option1_value";
});

모든 구성 인스턴스를 사후 구성하려면 PostConfigureAll을 사용합니다.

builder.Services.PostConfigureAll<CustomOptions>(customOptions =>
{
    customOptions.Option1 = "post_configured_option1_value";
});

참고 항목