Refactroing - HttpClient

2023. 10. 23. 00:00CSharp/Advance

반응형

개발을 하다보면 직접적으로 library 를 이용하여 개발하거나

직접 특정 기능을 개발 하기도 하지만 Http api 를 이용하여 외부 api 를 이용하는 경우가 있다. 

아마 요즘에는 대부분의 개발에 external service 인 Http API 를 사용할 것이다. 

c# 에서는 이러한 Http API 를 호출하는데 최적화된 class 가 있고 그것중에 하나가 HttpClient 이다. 

 

일반적으로 다음과 같이 사용한다. 

using HttpClient httpClient = new HttpClient(); // 여기 집중!!!
string url = @"https://www.naver.com";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await httpClient.SendAsync(request);

response.EnsureSuccessStatusCode();
var responsString = await response.Content.ReadAsStringAsync();

Console.WriteLine(responsString);

위는 naver.com site 에 접속해서 contents 를 읽어 console 에 뿌리는 간단한 code 이다.

 

위에 code 를 실행하면 정상적으로 동작할 것이다. 

 

뭐 특별한 코드는 아니다. 내용을 봐서는 뭐 문제될것도 없어보이는 아주 간단한 코드이다.

하지만 여기에는 아주 커다란 문제점이 있다. 

HttpClient 는 특별하게 구현된 api 용 library 이다. 

이 class 는 내부적으로 tcp 를 관리하고 같은 dns 에 대해  같은 tcp socket 을 사용하도록 구성한다.

즉 tcp socket 을 재사용하게 된다는 것이다. (내부적으로 pool 을 이용하겠지요)

 

그런데 문제는 위에 using 부분이다. 

만약 위에 코드가 특정한 class 에 function 형태로 되어 있다면 function 을 벗어난 순간

HttlClient 는 dispose 되게 된다. 

"그게 뭐?!! 당연히 안전하게 memory leak 나지 않게 dispose 해야 지.."

라고 할수 있으나 아주 잘못된 생각이다.....

자 위 글 보이는가? 뭔가 feel 이 온다면 당신은 tcp socket 에 대해 잘 알고 있는 사람이다. 

가장 중요한 것은 재사용 부분이다. 

즉 HttpClient 는 특별히 dispose 하지 않는 이상 tcp socket 을 재사용하게 된다. 

그런데 위에서 using 으로 우리는 HttpClient 를 dispose 하였다. 

자 이제 어떤 일이 일어날까? 

tcp socket 이 dispose 와 함께 같이 바로 close 되면 좋겠지만 그렇지 않다.

windows system 상에서도 그렇고 dotnet 자체도 그렇고 바로 close 하지 않는다. 

1~2분 정도의 timewait 을 갖는다. 

 

특정한 api 를 한두번 호출 한다고 했을때 위와 같은 방식을 쓴다고 해서 문제가 일어 나지는 않는다. 

그런데 초당 상당히 많은 호출을 하는 어떤 app 이 있다고 가정했을때 이것은 문제를 일으킬수 있다. 

tcp socket 은 계속적으로 timewait 될 것이고 ..

결국 socket 이 부족하다는 (port 부족) message 를 받게 될것이다. 

 

자 일단 요문제를 재현해보자

(사실 socket 부족은 안나올것이다. parallel 실행해서 해야 할 텐데 쉽지 않을것이다.)

 

위에 코드를 수정해서 HttpClientHelper 라는 것을 만들어보자

public static class HttpClientHelper
{

    public static async Task<HttpResponseMessage> GetAsync(string url)
    {
        using var _client = new HttpClient(new HttpClientTraceHandler(new HttpClientHandler()));
        return await _client.GetAsync(url);
    }
    
    public static async Task<HttpResponseMessage> PostAsync<Tinput>(string url, Tinput input)
    {
        StringContent stringContent = null;
        try
        {
            using var _client = new HttpClient(new HttpClientTraceHandler(new HttpClientHandler()));
            var jsonString = JsonSerializer.Serialize(input);
            stringContent = new StringContent(jsonString, Encoding.UTF8, "application/json");
            return await _client.PostAsync(url, stringContent);
        }
        finally
        {
            stringContent?.Dispose();
        }
    }
}

