왜 Scrapy에서 프록시 미들웨어가 핵심인가
Scrapy로 대규모 크롤링을 운영하다 보면 반드시 만나는 벽이 있다. 바로 IP 밴(IP ban)이다. 단일 IP로 수천 페이지를 요청하면 타겟 사이트의 레이트 리밋에 걸리거나, CAPTCHA가 뜨거나, 403이 반환된다. 이 문제를 해결하려면 Scrapy proxy middleware 계층에서 요청마다 다른 IP를 순환시키는 전략이 필요하다.
이 글에서는 Scrapy의 다운로더 미들웨어 모델을 분석하고, Scrapy residential proxies를 활용한 커스텀 프록시 회전 미들웨어를 직접 구현하며, 재시도 로직, JS 렌더링 연동, 배포, 모니터링까지 프로덕션 수준의 파이프라인을 완성한다.
Scrapy 다운로더 미들웨어 아키텍처
Scrapy의 요청-응답 라이프사이클에서 다운로더 미들웨어(Downloader Middleware)는 엔진과 다운로더 사이에 위치한다. 요청이 다운로더로 전달되기 전, 응답이 엔진으로 돌아오기 전에 개입할 수 있다.
핵심 훅(hook) 메서드는 다음과 같다:
process_request(request, spider)— 요청이 다운로더로 전달되기 전 호출. 여기서request.meta['proxy']를 설정한다.process_response(request, response, spider)— 다운로더가 응답을 반환한 후 호출. 상태 코드를 검사해 밴 여부를 판단할 수 있다.process_exception(request, exception, spider)— 타임아웃, 연결 거부 등 예외 발생 시 호출. 프록시 장애를 감지하는 지점이다.
Scrapy에 내장된 HttpProxyMiddleware는 request.meta['proxy']가 이미 설정된 경우 자바의 Proxy-Authorization 헤더를 자동으로 붙여주는 역할만 한다. 실제 IP 회전 로직은 없다. 따라서 커스텀 미들웨어에서 프록시 URL을 주입하고, 내장 HttpProxyMiddleware는 인코딩만 담당하도록 분리하는 것이 정석이다.
미들웨어 우선순위(priority)가 낮을수록 먼저 실행된다. 커스텀 프록시 주입 미들웨어는 내장 HttpProxyMiddleware(priority 560)보다 높은 숫자로 설정해야 나중에 실행되어 인코딩이 정상 적용된다.
커스텀 ProxyRotationMiddleware 구현
가장 중요한 코드 블록이다. ProxyHat의 주거용 프록시 풀에서 요청마다 다른 IP를 할당하고, 국가 타겟팅과 스티키 세션도 지원하는 미들웨어를 구현한다.
import random
import base64
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.log import configure_logging
class ProxyRotationMiddleware:
"""Scrapy residential proxy rotation middleware for ProxyHat."""
def __init__(self, username, password, country_codes=None, sticky=False):
self.username = username
self.password = password
self.country_codes = country_codes or []
self.sticky = sticky
self._sessions = {} # sticky session map: fingerprint -> proxy URL
@classmethod
def from_crawler(cls, crawler):
s = crawler.settings
return cls(
username=s.get('PROXYHAT_USER'),
password=s.get('PROXYHAT_PASS'),
country_codes=s.getlist('PROXYHAT_COUNTRIES', []),
sticky=s.getbool('PROXYHAT_STICKY', False),
)
def _build_proxy_url(self, request, spider):
"""Construct a ProxyHat proxy URL with geo-targeting."""
user = self.username
country = request.meta.get('proxy_country')
session_id = request.meta.get('proxy_session')
# Sticky session: reuse same proxy for same fingerprint
if self.sticky and not session_id:
fp = request.meta.get('download_fingerprint', request.url)
if fp in self._sessions:
return self._sessions[fp]
session_id = f'sess-{fp[:12]}'
# Build username with flags
parts = [user]
if country:
parts.append(f'country-{country}')
elif self.country_codes:
parts.append(f'country-{random.choice(self.country_codes)}')
if session_id:
parts.append(f'session-{session_id}')
constructed_user = '-'.join(parts)
proxy_url = f'http://{constructed_user}:{self.password}@gate.proxyhat.com:8080'
if self.sticky and session_id:
self._sessions[request.meta.get('download_fingerprint', request.url)] = proxy_url
return proxy_url
def process_request(self, request, spider):
# Skip proxy for non-HTTP(S)
if request.url.startswith('data:'):
return
# Allow per-request override
if request.meta.get('dont_proxy'):
return
proxy_url = self._build_proxy_url(request, spider)
request.meta['proxy'] = proxy_url
# ProxyHat uses URL auth, but set header as fallback
creds = base64.b64encode(
f'{self.username}:{self.password}'.encode()
).decode()
request.headers['Proxy-Authorization'] = f'Basic {creds}'
def process_response(self, request, response, spider):
# Detect bans and trigger retry with different proxy
if response.status in (403, 429, 503):
spider.logger.warning(
f'Ban detected: {response.status} for {request.url}'
)
request.meta['proxy'] = None # Force new proxy on retry
request.meta.pop('proxy_session', None) # Kill sticky session
request.dont_filter = True
return request.replace(meta=request.meta)
return response
def process_exception(self, request, exception, spider):
spider.logger.debug(
f'Proxy exception {type(exception).__name__} for {request.url}'
)
# On timeout/connection error, retry with fresh proxy
request.meta['proxy'] = None
request.meta.pop('proxy_session', None)
request.dont_filter = True
return request.replace(meta=request.meta)
settings.py에 등록한다:
# settings.py
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyRotationMiddleware': 570,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 580,
}
PROXYHAT_USER = 'myuser'
PROXYHAT_PASS = 'mypassword'
PROXYHAT_COUNTRIES = ['US', 'DE', 'JP']
PROXYHAT_STICKY = False
# Increase retry count for proxy rotation
RETRY_TIMES = 5
RETRY_HTTP_CODES = [403, 429, 500, 502, 503, 504]
우선순위 570으로 설정하면, ProxyRotationMiddleware.process_request가 먼저 request.meta['proxy']를 주입하고, 그다음 내장 HttpProxyMiddleware(580)가 Proxy-Authorization 헤더를 정리한다. 절대 560 이하로 설정하지 마라 — 그러면 프록시 URL이 주입되기 전에 내장 미들웨어가 실행되어 인증이 누락된다.
scrapy-rotating-proxies vs 직접 구현
커뮤니티 미들웨어인 scrapy-rotating-proxies는 빠른 프로토타이핑에 유용하지만, 프로덕션에서는 한계가 뚜렷하다.
| 기준 | scrapy-rotating-proxies | 커스텀 미들웨어 |
|---|---|---|
| 설정 복잡도 | 프록시 목록 파일만 제공 | 프록시 URL 생성 로직 필요 |
| 주거용 프록시 지원 | 제한적 (IP:PORT 목록 기반) | 게이트웨이 방식 완벽 지원 |
| 지오타겟팅 | 미지원 | 요청별 국가/도시 지정 가능 |
| 스티키 세션 | 미지원 | 세션 ID 기반 유지 가능 |
| 밴 감지 | 기본 상태 코드만 | 커스텀 로직 자유롭게 추가 |
| 유지보수 | 2020년 이후 업데이트 없음 | 완전한 제어권 |
결론: ProxyHat 같은 게이트웨이 방식 주거용 프록시를 사용한다면, 직접 구현하는 것이 확장성과 안정성 모두에서 유리하다. scrapy-rotating-proxies는 데이터센터 IP 리스트를 수동 관리하는 시나리오에만 적합하다.
재시도 미들웨어와 프록시 회전 연동
Scrapy의 내장 RetryMiddleware는 실패한 요청을 재시도하지만, 같은 프록시로 재시도하면 같은 밴을 다시 만난다. 프록시 회전과 재시도를 통합한 미들웨어를 작성하자.
from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.response import response_status_message
class ProxyRetryMiddleware(RetryMiddleware):
"""Retry with a fresh proxy on each attempt."""
def __init__(self, settings):
super().__init__(settings)
self.max_proxy_retries = settings.getint('PROXY_RETRY_TIMES', 3)
def process_response(self, request, response, spider):
# Track retry count per request
retry_count = request.meta.get('proxy_retry_count', 0)
if response.status in (403, 429, 503) and retry_count < self.max_proxy_retries:
reason = response_status_message(response.status)
spider.logger.info(
f'Proxy retry {retry_count + 1}/{self.max_proxy_retries} '
f'for {request.url} (status: {response.status})'
)
# Discard old proxy so ProxyRotationMiddleware assigns a new one
request.meta['proxy'] = None
request.meta['proxy_retry_count'] = retry_count + 1
request.meta.pop('proxy_session', None)
request.dont_filter = True
return self._retry(request, reason, spider) or response
return response
def process_exception(self, request, exception, spider):
retry_count = request.meta.get('proxy_retry_count', 0)
if retry_count >= self.max_proxy_retries:
return None # Give up, let default handler take over
spider.logger.debug(
f'Proxy exception retry {retry_count + 1} for {request.url}'
)
request.meta['proxy'] = None
request.meta['proxy_retry_count'] = retry_count + 1
request.meta.pop('proxy_session', None)
request.dont_filter = True
return self._retry(request, exception, spider)
settings.py에서 내장 RetryMiddleware를 비활성화하고 커스텀 버전으로 교체한다:
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyRotationMiddleware': 570,
'myproject.middlewares.ProxyRetryMiddleware': 550,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': None,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 580,
}
PROXY_RETRY_TIMES = 3
RETRY_TIMES = 2 # Fallback for non-proxy errors
우선순위가 ProxyRetryMiddleware(550) → ProxyRotationMiddleware(570) → HttpProxyMiddleware(580) 순으로 실행된다. 재시도 미들웨어가 request.meta['proxy'] = None으로 초기화하면, 그다음 실행되는 회전 미들웨어가 새 프록시를 할당하는 구조다.
JS 렌더링 환경에서 프록시 사용하기
최근 웹사이트는 JavaScript로 핵심 콘텐츠를 렌더링하는 경우가 많다. Scrapy에서 JS 렌더링을 다루는 두 가지 주요 접근을 살펴보자.
scrapy-splash + 프록시
Splash는 headless 브라우저 프록시 서버다. Splash 자체가 렌더링을 수행하므로, Splash 서버에서 프록시를 타도록 설정해야 한다.
# settings.py
SPLASH_URL = 'http://splash:8050'
DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,
'scrapy_splash.SplashMiddleware': 725,
'myproject.middlewares.ProxyRotationMiddleware': 570,
}
# In your spider, pass proxy to Splash:
def parse_listing(self, response):
yield SplashRequest(
response.url,
self.parse_detail,
endpoint='render.html',
args={
'proxy': 'http://user-country-US:pass@gate.proxyhat.com:8080',
'wait': 3,
},
)
scrapy-playwright + 프록시
scrapy-playwright는 Playwright 브라우저를 Scrapy와 통합한다. Playwright는 자체 프록시 설정을 지원하므로, request.meta를 통해 전달한다:
# settings.py
DOWNLOAD_HANDLERS = {
'http': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
'https': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
}
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
# Spider request with Playwright proxy
def start_requests(self):
yield scrapy.Request(
'https://example.com',
meta={
'playwright': True,
'playwright_proxy': {
'server': 'http://gate.proxyhat.com:8080',
'username': 'user-country-US',
'password': 'pass',
},
},
)
Playwright는 브라우저 컨텍스트 수준에서 프록시를 적용하므로, Scrapy의 다운로더 미들웨어가 아닌 Playwright 자체의 프록시 설정을 사용해야 한다. 이 경우 ProxyRotationMiddleware은 Playwright 요청을 스킵하도록 조건을 추가해야 한다:
def process_request(self, request, spider):
if request.meta.get('playwright'):
return # Playwright handles its own proxy
# ... rest of the logic
배포: Scrapyd, ScrapeOps, Docker + Cron
Scrapyd — 가벼운 자체 호스팅
Scrapyd는 Scrapy 스파이더를 데몬으로 실행하는 공식 서비스다. 소규모 프로젝트에 적합하다.
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["scrapyd"]
배포 후 curl로 스파이더를 스케줄한다:
curl http://scrapyd:6800/schedule.json \
-d project=myproject \
-d spider=price_monitor
ScrapeOps — 관리형 플랫폼
ScrapeOps는 Scrapy 스파이더를 관리형 환경에서 실행한다. 대시보드, 로깅, 스케줄링이 내장되어 있어 운영 부담을 줄여준다. 다만, 프록시 설정은 ProxyHat 게이트웨이를 그대로 사용하면 된다 — ScrapeOps 자체 프록시와 ProxyHat을 함께 쓸 필요는 없다.
Docker + Cron — 가장 유연한 선택
대규모 크롤링에서는 Docker 컨테이너로 스파이더를 실행하고 cron으로 스케줄링하는 방식이 가장 제어력이 높다.
# docker-compose.yml
version: '3.8'
services:
scraper:
build: .
environment:
- PROXYHAT_USER=${PROXYHAT_USER}
- PROXYHAT_PASS=${PROXYHAT_PASS}
volumes:
- ./data:/app/data
deploy:
replicas: 3 # Run 3 concurrent instances
resources:
limits:
memory: 1G
# Cron schedule (add to crontab on host)
# 0 */4 * * * cd /path/to/project && docker-compose up --build scraper
멀티 컨테이너 환경에서는 각 컨테이너가 다른 국가의 프록시를 사용하도록 환경 변수를 달리 설정할 수 있다:
# .env.us
PROXYHAT_COUNTRIES=US
# .env.de
PROXYHAT_COUNTRIES=DE
모니터링: IP별 성공률과 밴 감지
프록시 기반 크롤링에서 모니터링은 선택이 아닌 필수다. 어떤 IP가 밴당했는지, 어떤 국가의 성공률이 낮은지를 실시간으로 파악해야 한다.
스파이더 내 통계 수집
Scrapy의 stats 컬렉션을 활용해 국가별 성공/실패율을 추적하자:
class StatsMiddleware:
"""Collect per-country success/ban statistics."""
def __init__(self, stats):
self.stats = stats
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.stats)
def process_response(self, request, response, spider):
country = request.meta.get('proxy_country', 'unknown')
key = f'proxy/{country}'
if response.status == 200:
self.stats.inc_value(f'{key}/success')
elif response.status in (403, 429, 503):
self.stats.inc_value(f'{key}/banned')
else:
self.stats.inc_value(f'{key}/other')
return response
def process_exception(self, request, exception, spider):
country = request.meta.get('proxy_country', 'unknown')
self.stats.inc_value(f'proxy/{country}/error')
return None
크롤링 종료 시 scrapy.stats에서 국가별 성공률 리포트를 확인할 수 있다:
# Example stats output at spider close:
# proxy/US/success: 4521
# proxy/US/banned: 23
# proxy/DE/success: 3100
# proxy/DE/banned: 189 ← investigate this!
밴 감지 자동화
성공률이 임계값 이하로 떨어지면 슬랙 알림을 보내는 익스텐션을 추가하자:
from scrapy import signals
import requests as http_requests
class BanAlertExtension:
def __init__(self, slack_webhook, threshold=0.85):
self.slack_webhook = slack_webhook
self.threshold = threshold
@classmethod
def from_crawler(cls, crawler):
ext = cls(
slack_webhook=crawler.settings.get('SLACK_WEBHOOK_URL'),
threshold=crawler.settings.getfloat('BAN_THRESHOLD', 0.85),
)
crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed)
return ext
def spider_closed(self, spider, reason):
stats = spider.crawler.stats.get_stats()
for key, value in stats.items():
if '/banned' in key and value > 50:
country = key.split('/')[1]
success = stats.get(key.replace('banned', 'success'), 0)
rate = success / (success + value) if (success + value) else 0
if rate < self.threshold:
msg = (
f'⚠️ Low success rate for {country}: '
f'{rate:.1%} ({success} success / {value} banned)'
)
http_requests.post(self.slack_webhook, json={'text': msg})
프록시 회전 전략 선택 가이드
용도에 따라 최적의 회전 전략이 다르다:
- SERP 스크래핑: 요청마다 새 IP(비순환). 속도가 중요하고 세션 유지가 불필요하다. SERP 트래킹 가이드 참고.
- 이커머스 가격 모니터링: 스티키 세션(5~10분). 로그인 상태를 유지해야 하는 경우가 많다.
- 소셜 미디어 수집: 국가별 스티키 세션. 특정 지역 콘텐츠를 일관되게 수집해야 한다.
- 대규모 데이터 수집: 비순환 + 높은 동시성. 웹 스크래핑 가이드에서 병렬 패턴을 확인하라.
Key Takeaways
- Scrapy에서 프록시 회전은 다운로더 미들웨어 계층에서 구현한다.
process_request에서request.meta['proxy']를 주입하는 것이 핵심 패턴이다. - 커스텀
ProxyRotationMiddleware를 직접 구현하면 지오타겟팅, 스티키 세션, 밴 감지를 완벽하게 제어할 수 있다. scrapy-rotating-proxies는 레거시 프로젝트에만 적합하다. - 재시도와 프록시 회전을 통합해야 한다. 같은 프록시로 재시도하면 밴 상태가 지속된다.
- Playwright/Splash 환경에서는 각각의 프록시 설정 방식을 사용해야 한다. Scrapy 미들웨어가 직접 프록시를 주입하지 않는다.
- 프로덕션에서는 국가별 성공률 모니터링과 밴 알림이 필수다. Scrapy stats + 슬랙 웹훅으로 자동화하라.
- 배포는 Docker + Cron이 가장 유연하고, ProxyHat 요금제에서 트래픽 용량에 맞는 플랜을 선택하면 된다.
마무리
Scrapy의 미들웨어 아키텍처는 Scrapy proxy rotation을 엘리건트하게 구현할 수 있는 구조를 제공한다. 핵심은 세 가지다: (1) process_request에서 프록시를 주입하고, (2) process_response에서 밴을 감지해 재시도하며, (3) process_exception에서 연결 장애를 복구한다. ProxyHat의 주거용 프록시 게이트웨이와 이 미들웨어 패턴을 결합하면, 대규모 크롤링에서도 안정적인 IP 회전과 지오타겟팅을 확보할 수 있다.
ProxyHat의 50개국 이상 주거용 프록시 로케이션을 확인하고, 오늘 바로 프로덕션급 Scrapy 파이프라인을 구축하라.






