실제 사이트에서 5분 이상 작동하는 모든 파서는 언젠가는 IP 차단에 부딪히게 됩니다.
Rust는 reqwest 라이브러리와 함께 뛰어난 성능을 제공하지만,
기본적으로 프록시 회전 문제를 해결하지는 않으므로 직접 구현해야 합니다.
이 기사에서는 SOCKS5 및 HTTP(S) 프록시를 연결하고 클라이언트 풀을 구성하며 IP 회전을 조직하여,
파서가 첫 번째 429 및 403 응답 후에 중단되지 않도록 하는 방법을 설명합니다.
파서에 프록시가 필요한 이유와 그 부재의 위험성
마켓플레이스, 가격 집계기 및 소셜 미디어와 같은 사이트는 하나의 IP에서 비정상적인 활동을 추적합니다: 초당 수십 개의 요청, 동일한 User-Agent, 요청 간 쿠키 없음. 의심스러운 행동의 수가 임계값을 초과하면, IP는 차단 목록에 올라갑니다 — 일시적이거나 영구적입니다. 파서에게 이는 IP가 수동으로 변경될 때까지 데이터 수집이 완전히 중단됨을 의미합니다.
프록시는 요청을 수십 또는 수백 개의 IP 주소 간에 분산시켜 이 문제를 해결합니다. 이때 단순히 "프록시를 대입하는 것"이 아니라 연결 풀과 회전을 잘 조직하는 것이 중요합니다. 그렇지 않으면 reqwest 자체의 한도(하나의 클라이언트 = 하나의 연결 풀)에 부딪히거나 동일한 프록시에서 너무 자주 요청을 보내게 됩니다. 이후 두 가지 문제를 차례로 살펴보겠습니다: 먼저 기본 연결, 그 다음 풀의 아키텍처입니다.
프로덕션 파서에게는 IP 회전뿐만 아니라 주소의 질도 중요합니다. Wildberries, Ozon 또는 Avito를 파싱하는 경우, 데이터 센터 IP는 실제 사용자 IP보다 더 빨리 차단됩니다 — 프록시 유형 선택 섹션에서 자세히 설명합니다.
reqwest에서 HTTP/HTTPS 프록시 기본 설정
reqwest는 Proxy 구조체를 통해 프록시를 지원하며,
이를 ClientBuilder에 전달해야 합니다.
Cargo.toml에 종속성을 추가하세요:
[dependencies]
reqwest = { version = "0.12", features = ["socks", "json"] }
tokio = { version = "1", features = ["full"] }
로그인 및 비밀번호 인증이 필요한 HTTP 프록시의 최소 예제:
use reqwest::{Client, Proxy};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let proxy = Proxy::all("http://gate.proxycove.com:8000")?
.basic_auth("user_login", "user_password");
let client = Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client
.get("https://httpbin.org/ip")
.send()
.await?
.text()
.await?;
println!("프록시를 통한 응답: {resp}");
Ok(())
}
Proxy::all 메소드는
HTTP 및 HTTPS 트래픽을 모두 프록시를 통해 전달합니다. 프로토콜을 분리해야 하는 경우,
Proxy::http() 및
Proxy::https()
를 별도로 사용하세요 — 이는 프록시 제공자가 서로 다른 포트를 다른 프로토콜에 대해 수신하는 경우 유용합니다.
SOCKS5 프록시 연결
SOCKS5는 HTTP 프록시보다 더 낮은 수준에서 작동하며, 요청의 내용을 분석하지 않고 TCP 연결을 지원합니다 —
이는 특히 HTTP(S)뿐만 아니라 WebSocket 엔드포인트를 호출하는 경우 파싱에 더 빠르고 안정적입니다.
SOCKS5를 사용하려면 Cargo.toml에서 socks 기능을 반드시 활성화해야 합니다
(위에서 이미 언급했습니다). 연결 예제는 다음과 같습니다:
use reqwest::{Client, Proxy};
async fn build_socks5_client(
host: &str,
login: &str,
password: &str,
) -> reqwest::Result<Client> {
let proxy_url = format!("socks5h://{host}");
let proxy = Proxy::all(&proxy_url)?.basic_auth(login, password);
Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(20))
.build()
}
socks5h 스킴을 사용해야 한다는 점에 유의하세요
socks5 대신.
끝의 "h"는 도메인 DNS 해석이 로컬이 아니라 프록시 서버 측에서 발생함을 의미합니다.
이는 익명성에 매우 중요합니다: 그렇지 않으면 실제 DNS 해석기가 요청된 도메인을 프록시를 우회하여 "유출"할 수 있습니다,
비록 트래픽 자체가 SOCKS5 터널을 통해 이동하더라도 말입니다.
프록시 제공자가 로그인/비밀번호 인증을 요구하지 않는 경우 (IP 화이트리스트),
basic_auth를 호출하지 마세요 —
제공자의 패널에 요청이 발생하는 서버의 IP 주소를 지정하세요.
다양한 프록시를 위한 reqwest 클라이언트 풀
자주 발생하는 실수는 각 요청마다 새로운 Client를 생성하는 것입니다.
이는 비용이 많이 듭니다: reqwest는 내부적으로 TCP 연결 및 TLS 세션 풀을 유지하며, 클라이언트를 재생성하면
keep-alive의 모든 이점이 사라집니다. 올바른 접근 방식은 각 프록시마다 미리 하나의 클라이언트를 생성하고
이를 공용 풀에서 재사용하는 것입니다.
use reqwest::{Client, Proxy};
use std::sync::Arc;
#[derive(Clone)]
struct ProxyEndpoint {
url: String,
login: String,
password: String,
}
struct ClientPool {
clients: Vec<Client>,
}
impl ClientPool {
fn build(endpoints: &[ProxyEndpoint]) -> reqwest::Result<Self> {
let mut clients = Vec::with_capacity(endpoints.len());
for ep in endpoints {
let proxy = Proxy::all(&ep.url)?
.basic_auth(&ep.login, &ep.password);
let client = Client::builder()
.proxy(proxy)
.pool_max_idle_per_host(4)
.timeout(std::time::Duration::from_secs(15))
.build()?;
clients.push(client);
}
Ok(Self { clients })
}
}
이러한 풀은 파서 시작 시 한 번 생성됩니다. 클라이언트 수는 일반적으로 풀에 있는 고유한 프록시 주소 수와 같습니다 — 제공자가 게이트웨이 수준에서 회전을 제공하는 경우 (gateway), 하나 또는 두 개의 클라이언트면 충분합니다, 왜냐하면 IP 회전은 매 새로운 TCP 연결 시 제공자 측에서 자동으로 발생하기 때문입니다.
높은 병렬성을 요구하는 작업의 경우 Arc로 풀을 감싸고
tokio 작업 간에 공유하세요 — reqwest 클라이언트는 스레드 안전하며 동시 사용을 위해 설계되었습니다.
IP 회전 전략
회전에는 두 가지 근본적으로 다른 접근 방식이 있습니다. 첫 번째는 제공자 측 회전입니다: 하나의 게이트웨이 주소에 연결하고, 제공자가 각 요청마다 또는 N분마다 새로운 IP를 자동으로 대입합니다. 두 번째는 사용자 측 회전입니다: 정적 프록시 목록을 유지하고 다음 요청에 사용할 프록시를 직접 선택합니다.
주거용 프록시를 사용하고 게이트웨이를 통해 자동 회전을 설정하면, 코드는 최소한으로 단순화됩니다 — 같은 클라이언트를 통해 요청을 보내기만 하면 되고, 제공자가 자체적으로 출발 IP를 변경합니다. 그러나 제어가 필요하다면 (예: 실제 사용자 행동을 모방하기 위해 세션에 IP를 10분 동안 고정해야 하는 경우), 수동으로 회전을 구현하세요:
use rand::seq::SliceRandom;
use std::sync::atomic::{AtomicUsize, Ordering};
struct RotatingPool {
clients: Vec<Client>,
counter: AtomicUsize,
}
impl RotatingPool {
// 라운드 로빈: 클라이언트를 차례로 순회
fn next_round_robin(&self) -> &Client {
let idx = self.counter.fetch_add(1, Ordering::SeqCst) % self.clients.len();
&self.clients[idx]
}
// 무작위 선택 — 패턴 분석에 유용
fn next_random(&self) -> &Client {
self.clients.choose(&mut rand::thread_rng()).unwrap()
}
}
라운드 로빈은 모든 IP에 고른 부하가 중요할 때 적합합니다. 무작위 선택은 특정 사이트가 하나의 주소에서 요청 간의 간격을 분석할 경우 요청 패턴을 숨기는 데 더 좋습니다. Ozon의 가격 모니터링이나 Avito의 광고 수집과 같은 작업의 경우, 실제로는 조합이 더 효과적입니다: 무작위 프록시 선택과 동일한 클라이언트의 요청 간에 1-3초의 무작위 지연을 추가하는 것입니다.
스티키 세션에 대해 별도로 언급할 필요가 있습니다. 파서가 다단계 시나리오(로그인 → 페이지 전환 → 작업)를 수행하는 경우, 세션 중간에 IP를 변경할 수 없습니다 — 사이트는 활성 쿠키 세션에서 IP 변경을 감지하고 계정을 로그아웃시키거나 차단할 수 있습니다. 이러한 시나리오에서는 특정 "세션"의 파서에 대해 하나의 클라이언트(및 따라서 하나의 IP)를 고정하세요.
오류 처리 및 재시도 로직
프록시는 가끔씩 작동하지 않습니다: 타임아웃, 407 (프록시 인증 필요), TCP 연결 끊김. 파서는 "프록시가 고장났습니다 — 풀에서 제외"와 "사이트가 429를 반환했습니다 — 그냥 기다리고 IP를 변경하세요"를 구분할 수 있어야 합니다. 다음은 재시도 및 죽은 프록시를 제외하는 작업 패턴입니다:
use reqwest::StatusCode;
use std::time::Duration;
async fn fetch_with_retry(
pool: &RotatingPool,
url: &str,
max_attempts: u32,
) -> Result<String, String> {
let mut last_error = String::new();
for attempt in 0..max_attempts {
let client = pool.next_random();
match client.get(url).send().await {
Ok(resp) if resp.status() == StatusCode::TOO_MANY_REQUESTS => {
last_error = "429: 요청 한도 초과, IP를 변경합니다".into();
tokio::time::sleep(Duration::from_millis(500 * (attempt as u64 + 1))).await;
continue;
}
Ok(resp) if resp.status().is_success() => {
return resp.text().await.map_err(|e| e.to_string());
}
Ok(resp) => {
last_error = format!("예상치 못한 상태: {}", resp.status());
}
Err(e) if e.is_timeout() || e.is_connect() => {
last_error = format!("프록시 문제: {e}");
continue;
}
Err(e) => {
last_error = e.to_string();
}
}
}
Err(format!("모든 시도가 소진되었습니다: {last_error}"))
}
중요한 점: 지수 대기 시간 (500 * attempt)
는 대량의 429 응답이 발생할 때 대상 사이트에 대한 부하를 줄이고 동시에 전체 프록시 풀의 완전 차단 위험을 줄입니다.
프로덕션 파서의 경우, 풀 내 각 프록시에 대한 오류 카운터를 추가하고, 연속적으로 5회 이상의 오류를 발생시키는 주소를 자동으로 제외하세요 —
이는 이미 차단된 IP를 통해 무의미한 시도를 피하는 데 도움이 됩니다.
파싱을 위한 프록시 유형 선택
프록시 유형 선택은 차단되기 전에 얼마나 많은 성공적인 요청을 받을 수 있는지에 직접적인 영향을 미칩니다. 데이터 센터 IP는 가장 저렴하고 빠르지만, 잘 알려진 호스팅 제공업체의 범위에 속합니다 — 마켓플레이스 및 소셜 미디어의 안티봇 시스템은 이를 거의 즉시 ASN 데이터베이스를 통해 감지합니다. 주거용 및 모바일 IP는 일반 사용자처럼 보이므로 더 오랫동안 살아남습니다.
| 프록시 유형 | 속도 | 차단 위험 | 언제 사용해야 하는가 |
|---|---|---|---|
| 데이터 센터 | 높음 | 높음 | 열린 API, 안티봇이 없는 사이트 |
| 주거용 | 중간 | 낮음 | Wildberries, Ozon, Avito, 소셜 미디어 |
| 모바일 | 중간 | 최소 | Instagram, TikTok, 엄격한 안티봇 |
대량의 마켓플레이스 파싱을 위해서는 주거용 프록시가 합리적인 균형을 이룹니다: 모바일보다 트래픽당 비용이 덜 비쌉니다. 그러나 데이터 센터 범위를 차단하는 "실제 사용자" 수준의 검사를 통과합니다. 만약 파서가 소셜 미디어 API를 통해 작동하거나 모바일 애플리케이션을 모방한다면, 모바일 프록시는 통신사에 고정된 IP 덕분에 캡차 및 임시 차단의 비율이 가장 낮습니다.
성능 및 부하에 대한 조언
Rust는 하나의 인스턴스에서 수천 개의 동시 요청을 유지할 수 있지만, 프록시 제공자와 대상 사이트는 여전히 실제 대역폭을 제한합니다. 프로덕션 파서를 위한 몇 가지 실용적인 규칙:
tokio::sync::Semaphore를 통해 병렬성을 제한하세요 — 하나의 프록시 풀에 대해 20-50개의 동시 요청을 초과하지 마세요, 그렇지 않으면 일부 연결이 타임아웃으로 끊어질 수 있습니다.pool_idle_timeout을 ClientBuilder에서 사용하여 reqwest가 불안정한 프록시를 통해 죽은 연결을 유지하지 않도록 하세요.- 각 프록시별로 HTTP 상태 및 응답 시간을 기록하세요 — 이렇게 하면 어떤 주소가 "썩기" 시작했는지 빠르게 확인하고 교체할 수 있습니다.
- IP 회전과 함께 User-Agent를 회전하면 탐지 가능성을 크게 줄입니다: 이를 동기화하여 변경하고, 개별 주기로 변경하지 마세요.
- 장기 생존하는 파서를 위해 헬스 체크를 추가하세요: 몇 분마다 각 프록시를 통해 https://httpbin.org/ip에 테스트 요청을 보내고 응답하지 않는 프록시를 제외하세요.
TLS 핑거프린트(JA3/JA3S)에 특별한 주의를 기울이세요. reqwest는 기본적으로 rustls 또는 nativetls를 사용하며,
이는 빌드 기능에 따라 다릅니다. 두 가지 모두 TLS 핸드셰이크의 핑거프린트가 다릅니다. 일부 안티봇 시스템(예: 대형 마켓플레이스)은
IP뿐만 아니라 TLS 핑거프린트를 분석합니다 — 따라서 완벽한 프록시라도 클라이언트가 비표준 TLS 핸드셰이크를 제공하면
구제되지 않습니다. "깨끗한" 프록시에서 차단되는 경우, reqwest 기능을 확인하고 rustls 대신 native-tls 기능을 시도하거나 그 반대의 경우도 시도해 보세요.
결론
Rust reqwest에서 프록시는 빠르게 설정됩니다 — Proxy::all
또는 SOCKS5 스킴 socks5h://로 몇 줄이면 됩니다.
주요 작업은 그 다음에 시작됩니다: 클라이언트 풀 구축, 회전 전략 선택(라운드 로빈, 무작위 또는 스티키 세션) 및
오류 처리 및 비작동 주소를 풀에서 제외하는 것입니다. 이를 수행하지 않으면 가장 빠른 Rust 파서도 몇 분 내에 차단될 것입니다.
마켓플레이스나 소셜 미디어와 함께 작업하는 경우, 안티봇 보호가 요청 빈도뿐만 아니라 IP 출처를 분석하는 경우, 주거용 프록시로 시작하세요 — 이들은 가격과 세션 생존성의 최상의 균형을 제공합니다. Instagram 및 TikTok과 같은 더 엄격한 목표를 위해서는 모바일 IP를 테스트하고, 안티봇이 없는 간단한 작업에는 빠르고 저렴한 데이터 센터 프록시로 충분합니다.