다음을 통해 공유


QUIC 프로토콜

QUIC는 RFC 9000으로 표준화된 네트워크 전송 계층 프로토콜입니다. UDP를 기본 프로토콜로 사용하며 TLS 1.3 사용을 의무화하므로 기본적으로 안전합니다. 자세한 내용은 RFC 9001을 참조하세요. TCP 및 UDP와 같은 잘 알려진 전송 프로토콜의 또 다른 흥미로운 차이점은 전송 계층에 스트림 멀티플렉싱이 기본 제공된다는 것입니다. 이렇게 하면 서로 영향을 주지 않는 여러 개의 동시 독립 데이터 스트림을 가질 수 있습니다.

QUIC 자체는 전송 프로토콜이기 때문에 교환된 데이터에 대한 의미 체계를 정의하지 않습니다. 대신 애플리케이션 계층 프로토콜(예: HTTP/3 또는 QUIC를 통한 SMB)에서 사용됩니다. 사용자 지정 정의 프로토콜에도 사용할 수 있습니다.

프로토콜은 TLS를 사용하는 TCP에 비해 많은 이점을 제공하는데, 몇 가지는 다음과 같습니다.

  • TLS가 있는 TCP만큼 많은 왕복이 필요하지 않으므로 연결을 더 빠르게 설정합니다.
  • 하나의 패킷 손실이 다른 모든 스트림의 데이터를 차단하지 않는 헤드 오브 라인 차단 문제를 방지합니다.

반면에 QUIC를 사용할 때 고려해야 할 잠재적인 단점이 있습니다. 최신 프로토콜이기 때문에 도입은 여전히 증가하고 있지만 제한적입니다. 그 외에도 일부 네트워킹 구성 요소에 의해 QUIC 트래픽이 차단될 수도 있습니다.

.NET의 QUIC

QUIC 구현은 .NET 5에서 System.Net.Quic 라이브러리로 도입되었습니다. 그러나 .NET 7까지 라이브러리는 엄밀히 말해 내부용이었고 HTTP/3의 구현으로만 사용되었습니다. .NET 7을 사용하면 라이브러리가 공개되어 API가 노출되었습니다.

참고 항목

.NET 7.0 및 8.0에서는 API가 미리 보기 기능으로 게시되었습니다. .NET 9부터 이러한 API는 더 이상 미리 보기 기능으로 간주되지 않으며 이제 안정적인 것으로 간주됩니다.

구현 관점에서 System.Net.Quic는 QUIC 프로토콜의 네이티브 구현인 MsQuic에 따라 달라집니다. 따라서 System.Net.Quic 플랫폼 지원 및 종속성은 MsQuic에서 상속되고 플랫폼 종속성 섹션에 설명되어 있습니다. 즉, MsQuic 라이브러리는 Windows용 .NET의 일부로 제공됩니다. 그러나 Linux의 경우 적절한 패키지 관리자를 통해 libmsquic를 수동으로 설치해야 합니다. 다른 플랫폼의 경우 여전히 SChannel 또는 OpenSSL에 대해 MsQuic를 수동으로 빌드하고 System.Net.Quic와 함께 사용할 수 있습니다. 그러나 이러한 시나리오는 테스트 매트릭스의 일부가 아니며 예기치 않은 문제가 발생할 수 있습니다.

플랫폼 종속성

다음 섹션에서는 .NET의 QUIC에 대한 플랫폼 종속성에 대해 설명합니다.

Windows

  • Windows 11, Windows Server 2022 이상 (이전 Windows 버전에는 QUIC를 지원하는 데 필요한 암호화 API가 없습니다.)

Windows에서 msquic.dll .NET 런타임의 일부로 배포되며 설치하는 데 다른 단계가 필요하지 않습니다.

Linux

참고 항목

.NET 7 이상은 libmsquic의 2.2 이상 버전과만 호환됩니다.

libmsquic 패키지는 Linux에 필요합니다. 이 패키지는 Microsoft의 공식 Linux 패키지 리포지토리에 게시되며 Alpine https://packages.microsoft.com Packages - libmsquic와 같은 일부 공식 리포지토리에서도 사용할 수 있습니다.

