.NET은 백엔드 데이터 수집에 있어 믿음직한 도구다. 빠른 비동기 I/O, 강력한 타입 시스템, 그리고 높은 동시성을 무리없이 처리하는 런타임을 갖추고 있다. 레지덴셜 프록시를 HttpClient에 연결하는 것은 몇 줄이면 되지만, C#에는 프록시와는 무관하고 오히려 HttpClient를 어떻게 사용해야 하는지에 관한 자체적인 함정이 있다. 클라이언트의 수명 주기만 제대로 관리하면 프록시 부분은 쉽다.
이 글은 Python에서 레지덴셜 프록시 사용하기, Go에서, Node.js에서와 같은 시리즈의 C# 편이다: 실제로 작동하는 코드와 .NET 특유의 함정들을 다룬다.
아래 내용은 모두 Shifter의 레지덴셜 게이트웨이를 사용한다: 엔드포인트는 p.shifter.io:443 하나이며, 모든 타겟팅 정보는 사용자 이름에 인코딩되어 있다. 다른 제공업체를 사용하려면 호스트와 인증 정보만 바꾸면 되며, 구조는 동일하다.
게이트웨이 모델을 한 문단으로 정리하면
프록시 사용자 이름에는 인증 정보와 타겟팅 정보가 함께 담겨 있다. 국가나 세션을 바꾸기 위해 엔드포인트를 바꾸는 것이 아니라 사용자 이름 문자열을 바꾼다:
customer-USERNAME-country-us-sid-abc123-ttl-600country-us는 미국을 타겟팅하고, sid는 고정 세션을 지정하며, ttl은 해당 IP를 N초 동안 유지한다. sid/ttl을 생략하면 모든 새 연결마다 로테이션된다. 비밀번호는 그대로 유지된다. .NET에서는 이 사용자 이름을 프록시 URL이 아니라 NetworkCredential에 넣는다.
기본 설정: WebProxy와 핸들러
프록시는 메시지 핸들러에 설정하고 이를 HttpClient에 전달한다. 인증 정보는 URI에 인라인으로 넣는 것이 아니라 NetworkCredential을 통해 WebProxy에 설정한다.
using System.Net;
var user = Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-us";var pass = Environment.GetEnvironmentVariable("SHIFTER_PASS");
var proxy = new WebProxy("http://p.shifter.io:443"){ Credentials = new NetworkCredential(user, pass) // not user:pass@ in the URL};
var handler = new SocketsHttpHandler{ Proxy = proxy, UseProxy = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) // see gotcha below};
var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var ip = await client.GetStringAsync("https://api.ipify.org");Console.WriteLine(ip); // a US residential IP여기서 두 가지를 명심해야 한다. user 문자열에는 타겟팅 플래그(-country-us)가 포함되는데, 지역 정보가 그 안에 담기기 때문이다. 그리고 인증 정보는 NetworkCredential로 WebProxy에 설정해야 한다. HttpClient는 브라우저와 달리 프록시 URI에서 user:pass@host 형태를 읽어들이지 않는다.
오래된 HttpClientHandler보다 SocketsHttpHandler(최신 .NET의 기본 핸들러)를 사용하는 것이 좋다. 이는 최신의 크로스 플랫폼 구현체이며 PooledConnectionLifetime을 제공하는데, 아래에서 설명할 이유로 이것이 필요하다.
함정 1: HttpClient 수명 주기 문제
이것이 .NET 특유의 함정이며, 정반대의 두 방향에서 문제를 일으킨다.
요청마다 new HttpClient()를 새로 생성하면 소켓이 누수된다. 각 클라이언트는 자체 연결 풀을 가지며, dispose된 클라이언트는 소켓을 TIME_WAIT 상태로 남긴다. 부하가 걸리면 사용 가능한 포트가 고갈되어 앱에서 SocketException이 발생하기 시작한다. 그래서 잘 알려진 조언은 단일 HttpClient를 재사용하라는 것이다.
하지만 하나의 클라이언트를 영구히 유지하면 반대의 문제가 생긴다. 연결 풀이 살아있는 동안 DNS 조회 결과를 캐시하고, 엔드포인트의 IP가 바뀌어도 이를 알아채지 못한다. 이에 대한 해결책은 둘 중 하나를 선택하는 것이 아니라, SocketsHttpHandler의 PooledConnectionLifetime을 사용하는 것이다. 이는 일정 주기로 풀링된 연결을 재활용해 오래된 DNS에 고정되지 않으면서도 연결 재사용의 이점을 유지한다. 이를 몇 분 정도로 설정하고 클라이언트를 재사용하라.
클라이언트를 재사용하면 프록시를 통한 연결도 계속 웜 상태로 유지되는데, 이는 지연 시간 가이드가 없애고자 하는 바로 그 오버헤드다.
ASP.NET Core 방식: IHttpClientFactory
DI 기반 앱이라면 HttpClient를 직접 관리하지 않는 것이 좋다. IHttpClientFactory가 핸들러 수명 주기와 연결 재활용을 대신 처리해주며, 이름이 지정된 클라이언트에 프록시를 한 번만 설정하면 된다.
services.AddHttpClient("shifter") .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { Proxy = new WebProxy("http://p.shifter.io:443") { Credentials = new NetworkCredential( Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-us", Environment.GetEnvironmentVariable("SHIFTER_PASS")) }, UseProxy = true });
// then inject IHttpClientFactory and call factory.CreateClient("shifter")팩토리는 자체 일정에 따라 내부 핸들러를 회전시켜주므로, PooledConnectionLifetime을 직접 건드리지 않아도 풀링과 최신 DNS를 모두 얻을 수 있다. 장기 실행 서비스라면 이 방식을 권장한다.
함정 2: 프록시는 핸들러에 있으므로, 아이덴티티별로 클라이언트를 두어 로테이션하라
프록시와 그 인증 정보가 핸들러에 고정되어 있기 때문에, 하나의 HttpClient에서 요청마다 프록시 사용자 이름을 바꿀 수 없다. 다른 아이덴티티는 다른 핸들러를 의미하고, 따라서 다른 클라이언트를 의미한다. 효율적인 패턴은 아이덴티티별로 클라이언트 하나씩을 캐시해 각 세션이 자체 연결 풀을 유지하도록 하는 것으로, 다른 스택에서의 아이덴티티별 에이전트 맵과 같은 개념이다.
using System.Collections.Concurrent;
static readonly ConcurrentDictionary<string, HttpClient> Clients = new();
static HttpClient ClientFor(string country, string sid){ var key = $"{country}:{sid ?? "rotate"}"; return Clients.GetOrAdd(key, _ => { var u = Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-" + country + (sid != null ? $"-sid-{sid}-ttl-600" : ""); var handler = new SocketsHttpHandler { Proxy = new WebProxy("http://p.shifter.io:443") { Credentials = new NetworkCredential(u, Environment.GetEnvironmentVariable("SHIFTER_PASS")) }, UseProxy = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }; return new HttpClient(handler); });}논리적인 작업 단위마다 자체 sid를 부여하고 스트림 도중이 아니라 작업 단위 사이에서 로테이션하며(고정 프록시 대 로테이팅 프록시가 이 구분을 다룬다), 로드 밸런싱 게시글에서 설명하는 방식대로 작업을 아이덴티티에 매핑하라. 이 클라이언트들은 캐시해 재사용해야 하며, 요청마다 새로 만들면 안 된다.
함정 3: 동시성에 한계를 두어라
.NET에서는 Task.WhenAll로 수천 개의 요청을 한꺼번에 발사하는 것이 매우 쉬우며, 모든 연결을 동시에 여는 것을 막을 장치가 없다. 그렇게 하면 자기 쪽 소켓이 고갈되고 대상 입장에서는 공격처럼 보인다. SemaphoreSlim으로 진행 중인 요청 수를 제한하라:
var gate = new SemaphoreSlim(8); // at most 8 concurrent requests
async Task<string> Fetch(HttpClient client, string url){ await gate.WaitAsync(); try { return await client.GetStringAsync(url); } finally { gate.Release(); }}전역적으로만이 아니라 타겟 호스트별로 동시성을 제한해, 취약한 사이트 하나가 두들겨 맞는 동안 관대한 사이트가 굶주리는 일이 없도록 하라. 대상이 감당할 수 있는 수준을 넘어선 병렬 처리는 처리량이 아니라 차단을 불러온다(차단을 피하는 방법).
실제로 프록시를 타고 있는지 확인하라
벤치마크나 다른 디버깅을 하기 전에, 우선 종료 IP를 확인하라:
var json = await client.GetStringAsync("http://ip-api.com/json");Console.WriteLine(json); // expect a residential IP in the targeted country자신의 IP가 나온다면 핸들러가 적용되지 않고 있다는 뜻이다(보통 UseProxy가 false로 남아 있거나, 인증 정보가 잘못된 객체에 설정된 경우다). 응답이 멈추면 로컬 아웃바운드가 차단된 것이다. 둘 다 타임아웃 진단 가이드에서 다룬다.
FAQ
프록시 인증 정보를 URL에 넣으면 왜 작동하지 않는가?
HttpClient는 프록시 URI에서 user:pass@host 형식을 읽지 않는다. 인증 정보는 NetworkCredential로 WebProxy 객체에 설정해야 한다. 게이트웨이가 사용자 이름에 타겟팅 정보를 인코딩하므로, -country-... 플래그가 포함된 그 전체 사용자 이름이 NetworkCredential의 사용자 이름이 되고, 비밀번호는 고정된다.
HttpClientHandler와 SocketsHttpHandler 중 무엇을 써야 하는가?
최신 .NET에서는 SocketsHttpHandler를 권장한다. 이는 기본 관리형 핸들러이고, 크로스 플랫폼이며, 장기간 유지되는 클라이언트에서 오래된 DNS를 피하는 데 필요한 PooledConnectionLifetime을 제공한다. HttpClientHandler도 여전히 작동하며 내부적으로 이를 위임한다.
부하가 걸리면 .NET 스크레이퍼에서 SocketException이 발생하는데 왜 그런가?
요청마다 new HttpClient()를 생성해 소켓이 고갈되고 있을 가능성이 매우 높다. 단일 클라이언트를 재사용하거나(또는 IHttpClientFactory를 사용하거나), PooledConnectionLifetime을 설정해 재사용 시 오래된 DNS에 고정되지 않도록 하라.
C#에서 요청마다 IP를 로테이션하려면 어떻게 해야 하는가?
프록시 사용자 이름을 바꾸면 되는데, 이는 다른 핸들러, 따라서 다른 클라이언트를 의미한다. 아이덴티티별로 HttpClient 하나씩을 ConcurrentDictionary에 캐시해 각 세션이 자체 풀을 유지하도록 하고, 작업 단위별로 클라이언트를 선택하라. 새 연결마다 로테이션하려면 사용자 이름에서 sid를 생략하라.
IHttpClientFactory는 프록시와 함께 작동하는가?
그렇다. ConfigurePrimaryHttpMessageHandler를 통해 기본 핸들러에 프록시를 설정하면 된다. 팩토리가 핸들러 수명 주기와 DNS 신선도를 대신 관리해주므로, DI 기반 서비스에서는 가장 깔끔한 선택이다.
결론
C#과 레지덴셜 프록시의 조합은 두 가지만 지키면 빠르게 구현할 수 있다. 인증 정보는 URL이 아니라 NetworkCredential로 WebProxy에 설정할 것, 그리고 HttpClient의 수명 주기를 제대로 관리할 것이다. PooledConnectionLifetime을 설정한 SocketsHttpHandler를 쓰거나, DI 앱이라면 IHttpClientFactory를 사용해 오래된 DNS에 고정되지도, 소켓이 누수되지도 않으면서 연결을 재사용하라. 지역과 세션은 아이덴티티별로 클라이언트를 캐시해 로테이션하고, SemaphoreSlim으로 동시성을 제한하라.
이것만 제대로 하면 .NET은 동시 수집을 그 무엇 못지않게 잘 처리한다. 레지덴셜 게이트웨이를 활용하고, 풀 품질이 재시도 빈도 자체를 좌우한다는 점을 기억하라(IP 평판). 가격 페이지에서 GB당 요금제를 확인하고 직접 타겟에 맞춰 테스트해보라.