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]

... 31  32  33  34  35  36  [37]  38  39  40  41  42  43  44  45  ...
NoWriterDateCnt.TitleFile(s)
12714정성태7/16/20218280오류 유형: 735. VCRUNTIME140.dll, MSVCP140.dll, VCRUNTIME140.dll, VCRUNTIME140_1.dll이 없어 exe 실행이 안 되는 경우
12713정성태7/16/20218796.NET Framework: 1077. C# - 동기 방식이면서 비동기 규약을 따르게 만드는 Task.FromResult파일 다운로드1
12712정성태7/15/20218235개발 환경 구성: 579. Azure - 리눅스 호스팅의 Site Extension 제작 방법
12711정성태7/15/20218609개발 환경 구성: 578. Azure - Java Web App Service를 위한 Site Extension 제작 방법
12710정성태7/15/202110395개발 환경 구성: 577. MQTT - emqx.io 서비스 소개
12709정성태7/14/20216984Linux: 42. 실행 중인 docker 컨테이너에 대한 구동 시점의 docker run 명령어를 확인하는 방법
12708정성태7/14/202110394Linux: 41. 리눅스 환경에서 디스크 용량 부족 시 원인 분석 방법
12707정성태7/14/202177668오류 유형: 734. MySQL - Authentication method 'caching_sha2_password' not supported by any of the available plugins.
12706정성태7/14/20218825.NET Framework: 1076. C# - AsyncLocal 기능을 CallContext만으로 구현하는 방법 [2]파일 다운로드1
12705정성태7/13/20219003VS.NET IDE: 168. x64 DLL 프로젝트의 컨트롤이 Visual Studio의 Designer에서 보이지 않는 문제 - 두 번째 이야기
12704정성태7/12/20218142개발 환경 구성: 576. Azure VM의 서비스를 Azure Web App Service에서만 접근하도록 NSG 설정을 제한하는 방법
12703정성태7/11/202113806개발 환경 구성: 575. Azure VM에 (ICMP) ping을 허용하는 방법
12702정성태7/11/20218921오류 유형: 733. TaskScheduler에 등록된 wacs.exe의 Let's Encrypt 인증서 업데이트 문제
12701정성태7/9/20218589.NET Framework: 1075. C# - ThreadPool의 스레드는 반환 시 ThreadStatic과 AsyncLocal 값이 초기화 될까요?파일 다운로드1
12700정성태7/8/20218967.NET Framework: 1074. RuntimeType의 메모리 누수? [1]
12699정성태7/8/20217765VS.NET IDE: 167. Visual Studio 디버깅 중 GC Heap 상태를 보여주는 "Show Diagnostic Tools" 메뉴 사용법
12698정성태7/7/202111737오류 유형: 732. Windows 11 업데이트 시 3% 또는 0%에서 다운로드가 멈춘 경우
12697정성태7/7/20217584개발 환경 구성: 574. Windows 11 (Insider Preview) 설치하는 방법
12696정성태7/6/20218194VC++: 146. 운영체제의 스레드 문맥 교환(Context Switch)을 유사하게 구현하는 방법파일 다운로드2
12695정성태7/3/20218226VC++: 145. C 언어의 setjmp/longjmp 기능을 Thread Context를 이용해 유사하게 구현하는 방법파일 다운로드1
12694정성태7/2/202110219Java: 24. Azure - Spring Boot 앱을 Java SE(Embedded Web Server)로 호스팅 시 로그 파일 남기는 방법 [1]
12693정성태6/30/20217967오류 유형: 731. Azure Web App Site Extension - Failed to install web app extension [...]. {1}
12692정성태6/30/20217887디버깅 기술: 180. Azure - Web App의 비정상 종료 시 남겨지는 로그 확인
12691정성태6/30/20218666개발 환경 구성: 573. 테스트 용도이지만 테스트에 적합하지 않은 Azure D1 공유(shared) 요금제
12690정성태6/28/20219497Java: 23. Azure - 자바(Java)로 만드는 Web App Service - Tomcat 호스팅
12689정성태6/25/202110117오류 유형: 730. Windows Forms 디자이너 - The class Form1 can be designed, but is not the first class in the file. [1]
... 31  32  33  34  35  36  [37]  38  39  40  41  42  43  44  45  ...