HttpClientTraceHandler.cs  (Logging 을 위해 사용)

public class HttpClientTraceHandler : DelegatingHandler
{

    public HttpClientTraceHandler(HttpMessageHandler innerHandler)
        : base(innerHandler)
    {
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var body = request.Content == null ? null : await request.Content.ReadAsStringAsync();
        var callStartTime = DateTime.Now;
        var response = await base.SendAsync(request, cancellationToken);
        var responseContent = response.Content == null ? null : await response.Content.ReadAsStringAsync();
        var callEndTime = DateTime.Now;

        var callElapsed = $"Calling host service = {request.RequestUri.AbsoluteUri} , elapsed [{(callEndTime - callStartTime).TotalMilliseconds} /ms ] ,  start [{callStartTime}] , end [{callEndTime}] , ";
        var log = new
        {
            request.Method.Method,
            Url = request.RequestUri.AbsoluteUri,
            //RequestHeaders = JsonConvert.SerializeObject(request.Headers.ToDictionary(h => h.Key, h => h.Value)),
            RequestHeaders = request.Headers?.ToString(),
            Body = body,
            response.StatusCode,
            response.ReasonPhrase,
            responseContent.Length,
            Response = responseContent,
            ResponseHeaders = response.Headers?.ToString(),
            RequestDate = DateTime.UtcNow
        };

        string writeLog = @$"Method : {log.Method}" + Environment.NewLine +
                          @$"Url : {log.Url}" + Environment.NewLine +
                          @$"RequestHeaders : {log.RequestHeaders}" + Environment.NewLine +
                          @$"Body : {log.Body}" + Environment.NewLine +
                          @$"StatusCode : {log.StatusCode}" + Environment.NewLine +
                          @$"ReasonPhrase : {log.ReasonPhrase}" + Environment.NewLine +
                          @$"Length : {log.Length}" + Environment.NewLine +
                          @$"Response : {log.Response}" + Environment.NewLine +
                          @$"ResponseHeaders : {log.ResponseHeaders}" + Environment.NewLine +
                          @$"RequestDate : {log.RequestDate}" + Environment.NewLine;

        _ = Task.Run(() =>
        {
            try
            {
                // Log request and response here i.e Send message to queue, or log to a remote server
                //_logger.LogInformation($"{log.SerializeToJson()}");
                Console.WriteLine($"{writeLog}");
                Console.WriteLine(callElapsed);
            }
            catch (Exception ex)
            {
                // Do nothing or log why the request log had an exception
                Console.WriteLine(ex.ToString() + "\nHttpClient Logging error");
            }
        });

        return response;
    }
}

 

자 이제 이것을 이용해서  api 를 여러번 호출해 보자. 

www.naver.com  을 호출해도 되지만 그렇게 되면 netstat 시 TIME_WAIT 을 제대로 확인하기 어려우니

자신이 만들어 놓은 간단한 api server 를 이용해보자

우리는 아래 링크를 기반으로 api server 를 구성했었고 그것을 사용할 것이다. 

https://yogingang.tistory.com/404

 

Rest API Template 만들기 - EP 01 (Create , Swagger, Logging)

Create visual studio 2022 를 실행하고 Create a new project 를 선택한다. asp.net core web api 선택 Project name 정하기 Additional Information 설정 Create 하면 Project 가 생성된다. F5 를 누르면 실행 된다. 위와 같이 swagger

yogingang.tistory.com

string baseUrl = "http://localhost:5006";

string id = "test";
string path = $"/User/{id}";
string url = baseUrl + path;

for (int i = 0; i < 100; i++)
{
    using var responseMessage = await HttpClientHelper.GetAsync(url);
    var responsString = await responseMessage.Content.ReadAsStringAsync();
    Console.WriteLine(responsString);
}

자 위에 코드를 실행하고 cmd 창에서 아래 명령을 실행해보자

