.NET 8에서 C# HTTP 프록시 완벽 가이드: HttpClient부터 회전 프록시 풀까지

.NET 8 개발자를 위한 C# HTTP 프록시 종합 가이드. HttpClient, SocketsHttpHandler, Polly 재시도 패턴, 병렬 스크래핑, DI 기반 회전 프록시 풀, TLS 인증서 핀닝까지 운영 수준 코드로 설명합니다.

.NET 8에서 C# HTTP 프록시 완벽 가이드: HttpClient부터 회전 프록시 풀까지

.NET 8에서 HTTP 프록시를 사용하는 방법은 단순한 WebProxy 설정을 넘어, 연결 풀 관리, 재시도 전략, TLS 보안, 그리고 대규모 병렬 스크래핑까지 고려해야 합니다. 이 가이드는 C# HTTP 프록시를 처음 접하는 개발자부터 운영 환경에서 수백만 요청을 처리해야 하는 엔지니어까지, 실전에서 바로 사용할 수 있는 코드 패턴을 제공합니다.

1. HttpClient와 HttpClientHandler로 기본 프록시 설정하기

.NET에서 프록시를 사용하는 가장 기본적인 방법은 HttpClientHandlerWebProxy를 설정하는 것입니다. 이 방식은 .NET Framework 시절부터 사용되던 패턴이지만, .NET 8에서도 여전히 유효합니다.

using System.Net;
using System.Net.Http;

// 기본 프록시 설정
var proxy = new WebProxy("http://gate.proxyhat.com:8080");

// 인증이 필요한 경우
proxy.Credentials = new NetworkCredential("user-country-US", "PASSWORD");

// 특정 호스트는 프록시를 거치지 않도록 우회 목록 설정
proxy.BypassList = new string[]
{
    "localhost",
    "127\\.0\\.0\\.1",
    "192\\.168\\..*",
    "^https?://internal\\.company\\.com/.*"
};

// BypassProxyOnLocal은 로컬 요청을 자동으로 우회
proxy.BypassProxyOnLocal = true;

var handler = new HttpClientHandler
{
    Proxy = proxy,
    UseProxy = true,
    // 프록시 인증 실패 시 예외 대신 407 응답 허용
    UseDefaultCredentials = false,
    // 자동 리다이렉트 처리 (프록시 뒤에서 유용)
    AllowAutoRedirect = true,
    MaxAutomaticRedirections = 10
};

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

// 요청 실행
var response = await client.GetAsync("https://httpbin.org/ip");
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);

WebProxy는 HTTP, HTTPS, SOCKS4, SOCKS5를 모두 지원합니다. 하지만 .NET 기본 HttpClientHandler는 SOCKS5를 완전히 지원하지 않으므로, SOCKS5가 필요하다면 후술할 SocketsHttpHandler를 사용해야 합니다.

프록시 설정을 IHttpClientFactory와 함께 사용하기

ASP.NET Core나 DI를 사용하는 애플리케이션에서는 IHttpClientFactory를 통해 프록시가 설정된 HttpClient를 주입받는 것이 권장됩니다.

// Program.cs 또는 Startup.cs
using System.Net;

builder.Services.AddHttpClient("ProxiedClient")
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var proxy = new WebProxy("http://gate.proxyhat.com:8080")
        {
            Credentials = new NetworkCredential("user-country-KR", "PASSWORD")
        };
        
        return new HttpClientHandler
        {
            Proxy = proxy,
            UseProxy = true
        };
    });

// 컨트롤러나 서비스에서 주입
public class ScraperService
{
    private readonly HttpClient _client;
    
    public ScraperService(IHttpClientFactory factory)
    {
        _client = factory.CreateClient("ProxiedClient");
    }
    
    public async Task<string> FetchAsync(string url)
    {
        return await _client.GetStringAsync(url);
    }
}

2. SocketsHttpHandler로 고급 프록시 제어하기

.NET Core 2.1부터 도입된 SocketsHttpHandlerHttpClientHandler보다 더 세밀한 제어를 제공합니다. 특히 연결 풀 관리, 프록시 선택 콜백, TLS 설정에서 강력한 기능을 발휘합니다.

using System.Net;
using System.Net.Http;

