Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

(시리즈 글이 2개 있습니다.)
.NET Framework: 1009. .NET 5에서의 네트워크 라이브러리 개선 (1) - HTTP 관련
; https://www.sysnet.pe.kr/2/0/12493

.NET Framework: 1017. .NET 5에서의 네트워크 라이브러리 개선 (2) - HTTP/2, HTTP/3 관련
; https://www.sysnet.pe.kr/2/0/12504




.NET 5에서의 네트워크 라이브러리 개선 (1) - HTTP 관련

이번엔 다음의 글을 정리해보겠습니다. ^^

.NET 5 Networking Improvements
; https://devblogs.microsoft.com/dotnet/net-5-new-networking-improvements/

내용이 길어, HTTP 관련 글만 이번에 포함하고 나머지는 두 번째 글에서 설명하겠습니다.





#1 에러 처리 개선

HttpClient는 지난 3.1에서 요청에 대한 시간 만료와 취소에 대한 구분을 지원했습니다. 간단한 테스트를 위해 서버를 이렇게 만들고,

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Http;
using System.Threading;
using System.Threading.Tasks;

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup();
            });
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services) { }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", async context =>
            {
                if (context.Request.Query.ContainsKey("delay") == true)
                {
                    int seconds = int.Parse(context.Request.Query["delay"]);
                    await Task.Delay(seconds * 1000);
                }

                await context.Response.WriteAsync("Hello World!");
            });
        });
    }
}

클라이언트는 CancellationTokenSource.Cancel 메서드의 호출 유무에 따라 TaskCanceledException 처리를 구분해 다음과 같이 할 수 있습니다.

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    private static readonly HttpClient _client = new HttpClient()
    {
        Timeout = TimeSpan.FromSeconds(10)
    };

    static async Task Main()
    {
        var cts = new CancellationTokenSource();

        new Thread(() =>
        {
            Thread.Sleep(3000);
            cts.Cancel();
        }).Start();

        try
        {
            using HttpResponseMessage response = await _client.GetAsync("http://localhost:15000/", cts.Token);
        }
        // If the token has been canceled, it is not a timeout.
        catch (TaskCanceledException ex) when (cts.IsCancellationRequested)
        {
            // Handle cancellation.
            Console.WriteLine("Canceled: " + ex.Message);
        }
        catch (TaskCanceledException ex)
        {
            // Handle timeout.
            Console.WriteLine("Timed out: " + ex.Message);
        }
    }
}

.NET 5 이후부터는 위의 구분과 함께 time-out일 경우 InnerException에 TimeoutException을 명시적으로 전달하도록 바뀌었으므로 다음과 같이 처리하는 것도 가능합니다.

try
{
    using var response = await _client.GetAsync("http://localhost:15000/", cts.Token);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    Console.WriteLine("Timed out: " + ex.Message);
}
catch (TaskCanceledException ex)
{
    Console.WriteLine("Canceld: " + ex.Message);
}

또 다른 개선 사항으로는 HttpRequestException 예외에 추가한 StatusCode 속성입니다.

HttpRequestException.StatusCode Property
; https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httprequestexception.statuscode

이에 대한 예제를 위해 기존의 404 NOT FOUND를 보겠습니다. 기본적으로 NOT FOUND는 예외를 발생시키지 않는데요,

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);
}
catch (HttpRequestException ex) // 404 NOT FOUND로 인한 예외는 발생하지 않음
{
    Console.WriteLine("HttpRequestException: " + ex.Message);
}

이것을 예외로 처리하려면 HttpResponseMessage 타입의 EnsureSuccessStatusCode 메서드를 호출해 주어야 합니다.

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);
    response.EnsureSuccessStatusCode(); // HTTP 상태 코드가 2xx이 아닌 경우 예외 처리
}
catch (HttpRequestException ex)
{       
    Console.WriteLine("HttpRequestException: " + ex.Message);
}

여기서 문제는, EnsureSuccessStatusCode로 인한 예외 발생 시 404 NOT FOUND로 인한 예외인지에 대한 구분을 할 수 없다는 것입니다. 억지로 하려면 Exception.Message 속성에 "Response status code does not indicate success: 404 (Not Found)."라고 담긴 문자열로부터 유추해야 하는데, 이에 대한 보완으로 .NET 5.0부터 추가된 StatusCode를 이용하는 경우 정확한 오류 코드를 짚어낼 수 있습니다.

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);
    response.EnsureSuccessStatusCode(); // HTTP 상태 코드가 2xx이 아닌 경우 예외 처리
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) /* .NET 5부터 가능 */
{       
    Console.WriteLine("Not found: " + ex.Message);
}