libmsquic Microsoft의 공식 Linux 패키지 리포지토리에서 설치

패키지를 설치하기 전에 패키지 관리자에 이 리포지토리를 추가해야 합니다. 자세한 내용은 Microsoft 제품용 Linux 소프트웨어 리포지토리를 참조하세요.

주의

배포의 리포지토리에서 .NET 및 기타 Microsoft 패키지를 제공하는 경우 Microsoft 패키지 리포지토리를 추가하면 배포의 리포지토리와 충돌할 수 있습니다. 패키지 혼합을 방지하거나 문제를 해결하려면 Linux에서 누락된 파일과 관련된 .NET 오류 문제를 검토합니다.

예제

패키지 관리자를 사용하여 libmsquic를 설치하는 몇 가지 예는 다음과 같습니다.

  • APT

    sudo apt-get install libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • YUM

    sudo yum install libmsquic
    

libmsquic 배포 패키지 리포지토리에서 설치

libmsquic 배포 패키지 리포지토리에서 설치할 수도 있지만 현재는 .에Alpine만 사용할 수 있습니다.

예제

패키지 관리자를 사용하여 libmsquic를 설치하는 몇 가지 예는 다음과 같습니다.

  • Alpine 3.21 이상
apk add libmsquic
  • Alpine 3.20 이상
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
libmsquic의 종속성

다음 종속성은 모두 libmsquic 패키지 매니페스트에 명시되며 패키지 관리자가 자동으로 설치합니다.

  • OpenSSL 3 이상 또는 1.1 - 배포 버전의 기본 OpenSSL 버전(예: Ubuntu 22의 경우 OpenSSL 3 및 Ubuntu 20의 경우 OpenSSL 1.1)에 따라 달라집니다.

  • libnuma 1

macOS

QUIC는 현재 macOS에서 지원되지 않지만 향후 릴리스에서 제공될 수 있습니다.

API 개요

System.Net.Quic는 QUIC 프로토콜을 사용할 수 있도록 하는 세 가지 주요 클래스를 제공합니다.

그러나 이러한 클래스를 사용하기 전에 libmsquic가 누락되었거나 TLS 1.3이 지원되지 않을 수 있기 때문에 코드는 QUIC가 현재 지원되는지 여부를 검사해야 합니다. 이를 위해 QuicListenerQuicConnection은 모두 정적 속성 IsSupported를 노출합니다.

if (QuicListener.IsSupported)
{
    // Use QuicListener
}
else
{
    // Fallback/Error
}

if (QuicConnection.IsSupported)
{
    // Use QuicConnection
}
else
{
    // Fallback/Error
}

이러한 속성은 동일한 값을 보고하지만 나중에 변경될 수 있습니다. 서버 시나리오의 경우 IsSupported를, 클라이언트 시나리오의 경우 IsSupported를 검사하는 것이 좋습니다.

QuicListener

QuicListener는 클라이언트에서 들어오는 연결을 허용하는 서버 쪽 클래스를 나타냅니다. 수신기가 생성되고 정적 메서드 ListenAsync(QuicListenerOptions, CancellationToken)로 시작됩니다. 이 메서드는 수신기를 시작하고 들어오는 연결을 수락하는 데 필요한 모든 설정을 사용하여 QuicListenerOptions 클래스의 인스턴스를 허용합니다. 그 후 수신기는 AcceptConnectionAsync(CancellationToken)를 통해 연결을 전달할 준비를 갖추게 됩니다. 이 메서드에서 반환된 연결은 항상 완전히 연결되므로 TLS 핸드셰이크가 완료되고 연결을 사용할 준비가 된 것입니다. 마지막으로, 수신 대기를 중지하고 모든 리소스를 해제하려면 DisposeAsync()를 호출해야 합니다.