var handler = new SocketsHttpHandler
{
    // 요청별로 프록시를 동적으로 선택
    Proxy = new WebProxy("http://gate.proxyhat.com:8080")
    {
        Credentials = new NetworkCredential("user-country-US-session-abc123", "PASSWORD")
    },
    UseProxy = true,
    
    // 연결 풀 수명 설정 (프록시 IP 회전 시 중요)
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
    MaxConnectionsPerServer = 100,
    
    // 자동 압축 해제
    AutomaticDecompression = DecompressionMethods.All,
    
    // 연결 타임아웃
    ConnectTimeout = TimeSpan.FromSeconds(10)
};

var client = new HttpClient(handler);

// 여러 요청 실행 (연결 풀 재사용)
var urls = new[]
{
    "https://httpbin.org/ip",
    "https://httpbin.org/headers",
    "https://httpbin.org/user-agent"
};

foreach (var url in urls)
{
    var result = await client.GetStringAsync(url);
    Console.WriteLine($"{url}: {result.Length} bytes");
}

요청별 프록시 동적 선택

SocketsHttpHandler.ConnectCallback을 사용하면 요청마다 다른 프록시를 적용할 수 있습니다. 이는 로테이팅 프록시 서비스에서 필수적인 패턴입니다.

using System.Net.Http;
using System.Net.Sockets;
using System.Net;

public class DynamicProxyHandler
{
    private readonly string[] _proxyCredentials;
    private int _currentIndex = 0;
    private readonly object _lock = new();
    
    public DynamicProxyHandler()
    {
        // 여러 세션/국가 조합
        _proxyCredentials = new[]
        {
            "user-country-US-session-a",
            "user-country-DE-session-b",
            "user-country-GB-session-c",
            "user-country-JP-session-d",
            "user-country-KR-session-e"
        };
    }
    
    public string GetNextCredential()
    {
        lock (_lock)
        {
            var cred = _proxyCredentials[_currentIndex];
            _currentIndex = (_currentIndex + 1) % _proxyCredentials.Length;
            return cred;
        }
    }
    
    public HttpClient CreateClient()
    {
        var handler = new SocketsHttpHandler
        {
            // 연결 콜백에서 프록시 선택
            ConnectCallback = async (context, token) =>
            {
                var proxyHost = "gate.proxyhat.com";
                var proxyPort = 8080;
                
                // 프록시 서버에 연결
                var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                await socket.ConnectAsync(proxyHost, proxyPort, token);
                
                return new NetworkStream(socket, ownsSocket: true);
            },
            
            // 프록시 인증은 별도 처리 필요
            Proxy = null, // ConnectCallback에서 직접 처리
            UseProxy = true,
            PooledConnectionLifetime = TimeSpan.FromSeconds(30) // 짧게 유지
        };
        
        return new HttpClient(handler);
    }
}

// 더 간단한 방법: IWebProxy 구현체 사용
public class RotatingWebProxy : IWebProxy
{
    private readonly string _password;
    private readonly Random _random = new();
    private readonly string[] _countries = { "US", "DE", "GB", "JP", "KR", "FR", "CA" };
    
    public RotatingWebProxy(string password)
    {
        _password = password;
    }
    
    public Uri? GetProxy(Uri destination)
    {
        // 매 요청마다 다른 국가
        var country = _countries[_random.Next(_countries.Length)];
        var session = Guid.NewGuid().ToString("N").Substring(0, 8);
        
        // 실제로는 Credentials에서 사용자명을 조합
        return new Uri("http://gate.proxyhat.com:8080");
    }
    
    public ICredentials? Credentials
    {
        get => new NetworkCredential($"user-country-{_countries[_random.Next(_countries.Length)]}", _password);
        set => throw new NotSupportedException();
    }
    
    public bool IsBypassed(Uri host) => false;
}

// 사용
var rotatingProxy = new RotatingWebProxy("YOUR_PASSWORD");
var handler = new SocketsHttpHandler
{
    Proxy = rotatingProxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(1)
};

var client = new HttpClient(handler);

3. Polly로 재시도 및 서킷 브레이커 패턴 구현하기

프록시 요청은 일반 HTTP 요청보다 실패 가능성이 높습니다. 프록시 서버 장애, IP 차단, 타임아웃 등 다양한 실패 시나리오에 대응하려면 Polly 라이브러리를 사용해야 합니다.