netstat |findstr /s ":5006"

# 아래와 같이 TIME_WAIT 이 보인다.
# 이것은 1~2 분 정도 유지되고 저기서 점유한 [::1]:50641 <--- 이 port 들은
# TIME_WAIT 이 사라지기전에 다시 사용할 수 없다.
  TCP    [::1]:50641            yogingang:5006         TIME_WAIT
  TCP    [::1]:50642            yogingang:5006         TIME_WAIT
  TCP    [::1]:50643            yogingang:5006         TIME_WAIT
  TCP    [::1]:50644            yogingang:5006         TIME_WAIT
  TCP    [::1]:50645            yogingang:5006         TIME_WAIT
  TCP    [::1]:50646            yogingang:5006         TIME_WAIT
  TCP    [::1]:50647            yogingang:5006         TIME_WAIT
  TCP    [::1]:50648            yogingang:5006         TIME_WAIT
  TCP    [::1]:50649            yogingang:5006         TIME_WAIT
  TCP    [::1]:50650            yogingang:5006         TIME_WAIT
  TCP    [::1]:50651            yogingang:5006         TIME_WAIT
  TCP    [::1]:50652            yogingang:5006         TIME_WAIT
  TCP    [::1]:50653            yogingang:5006         TIME_WAIT
  TCP    [::1]:50654            yogingang:5006         TIME_WAIT
  TCP    [::1]:50655            yogingang:5006         TIME_WAIT
  TCP    [::1]:50656            yogingang:5006         TIME_WAIT
  TCP    [::1]:50657            yogingang:5006         TIME_WAIT
  TCP    [::1]:50658            yogingang:5006         TIME_WAIT
  TCP    [::1]:50659            yogingang:5006         TIME_WAIT
  TCP    [::1]:50660            yogingang:5006         TIME_WAIT
  TCP    [::1]:50661            yogingang:5006         TIME_WAIT
  TCP    [::1]:50662            yogingang:5006         TIME_WAIT
  TCP    [::1]:50663            yogingang:5006         TIME_WAIT
  TCP    [::1]:50664            yogingang:5006         TIME_WAIT
  TCP    [::1]:50665            yogingang:5006         TIME_WAIT
  TCP    [::1]:50666            yogingang:5006         TIME_WAIT
  TCP    [::1]:50667            yogingang:5006         TIME_WAIT
  TCP    [::1]:50668            yogingang:5006         TIME_WAIT
  TCP    [::1]:50669            yogingang:5006         TIME_WAIT
  TCP    [::1]:50670            yogingang:5006         TIME_WAIT
  TCP    [::1]:50671            yogingang:5006         TIME_WAIT
  TCP    [::1]:50672            yogingang:5006         TIME_WAIT
  TCP    [::1]:50673            yogingang:5006         TIME_WAIT
  TCP    [::1]:50674            yogingang:5006         TIME_WAIT
  TCP    [::1]:50675            yogingang:5006         TIME_WAIT
  TCP    [::1]:50676            yogingang:5006         TIME_WAIT

그리고 일정 시간후에 다시 명령을 해보면 이제 TIME_WAIT 이 없는 것을 알수 있다. 

자 이런 문제가 있는 것이다. 

 

즉 HttpClient 는 api 를 호출 하고 dispose 를 바로 하는 것이 아니고

app 의 전체 lifecycle 동안 유지 해야 하는 것이다. 

다시 HttpClient 를 new 로 생성해야 하는 상황에서만 이전 HttpClient 를 dispose 하는 것이다. 

(이해가 되셨습니까?)

 

자 그럼 이제 어떻게 해야 할까?

문제를 해결해 보자..

우리는 HttpClient 를 생성해서 dispose 할게 아니고

HttpClient 를 static 으로 또는 singleton 으로 생성하고 app 에서 계속 재사용 할 것이다. 

주의 할 점은 DefaultRequestHeader 같이 HttpClient 에 연결된 전역적인 값들은  clear 해줘야 한다.

(그러니 저런것들은 사용하지 말자)

