Scrapy 파서가 타임아웃으로 중단되고, 프록시 풀의 소모가 예상보다 몇 배나 빨라지며, 로그가 407 및 403 응답으로 가득 차 있다면 — 익숙한 상황인가요? 90%의 경우 문제는 프록시에 있는 것이 아니라 DownloaderMiddleware의 작성 방식에 있습니다. 비싼 트래픽을 쓰레기 요청으로 바꾸는 미들웨어에서 가장 흔한 다섯 가지 오류를 분석하고, 이를 수정하는 방법을 코드와 함께 보여드립니다.
오류 1: 프록시 상태를 고려하지 않은 단순 회전
튜토리얼에서 가장 흔히 볼 수 있는 구조는 random.choice(PROXY_LIST)를 process_request 내에서 사용하는 것입니다. 문제는 이러한 회전 방식이 어떤 프록시가 방금 차단되었고 어떤 프록시가 여전히 살아 있는지를 알지 못한다는 것입니다. 결국 파서는 이미 차단된 IP를 통해 요청을 계속 보내고, 403/429 응답을 받고, 재시도를 하며 — 다시 같은 주소를 선택하게 됩니다. 왜냐하면 선택이 무작위이며 "나쁜" 노드를 제외하지 않기 때문입니다.
올바른 접근 방식은 각 프록시의 상태를 추적하는 것입니다: 성공적인 요청 수, 오류 수, 마지막 사용 시간. 다음은 최소한의 작동 가능한 예입니다:
import random
import time
class ProxyPool:
def __init__(self, proxies):
self.proxies = {p: {"fails": 0, "last_used": 0, "banned_until": 0} for p in proxies}
def get_proxy(self):
now = time.time()
available = [
p for p, state in self.proxies.items()
if state["banned_until"] < now
]
if not available:
# 모든 프록시가 차단된 경우 가장 "오래된" 차단을 초기화합니다.
available = list(self.proxies.keys())
return random.choice(available)
def mark_fail(self, proxy, cooldown=300):
self.proxies[proxy]["fails"] += 1
self.proxies[proxy]["banned_until"] = time.time() + cooldown
def mark_success(self, proxy):
self.proxies[proxy]["fails"] = 0
self.proxies[proxy]["last_used"] = time.time()
이러한 풀은 "쿨다운" 시간 동안 차단된 IP를 제외하고, 나중에 다시 사용 가능하게 합니다. 이는 같은 차단된 노드를 반복해서 요청하지 않기 때문에 트래픽 소모를 크게 줄입니다.
오류 2: 재시도 및 상태 코드의 잘못된 처리
두 번째 전형적인 오류는 수정 없이 Scrapy의 기본 RetryMiddleware를 사용하는 것입니다. 기본적으로 이 미들웨어는 명시적으로 프록시를 변경하지 않는 한, 실패한 동일한 프록시에서 요청을 재시도합니다. 결과적으로 우리는 고전적인 상황을 얻게 됩니다: 3번의 재시도, 3번의 차단, 요청은 여전히 실패하고, 트래픽은 이미 소모되었습니다.
두 번째 사항은 모든 상태 코드를 동일하게 재시도할 필요는 없다는 것입니다. 429 (Too Many Requests)는 대기 및 IP 변경이 필요하고, 403은 일반적으로 특정 프록시의 차단을 의미합니다 (즉각적인 교체 필요), 5xx는 종종 서버 측의 일시적인 문제이므로 동일한 프록시에서 재시도할 수 있습니다. 모든 것을 한데 묶으면, 미들웨어는 너무 공격적으로 프록시를 소모하거나, 즉시 IP를 변경해야 할 곳에서 너무 오랜 시간을 기다리게 됩니다.
class SmartRetryMiddleware:
def __init__(self, pool):
self.pool = pool
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy")
if response.status in (403, 407):
if proxy:
self.pool.mark_fail(proxy, cooldown=600)
new_request = request.copy()
new_request.meta["proxy"] = self.pool.get_proxy()
new_request.dont_filter = True
return new_request
if response.status == 429:
if proxy:
self.pool.mark_fail(proxy, cooldown=120)
new_request = request.copy()
new_request.meta["proxy"] = self.pool.get_proxy()
new_request.dont_filter = True
return new_request
if proxy:
self.pool.mark_success(proxy)
return response
여기서 중요한 것은 오류 유형에 따라 쿨다운을 구분하는 것입니다: 강력한 차단 (403/407) — 긴 대기, 요청 한도 (429) — 짧은 대기. 이는 긴 실행에서 수십 퍼센트의 트래픽을 절약합니다.
오류 3: 인증이 필요한 사이트에 대한 스티키 세션의 부재
파서가 로그인, 장바구니, 상태 유지가 필요한 페이지네이션 또는 IP 확인을 위한 CAPTCHA가 있는 사이트와 작업할 때 — 매 요청마다 프록시를 변경하면 세션이 끊어집니다. 사이트는 요청 №1이 한 IP에서 왔고, 요청 №2 (같은 쿠키 세션 내에서)가 다른 IP에서 왔다는 것을 인식하고, 이는 봇 방지 시스템을 즉시 트리거합니다. 두 IP 모두 "깨끗한" 경우에도 마찬가지입니다.
해결책은 하나의 프록시를 하나의 논리적 세션 (예: 특정 계정이나 하나의 도메인 내의 요청 체인)에 고정된 시간 동안 연결하는 것입니다. 이를 스티키 세션이라고 합니다.
class StickySessionMiddleware:
def __init__(self, pool, ttl=600):
self.pool = pool
self.ttl = ttl
self.sessions = {} # session_id -> (proxy, expires_at)
def process_request(self, request, spider):
session_id = request.meta.get("session_id")
if not session_id:
return
now = time.time()
session = self.sessions.get(session_id)
if session and session[1] > now:
request.meta["proxy"] = session[0]
else:
proxy = self.pool.get_proxy()
self.sessions[session_id] = (proxy, now + self.ttl)
request.meta["proxy"] = proxy
세션 동안 IP의 안정성이 필요한 작업 — 인증, 개인 계정 작업, 다단계 양식 — 에는 주거용 프록시가 가장 적합합니다. 이들은 동일한 출발 IP를 몇 분 또는 몇 시간 동안 유지할 수 있으며, 이후에는 무작위가 아닌 통제된 방식으로 변경할 수 있습니다.
오류 4: 프록시 인증의 잘못된 전달
네 번째 오류는 기술적인 것이지만 거의 모든 두 번째 프로젝트에서 발생합니다. 개발자들은 프록시의 로그인과 비밀번호를 http://user:pass@ip:port 형식의 URL로 request.meta["proxy"]를 통해 전달합니다. 이는 대부분의 경우 작동하지만, HTTPS 터널을 통해 프록시를 사용할 때나 일부 공급자와 작업할 때는 이러한 인증 방식이 표준 HttpProxyMiddleware에 의해 올바르게 처리되지 않아, 요청이 407 Proxy Authentication Required로 실패하게 됩니다. 비밀번호가 올바른 경우에도 말입니다.
더 신뢰할 수 있는 방법은 Proxy-Authorization 헤더를 명시적으로 base64로 인코딩하여 전달하는 것입니다:
import base64
class ProxyAuthMiddleware:
def process_request(self, request, spider):
proxy = request.meta.get("proxy")
if not proxy:
return
# URL에 자격 증명이 없는 프록시
request.meta["proxy"] = proxy
user = request.meta.get("proxy_user")
password = request.meta.get("proxy_pass")
if user and password:
credentials = f"{user}:{password}"
encoded = base64.b64encode(credentials.encode()).decode()
request.headers["Proxy-Authorization"] = f"Basic {encoded}"
이러한 접근 방식은 수백 개의 병렬 요청으로 확장할 때 더 안정적으로 작동하며, 특정 라이브러리 버전이 URL을 어떻게 파싱하는지에 따라 달라지지 않습니다. 특히 모바일 프록시와 작업할 때는 인증이 종종 IP 화이트리스트 또는 헤더의 엄격한 검증에 의존하기 때문에 매우 중요합니다.
오류 5: 차단 모니터링 및 로깅의 부재
마지막으로, 아마도 결과적으로 가장 비용이 많이 드는 오류는 어떤 프록시가 차단되는지, 얼마나 자주 차단되는지, 어떤 도메인에서 차단되는지를 로깅하지 않는 것입니다. 이러한 데이터가 없으면 무엇이 트래픽을 소모하는지 이해할 수 없습니다: 프록시 풀이 특정 사이트에서 소진되었는지, 아니면 파서 자체에 문제가 있는지 (너무 잦은 요청, 지연 없음, 의심스러운 헤더).
미들웨어에서 로깅해야 할 최소한의 메트릭 세트는 다음과 같습니다:
- 파싱 세션 동안 각 프록시당 요청 수
- 각 프록시에 대한 오류 수 및 코드 (403, 407, 429, 5xx)
- 첫 번째 차단까지의 프록시 수명
- 차단이 가장 자주 발생하는 도메인
import logging
import json
logger = logging.getLogger("proxy_stats")
class ProxyStatsMiddleware:
def __init__(self):
self.stats = {}
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy", "unknown")
domain = request.url.split("/")[2]
key = f"{proxy}|{domain}"
entry = self.stats.setdefault(key, {"requests": 0, "errors": 0})
entry["requests"] += 1
if response.status in (403, 407, 429):
entry["errors"] += 1
if entry["requests"] % 50 == 0:
logger.info(json.dumps(self.stats))
return response
이러한 통계 없이는 미들웨어를 "최적화"하려는 모든 시도가 추측으로 귀결됩니다. 이를 통해 특정 도메인이 처음 10개의 요청에서 80%의 프록시 풀을 차단하는 경우 — 문제는 프록시에 있는 것이 아니라 요청 패턴에 있다는 것을 확실히 알 수 있습니다 (User-Agent 회전 없음, 너무 높은 빈도, 요청 간 지연 없음).
전체 미들웨어의 작동 예제
모든 것을 settings.py에 모읍니다 — 미들웨어의 순서가 중요하며, 이는 검증이 적용되는 순서에 영향을 미칩니다:
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyAuthMiddleware": 350,
"myproject.middlewares.StickySessionMiddleware": 400,
"myproject.middlewares.SmartRetryMiddleware": 550,
"myproject.middlewares.ProxyStatsMiddleware": 900,
}
RETRY_ENABLED = False # 기본 재시도를 비활성화하고 사용자 정의 재시도를 사용합니다.
DOWNLOAD_TIMEOUT = 15
CONCURRENT_REQUESTS_PER_DOMAIN = 8
RETRY_ENABLED = False에 주목하세요 — 이는 매우 중요합니다. 그렇지 않으면 Scrapy의 내장 재시도 메커니즘이 프록시 변경 논리와 충돌하여 요청이 중복되거나 두 번 재시도하게 됩니다. 또한 CONCURRENT_REQUESTS_PER_DOMAIN를 제한하는 것도 중요합니다 — 하나의 도메인에 대해 너무 높은 병렬성은 프록시 회전이 있더라도 안티봇 시스템에 의심스럽게 보일 수 있습니다.
Scrapy에 적합한 프록시 유형 선택하기
미들웨어는 문제의 절반을 해결하지만, 나머지 절반은 작업에 적합한 프록시 풀을 올바르게 선택하는 것입니다. 아래는 일반적인 파싱 시나리오에 대한 비교입니다.
| 프록시 유형 | 언제 사용해야 하는가 | 장점 | 단점 |
|---|---|---|---|
| 데이터 센터 프록시 | 엄격한 안티봇 보호 없이 공개 페이지 대량 파싱 | 높은 속도, 낮은 트래픽 비용 | 쉽게 감지되며, 종종 블랙리스트에 올라갑니다. |
| 주거용 프록시 | 마켓플레이스, JS 보호가 있는 사이트, 인증 파싱 | 실제 IP, 낮은 차단 비율, 스티키 세션 지원 | DC에 비해 속도가 느립니다. |
| 모바일 프록시 | 엄격한 안티봇 보호가 있는 사이트 및 API의 모바일 버전 파싱 | 대상 사이트로부터 최대 신뢰를 얻습니다. | 가장 높은 트래픽 비용 |
실용적인 규칙: 강력한 보호 없이 정적 페이지를 파싱할 경우, 이 기사에서 설명한 적절한 미들웨어와 함께 저렴한 데이터 센터 프록시를 사용하는 것이 좋습니다. 사이트가 Cloudflare, PerimeterX, DataDome 또는 유사한 시스템을 사용하는 경우 — 데이터 센터 IP는 거의 즉시 차단되며, 이 경우 주거용 풀로 즉시 전환하는 것이 더 유리하여 미들웨어 디버깅에 소요되는 시간을 절약할 수 있습니다.
파서를 프로덕션에 배포하기 전 체크리스트
- 미들웨어는 쿨다운 동안 차단된 프록시를 제외하고 무작위로 선택하지 않습니다.
- 재시도 논리는 오류 유형 (403/407 vs 429 vs 5xx)에 따라 서로 다른 쿨다운을 구분합니다.
- 세션 작업에는 요청마다 프록시를 변경하는 것이 아니라 스티키 바인딩을 사용합니다.
- 프록시 인증은 URL뿐만 아니라 Proxy-Authorization 헤더를 통해 전달됩니다.
- 문제 진단을 위해 도메인 및 프록시 차단 통계를 기록합니다.
- 기본 RetryMiddleware Scrapy는 사용자 정의 논리와 충돌하지 않도록 비활성화됩니다.
- 동시성은 합리적인 값으로 제한되며 최대치로 설정되지 않습니다.
결론
Scrapy에서 프록시 트래픽 소모와 관련된 대부분의 문제는 더 많은 IP 풀을 구매하는 것이 아니라 미들웨어 논리를 수정하는 것으로 해결됩니다: 오류를 유형별로 구분하고, 복잡한 시나리오에 대한 스티키 세션을 사용하고, 올바른 인증을 수행하며, 차단 모니터링을 지속적으로 수행하는 것입니다. 이 기사에서 제공하는 코드는 특정 프로젝트에 맞게 조정할 수 있는 기초로 사용할 수 있으며, 구조는 소규모 파서와 분산 Scrapy 클러스터 설치 모두에서 작동합니다.
미들웨어가 이미 올바르게 설정되어 있고 차단이 여전히 너무 자주 발생한다면 — 문제는 IP 풀의 품질일 가능성이 높습니다. 고급 안티봇 보호가 있는 사이트를 파싱할 경우, 세션 지원이 있는 주거용 프록시를 시도해보는 것이 좋습니다 — 이는 동일한 미들웨어 논리를 사용할 때 데이터 센터 주소에 비해 보호 작동 비율을 현저히 줄입니다.