IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>

닷넷의 Host 객체는 DI, 설정, 그리고, 앱의 생애 주기 관련 도구를 제공합니다.

일반적으로 "호스트"라고 하면 네트워크가 연상되지만, 닷넷에서는 네트워크를 결부하지 않은 개념으로 사용합니다. 네트워크 기능을 결부하여, WebHost 라는 파생 객체도 제공하고, 이를 기반한 웹앱으로 WebApplication(Asp.Net Core 앱) 도 제공합니다.

닷넷에서 "Configuration"의 의미는 (앱의 재빌드/재시작 없이) 앱의 파라미터를 설정/변경하는 것을 가리킵니다. 참고로 닷넷 문서, VS 에서는 "구성"이라고 번역하는데, 이 글에서는 "설정"이라고 표현합니다.

닷넷 Host의 설정 시스템은, 설정 공급자( 대표적으로 JsonFileProvider - appsettings.json 의 컨텐츠를 설정값에 추가)가 제공한 값을 코드에 공급합니다.

공급하는 방식은 아래와 같습니다.

  • raw 값을 그대로 (이후 코드에서 객체에 수동 바인딩할 수 있음)
  • ConfigurationBinder를 통해 바인딩된 객체

이 글은 두 번째 방식과 연관된, 제하의 인터페이스 패밀리에 관한 것입니다.

예제를 위해,

설정 소스

appsettins.json

{
   "Upstreams" :  {
      "Backends" : [ "https://app1.mysite.com", "https://app2.mysite.com" ]
   }
}

바인딩 모델

public sealed class Upstreams // 기본 생성자 있어야 함.
{
    public Uri[] Backends { get; set; } = []; // get, set 모두 있어야 함.
}

설정 바인딩을 위한 모델은 아래의 요구 사항을 만족해야 합니다.

  • 기본생성자 존재
  • public read/write 속성

자동 바인딩 설정

// program.cs
builder.Configure<Upstreams>(builder.Configuration.GetSection("Upstreams"));

참고
Upstreams.BackendsUri[] 타입이고, appsettings.Upstreams.Backends의 형식은 string[] 타입니다. 설정 바이딩 시에, stringUri 에 자동 변환되는 편리 기능이 있습니다.

자동 바인딩 설정으로 인해, 우리 코드는 Upstreams 객체를 아래 세 가지 타입 중 하나로 선택해서 받을 수 있습니다.

  • IOptions<Upstreams>
  • IOptionsSnapshot<Upstreams>
  • IOptionsMonitor<Upstreams>

IOptions<T>

Value 속성을 처음으로 읽을 때 값이 확정되고, 이 값은 앱의 종료까지 변하지 않습니다.

class ScopedService(IOptions<Upstreams> upstreamsOptions)
{
   public void Propagate()
   {
       var uptreams = upstreamsOptions.Value; // 읽기
       // ...

주석에 “읽기” 라고 표시된 코드가 처음으로 실행될 때(Lazy), IOptions가 생성되고, 이후 appsettigns.json 을 통해 값을 변경하더라도 재생성되지 않습니다.

이는 마치 싱글톤 서비스와 유사하고, 실제로도 주입 받는데 제한이 없습니다.

IOptionsSnapshot<T>

동일하게 Value 속성을 제공하지만, 객체의 수명주기는 Scope 종속적입니다.
즉, Scope 마다 생성되고, 생성될 때 값을 설정 값을 반영합니다.

스코프 마다 반드시 확인해야 하는 값이라면, 이 인터페이스를 사용해야 합니다.

Scoped 서비스와 마찬가지로, 스코프 컨텍스트 없는 서비스 결정(Service resolution) 코드는 런타임 에러라는 점에 주의해야 합니다.

// 런타임 에러
 var upstreamsOptions = sp.GetRequiredService<IOptionsSnapshot<UpStreams>>();

반드시 스코프 내부에서 생성해야 합니다.

using var scope = serviceProvider.CreateScope();
var upstreamsOptions = scope.ServiceProvider
   .GetRequiredService<IOptionsSnapshot<UpStreams>>();
var upstreams = upstreamsOptions.Value;

IOptionsMonitor<T>

이 인터페이스는 다른 형제들과 다르게 CurrnetValue 속성을 제공하는데, 이 속성의 값이 설정 값(appsettings.json)과 리얼타임으로 일치함을 보장합니다.

설정 파일의 변경 사항을 실시간으로 알고 싶을 때 이 객체를 사용하면 되는데, 그 예중에 하나가 예제로 쓰인 Upstreams 객체일 것입니다.

life-time 은 싱글턴으로 생성/주입 시 특별한 주의가 필요치 않습니다.

또한 OnChange 라는 이벤트를 제공하는데, 설정 값에 변경이 있을 때 발행되어 데이터 바인딩 등, 발행자-구독자 패턴의 코드에 유용합니다.

인터페이스 별 성능

ValueCurrentValue 를 읽을 때는 특별한 성능 오버헤드는 없다고 합니다.

다만, IOptionsMonitor<T>.OnChange 에 많은 구독자가 등록된 경우에는 클라이언트 코드 성능에 영향을 줄 수 있어, 무거운 이벤트 핸들러는 가급적 비동기 패턴으로 구현하는 것이 권고됩니다.

options.OnChanged += async (value, name) => { await Task.Run(() => { }, value); };
11개의 좋아요

저는 section을 지정할 때 nameof 를 쓰는 편인데 저처럼 쓰는 분이 많이 없는 듯한데 많은 분들이 string 형태를 쓰시는 이유가 있을까요?

예시에서 주신 것이라면

// program.cs
builder. Configure<Upstreams>(builder.Configuration.GetSection("Upstreams"));

이렇게 쓴다는 의미입니다.

// program.cs
builder. Configure<Upstreams>(builder.Configuration.GetSection(nameof(Upstreams)));
5개의 좋아요

저는 갠적으루 string 을 포함해서

리터럴 자체를 직접 사용하는 것은 어떤 상황에서도 비추입니다.

타입 이름이나 메서드 이름 등은 nameof 를 활용하고 그외 리터럴은 따로 상수로 선언한 후 접근하는 게 좋죠.

나중에 버그 났을 때 눈 뜨고 못 찾는 경우가 허다하여서리… =ㅅ=;;

4개의 좋아요

일반적으로는 nameof 가 권고되지만, 설정 파일의 Section 과 같이 코드 실행 전에 결정되어 코드와 상관이 없는 대상을 지칭할 때는 주의가 필요합니다.

예를 들어, 리펙토링 중 UpstreamsBehinds로 이름만 바꾸는 경우,

  • nameof => 런타임에러
  • 문자열 => 문제 없음.

물론, 객체의 속성과 설정의 노드를 일치시키지 않으면, 무엇을 쓰든 지 문제가 발생합니다.

그래서, GetSection 에 문자열을 전달하냐, nameof 를 쓰냐 보다는, 바인딩 모델 자체를 별도로 관리하는 것이 더 중요할 것 같습니다.

namespace Options;

public sealed class Upstreams
{
    public Uri[] Backends { get; set; } = []; 
}
3개의 좋아요