Scrapy 프록시 미들웨어 완벽 가이드: 주거용 프록시 회전부터 배포까지

Scrapy의 다운로더 미들웨어 아키텍처를 기반으로 주거용 프록시 회전, 실패 재시도, JS 렌더링, 배포, 모니터링까지 프로덕션급 스크래퍼를 구축하는 방법을 코드 중심으로 설명합니다.

Scrapy 프록시 미들웨어 완벽 가이드: 주거용 프록시 회전부터 배포까지

왜 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에 내장된 HttpProxyMiddlewarerequest.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 파이프라인을 구축하라.

시작할 준비가 되셨나요?

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

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