핵심 정리
// 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);
}
}기본 흐름
어떤 HttpClient 사용 형태가 있나
실전에서는 아래 세 가지를 먼저 구분하면 됩니다.
builder.Services.AddHttpClient("weather", client => { ... }); // 기명 클라이언트
builder.Services.AddHttpClient<GitHubService>(client => { ... }); // 타입 지정 클라이언트
var response = await client.GetAsync("users/1", ct); // 직접 응답 처리- 기명 클라이언트: 설정을 이름으로 구분
- 타입 지정 클라이언트: 서비스 타입에 HttpClient를 묶음
- 직접
GetAsync: 상태 코드와 헤더를 세밀하게 봐야 할 때
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);// ❌ multipart 경계를 HttpClient가 직접 만들도록 두지 않으면 업로드가 깨질 수 있다
using var request = new HttpRequestMessage(HttpMethod.Post, "upload");
request.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data");
// ✅ MultipartFormDataContent가 boundary를 만들게 둔다
using var form = new MultipartFormDataContent();
form.Add(new StringContent("Mina"), "name");
form.Add(new StreamContent(fileStream), "file", "avatar.png");
await client.PostAsync("upload", form, 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 |
| 운영 환경에서 권장되는 기본 선택 | IHttpClientFactory 또는 장수명 HttpClient 전략 검토 |
주의할 점
HttpClient를 using으로 매번 new하는 방식은 운영 환경에서 문제가 됩니다. IHttpClientFactory가 현대 .NET 애플리케이션의 대표적인 해결책이지만, Microsoft 문서 기준으로는 SocketsHttpHandler.PooledConnectionLifetime를 설정한 장수명 HttpClient도 대안이 될 수 있습니다.
// ❌ using으로 매번 생성 — socket exhaustion
using var client = new HttpClient();
var data = await client.GetStringAsync(url);
// ⚠ 단순 static 재사용 — 연결 수명 전략이 없으면 DNS 갱신 문제 가능
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);
}
}장수명 클라이언트를 직접 관리한다면 SocketsHttpHandler.PooledConnectionLifetime 같은 연결 수명 전략을 같이 설정해야 합니다. EnsureSuccessStatusCode()는 4xx/5xx 응답 시 HttpRequestException을 던집니다. 404를 예외 없이 처리해야 한다면 response.IsSuccessStatusCode로 직접 확인하세요.