비동기 프로그래밍

Microsoft.Data.SqlClient가 연결 및 네트워크 I/O를 기다리는 동안 스레드가 차단되지 않도록 작업 기반 비동기 메서드를 사용하세요. 비동기 실행은 명령어나 리더를 동시 사용에 안전하게 만들지 않으며, 서버에서의 쿼리 실행 시간을 줄이지도 않습니다.

애플리케이션 테이블을 쿼리하는 예제는 AdventureWorks 샘플 데이터베이스를 사용합니다. C# 예제에는 using Microsoft.Data.SqlClient;가 필요합니다.

호출 경로를 비동기 코드로 변환합니다

한 번에 하나의 완전한 통화 경로를 변환하세요:

  1. 현재 코드를 포함하는 메서드를 비동기식으로 만드세요.
  2. 연결, 명령, 리더, 트랜잭션 호출을 작업 기반 호출로 대체하세요.
  3. await 같은 객체를 다시 사용하기 전에 각 작업을 수행합니다.
  4. 호출자가 취소할 수 있는 모든 연산에 호출자의 취소 토큰을 전달합니다.
  5. 데이터베이스 작업 제한으로 무한대로 설정하지 않은 명령 시간 제한을 유지하세요.

연결 개방부터 결과 처리까지 사용 await 하세요. 다음 발췌문은 sql 및 connectionString가 이미 정의되어 있다고 가정합니다. 유한한 작업 시간 제한이 있는 CancellationTokenSource를 만듭니다:

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));

await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellation.Token);

using var command = new SqlCommand(sql, connection);
await using SqlDataReader reader =
    await command.ExecuteReaderAsync(cancellation.Token);

while (await reader.ReadAsync(cancellation.Token))
{
    Console.WriteLine(reader.GetValue(0));
}

ExecuteReaderAsync 모든 행이 도착한 후가 아니라 첫 번째 결과가 나올 때 반환됩니다. 각 ReadAsync 호출은 한 행 앞으로 이동하므로, 루프는 행이 도착할 때마다 처리하고, 서버에서 추가 데이터를 기다리는 동안에만 스레드를 해제합니다. 30초 토큰은 읽기 루프를 포함한 전체 연산을 제한합니다.

하나의 SqlCommand에서 대기 중인 비동기 실행은 하나만 있을 수 있습니다. SqlDataReader에는 비동기 읽기, 결과 전환 또는 필드 연산 중 하나만 대기할 수 있습니다. 동일한 객체에서 다음 작업을 시작하기 전에 각 작업이 완료될 때까지 기다리십시오.

비동기 프로그래밍 기능

다음 동기 메서드에는 취소 토큰을 허용하는 작업 기반의 대응 메서드가 있습니다:

동기식 비동기 메서드 예시
SqlConnection.Open SqlConnection.OpenAsync 동기식 메서드 변환하기
SqlCommand.ExecuteNonQuery SqlCommand.ExecuteNonQueryAsync 비동기식 명령어 실행
SqlCommand.ExecuteScalar SqlCommand.ExecuteScalarAsync 비동기식 명령어 실행
SqlCommand.ExecuteXmlReader SqlCommand.ExecuteXmlReaderAsync 명령을 비동기적으로 실행
SqlCommand.ExecuteReader SqlCommand.ExecuteReaderAsync 호출 경로를 비동기 코드로 변환합니다
SqlDataReader.Read SqlDataReader.ReadAsync 호출 경로를 비동기 코드로 변환합니다
SqlDataReader.NextResult SqlDataReader.NextResultAsync 결과를 비동기식으로 읽기
SqlDataReader.IsDBNull SqlDataReader.IsDBNullAsync 결과를 비동기적으로 읽기
SqlDataReader.GetFieldValue<T> SqlDataReader.GetFieldValueAsync<T> 결과를 비동기적으로 읽기
SqlBatch.ExecuteNonQuery, ExecuteScalar 및 ExecuteReader SqlBatch.ExecuteNonQueryAsync, ExecuteScalarAsync 및 ExecuteReaderAsync 배치 실행하기
SqlBulkCopy.WriteToServer SqlBulkCopy.WriteToServerAsync SqlBulkCopy와 함께 비동기 연산 사용

객체가 IDisposable만 구현하는 경우 동기식 폐기를 사용하세요. IAsyncDisposable를 구현하는 객체에 await using를 사용하세요. 리더기의 연결을 다시 사용하기 전에 리더기를 폐기하세요.

명령을 비동기적으로 실행

다음 예시들은 connection가 열려 있다고 가정합니다. ExecuteScalarAsync를 단일 값에 사용하고, 호출자 소유 취소 토큰을 전달합니다:

using var cancellation = new CancellationTokenSource(
    TimeSpan.FromSeconds(30));
CancellationToken token = cancellation.Token;

using var count = new SqlCommand(
    "SELECT COUNT(*) FROM Production.Product;",
    connection);
int total = (int)(await count.ExecuteScalarAsync(token))!;
Console.WriteLine($"Row count: {total}");

ExecuteNonQueryAsync를 데이터 변경 문에 사용하십시오. ExecuteXmlReaderAsync 쿼리에는 FOR XML를 사용하세요. 같은 연결에서 다른 명령을 실행하기 전에 XML 리더를 폐기하세요. 다중 활성 결과 집합(MARS)이 없으면, 연결에서는 한 번에 하나의 열린 리더만 허용됩니다.

결과를 비동기적으로 읽기

다음 예시는 각 비동기 호출에 동일한 취소 토큰을 전달합니다:

  • ExecuteReaderAsync 는 쿼리를 실행합니다.
  • ReadAsync 각 행을 읽습니다.
  • GetFieldValueAsync 각 열의 값을 읽습니다.
  • NextResultAsync 다음 결과 집합으로 이동합니다.
  • NULL에 대해 IsDBNullAsync를 확인합니다.
using var cancellation = new CancellationTokenSource(
    TimeSpan.FromSeconds(30));
CancellationToken token = cancellation.Token;

using var query = new SqlCommand("""
    SELECT TOP (3) ProductID, Name
    FROM Production.Product
    ORDER BY ProductID;

    SELECT TOP (1) ProductID, Color
    FROM Production.Product
    WHERE Color IS NULL;
    """, connection);
await using SqlDataReader reader = await query.ExecuteReaderAsync(token);

while (await reader.ReadAsync(token))
{
    int id = await reader.GetFieldValueAsync<int>(0, token);
    string name = await reader.GetFieldValueAsync<string>(1, token);
    Console.WriteLine($"{id}: {name}");
}

if (await reader.NextResultAsync(token) && await reader.ReadAsync(token))
{
    int productId = await reader.GetFieldValueAsync<int>(0, token);
    bool colorIsNull = await reader.IsDBNullAsync(1, token);
    Console.WriteLine($"{productId}: Color is NULL: {colorIsNull}");
}

NextResultAsync 다음 결과 집합으로 이동합니다. GetFieldValueAsync 및 IsDBNullAsync는 현재 행의 한 열을 읽습니다. 기본 CommandBehavior를 사용하면 행이 이미 버퍼링되어 있어서 이 호출들은 보통 기다리지 않고 완료됩니다. CommandBehavior.SequentialAccess를 사용하면, 네트워크에서 열 데이터를 받아올 때까지 기다릴 수 있습니다. 순차 접근에 대한 자세한 내용은 SqlClient 스트리밍 지원을 참조하세요.

동기식 메서드 변환하기

이 동기식 메서드는 연결을 열고 명령을 실행한 후 sys.objects의 행 수를 반환합니다:

static int CountObjects(string connectionString)
{
    using var connection = new SqlConnection(connectionString);
    connection.Open();

    using var command = new SqlCommand("SELECT COUNT(*) FROM sys.objects;", connection);
    return (int)command.ExecuteScalar()!;
}

메서드를 비동기 코드로 변환하려면 다음과 같은 변경을 해야 합니다:

  • 반환 타입을 Task<int>로 변경하세요.
  • CancellationToken 매개변수를 추가하세요.
  • OpenAsync을(를) ExecuteScalarAsync(으)로 바꾸고, ExecuteScalar을(를) Open(으)로 바꾸십시오.
  • await using와의 연결을 폐기한다.

변환된 메서드는 다음과 같습니다:

static async Task<int> CountObjectsAsync(
    string connectionString,
    CancellationToken cancellationToken)
{
    await using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync(cancellationToken);

    using var command = new SqlCommand("SELECT COUNT(*) FROM sys.objects;", connection);
    return (int)(await command.ExecuteScalarAsync(cancellationToken))!;
}

호출자는 토큰을 생성하고 결과를 기다립니다:

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
int objectCount = await CountObjectsAsync(connectionString, cancellation.Token);
Console.WriteLine($"Objects: {objectCount}");

두 방법 모두 같은 개수를 반환합니다. 출력은 연결하는 데이터베이스에 따라 다릅니다.

Connect Timeout 여전히 연결 설정과 풀 대기를 제한합니다. 토큰을 취소하면 더 빨리 호출자에게 제어권을 돌려줄 수 있습니다.

기존 애플리케이션에 비동기 호출 추가하기