다음 QuicListener 예제 코드를 참조하세요.

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicListener.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// Share configuration for each incoming connection.
// This represents the minimal configuration necessary.
var serverConnectionOptions = new QuicServerConnectionOptions
{
    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Same options as for server side SslStream.
    ServerAuthenticationOptions = new SslServerAuthenticationOptions
    {
        // Specify the application protocols that the server supports. This list must be a subset of the protocols specified in QuicListenerOptions.ApplicationProtocols.
        ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
        // Server certificate, it can also be provided via ServerCertificateContext or ServerCertificateSelectionCallback.
        ServerCertificate = serverCertificate
    }
};

// Initialize, configure the listener and start listening.
var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
    // Define the endpoint on which the server will listen for incoming connections. The port number 0 can be replaced with any valid port number as needed.
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    // List of all supported application protocols by this listener.
    ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
    // Callback to provide options for the incoming connections, it gets called once per each connection.
    ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});

// Accept and process the connections.
while (isRunning)
{
    // Accept will propagate any exceptions that occurred during the connection establishment,
    // including exceptions thrown from ConnectionOptionsCallback, caused by invalid QuicServerConnectionOptions or TLS handshake failures.
    var connection = await listener.AcceptConnectionAsync();

    // Process the connection...
}

// When finished, dispose the listener.
await listener.DisposeAsync();

QuicListener 설계 방법에 대한 자세한 내용은 API 제안을 참조하세요.

QuicConnection

QuicConnection는 서버 및 클라이언트 쪽 QUIC 연결에 사용되는 클래스입니다. 서버 쪽 연결은 수신기에 의해 내부에서 만들어지고 AcceptConnectionAsync(CancellationToken)를 통해 전달됩니다. 클라이언트 쪽 연결을 열고 서버에 연결해야 합니다. 수신기와 마찬가지로 연결을 인스턴스화하고 연결하는 정적 메서드 ConnectAsync(QuicClientConnectionOptions, CancellationToken)가 있습니다. QuicServerConnectionOptions와 유사한 클래스의 QuicClientConnectionOptions인스턴스를 허용합니다. 그 후에는 클라이언트와 서버 간에 연결 작업이 달라지지 않습니다. 나가는 스트림을 열고 들어오는 스트림을 수락할 수 있습니다. 또한 연결에 대한 정보(예: LocalEndPoint, RemoteEndPoint 또는 RemoteCertificate)가 포함된 속성을 제공합니다.

연결 작업이 완료되면 연결을 닫고 삭제해야 합니다. 즉시 닫기 위해 애플리케이션 계층 코드를 사용하는 QUIC 프로토콜은 RFC 9000 섹션 10.2를 참조하세요. 이를 위해 애플리케이션 계층 코드가 포함된 CloseAsync(Int64, CancellationToken)를 호출할 수 있으며, 호출하지 않을 경우 DisposeAsync()DefaultCloseErrorCode에 제공된 코드를 사용합니다. 어느 쪽이든 연결된 모든 리소스를 완전히 해제하려면 연결 작업이 끝날 때 DisposeAsync()를 호출해야 합니다.

다음 QuicConnection 예제 코드를 참조하세요.

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicConnection.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// This represents the minimal configuration necessary to open a connection.
var clientConnectionOptions = new QuicClientConnectionOptions
{
    // End point of the server to connect to.
    RemoteEndPoint = listener.LocalEndPoint,

    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Optionally set limits for inbound streams.
    MaxInboundUnidirectionalStreams = 10,
    MaxInboundBidirectionalStreams = 100,

    // Same options as for client side SslStream.
    ClientAuthenticationOptions = new SslClientAuthenticationOptions
    {
        // List of supported application protocols.
        ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
        // The name of the server the client is trying to connect to. Used for server certificate validation.
        TargetHost = ""
    }
};

// Initialize, configure and connect to the server.
var connection = await QuicConnection.ConnectAsync(clientConnectionOptions);

Console.WriteLine($"Connected {connection.LocalEndPoint} --> {connection.RemoteEndPoint}");

// Open a bidirectional (can both read and write) outbound stream.
// Opening a stream reserves it but does not notify the peer or send any data. If you don't send data, the peer
// won't be informed about the stream, which can cause AcceptInboundStreamAsync() to hang. To avoid this, ensure
// you send data on the stream to properly initiate communication.
var outgoingStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);

