빠른 흐름
// 서비스 등록
builder.Services.AddSingleton<ICache, MemoryCache>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// options pattern 등록
builder.Services.Configure<AppOptions>(
builder.Configuration.GetSection("App"));
// 생성자 주입
public class UserService(ICache cache, IOptions<AppOptions> options)
{
private readonly AppOptions _opts = options.Value;
}설정 흐름
어떤 등록 형태가 있나
DI와 options는 아래 네 형태를 먼저 구분하면 흐름이 잡힙니다.
services.AddSingleton<ICache, MemoryCache>();
services.AddScoped<IUserService, UserService>();
services.AddTransient<IEmailSender, EmailSender>();
services.Configure<AppOptions>(config.GetSection("App"));Singleton: 앱 전체에서 하나Scoped: 요청 단위Transient: 주입마다 새 인스턴스Configure<T>: 설정 바인딩
DI 컨테이너가 하는 일
DI 컨테이너(IoC 컨테이너) 는 서비스의 생성, 의존성 연결, 수명 관리를 담당합니다. 서비스가 new로 직접 의존성을 만드는 대신, 컨테이너가 필요한 의존성을 생성자에 자동으로 주입합니다.
// ❌ 직접 생성 — 강한 결합
public class UserService
{
private readonly ICache _cache = new RedisCache("localhost"); // 직접 생성
}
// ✅ 생성자 주입 — 느슨한 결합
public class UserService
{
private readonly ICache _cache;
public UserService(ICache cache) => _cache = cache;
// 어떤 ICache 구현체가 들어올지 UserService는 모름
}서비스 수명 — Singleton, Scoped, Transient
수명은 DI의 핵심 개념입니다. 잘못된 수명 조합은 상태 공유 버그나 자원 누수를 일으킵니다.
| 수명 | 생성 시점 | 소멸 시점 | 공유 범위 |
|---|---|---|---|
| Singleton | 앱 최초 요청 | 앱 종료 | 앱 전체 |
| Scoped | HTTP 요청마다 | 요청 종료 | 요청 내 공유 |
| Transient | 주입될 때마다 | 사용 즉시 | 공유 없음 |
// 수명 등록
services.AddSingleton<ICache, MemoryCache>(); // 앱 전체 하나
services.AddScoped<IDbContext, AppDbContext>(); // 요청당 하나
services.AddTransient<IEmailSender, EmailSender>(); // 매번 새로 생성Captive dependency 문제: Singleton이 Scoped를 주입받으면 안 됩니다. Singleton은 앱 종료까지 살아있으므로, 주입된 Scoped가 요청이 끝나도 소멸되지 않습니다.
// ❌ Captive dependency — 런타임 오류 또는 버그
services.AddSingleton<MySingleton>(); // DbContext를 주입받음
services.AddScoped<AppDbContext>(); // 요청당 하나여야 함
// MySingleton이 첫 요청의 DbContext를 계속 들고 있음options pattern — 설정을 강한 타입으로
IConfiguration에서 문자열로 설정값을 꺼내는 대신, 강한 타입의 클래스에 바인딩하면 자동완성과 컴파일 타임 검사를 받을 수 있습니다.
// 설정 클래스
public class AppOptions
{
public string ApiKey { get; set; } = "";
public int MaxRetries { get; set; } = 3;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
// appsettings.json
// { "App": { "ApiKey": "...", "MaxRetries": 5, "Timeout": "00:00:15" } }
// 등록
services.Configure<AppOptions>(config.GetSection("App"));IOptions, IOptionsSnapshot, IOptionsMonitor
| 인터페이스 | 갱신 시점 | 수명 | 용도 |
|---|---|---|---|
IOptions<T> | 앱 시작 시 1번 | Singleton | 정적 설정 |
IOptionsSnapshot<T> | 요청마다 | Scoped | 요청별로 다를 수 있는 설정 |
IOptionsMonitor<T> | 파일 변경 시 즉시 | Singleton | 런타임 설정 변경 반영 |
// 정적 설정 — IOptions<T>
public class ApiClient(IOptions<ApiOptions> options)
{
private readonly string _key = options.Value.ApiKey;
}
// 런타임 변경 반영 — IOptionsMonitor<T>
public class DynamicService(IOptionsMonitor<FeatureOptions> monitor)
{
public bool IsEnabled => monitor.CurrentValue.FeatureX;
}// ❌ Singleton이 Scoped를 직접 잡으면 수명 계약이 깨진다
services.AddSingleton<ReportCache>();
services.AddScoped<AppDbContext>();
public class ReportCache(AppDbContext db) { }체크포인트
| 요소 | 역할 |
|---|---|
| DI 컨테이너 | 의존성 생성·연결·수명 관리 |
AddSingleton | 앱 전체에서 하나의 인스턴스 |
AddScoped | HTTP 요청당 하나 |
AddTransient | 주입될 때마다 새 인스턴스 |
Configure<T> | 설정을 강한 타입으로 바인딩 |
IOptions<T> | 정적 설정 읽기 |
IOptionsMonitor<T> | 런타임 설정 변경 감지 |
주의할 점
Captive dependency — Singleton이 Scoped 서비스를 직접 주입받으면 안 됩니다. .NET의 기본 DI는 개발 환경에서 이 문제를 감지하고 예외를 던집니다(ValidateScopes).
DI는 "new를 줄이는 기술"이 아니라 수명과 결합도를 관리하는 구조입니다. 모든 것을 DI로 등록하기보다, "이 객체의 수명과 교체 가능성이 중요한가?"를 먼저 판단하세요.
참고 링크
2 sources