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]

1  2  3  [4]  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13526정성태1/14/20242048닷넷: 2201. C# - Facebook 연동 / 사용자 탈퇴 처리 방법
13525정성태1/13/20242015오류 유형: 891. Visual Studio - Web Application을 실행하지 못하는 IISExpress
13524정성태1/12/20242064오류 유형: 890. 한국투자증권 KIS Developers OpenAPI - GW라우팅 중 오류가 발생했습니다.
13523정성태1/12/20241888오류 유형: 889. Visual Studio - error : A project with that name is already opened in the solution.
13522정성태1/11/20242026닷넷: 2200. C# - HttpClient.PostAsJsonAsync 호출 시 "Transfer-Encoding: chunked" 대신 "Content-Length" 헤더 처리
13521정성태1/11/20242108닷넷: 2199. C# - 한국투자증권 KIS Developers OpenAPI의 WebSocket Ping, Pong 처리
13520정성태1/10/20241858오류 유형: 888. C# - Unable to resolve service for type 'Microsoft.Extensions.ObjectPool.ObjectPool`....'
13519정성태1/10/20241937닷넷: 2198. C# - Reflection을 이용한 ClientWebSocket의 Ping 호출파일 다운로드1
13518정성태1/9/20242172닷넷: 2197. C# - ClientWebSocket의 Ping, Pong 처리
13517정성태1/8/20242016스크립트: 63. Python - 공개 패키지를 이용한 위성 이미지 생성 (pystac_client, odc.stac)
13516정성태1/7/20242103닷넷: 2196. IIS - AppPool의 "Disable Overlapped Recycle" 옵션의 부작용
13515정성태1/6/20242374닷넷: 2195. async 메서드 내에서 C# 7의 discard 구문 활용 사례 [1]
13514정성태1/5/20242064개발 환경 구성: 702. IIS - AppPool의 "Disable Overlapped Recycle" 옵션
13513정성태1/5/20242004닷넷: 2194. C# - WebActivatorEx / System.Web의 PreApplicationStartMethod 특성
13512정성태1/4/20241979개발 환경 구성: 701. IIS - w3wp.exe 프로세스의 ASP.NET 런타임을 항상 Warmup 모드로 유지하는 preload Enabled 설정
13511정성태1/4/20241995닷넷: 2193. C# - ASP.NET Web Application + OpenAPI(Swashbuckle) 스펙 제공
13510정성태1/3/20241936닷넷: 2192. C# - 특정 실행 파일이 있는지 확인하는 방법 (Linux)
13509정성태1/3/20241966오류 유형: 887. .NET Core 2 이하의 프로젝트에서 System.Runtime.CompilerServices.Unsafe doesn't support netcoreapp2.0.
13508정성태1/3/20242012오류 유형: 886. ORA-28000: the account is locked
13507정성태1/2/20242695닷넷: 2191. C# - IPGlobalProperties를 이용해 netstat처럼 사용 중인 Socket 목록 구하는 방법파일 다운로드1
13506정성태12/29/20232188닷넷: 2190. C# - 닷넷 코어/5+에서 달라지는 System.Text.Encoding 지원
13505정성태12/27/20232702닷넷: 2189. C# - WebSocket 클라이언트를 닷넷으로 구현하는 예제 (System.Net.WebSockets)파일 다운로드1
13504정성태12/27/20232318닷넷: 2188. C# - ASP.NET Core SignalR로 구현하는 채팅 서비스 예제파일 다운로드1
13503정성태12/27/20232189Linux: 67. WSL 환경 + mlocate(locate) 도구의 /mnt 디렉터리 검색 문제
13502정성태12/26/20232289닷넷: 2187. C# - 다른 프로세스의 환경변수 읽는 예제파일 다운로드1
13501정성태12/25/20232087개발 환경 구성: 700. WSL + uwsgi - IPv6로 바인딩하는 방법
1  2  3  [4]  5  6  7  8  9  10  11  12  13  14  15  ...