ASP.NET Core에서 DbContext 수명 관리: 비동기 작업에서의 성능과 스레드 안전성

ASP.NET Core에서 DbContext 수명 관리를 마스터합니다. 범위 지정 수명과 트랜지언트 수명의 사용 시기, 비동기 작업에서 스레드 안전성을 보장하는 방법, DbContext 풀링으로 성능을 최적화하는 방법을 학습합니다.

ASP.NET Core에서 DbContext 수명 관리 다이어그램

DbContext 수명 관리는 ASP.NET Core 애플리케이션에서 가장 일반적인 버그 원인 중 하나입니다. Entity Framework Core의 DbContext는 스레드로부터 안전하지 않으며, 비동기 작업에서 잘못 사용하면 상태 손상, 경쟁 조건, 재현하기 어려운 예외가 발생합니다.

DbContext는 스레드로부터 안전하지 않습니다

단일 DbContext 인스턴스는 여러 스레드에서 동시에 사용할 수 없습니다. 비동기 메서드가 다음 작업 시작 전에 await되지 않으면 내부 상태가 손상됩니다. 이 규칙은 EF Core 9를 포함한 모든 EF Core 버전에 적용됩니다.

범위 지정 수명이 웹 요청에 적합한 이유

ASP.NET Core의 의존성 주입은 AddDbContext<T>()를 사용할 때 기본적으로 범위 지정 수명으로 DbContext를 등록합니다. 각 HTTP 요청은 자체 DbContext 인스턴스를 받고, 해당 인스턴스는 요청이 완료되면 삭제됩니다.

이 접근 방식은 두 가지 문제를 해결합니다. 각 요청이 격리된 데이터베이스 상태를 갖도록 보장하고, 요청 처리 중에 변경 사항이 누적되어 마지막에 함께 커밋되는 작업 단위 패턴과 컨텍스트 수명을 일치시킵니다.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

범위 지정 수명은 표준 요청-응답 흐름에 적합합니다. 컨트롤러 액션이나 Razor Page 핸들러는 단일 스레드에서 실행되며, 모든 비동기 호출이 적절하게 await되는 한 DbContext는 요청 전체에서 일관된 상태를 유지합니다.

OrderController.cscsharp
public class OrderController : ControllerBase
{
    private readonly AppDbContext _context;

    public OrderController(AppDbContext context)
    {
        _context = context;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderDto dto)
    {
        var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total };
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

핵심 제약 사항은 모든 비동기 작업이 다음 작업 시작 전에 완료되어야 한다는 것입니다. 위 코드는 SaveChangesAsync가 메서드가 반환되기 전에 await되므로 올바릅니다.

병렬 작업에서의 스레드 안전성 문제

개발자가 단일 요청 내에서 데이터베이스 작업을 병렬화하려고 할 때 문제가 발생합니다. 다음의 잘못된 예제를 살펴보겠습니다.

csharp
// 잘못됨: 사용하지 마세요
public async Task<IActionResult> GetDashboard()
{
    var ordersTask = _context.Orders.CountAsync();
    var productsTask = _context.Products.CountAsync();
    var customersTask = _context.Customers.CountAsync();

    // 동일한 DbContext에서 세 개의 쿼리를 동시에 실행
    await Task.WhenAll(ordersTask, productsTask, customersTask);

    return Ok(new { Orders = ordersTask.Result, Products = productsTask.Result, Customers = customersTask.Result });
}

이 코드는 각 쿼리를 개별적으로 await하지 않고 세 개의 비동기 쿼리를 시작합니다. 세 작업 모두 동일한 DbContext 인스턴스에서 동시에 실행되어 EF Core의 스레드 안전성 요구 사항을 위반합니다. 결과는 예측할 수 없습니다. 때로는 작동하고, 때로는 InvalidOperationException을 던지며, 때로는 잘못된 데이터를 반환합니다.

Microsoft 공식 문서에서는 비동기 메서드를 즉시 await해야 한다고 명시하고 있습니다. 내부 변경 추적기, 연결 상태, 쿼리 컴파일 캐시는 동시 액세스를 위해 설계되지 않았습니다.

.NET 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

병렬 쿼리를 위한 IDbContextFactory 사용

병렬 데이터베이스 작업이 실제로 필요한 경우 IDbContextFactory<T>가 해결책을 제공합니다. 이 팩토리는 필요에 따라 새 DbContext 인스턴스를 생성하며, 각각 자체적으로 격리된 상태를 갖습니다.

Program.cscsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

팩토리가 등록되면 DbContext를 직접 주입하는 대신 팩토리를 주입합니다.

DashboardService.cscsharp
public class DashboardService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public DashboardService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task<DashboardStats> GetStatsAsync()
    {
        // 각 작업이 자체 DbContext 인스턴스를 가져옴
        var ordersTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Orders.CountAsync();
        });

        var productsTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Products.CountAsync();
        });

        var customersTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Customers.CountAsync();
        });

        await Task.WhenAll(ordersTask, productsTask, customersTask);

        return new DashboardStats
        {
            Orders = ordersTask.Result,
            Products = productsTask.Result,
            Customers = customersTask.Result
        };
    }
}