using Polly;
using Polly.CircuitBreaker;
using System.Net.Http;

var builder = WebApplication.CreateBuilder(args);

// Polly 재시도 전략 정의
var retryPolicy = Policy
    .Handle<HttpRequestException>()
    .Or<TimeoutException>()
    .OrResult<HttpResponseMessage>(r => 
        (int)r.StatusCode >= 500 || 
        r.StatusCode == System.Net.HttpStatusCode.TooManyRequests ||
        r.StatusCode == System.Net.HttpStatusCode.Forbidden)
    .WaitAndRetryAsync(
        retryCount: 5,
        sleepDurationProvider: retryAttempt => 
        {
            // 지수 백오피 + 지터
            var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
            var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000));
            return baseDelay + jitter;
        },
        onRetry: (outcome, timeSpan, retryCount, context) =>
        {
            Console.WriteLine($"Retry {retryCount} after {timeSpan.TotalSeconds:F1}s due to: {outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString()}");
        });

// 서킷 브레이커 전략
var circuitBreakerPolicy = Policy
    .Handle<HttpRequestException>()
    .OrResult<HttpResponseMessage>(r => (int)r.StatusCode >= 500)
    .CircuitBreakerAsync(
        exceptionsAllowedBeforeBreaking: 3,
        durationOfBreak: TimeSpan.FromSeconds(30),
        onBreak: (outcome, breakDelay) =>
        {
            Console.WriteLine($"Circuit OPEN for {breakDelay.TotalSeconds}s due to: {outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString()}");
        },
        onReset: () => Console.WriteLine("Circuit CLOSED - requests flowing normally"));

// 전략 조합
var resilientPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy);

// HttpClient에 적용
builder.Services.AddHttpClient("ResilientProxied")
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var proxy = new WebProxy("http://gate.proxyhat.com:8080")
        {
            Credentials = new NetworkCredential("user-country-US", "PASSWORD")
        };
        return new HttpClientHandler { Proxy = proxy, UseProxy = true };
    })
    .AddPolicyHandler(resilientPolicy);

// 사용 예시
public class ScraperService
{
    private readonly IHttpClientFactory _factory;
    private readonly ILogger<ScraperService> _logger;
    
    public ScraperService(IHttpClientFactory factory, ILogger<ScraperService> logger)
    {
        _factory = factory;
        _logger = logger;
    }
    
    public async Task<string?> ScrapeAsync(string url, CancellationToken ct = default)
    {
        var client = _factory.CreateClient("ResilientProxied");
        
        try
        {
            var response = await client.GetAsync(url, ct);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync(ct);
        }
        catch (BrokenCircuitException ex)
        {
            _logger.LogError(ex, "Circuit is broken - service unavailable");
            return null;
        }
    }
}

4. Parallel.ForEachAsync로 병렬 스크래핑 구현하기

.NET 8의 Parallel.ForEachAsync는 비동기 병렬 처리를 위한 최적의 API입니다. MaxDegreeOfParallelism으로 동시성을 제어하고, CancellationToken으로 우아한 종료를 처리할 수 있습니다.

using System.Net;
using System.Net.Http;
using System.Collections.Concurrent;

public class ParallelScraper
{
    private readonly HttpClient _client;
    private readonly ILogger<ParallelScraper> _logger;
    
    public ParallelScraper(ILogger<ParallelScraper> logger)
    {
        _logger = logger;
        
        var proxy = new WebProxy("http://gate.proxyhat.com:8080")
        {
            Credentials = new NetworkCredential("user-country-US", "PASSWORD")
        };
        
        var handler = new SocketsHttpHandler
        {
            Proxy = proxy,
            UseProxy = true,
            PooledConnectionLifetime = TimeSpan.FromMinutes(5),
            MaxConnectionsPerServer = 200 // 높은 동시성
        };
        
        _client = new HttpClient(handler)
        {
            Timeout = TimeSpan.FromSeconds(30)
        };
    }
    
