[ASP.NET Core] "Cannot consume scoped service from singleton" 오류 해결

ASP.NET Core DI 컨테이너에서 싱글톤(Singleton) 서비스가 스코프(Scoped) 서비스를 주입받을 때 발생하는 InvalidOperationException과 해결 방법입니다.
절차
- 에러 메시지 확인
- 원인: 수명주기(Lifetime) 불일치
- 해결 1: 서비스 수명주기 맞추기
- 해결 2: IServiceScopeFactory로 스코프 직접 생성
- 자주 하는 실수 정리
1. 에러 메시지
System.InvalidOperationException: Cannot consume scoped service 'AppDbContext' from singleton 'ReportCache'.
at Microsoft.Extensions.DependencyInjection.ServiceProvider...
앱 시작 시점(builder.Build())이나 첫 요청 시점에 터지며, 개발 환경에서만 검증되는 경우가 많습니다.
배포 후 첫 요청에서야 500이 나는 경우도 같은 원인입니다.
2. 원인: 수명주기 불일치
DI 컨테이너는 오래 사는 서비스가 짧게 사는 서비스를 붙잡는 것을 금지합니다.
- Singleton: 앱 시작부터 종료까지 1개 인스턴스
- Scoped: 요청(scope)마다 1개 인스턴스
- Transient: 주입될 때마다 새 인스턴스
AppDbContext는 기본적으로 Scoped입니다. 요청이 끝나면 Dispose되어야 하는데, Singleton이 그 참조를 계속 들고 있으면 이미 Dispose된 컨텍스트를 재사용하게 됩니다. 그래서 컨테이너가 아예 등록 단계에서 막습니다.
// 문제가 되는 코드
public class ReportCache // Singleton
{
private readonly AppDbContext _db;
public ReportCache(AppDbContext db) // Scoped를 주입받음 → 예외
{
_db = db;
}
}
// Program.cs (.NET 6+)
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddSingleton<ReportCache>(); // 여기서 터짐
3. 해결 1: 수명주기 맞추기
상태를 들고 있을 필요가 없다면 가장 단순한 해결은 등록을 Scoped로 바꾸는 것입니다.
builder.Services.AddScoped<ReportService>(); // DbContext와 같은 수명
캐시처럼 실제로 앱 전체에서 하나여야 한다면, 의존하는 DbContext를 필드로 들고 있지 말고 메서드 호출 시점에 짧게 얻어 쓰는 구조로 바꿉니다.
4. 해결 2: IServiceScopeFactory로 스코프 직접 생성
Singleton을 유지해야 하는 경우(백그라운드 잡, 캐시, 정적 헬퍼)는 IServiceScopeFactory로 요청과 동일한 스코프를 직접 만들어 사용합니다.
public class ReportCache
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<List<ReportDto>> GetAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Reports
.AsNoTracking()
.Select(r => new ReportDto(r.Id, r.Title))
.ToListAsync();
}
}
using을 빠뜨리면 스코프가 Dispose되지 않아 커넥션 풀이 마릅니다. CreateAsyncScope()를 쓰면 IAsyncDisposable 서비스도 정상 해제됩니다.
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
5. BackgroundService에서 같은 문제가 나는 이유
BackgroundService는 Singleton으로 등록됩니다. 생성자에 AppDbContext를 받으면 위와 같은 예외가 납니다.
public class CleanupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public CleanupWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Logs.Where(l => l.CreatedAt < DateTime.UtcNow.AddDays(-30))
.ExecuteDeleteAsync(stoppingToken);
}
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}
builder.Services.AddHostedService<CleanupWorker>();
6. 확인용 코드: 등록된 수명주기 출력
어떤 서비스가 어떤 수명으로 등록됐는지 헷갈릴 때는 시작 시점에 한 번 찍어봅니다.
var services = builder.Services; // Build 이전에 참조 보관
var app = builder.Build();
foreach (var d in services.Where(d => d.ServiceType.Namespace?.StartsWith("MyApp") == true))
{
Console.WriteLine($"{d.ServiceType.Name,-30} {d.Lifetime}");
}
운영에서 늦게 발견되는 경우가 많으므로, 시작 시점에 등록을 검증해 두면 배포 전에 잡을 수 있습니다.
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true; // 시작 시점에 전체 등록 검증
});
정리
- 에러 문구의 두 타입(Singleton → Scoped)을 먼저 확인
- 상태가 필요 없으면 서비스 등록을 Scoped로 변경
- Singleton을 유지해야 하면
IServiceScopeFactory.CreateScope()로 메서드 단위 스코프 사용 ValidateOnBuild로 배포 전에 잡기