다수의 HTTP API를 호출할 때는 비동기 병렬 처리를 통해 총 소요 시간을 크게 줄일 수 있습니다. 본 글에서는 HttpClient로 안전하고 효율적인 병렬 요청을 구현하는 실전 패턴을 정리합니다.
1. 핵심 요약
HttpClient는 재사용합니다. Task.WhenAll로 병렬 처리하고, 필요한 경우 SemaphoreSlim으로 동시성을 제한합니다. ASP.NET Core에서는 IHttpClientFactory를 활용하여 연결/수명/재시도 정책을 관리합니다.
2. 올바른 HttpClient 재사용과 핸들러 설정
요청마다 HttpClient를 새로 만들면 소켓 고갈과 성능 저하가 발생합니다. SocketsHttpHandler와 함께 HttpClient를 재사용하고, 서버당 연결 수와 자동 압축을 설정합니다.
using System;using System.Collections.Generic;using System.Linq;using System.Net;using System.Net.Http;using System.Text.Json;using System.Threading;using System.Threading.Tasks;public static class ParallelFetcher{ private static readonly HttpClient Http = new HttpClient(new SocketsHttpHandler { MaxConnectionsPerServer = 100, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }) { Timeout = TimeSpan.FromSeconds(30), DefaultRequestVersion = System.Net.HttpVersion.Version20 }; public static async Task<List<(string Url, JsonElement Data)>> FetchAllAsync(IEnumerable<string> urls, CancellationToken ct) { var tasks = urls.Select(async url => { using var req = new HttpRequestMessage(HttpMethod.Get, url); using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); res.EnsureSuccessStatusCode(); await using var stream = await res.Content.ReadAsStreamAsync(ct); var data = await JsonSerializer.DeserializeAsync<JsonElement>(stream, cancellationToken: ct); return (Url: url, Data: data); }); return (await Task.WhenAll(tasks)).ToList(); }}3. Task.WhenAll로 간단한 병렬 처리
여러 URL을 동시에 호출하고 모두 완료될 때까지 대기합니다. 예외는 WhenAll 시점에 집계되므로 try/catch로 감싸 처리합니다.
public static async Task UseWhenAllAsync(CancellationToken ct){ var urls = new[] { "https://api.example.com/a", "https://api.example.com/b", "https://api.example.com/c" }; try { var results = await ParallelFetcher.FetchAllAsync(urls, ct); foreach (var (url, json) in results) { Console.WriteLine($"{url} - ok"); } } catch (Exception ex) { Console.Error.WriteLine($"병렬 요청 실패: {ex.Message}"); }}4. 동시성 제한: 과부하 방지
대상 서버 또는 네트워크에 부담을 줄 수 있으므로 동시 요청 수를 제한하는 것이 안전합니다. SemaphoreSlim으로 간단히 구현할 수 있습니다.
using System.Collections.Generic;using System.Linq;using System.Threading;using System.Threading.Tasks;public static class LimitedFetcher{ private static readonly HttpClient Http = new HttpClient(); public static async Task<List<string>> FetchWithLimitAsync(IEnumerable<string> urls, int maxDegree, CancellationToken ct) { var sem = new SemaphoreSlim(maxDegree); var tasks = urls.Select(async url => { await sem.WaitAsync(ct); try { return await Http.GetStringAsync(url, ct); } finally { sem.Release(); } }); return (await Task.WhenAll(tasks)).ToList(); }}5. ASP.NET Core에서 IHttpClientFactory + Typed Client
IHttpClientFactory는 핸들러 수명, 연결 풀, 재시도 정책을 중앙에서 관리합니다. Typed Client로 병렬 호출을 캡슐화합니다.
using System;using System.Collections.Generic;using System.Linq;using System.Net;using System.Net.Http;using System.Threading;using System.Threading.Tasks;using Microsoft.Extensions.DependencyInjection;using Polly;using Polly.Extensions.Http;var services = new ServiceCollection();services.AddHttpClient<DataClient>(client =>{ client.Timeout = TimeSpan.FromSeconds(20); client.DefaultRequestVersion = System.Net.HttpVersion.Version20;}) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { MaxConnectionsPerServer = 50, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }) .AddPolicyHandler(GetRetryPolicy());services.AddTransient<MyService>();var provider = services.BuildServiceProvider();var svc = provider.GetRequiredService<MyService>();await svc.RunAsync(CancellationToken.None);static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() => HttpPolicyExtensions .HandleTransientHttpError() .WaitAndRetryAsync(3, i => TimeSpan.FromMilliseconds(200 * Math.Pow(2, i)));public class DataClient{ private readonly HttpClient _http; public DataClient(HttpClient http) => _http = http; public async Task<List<HttpResponseMessage>> FetchAllAsync(IEnumerable<string> urls, CancellationToken ct) { var tasks = urls.Select(url => _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)); return (await Task.WhenAll(tasks)).ToList(); }}public class MyService{ private readonly DataClient _client; public MyService(DataClient client) => _client = client; public async Task RunAsync(CancellationToken ct) { var urls = new[] { "https://api.example.com/a", "https://api.example.com/b" }; var responses = await _client.FetchAllAsync(urls, ct); foreach (var res in responses) { res.EnsureSuccessStatusCode(); var content = await res.Content.ReadAsStringAsync(ct); Console.WriteLine($"{res.RequestMessage!.RequestUri} - {content.Length} bytes"); res.Dispose(); } }}6. 취소, 타임아웃, 스트리밍 옵션
긴 요청을 빠르게 중단하려면 취소 토큰과 타임아웃을 함께 사용합니다. ResponseHeadersRead로 헤더 수신 즉시 스트리밍을 시작합니다.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));var urls = new[] { "https://api.example.com/a", "https://api.example.com/b" };var results = await ParallelFetcher.FetchAllAsync(urls, cts.Token);7. 재시도/백오프: Polly 통합
일시적 오류(5xx, 타임아웃)에는 지수 백오프 재시도가 효과적입니다. IHttpClientFactory에 Polly 정책을 추가해 표준화합니다.
using Polly;using Polly.Extensions.Http;static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy(){ return HttpPolicyExtensions .HandleTransientHttpError() .WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt)));}8. 성능 팁과 체크리스트
HTTP/2를 사용해 연결을 효율화합니다. 서버당 연결(MaxConnectionsPerServer)을 적절히 조정합니다. 자동 압축을 활성화하고, 큰 응답은 스트리밍으로 처리합니다. JSON은 System.Text.Json으로 비동기 역직렬화합니다. 작업은 async/await로 끝까지 비동기로 유지합니다.
9. 마무리
다중 요청 병렬 처리는 Task.WhenAll, 적절한 동시성 제한, 올바른 HttpClient 재사용, 그리고 IHttpClientFactory+Polly 조합이 핵심입니다. 위 패턴을 적용하면 안정성과 처리량이 함께 향상됩니다.
'C#' 카테고리의 다른 글
| C# IDisposable 구현 시 Finalizer와의 상호작용 (0) | 2026.07.07 |
|---|---|
| C# Expression Tree로 동적 LINQ 쿼리 빌드하기 (1) | 2026.07.07 |
| C# Stopwatch를 활용한 코드 블록별 성능 분석 (0) | 2026.07.03 |
| C# StreamReader와 StreamWriter의 고급 활용 (0) | 2026.07.03 |
| C# 메서드 Attribute 기반 캐싱 구현 (1) | 2026.07.02 |