    public async Task<Dictionary<string, string>> ScrapeUrlsAsync(
        IEnumerable<string> urls, 
        int maxDegreeOfParallelism = 20,
        CancellationToken ct = default)
    {
        var results = new ConcurrentDictionary<string, string>();
        var errors = new ConcurrentBag<string>();
        var successCount = 0;
        var errorCount = 0;
        
        await Parallel.ForEachAsync(
            urls,
            new ParallelOptions
            {
                MaxDegreeOfParallelism = maxDegreeOfParallelism,
                CancellationToken = ct
            },
            async (url, token) =>
            {
                try
                {
                    var content = await _client.GetStringAsync(url, token);
                    results.TryAdd(url, content);
                    Interlocked.Increment(ref successCount);
                    
                    if (successCount % 100 == 0)
                    {
                        _logger.LogInformation("Progress: {Success} success, {Error} errors", 
                            successCount, errorCount);
                    }
                }
                catch (HttpRequestException ex)
                {
                    errors.Add($"{url}: {ex.Message}");
                    Interlocked.Increment(ref errorCount);
                    _logger.LogWarning(ex, "Failed to scrape {Url}", url);
                }
                catch (TaskCanceledException)
                {
                    _logger.LogWarning("Timeout for {Url}", url);
                    Interlocked.Increment(ref errorCount);
                }
            });
        
        _logger.LogInformation("Completed: {Success} success, {Error} errors", successCount, errorCount);
        return results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
    }
}

// 사용 예시
var urls = Enumerable.Range(1, 1000)
    .Select(i => $"https://httpbin.org/delay/{Random.Shared.Next(1, 3)}")
    .ToList();

var scraper = new ParallelScraper(LoggerFactory.Create(b => b.AddConsole()).CreateLogger<ParallelScraper>());
var results = await scraper.ScrapeUrlsAsync(urls, maxDegreeOfParallelism: 50);

Console.WriteLine($"Scraped {results.Count} URLs successfully");

5. DI 기반 회전 프록시 풀 서비스 구현하기

운영 환경에서는 프록시 IP를 주기적으로 교체하고, 실패한 프록시를 제외하며, 요청 분산을 관리하는 프록시 풀 서비스가 필요합니다. 의존성 주입과 함께 구현해 봅니다.

using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

// 프록시 설정 모델
public record ProxyConfig
{
    public string Username { get; init; } = string.Empty;
    public string Password { get; init; } = string.Empty;
    public string Host { get; init; } = "gate.proxyhat.com";
    public int Port { get; init; } = 8080;
    public string? Country { get; init; }
    public string? Session { get; init; }
    public DateTime LastUsed { get; set; }
    public int SuccessCount { get; set; }
    public int FailureCount { get; set; }
    public bool IsHealthy { get; set; } = true;
}

// 프록시 풀 인터페이스
public interface IProxyPool
{
    HttpClient CreateClient();
    ProxyConfig? GetNextProxy();
    void MarkSuccess(ProxyConfig proxy);
    void MarkFailure(ProxyConfig proxy);
    void Rotate();
}

// 프록시 풀 구현
public class RotatingProxyPool : IProxyPool, IDisposable
{
    private readonly ConcurrentBag<ProxyConfig> _proxies;
    private readonly ILogger<RotatingProxyPool> _logger;
    private readonly TimeSpan _rotationInterval = TimeSpan.FromMinutes(5);
    private readonly Timer _rotationTimer;
    private int _currentIndex = 0;
    
    public RotatingProxyPool(
        IConfiguration config, 
        ILogger<RotatingProxyPool> logger)
    {
        _logger = logger;
        _proxies = new ConcurrentBag<ProxyConfig>();
        
        // 설정에서 프록시 목록 로드
        var proxySettings = config.GetSection("Proxies");
        var countries = proxySettings.GetValue<string[]>("Countries") ?? new[] { "US", "DE", "GB" };
        var password = proxySettings.GetValue<string>("Password") ?? throw new InvalidOperationException("Proxy password required");
        
        // 국가별 세션 생성
        foreach (var country in countries)
        {
            for (int i = 0; i < 3; i++) // 국가당 3개 세션
            {
                var session = Guid.NewGuid().ToString("N").Substring(0, 8);
                _proxies.Add(new ProxyConfig
                {
                    Username = $"user-country-{country}-session-{session}",
                    Password = password,
                    Country = country,
                    Session = session
                });
            }
        }
        
        _logger.LogInformation("Initialized proxy pool with {Count} proxies", _proxies.Count);
        
        // 주기적 로테이션
        _rotationTimer = new Timer(_ => Rotate(), null, _rotationInterval, _rotationInterval);
    }
    