또한, StatusCode를 받아들이는 HttpRequestException 생성자가 .NET 5에 추가되면서 다음과 같은 처리도 가능해졌습니다.

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);

    if (response.StatusCode >= HttpStatusCode.BadRequest)
    {
        throw new HttpRequestException("BAD REQUEST", inner: null, response.StatusCode);  /* .NET 5부터 가능 */
    }
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)  /* .NET 5부터 가능 */
{       
    Console.WriteLine("Not found: " + ex.Message);
}

참고로, HttpClient의 메서드 중 HttpResponseMessage가 반환값이 아니어서 EnsureSuccessStatusCode 메서드를 호출할 수 없는 GetStringAsync, GetByteArraysAsync, GetStreamAsync 메서드 등이 있는데, 이들은 내부적으로 EnsureSuccessStatusCode 메서드를 호출해 주고 있으므로 404 등의 반환인 경우 GetAsync와는 달리 항상 예외가 발생하게 됩니다.





#2 다중 플랫폼에서의 일관성

초기 .NET Core는 플랫폼 별로 상이한 HTTP Handler에 의존하고 있었습니다.

  • WinHttpHandler: 윈도우 환경에서 WinHTTP 구성 요소에 기반
  • CurlHandler: 리눅스/맥 환경에서 libcurl 구성 요소에 기반

따라서, HttpClient의 동작 방식이 플랫폼 별로 달라질 수 있었는데 이런 문제를 .NET Core 2.1부터 (순수 managed 코드로만 구현한) SocketsHttpHandler를 공통 제공함으로써 해결했고, .NET 5부터는 아예 플랫폼 별로 구현된 Handler를 System.Net.Http.dll로부터 제거했다고 합니다. 이로 인해, 기존에는 .NET Core 2.1에서 SocketsHttpHandler가 아닌 기존 Handler를 사용하려는 경우 "System.Net.Http.UseSocketsHttpHandler" 설정으로 가능했는데,

System.Net.Http.UseSocketsHttpHandler
; https://learn.microsoft.com/en-us/dotnet/api/system.net.http.socketshttphandler?view=net-5.0#remarks

// 방법 1: 코드로 설정
AppContext.SetSwitch("System.Net.Http.UseSocketsHttpHandler", false);

// 방법 2: runtimeconfig.json에 설정
"runtimeOptions": {
  "configProperties": {
      "System.Net.Http.UseSocketsHttpHandler": false
  }
}

// 방법 3: 환경 변수 설정
SET DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER=0

.NET 5부터는 저렇게 설정해도 구현체가 제거되었기 때문에 SocketsHttpHandler가 무조건 사용됩니다. 만약, 그래도 사용하길 원한다면 Nuget으로부터 System.Net.Http.WinHttpHandler를 참조 추가하고,

Install-Package System.Net.Http.WinHttpHandler

다음과 같이 명시적으로 해당 handler를 사용하겠다고 코드 변경을 해야 합니다.

class Program
{
    private static readonly HttpClient _client = new HttpClient(new WinHttpHandler());

    static async Task Main()
    {
        using var response = await _client.GetAsync("http://localhost:5001/");
    }
}





#3 SocketsHttpHandler의 확장성

.NET 5부터 SocketsHttpHandler에는 2가지 확장을 제공합니다.


우선 ConnectCallback은 이름에서 유추할 수 있듯이 새로운 TCP 연결이 있을 때마다 호출되는데, 이때 연결 대상의 주소를 식별할 수 있는 DnsEndPoint와 HTTP 요청 메시지를 담고 있는 HttpRequestMessage를 함께 전달받습니다.

따라서, 이를 통해 임의적으로 DNS 주소 풀이를 하거나 사용하게 될 소켓마다 공통 설정이 필요한 작업 등을 콜백에서 수행할 수 있고, 혹은 순수하게 새로운 연결이 발생했음을 감지하는 용도로도 사용할 수 있습니다. 재미있는 것은, ConnectCallback이 요구하는 반환 형식은 ValueTask<Stream>이기 때문에 Stream 구문에만 맞으면 됩니다. 즉, 대상이 프로세스 내부인 경우 소켓이 아닌 별도의 in-process 통신 방식으로 우회하는 등의 작업도 가능합니다. 실제로 단순히 Stream만을 구현해 다음과 같이 ConnectCallback 테스트를 할 수 있습니다.