// Work with the outgoing stream ...

// To accept any stream on a client connection, at least one of MaxInboundBidirectionalStreams or MaxInboundUnidirectionalStreams of QuicConnectionOptions must be set.
while (isRunning)
{
    // Accept an inbound stream.
    var incomingStream = await connection.AcceptInboundStreamAsync();

    // Work with the incoming stream ...
}

// Close the connection with the custom code.
await connection.CloseAsync(0x0C);

// Dispose the connection.
await connection.DisposeAsync();

QuicConnection 설계 방법에 대한 자세한 내용은 API 제안을 참조하세요.

QuicStream

QuicStream은 QUIC 프로토콜에서 데이터를 보내고 받는 데 사용되는 실제 형식입니다. 일반 Stream에서 파생되며 이와 같이 사용할 수 있지만 QUIC 프로토콜과 관련된 몇 가지 기능도 제공합니다. 첫째, QUIC 스트림은 단방향일 수도 있고 양방향일 수도 있습니다. RFC 9000 섹션 2.1을 참조하세요. 양방향 스트림은 양쪽에서 데이터를 보내고 받을 수 있는 반면 단방향 스트림은 시작 쪽에서만 쓰고 수락된 스트림에서 읽을 수 있습니다. 각 피어는 각 형식의 동시 스트림이 허용할 수를 제한할 수 있습니다. MaxInboundBidirectionalStreamsMaxInboundUnidirectionalStreams를 참조하세요.

QUIC 스트림의 또 다른 특성은 스트림 작업 중에 쓰기 쪽을 명시적으로 닫는 기능입니다. completeWrites 인수를 사용한 CompleteWrites() 또는 WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) 오버로드를 참조하세요. 쓰기 쪽을 닫으면 피어가 더 이상 데이터가 도착하지 않음을 알 수 있지만 피어는 양방향 스트림의 경우 계속 전송할 수 있습니다. 이는 클라이언트가 요청을 보내고 쓰기 쪽을 닫아 서버가 요청 콘텐츠의 끝임을 알리는 HTTP 요청/응답 교환과 같은 시나리오에서 유용합니다. 서버는 그 후에도 응답을 보낼 수 있지만 클라이언트에서 더 이상 데이터가 도착하지 않는다는 것을 알고 있습니다. 그리고 잘못된 경우 스트림의 쓰기 또는 읽기 쪽을 중단할 수 있습니다. Abort(QuicAbortDirection, Int64)를 참조하세요.

참고 항목

스트림을 열면 데이터를 보내지 않고 스트림만 예약됩니다. 이 접근 방식은 거의 빈 프레임의 전송을 방지하여 네트워크 사용량을 최적화하도록 설계되었습니다. 실제 데이터가 전송될 때까지 피어에 알림이 전송되지 않으므로, 피어의 입장에서는 스트림이 비활성 상태로 유지됩니다. 데이터를 보내지 않으면 피어가 스트림을 인식하지 못하므로 의미 있는 스트림을 기다리는 동안 AcceptInboundStreamAsync()이(가) 중단될 수 있습니다. 적절한 통신을 보장하려면 스트림을 연 후 데이터를 보내야 합니다.

각 스트림 유형에 대한 개별 메서드의 동작은 다음 표에 요약되어 있습니다(클라이언트와 서버 모두 스트림을 열고 수락할 수 있음).

메서드 피어 열기 스트림 피어 수락 스트림
CanRead 양방향: true
단방향: false
true
CanWrite true 양방향: true
단방향: false
ReadAsync 양방향: 데이터 읽기
단방향: InvalidOperationException
데이터 읽기
WriteAsync 데이터 보내기 => 피어 읽기를 통해 데이터 반환 양방향: 데이터 보내기 => 피어 읽기를 통해 데이터 반환
단방향: InvalidOperationException
CompleteWrites 쓰기 쪽 닫기=> 피어 읽기를 통해 0 반환 양방향: 쓰기 쪽 닫기 => 피어 읽기를 통해 0 반환
단방향: no-op
Abort(QuicAbortDirection.Read) 양방향: STOP_SENDING => 피어 쓰기를 통해 QuicException(QuicError.OperationAborted) throw
단방향: no-op
STOP_SENDING => 피어 쓰기를 통해 QuicException(QuicError.OperationAborted) throw
Abort(QuicAbortDirection.Write) RESET_STREAM => 피어 읽기를 통해 QuicException(QuicError.OperationAborted) throw 양방향: RESET_STREAM => 피어 읽기를 통해 QuicException(QuicError.OperationAborted) throw
단방향: no-op