    public HttpClient CreateClient()
    {
        var proxy = GetNextProxy();
        if (proxy == null)
            throw new InvalidOperationException("No healthy proxies available");
        
        var webProxy = new WebProxy($"http://{proxy.Host}:{proxy.Port}")
        {
            Credentials = new NetworkCredential(proxy.Username, proxy.Password)
        };
        
        var handler = new SocketsHttpHandler
        {
            Proxy = webProxy,
            UseProxy = true,
            PooledConnectionLifetime = TimeSpan.FromMinutes(2),
            ConnectTimeout = TimeSpan.FromSeconds(15)
        };
        
        return new HttpClient(handler)
        {
            Timeout = TimeSpan.FromSeconds(60)
        };
    }
    
    public ProxyConfig? GetNextProxy()
    {
        var healthyProxies = _proxies.Where(p => p.IsHealthy).ToList();
        
        if (!healthyProxies.Any())
        {
            _logger.LogWarning("No healthy proxies available");
            return null;
        }
        
        // 라운드 로빈 + 최소 사용 우선
        var selected = healthyProxies
            .OrderBy(p => p.LastUsed)
            .First();
        
        selected.LastUsed = DateTime.UtcNow;
        return selected;
    }
    
    public void MarkSuccess(ProxyConfig proxy)
    {
        proxy.SuccessCount++;
        proxy.IsHealthy = true;
    }
    
    public void MarkFailure(ProxyConfig proxy)
    {
        proxy.FailureCount++;
        
        // 실패율이 30% 이상이면 비활성화
        var totalRequests = proxy.SuccessCount + proxy.FailureCount;
        if (totalRequests > 10 && (double)proxy.FailureCount / totalRequests > 0.3)
        {
            proxy.IsHealthy = false;
            _logger.LogWarning("Proxy {Session} marked unhealthy (failures: {Failures})", 
                proxy.Session, proxy.FailureCount);
        }
    }
    
    public void Rotate()
    {
        _logger.LogInformation("Rotating proxy pool...");
        
        foreach (var proxy in _proxies)
        {
            // 새 세션 ID 생성
            var newSession = Guid.NewGuid().ToString("N").Substring(0, 8);
            var newUsername = $"user-country-{proxy.Country}-session-{newSession}";
            
            // 불변 record이므로 새 인스턴스 생성
            var newProxy = proxy with
            {
                Username = newUsername,
                Session = newSession,
                LastUsed = DateTime.MinValue,
                SuccessCount = 0,
                FailureCount = 0,
                IsHealthy = true
            };
            
            _proxies.Add(newProxy);
        }
        
        // 오래된 프록시 제거
        var toRemove = _proxies
            .Where(p => DateTime.UtcNow - p.LastUsed > TimeSpan.FromHours(1))
            .ToList();
        
        foreach (var p in toRemove)
        {
            // ConcurrentBag에서 제거는 복잡하므로 실제로는 ConcurrentDictionary 권장
            _logger.LogDebug("Removing stale proxy session {Session}", p.Session);
        }
    }
    
    public void Dispose()
    {
        _rotationTimer.Dispose();
    }
}

// DI 등록
// Program.cs
builder.Services.AddSingleton<IProxyPool, RotatingProxyPool>();

// 사용 예시
public class ScrapingBackgroundService : BackgroundService
{
    private readonly IProxyPool _proxyPool;
    private readonly ILogger<ScrapingBackgroundService> _logger;
    
    public ScrapingBackgroundService(IProxyPool proxyPool, ILogger<ScrapingBackgroundService> logger)
    {
        _proxyPool = proxyPool;
        _logger = logger;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var proxy = _proxyPool.GetNextProxy();
            if (proxy == null)
            {
                await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
                continue;
            }
            
            using var client = _proxyPool.CreateClient();
            
            try
            {
                var result = await client.GetStringAsync("https://httpbin.org/ip", stoppingToken);
                _proxyPool.MarkSuccess(proxy);
                _logger.LogInformation("Success with {Country}-{Session}: {Result}", 
                    proxy.Country, proxy.Session, result);
            }
            catch (Exception ex)
            {
                _proxyPool.MarkFailure(proxy);
                _logger.LogError(ex, "Failed with {Country}-{Session}", proxy.Country, proxy.Session);
            }
            
            await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
        }
    }
}

