Today
-
Yesterday
-
Total
-

ABOUT ME

-

방문자

  • [ASP.NET Core] "Cannot consume scoped service from singleton" 오류 해결
    Microsoft .NET/ASP.Net & ASP.Net Core 2026. 11. 10. 00:18

    ASP.NET Core DI 컨테이너에서 싱글톤(Singleton) 서비스가 스코프(Scoped) 서비스를 주입받을 때 발생하는 InvalidOperationException과 해결 방법입니다.

    절차

    1. 에러 메시지 확인
    2. 원인: 수명주기(Lifetime) 불일치
    3. 해결 1: 서비스 수명주기 맞추기
    4. 해결 2: IServiceScopeFactory로 스코프 직접 생성
    5. 자주 하는 실수 정리

     

    1. 에러 메시지

    System.InvalidOperationException: Cannot consume scoped service 'AppDbContext' from singleton 'ReportCache'.
       at Microsoft.Extensions.DependencyInjection.ServiceProvider...

    앱 시작 시점(builder.Build())이나 첫 요청 시점에 터지며, 개발 환경에서만 검증되는 경우가 많습니다.
    배포 후 첫 요청에서야 500이 나는 경우도 같은 원인입니다.

     

    2. 원인: 수명주기 불일치

    DI 컨테이너는 오래 사는 서비스가 짧게 사는 서비스를 붙잡는 것을 금지합니다.

    1. Singleton: 앱 시작부터 종료까지 1개 인스턴스
    2. Scoped: 요청(scope)마다 1개 인스턴스
    3. 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;   // 시작 시점에 전체 등록 검증
    });

     

    정리

    1. 에러 문구의 두 타입(Singleton → Scoped)을 먼저 확인
    2. 상태가 필요 없으면 서비스 등록을 Scoped로 변경
    3. Singleton을 유지해야 하면 IServiceScopeFactory.CreateScope()로 메서드 단위 스코프 사용
    4. ValidateOnBuild로 배포 전에 잡기
Posted by virtualgiraffe.