한 번에 하나의 완전한 통화 경로를 변환하세요. .Wait(), GetAwaiter().GetResult(), 또는 .Result 데이터베이스 작업을 호출하는 것은 피하세요. 이러한 호출은 동기화 컨텍스트가 있는 환경에서는 차단되거나 교착 상태가 발생할 수 있습니다.

비동기 명령을 시작한 후 작업이 끝나기 전에 같은 명령어나 리더를 동기식으로 사용하지 마세요. MARS가 없으면 작업이 완료되기 전에 같은 연결을 동기식으로 사용하지 마세요.

취소 및 시간 제한 추가

비동기 작업 취소

연결 개방에서 명령 실행까지 동일한 취소 토큰을 전달합니다:

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));

await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellation.Token);

using var command = new SqlCommand(
    "SELECT COUNT(*) FROM Production.Product;",
    connection);
await command.ExecuteScalarAsync(cancellation.Token);

취소는 가능한 범위에서 처리되지만 보장되지는 않습니다. 연산이 취소가 적용되기 전에 완료될 수 있으며, 관찰된 예외는 취소가 발생한 위치에 따라 달라집니다. 취소 요청을 받았다고 해서 서버가 완료된 작업을 롤백했다고 생각하지 마세요. 변화 집합이 하나의 단위로 성공하거나 실패해야 할 경우, 명시적 트랜잭션을 사용하세요. 자세한 내용은 로컬 트랜잭션을 참조하세요.

Cancel 현재 명령의 취소를 요청합니다. 호출자 소유 취소 토큰을 대체하지 않습니다. 취소 요청이 발효되기 전에 작업이 완료될 수도 있습니다.

취소나 타임아웃 후에는 작업을 기다린 후 리더와 명령 개체를 폐기합니다. 재사용 전에 연결 상태를 점검하세요. 연결 끊김 오류는 새로운 연결이 필요합니다.

시간 제한 구성

연결 및 명령 타임아웃은 서로 다른 단계에 적용됩니다:

설정 표지
Connect Timeout 연결 설정 및 풀링된 연결 대기
CommandTimeout 명령 실행 및 누적 네트워크 읽기 대기.
취소 토큰 대기 중지를 위한 발신자 제어 요청.

CommandTimeout은 초 단위로 측정됩니다. 0 명령 시간 제한이 없다는 뜻입니다. 기본값은 설정된 경우 연결의 Command Timeout 값이며, 그렇지 않으면 30초입니다.

리더의 경우, 명령어 타임아웃은 각 제공자의 읽기 작업의 누적 네트워크 대기에 적용됩니다. 애플리케이션 코드에서 행을 처리하는 데 소요되는 시간은 포함되지 않습니다. 타임아웃은 첫 번째 행 이전이나 이후 행을 가져오는 동안 발생할 수 있습니다.

취소와 명령 타임아웃 결합

유한 명령 타임아웃과 콜러 취소를 함께 사용하세요. 이 예는 connection가 열려 있다고 가정합니다:

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));

using var command = new SqlCommand("""
    SELECT TOP (10) SalesOrderID, OrderDate
    FROM Sales.SalesOrderHeader
    ORDER BY SalesOrderID;
    """, connection)
{
    CommandTimeout = 15
};

await using SqlDataReader reader =
    await command.ExecuteReaderAsync(cancellation.Token);

while (await reader.ReadAsync(cancellation.Token))
{
    Console.WriteLine($"{reader.GetInt32(0)} | {reader.GetDateTime(1):O}");
}

발신자가 먼저 취소하면, 대기 중인 호출은 예외를 던집니다. 예외 유형은 취소가 발생하는 위치에 따라 다릅니다. 명령 타임아웃이 먼저 만료되면 제공자가 타임아웃을 보고합니다. 어느 경우든 명령이 취소나 타임아웃이 발효되기 전에 성공적으로 완료될 수 있습니다.

고급 비동기 패턴

기본 공급자 형식을 통해 비동기 메서드 사용

제공자 독립 코드는 DbCommand, DbDataReader, DbConnection 비동기 메서드를 사용할 수 있습니다. 제공자 명령을 통해 매개변수를 생성하고, 애플리케이션에 Microsoft.Data.SqlClient 전용 기능이 필요한 경우가 아니면 다운캐스트를 피합니다.

작업 기반 SQL 트랜잭션 메서드 사용

대상 프레임워크가 이를 노출하는 경우 작업 기반 로컬 트랜잭션 메서드를 사용하십시오. 다음 예시는 connection가 열려 있고, sql 명령어 텍스트를 저장하며, 호출자가 cancellationToken를 전달한다고 가정합니다:

await using SqlTransaction transaction =
    (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken);