6. TLS 보안: 인증서 핀닝과 커스텀 루트 CA

중간자 프록시(MITM)를 사용하거나 자체 CA를 사용하는 프록시 환경에서는 TLS 인증서 검증을 커스터마이즈해야 합니다. SocketsHttpHandler.SslOptions를 통해 세밀한 제어가 가능합니다.

using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;

public class SecureProxyClient
{
    private readonly HttpClient _client;
    private readonly X509Certificate2[] _pinnedCerts;
    private readonly HashSet<string> _pinnedPublicKeys;
    
    public SecureProxyClient(string proxyUsername, string proxyPassword, IEnumerable<string> pinnedPublicKeys)
    {
        _pinnedPublicKeys = new HashSet<string>(pinnedPublicKeys, StringComparer.OrdinalIgnoreCase);
        
        var proxy = new WebProxy("http://gate.proxyhat.com:8080")
        {
            Credentials = new NetworkCredential(proxyUsername, proxyPassword)
        };
        
        var handler = new SocketsHttpHandler
        {
            Proxy = proxy,
            UseProxy = true,
            
            // TLS 설정
            SslOptions = new SslClientAuthenticationOptions
            {
                // TLS 1.3 강제
                EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12,
                
                // 인증서 검증 콜백
                RemoteCertificateValidationCallback = ValidateCertificate,
                
                // 클라이언트 인증서 (필요한 경우)
                // ClientCertificates = new X509Certificate2Collection { clientCert },
                
                // 인증서 해지 확인
                CertificateRevocationCheckMode = X509RevocationMode.Online,
                
                // 암호화 스위트 제한 (선택적)
                CipherSuitesPolicy = new CipherSuitesPolicy(new[]
                {
                    TlsCipherSuite.TLS_AES_256_GCM_SHA384,
                    TlsCipherSuite.TLS_CHACHA20_POLY1305_SHA256,
                    TlsCipherSuite.TLS_AES_128_GCM_SHA256
                })
            },
            
            PooledConnectionLifetime = TimeSpan.FromMinutes(5)
        };
        
        _client = new HttpClient(handler);
    }
    
    private bool ValidateCertificate(
        object sender,
        X509Certificate? certificate,
        X509Chain? chain,
        SslPolicyErrors sslPolicyErrors)
    {
        // 개발 환경에서는 자체 서명 인증서 허용
#if DEBUG
        if (sslPolicyErrors == SslPolicyErrors.None)
            return true;
            
        if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) ||
            sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
        {
            // 개발용 자체 CA 허용
            return true;
        }
#endif
        
        // 인증서 핀닝 검증
        if (certificate != null && _pinnedPublicKeys.Count > 0)
        {
            using var cert = new X509Certificate2(certificate);
            var publicKey = cert.GetPublicKeyString();
            
            if (!_pinnedPublicKeys.Contains(publicKey))
            {
                Console.WriteLine($"Certificate pinning failed for: {cert.Subject}");
                return false;
            }
            
            Console.WriteLine($"Certificate pinned: {cert.Subject}");
            return true;
        }
        
        // 기본 검증
        return sslPolicyErrors == SslPolicyErrors.None;
    }
    
    public async Task<string> FetchAsync(string url)
    {
        var response = await _client.GetAsync(url);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }
}

// 커스텀 루트 CA 사용 예시
public class CustomRootCaClient
{
    private readonly HttpClient _client;
    
    public CustomRootCaClient(string caCertPath, string proxyUsername, string proxyPassword)
    {
        // 커스텀 루트 CA 로드
        var caCert = new X509Certificate2(caCertPath);
        var chain = new X509Chain();
        chain.ChainPolicy.ExtraStore.Add(caCert);
        chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
        
        var proxy = new WebProxy("http://gate.proxyhat.com:8080")
        {
            Credentials = new NetworkCredential(proxyUsername, proxyPassword)
        };
        
        var handler = new SocketsHttpHandler
        {
            Proxy = proxy,
            UseProxy = true,
            SslOptions = new SslClientAuthenticationOptions
            {
                RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
                {
                    if (cert == null) return false;
                    
                    using var x509 = new X509Certificate2(cert);
                    chain.ChainPolicy.ExtraStore.Add(caCert);
                    
                    return chain.Build(x509);
                }
            }
        };
        
        _client = new HttpClient(handler);
    }
    
