Scrapy는 스크립트 하나로 감당하기 어려운 규모가 되었을 때 찾게 되는 프레임워크입니다. 스케줄링, 동시성, 재시도, 파이프라인을 기본으로 처리합니다. 레지덴셜 프록시를 추가하는 것은 어렵지 않지만, Scrapy는 일반 HTTP 클라이언트와 다른 방식으로 처리합니다. 프록시는 다운로더 미들웨어가 처리하는 요청별 설정이며, 이 아키텍처에는 로테이션을 조용히 망가뜨리는 인증 관련 함정이 하나 있습니다. 미들웨어 모델을 이해하면 모든 것이 명확해집니다.
이 글은 requests와 httpx를 다룬 Python으로 레지덴셜 프록시 사용하기에 이어지는 Scrapy 편입니다. Scrapy의 다운로더 미들웨어 파이프라인은 성격이 다르기 때문에 여기서 별도로 다룹니다.
아래 내용은 모두 Shifter의 레지덴셜 게이트웨이를 사용합니다. 엔드포인트는 p.shifter.io:443 하나이며, 모든 타겟팅 정보는 사용자 이름에 인코딩됩니다. 호스트와 인증 정보를 다른 프로바이더의 것으로 바꾸면, 구조는 동일합니다.
게이트웨이 모델을 한 문단으로
프록시 사용자 이름에는 인증 정보와 타겟팅 정보가 함께 담깁니다. 국가나 세션을 바꾸기 위해 엔드포인트를 전환하는 것이 아니라, 사용자 이름 문자열을 바꿉니다.
customer-USERNAME-country-us-sid-abc123-ttl-600country-us는 미국을 타겟팅하고, sid는 고정 세션을 지정하며, ttl은 해당 IP를 N초 동안 유지합니다. sid/ttl을 생략하면 매 연결마다 로테이션됩니다. 비밀번호는 고정된 값을 유지합니다. Scrapy에서는 이 사용자 이름이 Proxy-Authorization 헤더가 되며, 아이덴티티를 로테이션한다는 것은 이를 요청별로 바꾼다는 의미입니다.
Scrapy가 프록시를 처리하는 방식
Scrapy는 모든 요청의 프록시를 내장된 HttpProxyMiddleware를 통해 라우팅하며, 이 미들웨어는 request.meta['proxy']를 읽습니다. 흔히 생각하기 쉬운 방식은 이 URL에 인증 정보를 직접 넣는 것입니다.
# The tempting one-liner. It works, until you rotate.request.meta['proxy'] = 'http://customer-USER-country-us:PASS@p.shifter.io:443'이는 고정된 단일 아이덴티티에는 잘 작동합니다. 하지만 로테이션을 시도하는 순간 문제가 생기는데, 그 이유는 알아둘 만한 함정입니다.
함정: Proxy-Authorization이 캐시된다
HttpProxyMiddleware는 프록시 URL에 인증 정보가 있으면 이를 base64로 인코딩하여 Proxy-Authorization 헤더로 만들고, 결정적으로 이 헤더를 요청에 캐시합니다. 이후 요청이 재시도되거나 리다이렉트되면서 meta['proxy']가 다른 아이덴티티로 바뀌더라도, 미들웨어가 항상 헤더를 다시 계산하는 것은 아니어서, 요청은 이전 사용자 이름에 대한 오래된 Proxy-Authorization을 그대로 사용한 채 나가게 됩니다. 사용자 이름에 지역과 세션 정보가 담기는 게이트웨이에서는, 이는 곧 로테이션이 조용히 작동하지 않는다는 뜻입니다. meta['proxy']의 사용자 이름은 바꿨지만, 요청은 여전히 이전 아이덴티티로 인증됩니다.
해결책은 애초에 프록시 URL에 인증 정보를 넣지 않는 것입니다. 프록시 호스트는 userinfo 없이 설정하고, Proxy-Authorization 헤더는 매 요청마다 직접, 명시적으로 설정합니다. 바로 이것이 커스텀 미들웨어가 필요한 이유입니다.
커스텀 로테이션 미들웨어
meta['proxy']에는 인증 정보 없이 호스트만 넣고, 원하는 아이덴티티로부터 요청별로 인증 헤더를 계산합니다. 타겟팅 정보가 사용자 이름에 담기기 때문에, 국가와 세션을 선택하는 것은 곧 올바른 사용자 이름을 만드는 것입니다.
import osfrom w3lib.http import basic_auth_header
class ShifterProxyMiddleware: def __init__(self): self.user = os.environ['SHIFTER_USER'] self.password = os.environ['SHIFTER_PASS'] self.endpoint = 'http://p.shifter.io:443' # host only, no credentials
def process_request(self, request, spider): country = request.meta.get('country', 'us') sid = request.meta.get('sid') # set for a sticky session, omit to rotate username = f"{self.user}-country-{country}" + (f"-sid-{sid}-ttl-600" if sid else "") request.meta['proxy'] = self.endpoint request.headers['Proxy-Authorization'] = basic_auth_header(username, self.password)이를 활성화하고, 내장 프록시 미들웨어보다 먼저 실행되도록 하여 여러분이 설정한 헤더가 그대로 전달되도록 합니다.
DOWNLOADER_MIDDLEWARES = { 'myproject.middlewares.ShifterProxyMiddleware': 350, # before HttpProxyMiddleware (750)}이제 모든 요청은 새로 계산된 자신만의 Proxy-Authorization을 갖게 되므로, 요청의 meta에서 country나 sid를 바꾸면 실제로 아이덴티티가 바뀝니다. 하나의 논리적 작업 단위에 속하는 요청들에는 sid를 설정하여 같은 IP를 공유하게 하고, 연결마다 로테이션하려면 이를 생략합니다 (고정 세션과 로테이션의 차이에서 이 구분을 다룹니다). 이런 방식으로 작업을 아이덴티티에 매핑하는 것이 Scrapy 형태의 로드 밸런싱 패턴입니다.
스케줄뿐 아니라 재시도 시에도 로테이션하기
Scrapy의 RetryMiddleware는 타임아웃과 5xx 응답을 이미 재시도하지만, 기본적으로는 같은 아이덴티티로 재시도하는데, 실패의 원인이 바로 그 아이덴티티가 차단된 것이었다면 이는 의미가 없습니다. 가장 효과적인 방법은 요청이 실패하거나 챌린지 응답을 받았을 때 특별히 아이덴티티를 로테이션하는 것입니다. 미들웨어에서 소프트 블록이나 403/429를 감지하고, 새로운 아이덴티티로 요청을 다시 스케줄합니다.
def process_response(self, request, response, spider): if response.status in (403, 429) or looks_blocked(response): new = request.copy() new.meta.pop('sid', None) # drop the burned session -> fresh IP new.dont_filter = True return new # retry through a new identity return response소프트 블록을 감지하는 것은 그 자체로 별도의 훈련이 필요한 영역입니다. 200 응답도 여전히 차단 페이지일 수 있으므로, 이 로직은 차단되거나 가짜 콘텐츠 감지하기의 점검 방식과 함께 사용해야 합니다. 챌린지 응답을 그냥 받아들이는 대신 로테이션 신호로 취급하는 것이 긴 크롤링을 계속 살아있게 만드는 핵심입니다.
Scrapy의 정중함 관련 설정 활용하기
Scrapy는 직접 만든 스크래퍼라면 스스로 구축해야 할 속도 제한 제어 기능을 제공하며, 프록시 풀을 사용할 때는 그 중요성이 줄어드는 것이 아니라 오히려 커집니다. 하나의 대상이 과도하게 요청받지 않도록 도메인별 동시성을 제한하고, 지연을 추가하며, AutoThrottle을 켜서 사이트의 응답에 맞춰 적응하게 합니다.
CONCURRENT_REQUESTS = 32CONCURRENT_REQUESTS_PER_DOMAIN = 8 # per-target cap, the one that mattersDOWNLOAD_DELAY = 0.5AUTOTHROTTLE_ENABLED = TrueRETRY_ENABLED = TrueRETRY_TIMES = 3도메인별 동시성은 프록시 풀을 분산형 망치로 만들지 않도록 막아주는 설정입니다. 대상의 허용 범위를 넘어서는 병렬성은 처리량이 아니라 차단만 늘릴 뿐입니다 (차단 피하는 방법과 책임감 있는 스크래핑 모두 관련이 있습니다). AutoThrottle이 느린 응답에 맞춰 속도를 늦추는 것이 바로 건강하게 오래 지속되는 크롤링에 필요한 절제입니다.
실제로 프록시를 사용하고 있는지 확인하기
스파이더를 IP 에코 엔드포인트에 연결하여, 실행을 신뢰하기 전에 출구 IP를 확인합니다.
def start_requests(self): yield scrapy.Request('http://ip-api.com/json', meta={'country': 'us'}, callback=self.parse) # expect a US residential IP자신의 IP가 나온다면 미들웨어가 적용되지 않았거나 HttpProxyMiddleware보다 뒤에 순서가 배치된 것입니다. 타임아웃이 대량으로 발생한다면 인증 헤더가 잘못되었거나 누락된 것입니다. 두 경우 모두 타임아웃 진단 가이드에서 다룹니다.
FAQ
Scrapy에서 프록시 로테이션이 실제로는 작동하지 않는 이유는 무엇인가요?
거의 확실히 Proxy-Authorization 캐싱 함정입니다. 프록시 URL에 인증 정보를 넣었고, HttpProxyMiddleware가 인증 헤더를 캐시했기 때문에, 재시도 시 meta['proxy']를 바꿔도 요청은 여전히 이전 인증 정보를 보냅니다. 인증 정보 없이 호스트만 설정하고, 미들웨어에서 요청별로 직접 Proxy-Authorization 헤더를 계산하세요.
타겟팅 플래그는 어디에 들어가나요?
사용자 이름 안에 들어가며, 이는 Proxy-Authorization이 됩니다. 커스텀 미들웨어는 요청별 meta로부터 customer-USER-country-<cc>-sid-<id>-ttl-<sec>를 구성하므로, 지역과 세션을 선택하는 것은 곧 요청에 country와 sid를 설정하는 것입니다.
특정 요청에 고정 세션을 부여하려면 어떻게 하나요?
해당 요청의 meta에 고정된 sid를 설정하고, 함께 처리되어야 하는 요청들에서 이를 재사용하세요. 매 연결마다 로테이션하려면 sid를 생략합니다. 미들웨어가 이를 올바른 사용자 이름으로 변환합니다.
모든 요청마다 로테이션해야 하나요, 아니면 재시도 시에만 해야 하나요? 둘 다 필요한 경우가 있습니다. 일반적인 트래픽에는 논리적 작업 단위마다 로테이션하고, 추가로 요청이 차단되거나 속도 제한에 걸렸을 때는 강제로 새로운 아이덴티티를 사용하여 소진된 IP로 자기 자신을 재시도하지 않도록 합니다.
로테이팅 프록시를 사용할 때도 DOWNLOAD_DELAY와 AutoThrottle이 필요한가요? 그렇습니다. 로테이션은 부하를 여러 IP에 분산시키지만, 도메인별 동시성, 지연, AutoThrottle은 IP 수가 얼마나 많든 상관없이 하나의 대상에 과부하를 주지 않도록 막아줍니다. 정중함과 로테이션은 서로 다른 문제를 해결합니다.
결론
Scrapy와 레지덴셜 프록시의 조합은 하나의 함정을 우회하면 강력해집니다. 프록시 URL에 인증 정보를 넣지 마세요. 캐시된 Proxy-Authorization이 로테이션을 조용히 무력화하기 때문입니다. 대신, meta['proxy']에 호스트만 설정하고 원하는 아이덴티티로부터 요청별로 Proxy-Authorization 헤더를 계산하는 작은 다운로더 미들웨어를 작성하고, 논리적 작업 단위마다 그리고 차단되거나 속도 제한된 응답을 받을 때마다 아이덴티티를 다시 로테이션하며, Scrapy의 도메인별 동시성과 AutoThrottle을 활용하여 정중함을 유지하세요.
이렇게 하면 Scrapy의 스케줄러, 재시도, 파이프라인이 프록시 계층과 반목하지 않고 함께 작동합니다. 크롤링을 레지덴셜 게이트웨이로 향하게 하고, 풀의 품질이 애초에 재시도가 얼마나 자주 필요한지를 결정한다는 점을 기억하세요 (IP 평판). 가격 페이지에서 GB당 요금제를 확인하고 여러분의 타겟에 맞춰 테스트해 볼 수 있습니다.