각 작업은 자체 컨텍스트를 생성하고, 사용하고, 삭제합니다. await using 문은 예외가 발생하더라도 적절한 정리를 보장합니다.

고처리량 애플리케이션을 위한 DbContext 풀링

새 DbContext를 생성하려면 메모리 할당, 변경 추적기 초기화, 내부 캐시 설정이 필요합니다. 초당 수천 개의 요청을 처리하는 고처리량 애플리케이션에서는 이 오버헤드가 측정 가능해집니다.

DbContext 풀링은 컨텍스트 인스턴스를 재사용하여 이 문제를 해결합니다. 풀링된 컨텍스트가 삭제되면 EF Core는 상태를 재설정하고 가비지 수집하는 대신 풀로 반환합니다.

Program.cscsharp
builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

Dave Callan의 벤치마크에 따르면 풀링은 마이크로벤치마크에서 컨텍스트 생성 시간을 90% 이상 줄입니다. 그러나 실제 영향은 애플리케이션의 쿼리 패턴에 따라 다릅니다. 데이터베이스 쿼리가 요청 처리 시간을 지배하는 경우 컨텍스트 생성 오버헤드는 비교적 무시할 수 있습니다.

풀링에는 한 가지 제약이 있습니다. DbContext 상태는 풀로 반환될 때 재설정됩니다. DbContext 클래스에 추가된 사용자 정의 필드나 속성은 값을 잃습니다. 변경 추적기가 지워지므로 커밋되지 않은 변경 사항이 사라집니다.

풀링과 IDbContextFactory 결합

풀링 성능과 병렬 쿼리 지원이 모두 필요한 애플리케이션에는 AddPooledDbContextFactory를 사용합니다.

Program.cscsharp
builder.Services.AddPooledDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

이 등록은 컨텍스트가 풀에서 가져오는 IDbContextFactory<AppDbContext>를 제공합니다. 각 CreateDbContext() 호출은 풀링된 인스턴스를 가져오고, 삭제하면 인스턴스가 풀로 반환됩니다.

BatchProcessor.cscsharp
public class BatchProcessor
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public BatchProcessor(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task ProcessBatchAsync(IEnumerable<OrderUpdate> updates)
    {
        // 풀링된 컨텍스트로 병렬 청크 처리
        var chunks = updates.Chunk(100);
        var tasks = chunks.Select(async chunk =>
        {
            await using var context = _contextFactory.CreateDbContext();
            foreach (var update in chunk)
            {
                var order = await context.Orders.FindAsync(update.OrderId);
                if (order != null)
                {
                    order.Status = update.NewStatus;
                }
            }
            await context.SaveChangesAsync();
        });

        await Task.WhenAll(tasks);
    }
}

Milan Jovanovic의 분석에서는 배치 처리 시나리오에서 풀링된 팩토리 성능에 대한 추가 벤치마크를 제공합니다.

Blazor Server: 특수한 경우

Blazor Server 애플리케이션은 DbContext 수명에 특별한 주의가 필요합니다. Blazor 회로는 명확한 경계를 가진 HTTP 요청과 달리 여러 사용자 상호 작용에 걸쳐 지속됩니다.

기본 범위 지정 수명이 문제가 됩니다. 단일 DbContext 인스턴스가 잠재적으로 몇 시간에 걸쳐 전체 회로 기간 동안 지속됩니다. 오래 지속되는 컨텍스트는 추적되는 엔터티를 누적하여 메모리 사용량을 늘리고 작업 속도를 저하시킵니다.

Blazor Server용 Program.cscsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

Blazor 구성 요소에서 팩토리를 주입하고 수명이 짧은 컨텍스트를 생성합니다.

OrderList.razor.cscsharp
public partial class OrderList : ComponentBase
{
    [Inject]
    private IDbContextFactory<AppDbContext> ContextFactory { get; set; } = default!;

    private List<Order> _orders = new();

