Rust로 HTTP 프록시를 다루어야 하는 이유
Rust로 웹 스크래퍼를 구축하다 보면 단일 IP로는 금방 차단당합니다. SERP 모니터링, 이커머스 가격 추적, 소셜 미디어 리서치 — 모두 Rust residential proxies가 필요한 대표적 사용 사례입니다. 하지만 Rust 생태계에서 프록시 설정은 C++나 Python만큼 문서화되어 있지 않아, reqwest proxy 하나 설정하는 데도 수시간을 허비하기 쉽습니다.
이 글에서는 Rust HTTP proxy를 프로덕션 수준으로 다루는 방법을 코드 중심으로 설명합니다. reqwest 기본 설정부터 시작해, hyper 저수준 CONNECT 터널, tokio 동시성, 회전 프록시 풀 추상화, thiserror 기반 에러 처리, 그리고 rustls vs native-tls 선택까지 다룹니다.
reqwest로 프록시 설정하기: 기본부터 인증까지
reqwest는 Rust에서 가장 널리 쓰이는 HTTP 클라이언트입니다. 프록시 지원은 기본적으로 활성화되어 있지만, 인증과 커스텀 TLS를 결합하려면 약간의 설정이 필요합니다.
기본 프록시 연결
가장 간단한 형태부터 시작합시다. ProxyHat의 주소와 포트를 직접 지정하는 방식입니다.
use reqwest::{Client, Proxy};
use std::error::Error;
#[tokio::main]
async fn main() -> Result<();, Box<dyn Error>> {
let proxy = Proxy::all("http://user-country-US:pass@gate.proxyhat.com:8080")?
.no_proxy("localhost,127.0.0.1");
let client = Client::builder()
.proxy(proxy)
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
.build()?;
let resp = client
.get("https://httpbin.org/ip")
.send()
.await?;
let body = resp.text().await?;
println!("IP via proxy: {}", body);
Ok(())
}ProxyHat의 사용자 이름 필드에 country-US 같은 지역 타겟팅 플래그를 넣으면, 해당 국가의 주거 IP가 할당됩니다. 도시 단위 타겟팅도 가능합니다: user-country-DE-city-berlin.
스티키 세션과 커스텀 TLS
세션 기반 스크래핑(로그인 후 연속 요청 등)에서는 같은 IP를 유지해야 합니다. ProxyHat은 사용자 이름에 session-<id>를 넣어 스티키 세션을 지원합니다.
use reqwest::{Client, Proxy};
use std::time::Duration;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<();, Box<dyn Error>> {
// 스티키 세션: session-abc123이 같으면 동일 IP 유지
let proxy = Proxy::all(
"http://user-country-US-session-abc123:pass@gate.proxyhat.com:8080"
)?;
let client = Client::builder()
.proxy(proxy)
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.tls_built_in_root_certs(false) // 기본 루트 인증서 비활성화
.tls_built_in_webpki_certs(false)
// rustls 사용 시 feature = "rustls-tls" 필요
.build()?;
// 동일 클라이언트로 여러 요청 → 같은 IP
for i in 0..5 {
let resp = client.get("https://httpbin.org/ip").send().await?;
let ip = resp.text().await?;
println!("Request {}: {}", i, ip.trim());
}
Ok(())
}tls_built_in_root_certs(false)를 설정하면 기본 인증서 번들을 비활성화합니다. 이후 add_root_certificate()로 커스텀 CA를 추가하거나, rustls/webpki의 기본 번들을 명시적으로 로드할 수 있습니다.
hyper 저수준: HTTP CONNECT 터널 직접 다루기
hyper는 reqwest의 기반이 되는 저수준 HTTP 라이브러리입니다. 프록시 CONNECT 메서드를 직접 제어해야 하거나, 커넥션 풀을 세밀하게 관리해야 할 때 사용합니다.
HTTPS 사이트를 HTTP 프록시를 통해 접근하려면 CONNECT 터널을 맺어야 합니다. hyper-util의 TokioExecutor와 proxy::Tunnel을 활용합니다.
use hyper::{Request, Uri, body::Incoming};
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use hyper_proxy2::{ProxyConnector, Intercept, Proxy};
use hyper_rustls::HttpsConnectorBuilder;
use tokio::net::TcpStream;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<();, Box<dyn Error>> {
// 1) TLS 커넥터 구성
let https = HttpsConnectorBuilder::new()
.with_webpki_roots()
.https_or_http()
.enable_http1()
.build();
// 2) 프록시 정의 — ProxyHat 인증 포함
let proxy_url: Uri =
"http://user-country-US:pass@gate.proxyhat.com:8080".parse()?;
let proxy = Proxy::new(Intercept::All, proxy_url);
// 3) 프록시 커넥터 조합
let connector = ProxyConnector::from_proxy(https, proxy)?;
// 4) 클라이언트 생성
let client: Client<_, Incoming> = Client::builder(TokioExecutor::new())
.build(connector);
// 5) 요청 전송
let req = Request::builder()
.uri("https://httpbin.org/ip")
.body(hyper::body::Bytes::new().into())?;
let resp = client.request(req).await?;
println!("Status: {}", resp.status());
let body = hyper::body::to_bytes(resp.into_body()).await?;
println!("Body: {}", String::from_utf8_lossy(&body));
Ok(())
}hyper를 직접 사용하면 커넥션 수명, 백프레셔, 프록시 체인(프록시-프록시) 등을 세밀하게 제어할 수 있습니다. 대부분의 스크래핑 작업에는 reqwest로 충분하지만, 고도로 커스터마이징된 인프라에서는 hyper가 필요합니다.
tokio + JoinSet로 동시 스크래핑 구현
수천 개의 페이지를 스크랩할 때 순차 요청은 너무 느립니다. tokio::task::JoinSet를 사용하면 동시성을 제어하면서 안전하게 병렬 요청을 실행할 수 있습니다.
use reqwest::{Client, Proxy};
use tokio::task::JoinSet;
use std::sync::Arc;
use std::time::Duration;
use std::error::Error;
#[derive(Debug, thiserror::Error)]
enum ScrapeError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Rate limited on {url}")]
RateLimited { url: String },
}
async fn fetch_page(
client: &Client,
url: &str,
) -> Result<String, ScrapeError> {
let resp = client.get(url).send().await?;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
return Err(ScrapeError::RateLimited { url: url.into() });
}
Ok(resp.text().await?)
}
#[tokio::main]
async fn main() -> Result<();, Box<dyn Error>> {
let proxy = Proxy::all(
"http://user-country-US:pass@gate.proxyhat.com:8080"
)?;
let client = Arc::new(
Client::builder()
.proxy(proxy)
.timeout(Duration::from_secs(20))
.pool_max_idle_per_host(0) // IP 회전 시 커넥션 재사용 방지
.build()?
);
let urls = vec![
"https://httpbin.org/ip",
"https://httpbin.org/headers",
"https://httpbin.org/user-agent",
"https://httpbin.org/get",
"https://httpbin.org/origin",
];
let mut tasks: JoinSet<Result<String, ScrapeError>> = JoinSet::new();
for url in &urls {
let c = Arc::clone(&client);
let u = url.to_string();
tasks.spawn(async move { fetch_page(&c, &u).await });
}
while let Some(result) = tasks.join_next().await {
match result {
Ok(Ok(body)) => println!("OK: {} bytes", body.len()),
Ok(Err(e)) => eprintln!("Scrape error: {e}"),
Err(join_err) => eprintln!("Task panicked: {join_err}"),
}
}
Ok(())
}pool_max_idle_per_host(0) 설정은 중요합니다. 프록시를 통해 매 요청마다 다른 IP로 나갈 때, 유휴 커넥션이 이전 IP에 묶이는 것을 방지합니다.
회전 프록시 풀: 트레이트로 추상화하기
프로덕션 스크래퍼에서는 프록시 공급자를 교체할 수 있어야 합니다. 트레이트로 추상화하면 ProxyHat에서 다른 공급자로 전환하거나, 데이터센터와 주거 프록시를 혼합 사용할 때 코드 변경을 최소화할 수 있습니다.
use reqwest::{Client, Proxy};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::VecDeque;
/// 프록시 제공자 추상화
#[async_trait::async_trait]
pub trait ProxyProvider: Send + Sync {
/// 다음 프록시 URL 반환 (회전 로직 포함)
async fn next_proxy_url(&self) -> Result<String, ProxyPoolError>;
/// 현재 활성 프록시 수
fn active_count(&self) -> usize;
/// 실패한 프록시 제거
async fn mark_failed(&self, proxy_url: &str);
}
/// ProxyHat 회전 프록시 풀
pub struct ProxyHatPool {
countries: Vec<String>,
username: String,
password: String,
session_counter: Arc<RwLock<u64>>,
}
#[async_trait::async_trait]
impl ProxyProvider for ProxyHatPool {
async fn next_proxy_url(&self) -> Result<String, ProxyPoolError> {
let mut counter = self.session_counter.write().await;
*counter += 1;
let country = self.countries
.choose(&mut rand::thread_rng())
unwrap_or(&"US".to_string());
Ok(format!(
"http://{}-country-{}-session-{}:{}@gate.proxyhat.com:8080",
self.username, country, counter, self.password
))
}
fn active_count(&self) -> usize {
self.countries.len()
}
async fn mark_failed(&self, _proxy_url: &str) {
// ProxyHat은 풀 기반이므로 개별 IP 제거 불필요
// 로깅만 수행
tracing::warn!("Proxy request failed: {}", _proxy_url);
}
}
/// 정적 프록시 리스트 기반 풀 (데이터센터 프록시용)
pub struct StaticProxyPool {
proxies: Arc<RwLock<VecDeque<String>>>,
}
#[async_trait::async_trait]
impl ProxyProvider for StaticProxyPool {
async fn next_proxy_url(&self) -> Result<String, ProxyPoolError> {
let mut proxies = self.proxies.write().await;
proxies.rotate_left(1);
proxies.front()
.cloned()
.ok_or(ProxyPoolError::Exhausted)
}
fn active_count(&self) -> usize {
self.proxies.read().await.len()
}
async fn mark_failed(&self, proxy_url: &str) {
let mut proxies = self.proxies.write().await;
proxies.retain(|p| p != proxy_url);
tracing::warn!("Removed failed proxy: {}", proxy_url);
}
}
/// 프록시 풀을 사용하는 범용 스크래퍼
pub struct PoolScraper<P: ProxyProvider> {
provider: Arc<P>,
base_client: Client,
}
impl<P: ProxyProvider> PoolScraper<P> {
pub fn new(provider: Arc<P>) -> Result<Self, reqwest::Error> {
Ok(Self {
provider,
base_client: Client::builder().build()?,
})
}
pub async fn fetch(&self, url: &str) -> Result<String, ProxyPoolError> {
let proxy_url = self.provider.next_proxy_url().await?;
let proxy = Proxy::all(&proxy_url)
.map_err(|e| ProxyPoolError::Config(e.to_string()))?;
let client = Client::builder()
.proxy(proxy)
.build()
.map_err(|e| ProxyPoolError::Config(e.to_string()))?;
let resp = client.get(url).send().await
.map_err(ProxyPoolError::Http)?;
if resp.status().is_server_error() {
self.provider.mark_failed(&proxy_url).await;
}
Ok(resp.text().await
.map_err(ProxyPoolError::Http)?)
}
}이 추상화의 장점은 ProxyProvider 트레이트만 구현하면 어떤 공급자든 교체 가능하다는 것입니다. ProxyHat 주거 프록시와 자체 데이터센터 프록시를 StaticProxyPool로 혼합 사용할 수도 있습니다.
thiserror로 에러 처리 체계화하기
프록시 기반 스크래핑에서는 네트워크 오류, 인증 실패, 레이트 리밋, 프록시 고갈 등 다양한 에러가 발생합니다. thiserror로 체계적인 에러 타입을 정의합시다.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProxyPoolError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Proxy configuration error: {0}")]
Config(String),
#[error("All proxies exhausted — no available proxy in pool")]
Exhausted,
#[error("Rate limited by target: retry after {retry_after_ms}ms")]
RateLimited { retry_after_ms: u64 },
#[error("Proxy authentication failed for {proxy_host}")]
AuthFailed { proxy_host: String },
#[error("TLS handshake failed: {0}")]
Tls(String),
#[error("Timeout after {timeout_ms}ms requesting {url}")]
Timeout { timeout_ms: u64, url: String },
}
/// 재시도 로직 포함 에러 변환
impl ProxyPoolError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
ProxyPoolError::Http(_) |
ProxyPoolError::RateLimited { .. } |
ProxyPoolError::Timeout { .. }
)
}
pub fn retry_delay_ms(&self) -> u64 {
match self {
ProxyPoolError::RateLimited { retry_after_ms } => *retry_after_ms,
ProxyPoolError::Http(e) if e.is_timeout() => 5_000,
ProxyPoolError::Http(e) if e.is_connect() => 2_000,
_ => 1_000,
}
}
}is_retryable()과 retry_delay_ms()를 결합하면 지수 백오프 재시도 로직을 간단히 구현할 수 있습니다:
use tokio::time::{sleep, Duration};
pub async fn fetch_with_retry<P: ProxyProvider>(
scraper: &PoolScraper<P>,
url: &str,
max_retries: u32,
) -> Result<String, ProxyPoolError> {
let mut attempt = 0u32;
loop {
match scraper.fetch(url).await {
Ok(body) => return Ok(body),
Err(e) if e.is_retryable() && attempt < max_retries => {
let delay = e.retry_delay_ms() * 2u64.pow(attempt);
tracing::warn!(
"Attempt {}/{} failed: {e}. Retrying in {delay}ms",
attempt + 1, max_retries
);
sleep(Duration::from_millis(delay)).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}rustls vs native-tls: 무엇을 선택할까
Rust TLS 생태계에는 두 가지 주요 선택지가 있습니다. 스크래핑 인프라에서는 이 선택이 성능과 호환성 모두에 영향을 미칩니다.
| 기준 | rustls | native-tls |
|---|---|---|
| 구현 언어 | 순수 Rust | C/C++ (OpenSSL / SChannel) |
| 크로스 컴파일 | 간편 (C 의존성 없음) | 복잡 (OpenSSL 링크 필요) |
| 바이너리 크기 | 작음 | 큼 (OpenSSL 번들 시) |
| TLS 1.3 | 지원 | OpenSSL 1.1.1+ 필요 |
| 인증서 호환성 | webpki 기반 (엄격) | 시스템 저장소 사용 (유연) |
| 성능 | 메모리 안전 + 경량 | 아주 큰 차이 없음 |
| 감사 | Cure53 + 이슈보드 | OpenSSL 수년간 감사 |
| 특이 인증서 | 거부 가능 (커스텀 CA 필요) | 시스템 CA 자동 로드 |
권장: 크로스 컴파일과 정적 링크가 중요한 스크래핑 인프라에서는 rustls를 기본으로 사용하세요. 기업 프록시 뒤에서 자체 CA 인증서를 사용해야 한다면 native-tls가 더 간편할 수 있습니다.
컴파일 타임 피처 플래그로 프록시 지원 제어하기
Cargo의 피처 플래그를 활용하면 빌드 시점에 프록시 지원을 켜고 끌 수 있습니다. 이는 라이브러리를 퍼블리싱하거나, 프록시 없는 경량 빌드가 필요할 때 유용합니다.
# Cargo.toml
[package]
name = "proxy-scraper"
version = "0.1.0"
edition = "2021"
[features]
default = ["proxy", "rustls-tls"]
proxy = ["reqwest/proxy"]
rustls-tls = ["reqwest/rustls-tls"]
native-tls = ["reqwest/native-tls"]
socks5 = ["reqwest/socks"]
[dependencies]
reqwest = { version = "0.12", default-features = false }
tokio = { version = "1", features = ["full"] }
thiserror = "2"
tracing = "0.1"
tracing-subscriber = "0.3"
rand = "0.8"
async-trait = "0.1"
[target.'cfg(feature = "socks5")'.dependencies]
# SOCKS5 지원은 별도 피처로이제 빌드 명령으로 원하는 조합을 선택할 수 있습니다:
# 기본: rustls + HTTP 프록시
cargo build --release
# SOCKS5 + native-tls (ProxyHat SOCKS5 포트 1080 사용 시)
cargo build --release --features "socks5,native-tls" --no-default-features
# 프록시 없는 경량 빌드
cargo build --release --no-default-features코드에서는 #[cfg(feature = "proxy")]로 조건부 컴파일을 적용합니다:
pub fn build_client() -> Result<Client, reqwest::Error> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(30));
#[cfg(feature = "proxy")]
{
let proxy = Proxy::all(
"http://user-country-US:pass@gate.proxyhat.com:8080"
)?;
builder = builder.proxy(proxy);
}
builder.build()
}실전 팁: 프로덕션 스크래핑 체크리스트
- 커넥션 풀 비활성화: IP 회전 시
pool_max_idle_per_host(0)설정으로 이전 IP에 묶인 커넥션 재사용을 방지하세요. - 지역 타겟팅 활용: ProxyHat의
-country-X플래그로 타겟 국가 IP를 지정하면 차단 확률이 크게 낮아집니다. - 스티키 세션: 로그인 상태를 유지해야 하는 스크래핑에서는
-session-<id>로 동일 IP를 보장하세요. - 지수 백오프: 429 응답 시 즉시 재시도하지 말고,
Retry-After헤더를 존중하세요. - User-Agent 회전: 프록시만으로는 충분하지 않습니다. 브라우저 UA 문자열도 회전하세요.
- robots.txt 준수: 윤리적 스크래핑을 위해
robots.txt를 확인하고, 서버 부하를 최소화하세요.
Key Takeaways
핵심 요약:
- reqwest로 90%의 프록시 사용 사례를 커버할 수 있습니다. 인증은 URL에 포함, 지역 타겟팅은 사용자 이름 필드에 플래그로 지정하세요.
- hyper는 CONNECT 터널과 커넥션 수명 제어가 필요할 때 선택하세요. 러닝 커브가 높지만 제어력이 뛰어납니다.
- JoinSet으로 동시성을 제어하면서 병렬 스크래핑을 구현하세요.
pool_max_idle_per_host(0)을 잊지 마세요.- ProxyProvider 트레이트로 프록시 공급자를 추상화하면, ProxyHat 주거 프록시와 정적 데이터센터 프록시를 동일 인터페이스로 사용할 수 있습니다.
- thiserror로 재시 가능 여부와 지연 시간을 포함한 풍부한 에러 타입을 정의하세요.
- rustls를 기본으로 사용하고, 기업 환경의 자체 CA가 필요할 때만 native-tls로 전환하세요.
- Cargo 피처 플래그로 프록시/비프록시 빌드를 분리하면 바이너리 크기와 컴파일 시간을 최적화할 수 있습니다.
ProxyHat의 글로벌 프록시 로케이션을 확인하고, 요금제에서 주거/모바일/데이터센터 프록시 옵션을 비교해 보세요. SERP 스크래핑과 웹 데이터 수집에 대한 더 많은 인사이트는 웹 스크래핑 모범 사례 글에서 확인할 수 있습니다.






