2023. 10. 23. 00:00ㆍCSharp/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 를 이용하여 해결하자.
관련동영상
'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 |