    public async Task<string> GetAsync(string url) => 
        await _client.GetStringAsync(url);
}

// 사용
var pinnedKeys = new[]
{
    "3082010A0282010100...", // 실제 공개키 (PEM에서 추출)
    "3082010A0282010101..."  // 백업 키
};

var secureClient = new SecureProxyClient(
    "user-country-US",
    "PASSWORD",
    pinnedKeys
);

var result = await secureClient.FetchAsync("https://example.com");

프록시 유형 비교: Residential vs Datacenter vs Mobile

C# HTTP 프록시를 선택할 때는 프록시 유형에 따른 특성을 이해해야 합니다. 각 유형은 용도와 비용, 안정성에서 큰 차이가 있습니다.

특성 Residential Datacenter Mobile
IP 출처 실제 가정 ISP 데이터센터 서버 모바일 통신사
탐지 난이도 낮음 (일반 사용자와 동일) 높음 (IP 대역으로 식별 가능) 매우 낮음
속도 중간 빠름 변동적
비용 중간~높음 낮음 높음
적합한 용도 SERP, 이커머스, 소셜미디어 대량 데이터 수집, 속도 중시 계정 생성, 높은 신뢰 필요 작업
차단 위험 낮음 높음 매우 낮음

Residential vs Datacenter 프록시 비교 글에서 각 유형의 장단점을 더 자세히 다룹니다.

Key Takeaways

1. HttpClient 수명 관리가 중요합니다. using으로 매번 생성하면 소켓 고갈이 발생합니다. IHttpClientFactory를 사용하거나 싱글톤 HttpClient를 유지하세요.

2. SocketsHttpHandler는 현대적인 선택입니다. 연결 풀 제어, 프록시 동적 선택, TLS 설정에서 HttpClientHandler보다 강력합니다.

3. PooledConnectionLifetime을 프록시 회전 주기에 맞추세요. 회전 프록시를 사용할 때는 연결이 오래 유지되면 IP가 변경되지 않습니다. 1~5분 정도가 적절합니다.

4. Polly는 선택이 아닌 필수입니다. 프록시 요청은 네트워크 오류, 타임아웃, 429 응답 등 다양한 실패에 직면합니다. 재시도와 서킷 브레이커로 안정성을 확보하세요.

5. 병렬 처리에서 MaxDegreeOfParallelism을 신중히 설정하세요. 너무 높으면 프록시 서버 부하로 차단될 수 있습니다. 대상 사이트의 robots.txt와 서비스 약관을 확인하세요.

6. TLS 인증서 핀닝은 보안상 권장됩니다. 특히 민감한 데이터를 다루거나 MITM 프록시 환경에서는 인증서 검증 로직을 직접 제어해야 합니다.

결론

.NET 8에서 C# HTTP 프록시를 사용하는 것은 단순한 WebProxy 설정을 넘어 아키텍처 수준의 고민이 필요합니다. SocketsHttpHandler로 연결 풀을 최적화하고, Polly로 재시도 전략을 구현하며, Parallel.ForEachAsync로 병렬 처리를 제어하는 것이 운영 수준의 애플리케이션을 만드는 핵심입니다.

C# residential proxies를 사용할 때는 특히 IP 회전 주기와 요청 패턴을 신중히 설계해야 합니다. ProxyHat과 같은 프록시 서비스를 사용하면 국가, 도시, 세션 기반 라우팅을 통해 안정적인 스크래핑이 가능합니다.

다음 단계로 프록시 플랜을 확인하거나, 전 세계 프록시 로케이션 목록을 참고하세요. 실제 코드는 GitHub 샘플 저장소에서 확인할 수 있습니다.

시작할 준비가 되셨나요?

AI 필터링으로 148개국 이상에서 5천만 개 이상의 레지덴셜 IP에 액세스하세요.

가격 보기레지덴셜 프록시
← 블로그로 돌아가기