이러한 메서드를 기반으로 QuicStream은 스트림의 읽기 또는 쓰기 쪽이 닫혀 있을 때마다 알림을 받을 수 있는 두 가지 특수 속성인 ReadsClosedWritesClosed를 제공합니다. 둘 다 성공 여부와 관계없이 해당하는 쪽이 닫히는 상태로 완료되는 Task를 반환하며, 이 경우 적절한 예외가 Task에 포함됩니다. 이러한 속성은 사용자 코드가 ReadAsync 또는 WriteAsync 호출을 실행하지 않고 스트림 쪽이 닫히는 것을 인식해야 하는 경우에 유용합니다.

마지막으로 스트림 작업이 완료되면 DisposeAsync()를 사용하여 삭제해야 합니다. 삭제 시 스트림 유형에 따라 읽기 및/또는 쓰기 쪽이 모두 닫혀 있는지 확인합니다. 스트림이 끝까지 제대로 읽혀지지 않은 경우 삭제는 해당하는 Abort(QuicAbortDirection.Read)를 발급합니다. 그러나 스트림 쓰기 쪽이 닫혀 있지 않으면 CompleteWrites를 사용한 경우처럼 정상적으로 닫힙니다. 이러한 차이가 발생하는 이유는 일반 Stream을 사용하는 시나리오가 예상대로 동작하여 성공적인 경로로 이어지도록 하기 위한 것입니다. 다음 예제를 참조하세요.

// Work done with all different types of streams.
async Task WorkWithStreamAsync(Stream stream)
{
    // This will dispose the stream at the end of the scope.
    await using (stream)
    {
        // Simple echo, read data and send them back.
        byte[] buffer = new byte[1024];
        int count = 0;
        // The loop stops when read returns 0 bytes as is common for all streams.
        while ((count = await stream.ReadAsync(buffer)) > 0)
        {
            await stream.WriteAsync(buffer.AsMemory(0, count));
        }
    }
}

// Open a QuicStream and pass to the common method.
var quicStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
await WorkWithStreamAsync(quicStream);

클라이언트 시나리오의 QuicStream 샘플 사용:

// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, cancellationToken);

// Send some data.
await stream.WriteAsync(data, cancellationToken);
await stream.WriteAsync(data, cancellationToken);

// End the writing-side together with the last data.
await stream.WriteAsync(data, completeWrites: true, cancellationToken);
// Or separately.
stream.CompleteWrites();

// Read data until the end of stream.
while (await stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...
}

// DisposeAsync called by await using at the top.

서버 시나리오의 QuicStream 샘플 사용:

// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptInboundStreamAsync(cancellationToken);

if (stream.Type != QuicStreamType.Bidirectional)
{
    Console.WriteLine($"Expected bidirectional stream, got {stream.Type}");
    return;
}

// Read the data.
while (stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...

    // Client completed the writes, the loop might be exited now without another ReadAsync.
    if (stream.ReadsCompleted.IsCompleted)
    {
        break;
    }
}

// Listen for Abort(QuicAbortDirection.Read) from the client.
var writesClosedTask = WritesClosedAsync(stream);
async ValueTask WritesClosedAsync(QuicStream stream)
{
    try
    {
        await stream.WritesClosed;
    }
    catch (Exception ex)
    {
        // Handle peer aborting our writing side ...
    }
}

// DisposeAsync called by await using at the top.

QuicStream 설계 방법에 대한 자세한 내용은 API 제안을 참조하세요.

참고 항목