← 블로그로 돌아가기

.NET HttpClient의 프록시: SocketsHttpHandler, SOCKS5 및 소켓 누수 없는 회전

.NET HttpClient에서 SocketsHttpHandler로 프록시를 올바르게 설정하고 SOCKS5를 연결하며 소켓 소진 없이 IP 회전을 구성하는 방법을 설명합니다.

📅2026년 10월 18일

만약 SocketException: Address already in use를 잡아본 적이 있거나 .NET 애플리케이션이 프록시와 함께 몇 시간 작업한 후 갑자기 사용 가능한 소켓이 바닥나는 것을 본 적이 있다면, 문제는 거의 항상 HttpClient의 잘못된 사용에 있습니다. 이 기사에서는 SocketsHttpHandler를 통해 프록시를 설정하고 SOCKS5를 연결하며 IP 회전을 조직하여 연결 풀에서 병목 현상이 발생하지 않도록 하는 방법을 설명합니다.

왜 HttpClient가 소켓을 "소모"하는가

전형적인 실수는 각 요청마다 HttpClient의 새 인스턴스를 using 내에서 생성하는 것입니다. 프록시와 함께 작업할 때 이 문제는 더욱 심각해집니다: 프록시 서버를 통한 각 연결은 별도의 TCP 핸드쉐이크가 필요하며, 프록시를 동적으로 변경하는 경우 연결 풀이 재사용되지 않고 소켓이 TIME_WAIT 상태에 걸리게 됩니다.

Windows에서는 특히 심각합니다 — 기본적으로 사용 가능한 동적 포트 수가 제한되어 있으며(보통 16384개), 애플리케이션이 프록시를 통해 데이터를 파싱하는 경우 모든 포트가 몇 분 안에 소진될 수 있습니다. Linux에서는 커널 설정 덕분에 상황이 조금 더 부드럽지만, 연결을 재사용하지 않으면 여전히 문제가 발생합니다.

오래된 HttpClientHandler는 .NET Framework 시절부터 사용 가능했지만, 연결의 수명 주기를 유연하게 제어할 수 없습니다. 그래서 .NET Core 2.1부터는 SocketsHttpHandler를 사용하는 것이 권장됩니다 — 이 핸들러는 완전히 관리 코드로 작성되었으며, 연결 풀, DNS 타임아웃 및 프록시 설정에 직접 접근할 수 있습니다.

SocketsHttpHandler: 왜 필요한가

SocketsHttpHandler는 .NET Core 2.1부터 Linux 및 macOS에서 기본적으로 HttpClient가 구축되는 전송 계층입니다. .NET 5부터는 Windows에서도 사용됩니다. 이전의 WinHttpHandler와 달리, 이 핸들러는:

  • PooledConnectionLifetime을 설정할 수 있어, 회전하는 프록시와 함께 작업할 때 중요합니다;
  • 프록시를 프로그램적으로 동적으로 재정의할 수 있는 Proxy 델리게이트를 통해 IWebProxy에 접근할 수 있습니다;
  • SOCKS5에 필요한 전송을 대체하기 위한 저수준 훅인 ConnectCallback을 지원합니다;
  • PooledConnectionIdleTimeout이 만료되면 소켓을 올바르게 해제하여 시스템에 쓰레기를 남기지 않습니다.

기본 설정은 다음과 같습니다:

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
    MaxConnectionsPerServer = 50,
    AutomaticDecompression = DecompressionMethods.All
};

var client = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(30)
};

주요 규칙: HttpClient는 이 핸들러와 함께 싱글톤이어야 하거나 IHttpClientFactory를 통해 생성되어야 합니다 — 매 요청마다 재생성하지 마십시오, 그렇지 않으면 연결 풀의 의미가 사라집니다.

SocketsHttpHandler를 통한 프록시 설정

HTTP/HTTPS 프록시의 경우 핸들러 수준에서 Proxy 속성을 설정하면 됩니다. 이는 HTTP CONNECT 프로토콜을 통해 접근을 제공하는 데이터 센터 프록시 및 주거 프록시에 대해 작동합니다:

var proxy = new WebProxy("http://proxy-host:8000")
{
    Credentials = new NetworkCredential("login", "password")
};

var handler = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(3)
};

var client = new HttpClient(handler);

var response = await client.GetAsync("https://example.com");
Console.WriteLine(await response.Content.ReadAsStringAsync());

서로 다른 호스트에 대해 서로 다른 프록시를 연결해야 하는 경우(예: 한 도메인에 대한 인증은 직접 진행하고 스크래핑은 프록시를 통해 진행하는 경우), 정적 객체 대신 델리게이트를 사용하십시오:

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy(),
    UseProxy = true
};

handler.Proxy = new CustomProxy(uri =>
{
    return uri.Host.Contains("target-site")
        ? new Uri("http://proxy-host:8000")
        : null;
});

마켓플레이스 파싱 작업 — 예를 들어 Wildberries 또는 Ozon의 가격 모니터링 — 에서는 두 가지 접근 방식을 조합하는 경우가 많습니다: 기본 트래픽은 데이터 센터 프록시를 통해 진행하고, 캡차 서비스나 인증 요청은 직접 진행합니다.

