Quick Reference
DI 등록은 객체를 만들고 버리는 수명을 정하는 일입니다. 앱 전체 공유 상태는 Singleton, 하나의 명시적 scope 안에서 공유할 작업 상태는 Scoped, 가벼운 무상태 객체를 매번 새로 만들면 Transient를 선택합니다. options는 관련 설정을 한 클래스에 묶어 바인딩·검증합니다.
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
builder.Services.AddTransient<EmailComposer>();
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.ValidateOnStart();
public sealed class MailService(IClock clock, IOptions<SmtpOptions> options)
{
public string Host => options.Value.Host;
}- ASP.NET Core에서는 보통 HTTP 요청 하나가 scope 하나지만, worker·console에서는 직접 scope를 만듭니다.
- container가 만든
IDisposable은 container 또는 그 scope가 정리합니다. 호출자가 임의로 Dispose하지 않습니다. - singleton에는 scoped service나
IOptionsSnapshot<T>를 직접 주입하지 않습니다.
서비스 수명
Singleton은 root provider가 살아 있는 동안 같은 인스턴스를 재사용하며, 실제 생성 시점은 등록 방식과 최초 resolve 시점에 따라 달라집니다. 공유 mutable state를 넣는다면 호출 자체가 thread-safe해야 합니다.
Scoped는 container가 만든 하나의 scope 안에서 같은 인스턴스를 재사용합니다. HTTP 요청 단위는 ASP.NET Core의 관례일 뿐, IServiceScopeFactory.CreateScope()로 만든 background job scope도 같은 규칙을 따릅니다. Transient는 resolve할 때마다 새 인스턴스를 만들지만, container가 생성한 disposable transient는 즉시 버려지는 것이 아니라 생성한 scope가 끝날 때 정리됩니다.
public sealed class CleanupWorker(IServiceScopeFactory scopeFactory)
{
public async Task RunOnceAsync(CancellationToken cancellationToken)
{
using IServiceScope scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<JobProcessor>();
await processor.RunAsync(cancellationToken);
}
}singleton이 scoped dependency를 constructor로 잡으면 scoped 객체가 root 수명으로 승격되거나 scope validation 오류가 납니다. singleton에서 scope별 작업이 필요하면 IServiceScopeFactory를 singleton으로 주입해 필요한 시점에 scope를 만들고, 그 안에서 scoped service를 resolve합니다.
options 바인딩과 검증
options class는 public parameterless constructor와 public read-write property가 필요합니다. field는 binder 대상이 아닙니다. Configure<T> 또는 AddOptions<T>().BindConfiguration(...)으로 configuration section을 연결합니다.
public sealed class SmtpOptions
{
[Required]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; } = 587;
}바인딩 성공은 값이 업무적으로 유효하다는 뜻이 아닙니다. ValidateDataAnnotations, Validate(...), ValidateOnStart()를 조합해 host, port, 조합 규칙을 검증합니다. ValidateOnStart는 host 시작 시점에 실패를 드러내므로, 잘못된 설정으로 요청을 받은 뒤에야 실패하는 일을 줄입니다.
IConfiguration을 여기저기 주입해 문자열 키를 읽기보다, 기능별 options class를 작게 나눕니다. 비밀값은 소스에 넣지 않고 해당 배포 환경의 configuration provider에서 공급합니다.
IOptions 접근 방식
IOptions<T>는 singleton service이며 reload를 읽지 않는 기본 선택입니다. IOptionsSnapshot<T>는 scoped service로, scope에서 처음 접근할 때 값을 만들고 그 scope 동안 같은 snapshot을 제공합니다. singleton에는 주입할 수 없습니다.
IOptionsMonitor<T>는 singleton이며 CurrentValue, named option, OnChange를 제공합니다. 변경을 감지하려면 사용 중인 configuration provider가 reload/change notification을 지원해야 합니다. 파일 감시는 container나 network share 환경에서 지연되거나 신뢰성이 다를 수 있으므로, 변경 즉시 반영된다는 업무 보장은 별도로 설계합니다.
public sealed class FeatureGate(IOptionsMonitor<FeatureOptions> monitor)
{
public bool IsEnabled() => monitor.CurrentValue.NewCheckout;
public IDisposable LogChanges(ILogger<FeatureGate> logger) =>
monitor.OnChange((options, _) => logger.LogInformation(
"New checkout: {Enabled}", options.NewCheckout));
}OnChange가 반환하는 구독도 수명 관리 대상입니다. 장수명 객체가 아닌 곳에서 구독했다면 더 이상 필요 없을 때 Dispose합니다.
자주 틀리는 부분
- 서비스 등록 중간에
BuildServiceProvider()로 별도 container를 만들지 않습니다. singleton이 둘로 나뉘고 disposal 경계도 흐려집니다. - singleton에 scoped dependency를 직접 주입하지 않습니다. scope factory로 작업 단위를 만듭니다.
- transient를 “사용 직후 Dispose된다”고 가정하지 않습니다. container가 만든 disposable의 정리 주체는 scope입니다.
IOptionsMonitor<T>가 설정값의 무중단 안전 교체, 연결 재생성, 보안 검증까지 대신한다고 가정하지 않습니다.
참고 링크
2 sources