기본 패턴
// Program.cs — IHttpClientFactory 등록
builder.Services.AddHttpClient("weather", client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(10);
});
// 서비스에서 사용
public class WeatherService(IHttpClientFactory httpFactory)
{
public async Task<WeatherData?> GetAsync(string city, CancellationToken ct)
{
var client = httpFactory.CreateClient("weather");
return await client.GetFromJsonAsync<WeatherData>($"current/{city}", ct);
}
}설명
new HttpClient() 직접 생성이 위험한 이유 — socket exhaustion
HttpClient를 using으로 매번 생성하고 폐기하면 내부 HttpMessageHandler(TCP 연결을 관리하는 소켓 핸들러)도 함께 닫힙니다. 그런데 소켓은 닫히더라도 OS 수준에서 TIME_WAIT 상태로 일정 시간 머물기 때문에, 짧은 시간에 많은 요청을 처리하면 사용 가능한 소켓이 고갈(exhaustion)됩니다. 이 문제는 로컬에서는 잘 나타나지 않다가 운영 환경 부하 테스트나 트래픽 급증 시에 SocketException으로 나타납니다.
// ❌ 매 요청마다 new + Dispose — socket exhaustion 유발
public async Task<string> GetDataAsync(string url)
{
using var client = new HttpClient(); // 소켓 낭비
return await client.GetStringAsync(url);
}
// ❌ static 재사용으로 socket exhaustion은 해결했지만 DNS 갱신 문제 발생
private static readonly HttpClient _client = new();
public async Task<string> GetDataAsync(string url)
{
// DNS가 바뀌어도 _client는 이전 연결을 계속 사용
return await _client.GetStringAsync(url);
}IHttpClientFactory — 핸들러 풀링으로 두 문제를 동시에 해결
IHttpClientFactory는 HttpMessageHandler를 풀링합니다. 매번 새 핸들러를 만들지 않고, 주기적으로(기본 2분) 핸들러를 재생성하여 DNS 갱신도 반영합니다. CreateClient()는 새 HttpClient 인스턴스를 반환하지만, 내부 핸들러는 풀에서 재사용합니다. 따라서 HttpClient 인스턴스 자체는 필요에 따라 생성하고 Dispose해도 안전합니다.
// Program.cs / DI 등록
// 1. 기명 클라이언트 — 이름으로 구분하여 설정
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0");
});
// 2. 타입 지정 클라이언트 — 서비스 타입으로 바인딩 (권장)
builder.Services.AddHttpClient<GitHubService>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0");
});
// 타입 지정 클라이언트 구현
public class GitHubService(HttpClient client)
{
public async Task<Repo[]?> GetReposAsync(string user, CancellationToken ct)
=> await client.GetFromJsonAsync<Repo[]>($"users/{user}/repos", ct);
}GetFromJsonAsync / PostAsJsonAsync — JSON 통신 실용 패턴
System.Net.Http.Json 패키지(또는 .NET 5+)의 확장 메서드를 사용하면 JSON 직렬화/역직렬화를 수동으로 처리하지 않아도 됩니다. JsonSerializer를 직접 쓰거나 StreamReader로 응답을 읽을 필요가 없습니다.
// GET — JSON 응답을 직접 역직렬화
var user = await client.GetFromJsonAsync<UserDto>($"users/{id}", ct);
// POST — 객체를 JSON으로 직렬화하여 전송
var newUser = new CreateUserRequest { Name = "Mina", Email = "mina@example.com" };
var response = await client.PostAsJsonAsync("users", newUser, ct);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<UserDto>(ct);
// PUT
await client.PutAsJsonAsync($"users/{id}", updateRequest, ct);
// DELETE
var delResponse = await client.DeleteAsync($"users/{id}", ct);
delResponse.EnsureSuccessStatusCode();
// 응답 상태 코드 확인이 필요할 때 — GetAsync 사용
using var res = await client.GetAsync($"users/{id}", ct);
if (res.StatusCode == HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
var dto = await res.Content.ReadFromJsonAsync<UserDto>(ct);Timeout 설정과 CancellationToken 결합
HttpClient.Timeout은 HttpClient 인스턴스 전체에 적용되는 기본 timeout입니다. 요청별로 다른 timeout이 필요하거나, 외부에서 온 취소 신호와 결합해야 할 때는 CancellationTokenSource를 함께 사용합니다. timeout으로 인한 취소는 TaskCanceledException(내부적으로 OperationCanceledException)으로 전달됩니다.
public async Task<WeatherData?> GetWeatherAsync(string city, CancellationToken externalCt)
{
// 요청별 timeout: 5초
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// 외부 취소 신호(예: ASP.NET Core 요청 취소) + 요청 timeout 결합
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
externalCt, timeoutCts.Token);
try
{
return await _client.GetFromJsonAsync<WeatherData>(
$"current/{city}", linkedCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
_logger.LogWarning("날씨 API 요청 시간 초과: {City}", city);
return null;
}
}빠른 정리
| 상황 | 적합한 선택 |
|---|---|
| 간단한 GET + JSON 파싱 | GetFromJsonAsync<T> |
| POST + JSON 본문 | PostAsJsonAsync |
| 응답 상태 코드 확인 필요 | GetAsync + EnsureSuccessStatusCode() |
| 전역 BaseAddress / 헤더 설정 | AddHttpClient<T> 타입 지정 클라이언트 |
| 요청별 다른 timeout | CancellationTokenSource + CreateLinkedTokenSource |
| 운영 환경 안전한 HttpClient | IHttpClientFactory (절대 new HttpClient() 직접 생성 금지) |
주의할 점
HttpClient를 using으로 매번 new하거나, static 필드에 단 하나의 인스턴스를 재사용하는 방법 모두 운영 환경에서 문제가 됩니다. IHttpClientFactory가 유일한 올바른 선택입니다.
// ❌ using으로 매번 생성 — socket exhaustion
using var client = new HttpClient();
var data = await client.GetStringAsync(url);
// ❌ static 재사용 — DNS 갱신 불가 (서비스 이전/스케일 아웃 시 오래된 IP 고착)
private static readonly HttpClient _shared = new();
// ✅ IHttpClientFactory — 핸들러 풀링 + 주기적 DNS 갱신
public class MyService(IHttpClientFactory factory)
{
public async Task<string> GetAsync(string url, CancellationToken ct)
{
var client = factory.CreateClient();
return await client.GetStringAsync(url, ct);
}
}EnsureSuccessStatusCode()는 4xx/5xx 응답 시 HttpRequestException을 던집니다. 404를 예외 없이 처리해야 한다면 response.IsSuccessStatusCode로 직접 확인하세요.