using System;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace s3_shh_extension
{
    class Program
    {
        private static readonly HttpClient _client;

        static Program()
        {
            SocketsHttpHandler handler = new SocketsHttpHandler();
            handler.ConnectCallback = MemoryConnectAsync;
            _client = new HttpClient(handler);
        }

        private static async ValueTask<Stream> MemoryConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
        {
            string fakeResponse = @"HTTP/1.1 200 OK
Server: MemoryStream
Content-Type: text/html; charset=UTF-8
Content-Length: 4
Connection: close

test
";
            MyStream stream = new MyStream(fakeResponse);
            return stream;
        }

        static async Task Main(string[] args)
        {
            using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", CancellationToken.None);
            Console.WriteLine(await response.Content.ReadAsStringAsync());
        }
    }

    public class MyStream : Stream
    {
        string _fakeResponse;

        public MyStream(string fakeResponse)
        {
            _fakeResponse = fakeResponse;
        }

        public override bool CanRead => true;

        public override bool CanSeek => false;

        public override bool CanWrite => true;

        public override long Length => throw new NotImplementedException();

        public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }

        public override void Flush()
        {
        }

        public override int Read(byte[] buffer, int offset, int count)
        {
            byte [] textBuffer = Encoding.UTF8.GetBytes(_fakeResponse);
            Array.Copy(textBuffer, buffer, textBuffer.Length);

            return textBuffer.Length;
        }

        public override long Seek(long offset, SeekOrigin origin)
        {
            throw new NotImplementedException();
        }

        public override void SetLength(long value)
        {
            throw new NotImplementedException();
        }

        public override void Write(byte[] buffer, int offset, int count)
        {
            string text = Encoding.UTF8.GetString(buffer, offset, count);
            Console.WriteLine(text);
        }
    }
}

/* 출력 결과
GET /notfound HTTP/1.1
Host: localhost:5000


test
*/

대개의 경우 저런 식으로 확장을 하는 경우는 드물 테고, 현실적으로 사용하려는 소켓에 대한 공통 설정 코드를 넣어두는 용도로 사용될 텐데요, 아래의 예는 HttpClient가 사용하는 소켓에 대해 Nagle 알고리즘을 끄는 방법을 제시합니다.

private static async ValueTask<Stream> DefaultConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
    // The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
    Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
    // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
    socket.NoDelay = true;

    try
    {
        await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
        // The stream should take the ownership of the underlying socket,
        // closing it when it's disposed.
        return new NetworkStream(socket, ownsSocket: true);
    }
    catch
    {
        socket.Dispose();
        throw;
    }
}

ConnectCallback이 연결 전의 후킹이라면, PlaintextStreamFilter 확장은 연결이 완료된 후 최초의 요청 데이터가 전달되기 전의 후킹을 지원합니다. 아래는 아무런 변경도 하지 않고 메시지 내용만 출력하는 예제 코드입니다.

SocketsHttpHandler handler = new SocketsHttpHandler();
handler.PlaintextStreamFilter = PlaintextStreamFilter;
_client = new HttpClient(handler);

private static ValueTask<Stream> PlaintextStreamFilter(SocketsHttpPlaintextStreamFilterContext context, CancellationToken cancellationToken)
{
    Console.WriteLine($"Request {context.InitialRequestMessage} --> negotiated version {context.NegotiatedHttpVersion}");
    return ValueTask.FromResult(context.PlaintextStream);
}

/* 출력 결과
Request Method: GET, RequestUri: 'http://localhost:5000/', Version: 1.1, Content: , Headers:
{
} --> negotiated version 1.1
*/





#4 동기 방식으로 동작하는 HttpClient.Send 메서드 추가

비록 비동기 버전을 추천하기는 하지만, 간혹 무리하게 동기식으로 호출하기 위해 HttpClient.SendAsync 류가 반환하는 Task로 블록킹 메서드를 호출하는 사례가 있는데 자칫 데드락(deadlock)의 위험을 내포하므로 이런 경우를 위해 동기 버전을 제공하게 되었다고 합니다.

대신 아래와 같은 제약이 있습니다.

  • HTTP/1.1 통신만 가능
  • ConnectCallback 확장과 함께 사용할 수 없음
  • .NET에서 기본 제공하는 SocketsHttpHandler/HttpContent가 아닌 사용자 정의 handler/HttpContent를 사용하는 경우 그것의 동기 버전에 해당하는 HttpMessageHandler.Send/HttpContent.SerializeToStream이 구현되어 있어야 함.




(첨부 파일은 이 글의 예제 코드를 포함합니다.)

참고로, 요즘 제가 자주 가는 사이트가 있습니다.

닷넷데브
; https://forum.dotnetdev.kr/