.NET에서 SOCKS5 지원

.NET 6부터 SocketsHttpHandler는 프록시 URI에서 스킴을 지정하여 SOCKS4/SOCKS4a/SOCKS5를 네이티브로 지원합니다 — 별도의 외부 라이브러리가 필요하지 않습니다:

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
    {
        Credentials = new NetworkCredential("login", "password")
    },
    UseProxy = true
};

var client = new HttpClient(handler);
var response = await client.GetAsync("https://httpbin.org/ip");

중요한 점: .NET 6 이전에는 이러한 기능이 없었고, 개발자들은 TLS 연결을 설정하기 전에 SOCKS5 핸드쉐이크를 수동으로 수행하는 ConnectCallback를 작성해야 했습니다. 만약 귀하의 프로젝트가 여전히 .NET Core 3.1 또는 .NET 5에 있다면, 이 경로는 여전히 유효합니다:

var handler = new SocketsHttpHandler
{
    ConnectCallback = async (context, cancellationToken) =>
    {
        var socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
        {
            NoDelay = true
        };

        await socket.ConnectAsync("proxy-host", 1080, cancellationToken);
        await PerformSocks5Handshake(socket, context.DnsEndPoint, cancellationToken);

        return new NetworkStream(socket, ownsSocket: true);
    }
};

실제로 프로덕션 프로젝트의 경우 .NET 6+로 업데이트하고 내장된 구현을 사용하는 것이 더 간단합니다 — 이는 런타임에서 테스트를 통과하며 핸드쉐이크 프로토콜을 직접 지원할 필요가 없습니다.

HttpClient를 재생성하지 않고 프록시 회전

IP 회전을 구현할 때 자주 발생하는 실수는 프록시가 변경될 때마다 새 HttpClient (즉, 새 핸들러와 새 연결 풀)를 생성하는 것입니다. 이는 미해결 소켓이 축적되는 결과를 보장합니다. 올바른 접근 방식은 매 호출 시 다른 프록시 주소를 반환하는 사용자 정의 IWebProxy를 구현하는 것입니다:

public class RotatingProxy : IWebProxy
{
    private readonly string[] _proxies;
    private int _index = -1;

    public RotatingProxy(string[] proxies) => _proxies = proxies;

    public ICredentials Credentials { get; set; }

    public Uri GetProxy(Uri destination)
    {
        var next = Interlocked.Increment(ref _index) % _proxies.Length;
        return new Uri(_proxies[next]);
    }

    public bool IsBypassed(Uri host) => false;
}

이 클래스는 여전히 동일한 SocketsHttpHandler에 연결됩니다:

var handler = new SocketsHttpHandler
{
    Proxy = new RotatingProxy(new[]
    {
        "http://proxy1:8000",
        "http://proxy2:8000",
        "http://proxy3:8000"
    }),
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromSeconds(60)
};

var client = new HttpClient(handler);

이 구조는 내부 소켓 풀을 재사용하지만, 각 요청은 실제로 새로운 IP를 통해 전송됩니다. 각 프록시마다 병렬 연결 풀을 구축하려는 경우(속도를 위해) 합리적인 PooledConnectionLifetime을 설정하십시오 — 일반적으로 30-90초로 설정하여 연결이 프록시 제공자의 IP보다 더 빨리 만료되지 않도록 합니다. Avito 파싱이나 Instagram API 데이터 수집과 같은 작업에는 주거 프록시를 사용하는 것이 더 편리합니다 — 그러면 코드에서 주소를 변경할 필요가 없으며, 자동으로 출력 IP를 교체하는 "입구"를 유지하면 됩니다.

PooledConnectionLifetime 및 DNS 회전

프록시 제공자가 DNS 회전을 사용하는 경우(하나의 호스트 이름이 매 요청마다 다른 IP로 해석됨), 제한된 PooledConnectionLifetime을 설정하는 것이 중요합니다. 기본적으로 이는 Infinite로 설정되어 있으며, .NET은 이전 IP에 대한 연결을 무한정 유지하며 DNS 업데이트를 무시합니다.

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromSeconds(30), // 연결을 재생성합니다
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(15),
    MaxConnectionsPerServer = 20
};

이는 모바일 프록시에서 특히 중요합니다. 이 경우 제공자가 IP를 변경할 수 있는 주기가 1-10분마다 발생할 수 있습니다. 연결을 더 오래 유지하면 요청이 이미 해제된 IP를 통해 계속 진행되거나 때때로 타임아웃 오류가 발생할 수 있습니다. 이러한 시나리오에는 모바일 프록시가 적합합니다 — 이는 안티프로드 시스템에서 가장 높은 신뢰를 제공하지만, 클라이언트 수준에서 TTL 연결을 신중하게 설정해야 합니다.