    protected override async Task OnInitializedAsync()
    {
        await using var context = ContextFactory.CreateDbContext();
        _orders = await context.Orders
            .AsNoTracking()
            .OrderByDescending(o => o.CreatedAt)
            .Take(50)
            .ToListAsync();
    }
}

AsNoTracking() 호출은 Blazor Server에서 특히 중요합니다. 이것이 없으면 로드된 모든 엔터티가 회로가 끝날 때까지 변경 추적기에 남아 메모리를 소비합니다.

백그라운드 서비스와 호스팅된 서비스

싱글톤으로 등록된 백그라운드 서비스는 범위 지정 DbContext를 직접 주입할 수 없습니다. 서비스가 모든 범위보다 오래 지속되어 수명 불일치 오류가 발생합니다.

OrderProcessingService.cscsharp
public class OrderProcessingService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public OrderProcessingService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var pendingOrders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var order in pendingOrders)
            {
                order.Status = OrderStatus.Processing;
            }

            await context.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

IServiceScopeFactory는 각 반복에 대해 새 범위를 생성합니다. 범위와 그 안의 DbContext는 각 루프 반복이 끝날 때 삭제됩니다.

또는 더 세밀한 제어를 위해 IDbContextFactory를 사용할 수 있습니다.

OrderProcessingService.cs (팩토리 버전)csharp
public class OrderProcessingService : BackgroundService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public OrderProcessingService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var context = _contextFactory.CreateDbContext();

            var pendingOrders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var order in pendingOrders)
            {
                order.Status = OrderStatus.Processing;
            }

            await context.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

스레드 안전성 위반을 유발하는 일반적인 실수

프로덕션 코드에서 DbContext 스레드 안전성 문제를 일으키는 몇 가지 패턴이 있습니다.

DbContext를 정적 필드나 싱글톤에 저장: DbContext는 의도된 범위를 넘어서 지속되어서는 안 됩니다. 정적 참조는 여러 스레드에서 동일한 인스턴스에 액세스할 수 있게 만듭니다.

Fire-and-forget 비동기 호출: 비동기 작업을 await하지 않고 시작하면서 현재 스레드에서 컨텍스트를 계속 사용하면 동시 액세스가 발생합니다.

csharp
// 잘못됨: 사용하지 마세요
public void UpdateAndNotify(int orderId)
{
    var order = _context.Orders.Find(orderId);
    order.Status = OrderStatus.Shipped;
    _context.SaveChangesAsync(); // await되지 않음!
    _notificationService.SendAsync(order.CustomerId); // 컨텍스트가 아직 저장 중일 수 있음
}

싱글톤 서비스에 DbContext 주입: DI 컨테이너는 개발 환경에서 예외를 던지지만 일부 구성에서는 이 오류가 마스킹됩니다.

실행 흐름을 이해하지 않고 여러 await에 걸쳐 DbContext 사용: 각 await는 일시 중지 지점입니다. 재개된 코드가 예상과 다른 스레드에서 실행되면 다른 코드 경로와 동시 액세스가 발생할 수 있습니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

DbContext 수명 구성 결정 가이드

올바른 DbContext 구성을 선택하는 것은 애플리케이션 유형과 성능 요구 사항에 따라 다릅니다.

  • 표준 웹 API 또는 MVC 애플리케이션: 기본 범위 지정 수명으로 AddDbContext<T>()를 사용합니다. 이것은 대부분의 시나리오를 올바르게 처리합니다.
  • 고처리량 API(초당 수천 요청): 할당 오버헤드를 줄이기 위해 AddDbContextPool<T>()을 사용합니다.
  • 병렬 데이터베이스 쿼리가 필요한 애플리케이션: AddDbContextFactory<T>() 또는 AddPooledDbContextFactory<T>()를 사용합니다.
  • Blazor Server 애플리케이션: 작업별로 수명이 짧은 컨텍스트를 생성하는 AddDbContextFactory<T>()를 사용합니다.
  • 백그라운드 서비스: IServiceScopeFactory 또는 IDbContextFactory<T>()를 사용하여 서비스 내에서 컨텍스트를 생성합니다.
  • 병렬 처리가 포함된 배치 처리: 최적의 처리량을 위해 AddPooledDbContextFactory<T>()를 사용합니다.

ASP.NET Core에서 비동기 패턴에 대한 더 자세한 내용은 비동기 프로그래밍 모듈에서 관련 개념을 다룹니다. EF Core 고급 모듈에서는 여기서 논의된 쿼리 최적화 및 변경 추적 동작에 대해 자세히 설명합니다.

오늘의 챌린지

.NET 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 9월 7일 업데이트

태그

#dotnet
#entity-framework
#aspnet-core
#async
#performance

공유

관련 기사