여러 가지 닷넷 관련 소식들이 올라오기 때문에 한 번씩 들러보는 것을 추천합니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 1/11/2024]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2021-01-20 03시02분
[이성환] 아.. 다행이군요. 리눅스에서 httpclient 가 Timeout 대신 TaskCancel 을 계속 발생시켜서 한동안 해멨었는데 이제야 제대로 동작하겠군요.(프로삽실러...;ㅁ; 윈도에서만 디버깅했다가 옴팡 당했던 1인)
[guest]

... 91  92  93  94  95  96  97  [98]  99  100  101  102  103  104  105  ...
NoWriterDateCnt.TitleFile(s)
11484정성태4/11/201824741.NET Framework: 737. C# - async를 Task 타입이 아닌 사용자 정의 타입에 적용하는 방법파일 다운로드1
11483정성태4/10/201828040개발 환경 구성: 358. "Let's Encrypt"에서 제공하는 무료 SSL 인증서를 IIS에 적용하는 방법 (2) [1]
11482정성태4/10/201820485VC++: 126. CUDA Core 수를 알아내는 방법
11481정성태4/10/201832106개발 환경 구성: 357. CUDA의 인덱싱 관련 용어 - blockIdx, threadIdx, blockDim, gridDim
11480정성태4/9/201822167.NET Framework: 736. C# - API를 사용해 Azure에 접근하는 방법 [2]파일 다운로드1
11479정성태4/9/201817774.NET Framework: 735. Azure - PowerShell로 Access control(IAM)에 새로운 계정 만드는 방법
11478정성태11/8/201920020디버깅 기술: 115. windbg - 덤프 파일로부터 PID와 환경변수 등의 정보를 구하는 방법 [1]
11477정성태4/8/201817467오류 유형: 460. windbg - sos 명령어 수행 시 c0000006 오류 발생
11476정성태4/8/201819031디버깅 기술: 114. windbg - !threads 출력 결과로부터 닷넷 관리 스레드(System.Threading.Thread) 객체를 구하는 방법
11475정성태3/28/201821329디버깅 기술: 113. windbg - Thread.Suspend 호출 시 응용 프로그램 hang 현상에 대한 덤프 분석
11474정성태3/27/201819444오류 유형: 459. xperf: error: TEST.Event: Invalid flags. (0x3ec).
11473정성태3/22/201824592.NET Framework: 734. C# - Thread.Suspend 호출 시 응용 프로그램 hang 현상파일 다운로드2
11472정성태3/22/201818565개발 환경 구성: 356. GTX 1070, GTX 960, GT 640M의 cudaGetDeviceProperties 출력 결과
11471정성태3/20/201821949VC++: 125. CUDA로 작성한 RGB2RGBA 성능 [1]파일 다운로드1
11470정성태3/20/201824114오류 유형: 458. Visual Studio - CUDA 프로젝트 빌드 시 오류 C1189, expression must have a constant value
11469정성태3/19/201817125오류 유형: 457. error MSB3103: Invalid Resx file. Could not load file or assembly 'System.Windows.Forms, ...' or one of its dependencies.
11468정성태3/19/201816660오류 유형: 456. 닷넷 응용 프로그램 실행 시 0x80131401 예외 발생
11467정성태3/19/201816084오류 유형: 455. Visual Studio Installer - 업데이트 실패
11466정성태3/18/201817233개발 환경 구성: 355. 한 대의 PC에서 2개 이상의 DirectX 게임을 실행하는 방법
11463정성태3/15/201819572.NET Framework: 733. 스레드 간의 read/write 시에도 lock이 필요 없는 경우파일 다운로드1
11462정성태3/14/201822433개발 환경 구성: 354. HTTPS 호출에 대한 TLS 설정 확인하는 방법 [1]
11461정성태3/13/201825066오류 유형: 454. 윈도우 업데이트 설치 오류 - 0x800705b4 [1]
11460정성태3/13/201817549디버깅 기술: 112. windbg - 닷넷 메모리 덤프에서 전역 객체의 내용을 조사하는 방법
11459정성태3/13/201818365오류 유형: 453. Debug Diagnostic Tool에서 mscordacwks.dll을 찾지 못하는 문제
11458정성태2/21/201819344오류 유형: 452. This share requires the obsolete SMB1 protocol, which is unsafe and could expose your system to attack. [1]
11457정성태2/17/201824061.NET Framework: 732. C# - Task.ContinueWith 설명 [1]파일 다운로드1
... 91  92  93  94  95  96  97  [98]  99  100  101  102  103  104  105  ...