using var command = new SqlCommand(sql, connection, transaction);

try
{
    await command.ExecuteNonQueryAsync(cancellationToken);
    await transaction.CommitAsync(cancellationToken);
}
catch (Exception operationException)
{
    try
    {
        await transaction.RollbackAsync(CancellationToken.None);
    }
    catch (Exception rollbackException)
    {
        throw new AggregateException(
            "The operation and transaction rollback both failed.",
            operationException,
            rollbackException);
    }

    throw;
}

롤백을 위해 별도의 정리 토큰을 사용하세요. 작업 토큰은 이미 취소되었을 수도 있습니다. 롤백 실패를 처리할 때 원래 작업 예외를 버리지 않습니다. 이 예시는 AggregateException에서 두 예외 모두 유지합니다. 애플리케이션은 대신 롤백 예외를 로그하고 원래 예외를 다시 던질 수 있습니다.

이 Microsoft.Data.SqlClient 메서드는 상속된 DbConnection 및 DbTransaction 구현을 사용하여 동기식 트랜잭션 메서드를 호출하고 완료된 Task를 반환합니다. 이를 await해도 트랜잭션 작업이 진행되는 동안 호출 스레드를 해제하지 않습니다. 취소는 작업 시작 전에 확인되며, 진행 중인 작업을 중단하지 않습니다. 트랜잭션 내 명령 실행은 여전히 비동기적입니다.

비동기 분산 트랜잭션 사용

비동기 명령은 TransactionScope 내부에서 실행될 수 있습니다. 스코프는 Transaction.Current를 설정합니다. .NET는 이를 주변 트랜잭션이라고 부릅니다. 범위 내에서 열리는 연결은 자동으로 해당 트랜잭션에 등록되는데, 이는 Enlist 연결 키워드의 기본값이 true이기 때문입니다. 트랜잭션이 TransactionScopeAsyncFlowOption.Enabled 전체에서 흐를 필요가 있는 경우 await를 구성하세요. 분산 트랜잭션에는 플랫폼, 서버, 코디네이터 요구사항이 있습니다. 자세한 내용은 System.Transactions 통합을 참조하세요.

SqlBulkCopy와 함께 비동기 작업 사용

비동기 대량 복사에 WriteToServerAsync를 사용합니다. 반환된 작업이 완료될 때까지 소스 리더나 행 컬렉션을 유지하세요. 작업의 배치 크기, 트랜잭션 동작 및 시간 제한을 설정하세요. 자세한 내용은 대량 복사 작업을 참조하세요.

MARS를 사용하여 여러 명령을 비동기적으로 사용

MARS는 한 물리적 연결에서 두 개 이상의 대기 명령을 허용합니다. 애플리케이션이 해당 연결 공유 모델이 필요할 때만 MultipleActiveResultSets=True로 활성화하세요.

비동기식 프로그래밍에는 MARS가 필수가 아닙니다. MARS가 없으면, 같은 연결에서 다른 명령을 실행하기 전에 활성 리더를 종료하거나 폐기하세요. 진정으로 독립적인 작업에는 두 번째 별도 연결이 더 명확한 경우가 많습니다.

세션 동작 및 트랜잭션 제한에 대해서는 다중 활성 결과 세트 활성화(Enable Multiple Active Result Sets)를 참조하세요.

MARS를 사용하여 비동기적으로 데이터 읽기 및 업데이트

MARS를 활성화하면 한 명령어가 행을 읽는 동안 다른 명령은 같은 연결에서 실행됩니다. 각 명령과 리더 작업을 따로따로 기다리십시오. MARS 작업은 스레드 안전하지 않으며, 서버 실행은 yield point에서 직렬화될 수 있습니다.

읽기 및 업데이트 작업이 원자적이어야 할 때는 모든 명령어에 동일한 로컬 트랜잭션을 할당하고 트랜잭션의 MARS 동작을 테스트하세요. 해당 트랜잭션의 다른 작업이 아직 대기 중일 때는 커밋하지 마세요.

레거시 비동기 프로그래밍

Microsoft.Data.SqlClient는 호환성을 위해 비동기 프로그래밍 모델(APM) 메서드 BeginExecuteNonQuery, BeginExecuteReader, BeginExecuteXmlReader를 유지합니다. 새 코드에서는 사용하지 마세요. CommandTimeout는 이러한 구식 비동기 호출에서는 무시되지만, ExecuteReaderAsync와 같은 작업 기반 메서드에서는 적용됩니다.

기존 방법은 Asynchronous Processing=true를 필요로 하지 않습니다. Microsoft.Data.SqlClient 4.0 이후 버전은 Asynchronous Processing 연결 속성을 지원하지 않습니다.