public static class HttpClientHelper
{
    // static 으로 선언하고 재사용하자
    private static readonly HttpClient _client = new HttpClient(new HttpClientTraceHandler(new HttpClientHandler()));

    public static async Task<HttpResponseMessage> GetAsync(string url)
    {	   
        //using var _client = new HttpClient(new HttpClientTraceHandler(new HttpClientHandler()));
        return await _client.GetAsync(url);
    }
    public static async Task<HttpResponseMessage> PostAsync<Tinput>(string url, Tinput input)
    {
        StringContent stringContent = null;
        try
        {
            //using var _client = new HttpClient(new HttpClientTraceHandler(new HttpClientHandler()));
            var jsonString = JsonSerializer.Serialize(input);
            stringContent = new StringContent(jsonString, Encoding.UTF8, "application/json");

            return await _client.PostAsync(url, stringContent);
        }
        finally
        {
            stringContent?.Dispose();
        }

    }
}

자 위와 같이 하게 되면 이제 socket 은 열렸다가 삭제되는 과정을 거치지 않고 하나의 HttpClient 만 사용하기 때문에 app 이 종료되면 함께 dispose 된다. 즉 memory leak 은 생각하지 않아도 된다는 이야기다.

그런데 한가지 문제가 있다. 위에 HttpClient 는 DNS 에 변경사항이 발생할 경우 에러가 발생하고 다시 생성되기 전까지 계속적으로 같은 (이전 DNS 정보) 에러를 일으킬 것이다. 

** DNS 변경사항이란.. DNS 에 특정 IP 가 할당되어 있는데 이 IP 에 변경을 의미한다. 

일반적인 경우에는 그럴일이 거의 일어나지 않겠지만 빈번히 일어나는 경우도 있다. 

어떤 경우냐면 azure 또는 aws 같은 cloud 서비스에서  배포 같은 경우에 그렇다. 

 product 에 바로 배포하는 것이 아니고 dev 와 product 를 일정비율로 배포하고

dev 에서 점점 product 으로 이동하는 방식이다. 

이 방식은 dns 의 변경이 일어난다. dev 에 IP 에서 product 의 ip 로 같은 DNS 의 정보를 변경한다는 의미이다. 

**

그래서 이제 이 문제를 해결하려면 일정 시간이 지났을때 요 dns 정보를 update 하게 해야 한다. 

dns 정보의 변화를 알수 있는방법은 사실 없으니 (이전에 무엇이었는지 ...)

connection 을 끊어서 이러한 문제를 해결한다. 

 

자 이제 아래 코드를 보자

...
private static readonly HttpClient _client = new HttpClient(new HttpClientTraceHandler(new SocketsHttpHandler()
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(1),
    //MaxConnectionsPerServer = 10 
    //MaxConnectionsPerServer의 기본값은 int.MaxValue이므로 연결은 본질적으로 제한되지 않는다. 
    // 제한하고 싶으면 이 수를 조절하자
}));
...

자 이제 1분마다 새로운 request 를 httpclient 를 이용하여 요청하면 connection 을 다시 맺게 된다. 

그래서 DNS 변경을 감지하게 되고 올바른 IP 로 접근하게 된다. 

 

자 문제가 해결되었다. 

 

요약 :

HttpClient 는 request 마다 생성하고 바로 dispose 하는 것이 아니고 전역적으로 생성하거나 Pool 을 이용하여 생성하고 그것을 공유하여 사용하여야 한다. 그리고 그 과정에서 생기는 문제들인 DNS 변경 문제는 SocketsHttpHandler 의 property 를 이용하여 해결하자.

 

 

 

관련동영상

https://youtu.be/uESROeNAbl8

반응형

'CSharp > Advance' 카테고리의 다른 글

알수 없는 문제를 해결하는 방법 - vite dev server 의 404 오류 해결  (0) 2023.11.06
Refactroing - HttpClient(EP02)  (1) 2023.10.30
Refactoring - Encryption Hash Helper  (0) 2023.10.16
C# deadlock prevent  (0) 2023.06.23
Singleton  (0) 2022.02.25