짧은 PooledConnectionLifetime은 무료가 아닙니다 — 각 연결 재생성은 새로운 TCP+TLS 핸드쉐이크를 의미하며, 이는 50-200ms의 지연을 추가합니다. IP의 신선함과 성능 간의 균형은 경험적으로 조정됩니다: 가격 모니터링 작업에는 60-120초가 충분하고, 고주파 계정 농사 작업에는 때때로 10-20초가 필요합니다.

.NET 애플리케이션을 위한 프록시 유형 비교

프록시 유형 프로토콜 권장 PooledConnectionLifetime 작업
데이터 센터 프록시 HTTP/HTTPS, SOCKS5 5-10분 파싱, API 통합, 높은 속도
주거 프록시 HTTP/HTTPS, SOCKS5 30-90초 가격 모니터링, 지리적 차단 우회
모바일 프록시 HTTP/HTTPS, SOCKS5 10-30초 소셜 미디어 자동화, 안티프로드 민감 API

IHttpClientFactory 및 프록시가 있는 타입 클라이언트

ASP.NET Core 프로젝트에서는 HttpClient를 IHttpClientFactory를 통해 등록하는 것이 권장됩니다 — 이는 개발자가 핸들러의 수명 주기를 관리하는 책임을 덜어주고 내부 SocketsHttpHandler를 자동으로 재사용합니다.

builder.Services.AddHttpClient("ProxyClient")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
        {
            Credentials = new NetworkCredential("login", "password")
        },
        UseProxy = true,
        PooledConnectionLifetime = TimeSpan.FromMinutes(2)
    })
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

SetHandlerLifetime에 주목하십시오 — 이는 팩토리 내에서 핸들러 자체를 회전시키는 별도의 메커니즘입니다(기본적으로 2분)로, 개별 연결의 PooledConnectionLifetime과 혼동하지 마십시오. 팩토리는 주기적으로 새 핸들러를 생성하지만, 이전 연결은 활성 요청이 완료될 때까지 계속 유지됩니다 — 가비지 컬렉터가 이를 자동으로 정리합니다.

여러 프록시 풀 시나리오에서는 여러 명명된 클라이언트를 등록하는 것이 편리합니다 — 예를 들어, 미국과 유럽을 위해 별도로 등록하여 Google Ads 또는 Facebook Ads의 광고 테스트를 서로 다른 지역에서 수행할 수 있습니다:

builder.Services.AddHttpClient("US-Proxy")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://us-proxy-host:8000"),
        UseProxy = true
    });

builder.Services.AddHttpClient("EU-Proxy")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://eu-proxy-host:8000"),
        UseProxy = true
    });

소켓 누수 진단

소켓이 축적되고 있다고 의심되면, 추측하지 말고 확인하십시오. Linux에서 프로세스의 열린 연결 수를 빠르게 확인하는 방법은 다음과 같습니다:

# TIME_WAIT 상태의 연결 수
ss -tan state time-wait | wc -l

# 특정 프로세스(PID)의 모든 연결
ls -la /proc/{PID}/fd | grep socket | wc -l

Windows에서는 netstat -ano 명령어를 사용하여 프로세스 PID로 필터링하여 유사한 통계를 제공합니다. .NET 수준에서 더 깊은 진단을 위해 내장 도구인 dotnet-counters를 사용하여 System.Net.Http 메트릭을 실시간으로 확인할 수 있습니다:

dotnet-counters monitor -p {PID} --counters System.Net.Http

# 주요 카운터:
# current-connections
# connections-duration
# requests-failed-rate

current-connections가 지속적으로 증가하고 결코 감소하지 않는다면 — 이는 핸들러가 너무 자주 재생성되거나 PooledConnectionIdleTimeout이 Infinite로 설정되어 요청이 올바르게 완료되지 않는다는 직접적인 신호입니다(예: 응답 본문이 끝까지 읽히지 않아서 연결이 풀로 반환되지 않음).

또 다른 일반적인 누수 원인은 읽지 않거나 해제되지 않은 response.Content입니다. 항상 using var response = ... 또는 응답 작업이 완료된 후 Dispose()를 명시적으로 호출하십시오.

결론

.NET에서 프록시를 올바르게 사용하는 것은 세 가지 원칙에 기반합니다: 구식 구현 대신 SocketsHttpHandler를 사용하고, HttpClient를 싱글톤으로 유지하거나(IHttpClientFactory를 통해) 프록시 회전을 전체 클라이언트를 재생성하는 것이 아니라 사용자 정의 IWebProxy를 통해 구현하십시오. .NET 6부터 SOCKS5는 네이티브로 지원되므로 핸드쉐이크 프로토콜을 직접 작성할 필요가 없습니다.

귀하의 애플리케이션이 마켓플레이스를 파싱하거나 높은 요청 빈도로 API 데이터를 수집하는 경우, 데이터 센터 프록시에 주목하십시오 — 이는 연결 풀을 올바르게 설정하면 최대 속도를 제공합니다. 익명성과 일반 사용자 트래픽과의 유사성이 중요한 작업에는 주거 프록시가 더 적합하며, 모바일 API 및 소셜 플랫폼과 작업할 때는 모바일 프록시를 사용하여 코드에서 짧은 연결 수명을 유지하십시오.