레지덴셜 프록시를 Python 스크립트에 연결하는 작업은 한 번 해보면 5분이면 끝나는 일이다. 문제가 되는 것은 코드가 아니라 누구도 적어두지 않는 세부 사항들이다. 인증 정보가 어떻게 타겟팅을 인코딩하는지, IP를 언제 로테이션하고 언제 고정할지, 프록시 관련 오류를 어떻게 처리할지, 그리고 requests, httpx, Scrapy 사이의 작은 라이브러리 차이 같은 것들이다.
이 글은 필자가 처음 시작할 때 있었으면 했던 가이드다. 실제로 실행되는 복사-붙여넣기 예제를 가장 많이 사용될 세 가지 라이브러리별로 제공하고, 작동하는 스니펫을 프로덕션에서 살아남는 스크레이퍼로 바꿔주는 부분들도 함께 다룬다.
아래 내용은 모두 Shifter의 레지덴셜 게이트웨이를 사용한다. 엔드포인트는 p.shifter.io:443 하나이며, 모든 타겟팅은 사용자 이름에 인코딩된다. 다른 제공업체를 사용 중이라면 형태는 동일하니 호스트와 인증 정보 형식만 바꾸면 된다.
가장 먼저 이해해야 할 한 가지
레지덴셜 게이트웨이에서는 프록시 사용자 이름이 인증 정보와 타겟팅 정보를 함께 담는다. 국가나 세션을 바꾸기 위해 엔드포인트를 바꾸는 것이 아니라 사용자 이름 문자열을 바꾼다. 사용자 이름은 다음과 같은 모습이다:
customer-USERNAME-country-us-sid-abc123-ttl-600왼쪽부터 순서대로 읽으면 된다: 계정 ID, 그다음 플래그들. country-us는 미국을 타겟팅한다. sid-abc123은 고정 세션을 지정한다. ttl-600은 해당 IP를 600초 동안 유지한다. sid/ttl을 빼면 모든 요청이 새로운 IP로 로테이션된다. 비밀번호는 고정값이다. 이것이 전체 개념 모델이며, 나머지는 이 문자열을 각 라이브러리의 프록시 슬롯에 끼워 넣는 작업일 뿐이다.
인증 정보는 하드코딩하지 말고 환경 변수에 보관한다:
import osUSER = os.environ["SHIFTER_USER"] # your account usernamePASS = os.environ["SHIFTER_PASS"]GATEWAY = "p.shifter.io:443"requests: 6줄로 끝내기
requests는 proxies 딕셔너리를 받는다. http와 https 키 모두 동일한 http:// 프록시 URL을 가리키는데, 이것이 맞다. requests는 CONNECT를 통해 HTTPS를 이 프록시로 터널링한다.
import os, requests
USER = os.environ["SHIFTER_USER"]PASS = os.environ["SHIFTER_PASS"]GATEWAY = "p.shifter.io:443"
def proxy(country="us"): url = f"http://{USER}-country-{country}:{PASS}@{GATEWAY}" return {"http": url, "https": url}
r = requests.get("https://api.ipify.org?format=json", proxies=proxy("us"), timeout=30)print(r.json()) # {'ip': '<a US residential IP>'}두 번 실행하면 서로 다른 IP가 나오는데, sid가 없으므로 모든 요청이 로테이션되기 때문이다. 이것이 기본 동작이며, 대부분의 스크레이핑 작업에는 이 방식이 정확히 필요한 것이다.
로테이팅 vs 고정, 코드로 보기
이 부분이 사람들을 헷갈리게 만드는 지점이니 구체적으로 짚어본다. (개념적인 설명은 고정 vs 로테이팅 레지덴셜 프록시 문서를 참고하라.)
로테이팅 (요청마다 새 IP)은 기본값이며, sid를 생략하기만 하면 된다:
for _ in range(3): r = requests.get("https://api.ipify.org", proxies=proxy("us"), timeout=30) print(r.text) # three different IPs고정 (요청들 간에 동일한 IP)은 사용자 이름에 세션 ID와 TTL이 필요하다. 여러 요청에 걸쳐 하나의 흐름이 이어지고 이것이 한 사용자처럼 보여야 할 때 사용한다. 예를 들어 로그인 후 그 뒤의 페이지들을 이어서 요청하는 경우다:
def sticky_proxy(country="us", session="s1", ttl=600): url = f"http://{USER}-country-{country}-sid-{session}-ttl-{ttl}:{PASS}@{GATEWAY}" return {"http": url, "https": url}
s = requests.Session()p = sticky_proxy(session="checkout-42", ttl=600)for path in ("/login", "/cart", "/checkout"): r = s.get(f"https://shop.example{path}", proxies=p, timeout=30) # all three requests share one IP for up to 600s세션 ID는 직접 정하면 되며, 논리적 세션마다 고유한 문자열이면 된다. 동일한 문자열은 TTL이 만료될 때까지 동일한 IP를 반환하며, 만료 후에는 다른 플래그를 유지한 채로 로테이션된다.
지역 타겟팅
타겟팅이 사용자 이름에 담기기 때문에, 지역 설정도 그저 또 하나의 플래그일 뿐이다. 국가가 가장 흔한 경우이고, 주(state), 도시, ASN도 같은 방식으로 동작한다:
def geo_proxy(country, city=None): parts = [USER, "country", country] if city: parts += ["city", city.lower().replace(" ", "_")] url = f"http://{'-'.join(parts)}:{PASS}@{GATEWAY}" return {"http": url, "https": url}
requests.get("https://example.com", proxies=geo_proxy("de")) # Germanyrequests.get("https://example.com", proxies=geo_proxy("us", "new york")) # New York City도시 이름은 공백 대신 언더스코어를 사용한다 (new_york). 플래그는 자유롭게 조합할 수 있으며, 게이트웨이 입장에서 순서는 상관없다.
httpx: 같은 개념, 비동기 지원
httpx는 async나 HTTP/2를 원할 때 현대적인 선택지다. 프록시는 클라이언트에 설정한다. 현재 버전의 httpx에서는 파라미터가 proxy= (단수형)이며, 이전 버전에서는 proxies=를 사용했다는 점에 유의하라.
import os, httpx
USER = os.environ["SHIFTER_USER"]PASS = os.environ["SHIFTER_PASS"]GATEWAY = "p.shifter.io:443"
def proxy_url(country="us"): return f"http://{USER}-country-{country}:{PASS}@{GATEWAY}"
# Syncwith httpx.Client(proxy=proxy_url("us"), timeout=30) as client: print(client.get("https://api.ipify.org").text)httpx가 진가를 발휘하는 곳은 비동기 버전으로, 여러 요청을 동시에 실행하면서 각각 새로운 로테이팅 IP를 사용할 수 있다:
import asyncio, httpx
async def fetch(client, url): r = await client.get(url, timeout=30) return r.status_code, r.text[:80]
async def main(urls): async with httpx.AsyncClient(proxy=proxy_url("us")) as client: return await asyncio.gather(*(fetch(client, u) for u in urls))
urls = ["https://api.ipify.org"] * 10print(asyncio.run(main(urls))) # 10 concurrent requests, rotating IPs이렇게 열두 줄 정도로 10개의 동시 레지덴셜 요청을 처리할 수 있다. 동시성 수치에는 주의하라. 무조건 많다고 빠른 것은 아니며, 한 대상에게 짧은 시간 안에 여러 IP로 몰아치면 행동 기반 탐지에 걸릴 수도 있다.
Scrapy: 프록시 미들웨어
대규모 크롤링에는 Scrapy가 무게감 있는 선택지다. 프록시를 붙이는 깔끔한 방법은 요청마다 request.meta["proxy"]를 설정하는 것으로, Scrapy 내장 HttpProxyMiddleware가 이 값을 읽고 URL에 담긴 인증 정보로 Proxy-Authorization 헤더를 처리해준다.
지역을 로테이션하며 요청마다 새로운 IP를 부여하는 작은 미들웨어:
import os
class ResidentialProxyMiddleware: def __init__(self): self.user = os.environ["SHIFTER_USER"] self.password = os.environ["SHIFTER_PASS"] self.gateway = "p.shifter.io:443"
def process_request(self, request, spider): country = request.meta.get("country", "us") request.meta["proxy"] = ( f"http://{self.user}-country-{country}:" f"{self.password}@{self.gateway}" )settings.py에서 활성화한다 (기본 프록시 미들웨어보다 먼저 실행되어야 한다):
DOWNLOADER_MIDDLEWARES = { "myproject.middlewares.ResidentialProxyMiddleware": 350, "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 400,}이제 어떤 스파이더든 레지덴셜 IP를 거쳐 라우팅되며, Request(url, meta={"country": "gb"})처럼 요청별로 타겟팅할 수 있다. 고정 세션이 필요하면 requests 예제와 동일하게 sid/ttl을 넣어 사용자 이름을 만들고 meta["proxy"]에 설정하면 된다.
실제로 발생하는 오류 처리하기
프록시 오류를 무시하는 스크레이퍼는 데모에서는 잘 작동하다가 밤사이 죽어버린다. 자주 마주치게 되는 세 가지는 다음과 같다:
- 407 Proxy Authentication Required, 사용자 이름/비밀번호가 틀렸거나 인식되지 않는 플래그가 있는 경우다 (
country나asn의 오타 등). 인증 문자열을 고쳐야 하며, 재시도해도 소용없다. - 429 Too Many Requests, 대상 서버가 속도를 제한하고 있는 것이다. 속도를 줄이고 IP를 로테이션하라 (고정 세션이 아니라면 게이트웨이가 요청마다 이미 로테이션해준다).
- 502 Bad Gateway, 현재 필터 조건에 맞는 IP가 없는 경우다 (보통 국가+도시+asn처럼 지역 조건이 너무 좁을 때 발생한다). 플래그 조건을 완화하고 재시도하라.
백오프와 함께 깔끔하게 포기하는 최소한의 재시도 래퍼:
import time, requests
def get_with_retry(url, proxies, tries=4): for attempt in range(tries): try: r = requests.get(url, proxies=proxies, timeout=30) if r.status_code in (429, 502, 503): raise requests.exceptions.RequestException(f"status {r.status_code}") r.raise_for_status() return r except requests.exceptions.RequestException as e: if attempt == tries - 1: raise sleep = 2 ** attempt # 1s, 2s, 4s print(f"retry {attempt+1}: {e}; sleeping {sleep}s") time.sleep(sleep)지수 백오프와 요청별 로테이션을 함께 쓰면 일시적인 오류 대부분을 처리할 수 있다. 간헐적이 아니라 지속적으로 차단당하고 있다면, 문제는 재시도가 아니라 IP 품질이나 요청 방식에 있는 것이며, 이는 스크레이핑 시 차단을 피하는 방법에서 다룬다.
실제로 작동하는지 확인하기
설정을 신뢰하기 전에 두 가지를 확인하라: IP가 바뀌는지 (로테이션), 그리고 올바른 국가에 있는지 (지역). 빠른 확인 방법은 다음과 같다:
import requests
r = requests.get("http://ip-api.com/json", proxies=proxy("de"), timeout=30)data = r.json()print(data["query"], data["countryCode"]) # expect a DE IPsid 없이 몇 번 호출해보면 서로 다른 IP가 나오면서 모두 DE여야 한다. 국가가 틀리게 나온다면 플래그 철자를 확인하라. IP가 전혀 바뀌지 않는다면 사용자 이름에 sid가 실수로 남아 있는 것이다.
SOCKS5에 관한 참고 사항
위의 모든 내용은 HTTP 프록시를 사용하며, 이는 웹 스크레이핑에 적합한 기본 선택이다. 워크로드에 SOCKS5가 필요하다면 (HTTP가 아닌 트래픽이거나 SOCKS5를 요구하는 도구인 경우), 동일한 게이트웨이가 이를 지원한다. 스킴을 socks5h://로 바꾸고 requests[socks]를 설치하면 된다 (또는 httpx의 SOCKS 확장을 사용하라). 트레이드오프에 관해서는 HTTP vs SOCKS5 프록시를 참고하라. 일반적인 스크레이핑이라면 HTTP를 계속 사용하라.
FAQ
국가마다 다른 엔드포인트가 필요한가?
아니다. 모든 경우에 엔드포인트는 p.shifter.io:443 하나다. 국가, 도시, 세션은 모두 호스트가 아니라 사용자 이름 문자열을 통해 바뀐다. 이것이 게이트웨이 모델의 핵심이다.
requests에서 http와 https 키 모두 http:// URL로 설정하는 이유는?
requests가 CONNECT 터널을 통해 HTTP 프록시로 HTTPS를 전송하기 때문이다. 프록시 URL의 스킴은 프록시와 통신하는 방식을 나타내는 것이지 (HTTP), 대상 서버를 나타내는 것이 아니다. 이는 올바르고 표준적인 방식이며, https 키를 https://로 설정하지 말아야 한다.
요청마다 IP를 로테이션하려면 어떻게 하나?
사용자 이름에서 sid를 생략하면 된다. 세션 ID가 없으면 게이트웨이가 요청마다 자동으로 새로운 IP를 준다. 프록시 목록을 직접 관리할 필요가 없으며, 풀 로테이션은 서버 측에서 처리된다.
여러 단계로 이루어진 흐름에서 동일한 IP를 유지하려면?
사용자 이름에 sid-<your-id>-ttl-<seconds>를 추가하고 해당 흐름의 각 요청마다 재사용하면 된다. 같은 ID는 TTL이 만료될 때까지 같은 IP를 반환한다.
요청이 느린데, 프록시가 문제인가?
보통은 아니다. 레지덴셜 IP는 직접 연결보다 어느 정도 지연이 추가되지만, 규모가 커졌을 때 느려지는 원인은 대부분 동시성이 너무 높거나, 연결 재사용이 없거나 (Session/Client 사용 필요), 대상 서버 자체가 느린 경우다. 프록시 탓을 하기 전에 프로파일링부터 하라.
aiohttp, urllib3, pycurl에서도 작동하는가?
그렇다. 인증 정보가 포함된 http://user:pass@host:port 프록시 URL을 받는 클라이언트라면 어떤 것이든 작동하며, 인증 정보에 타겟팅을 인코딩하는 방식은 동일하다. requests, httpx, Scrapy는 그저 가장 흔히 쓰이는 세 가지일 뿐이다.
마무리
패턴은 어떤 라이브러리에서도 동일하다: http://USER-flags:PASS@p.shifter.io:443 형태의 URL을 만들어 프록시 슬롯에 넣고, 로테이션하려면 sid를 생략하고 고정하려면 추가하면 된다. 지역 설정은 플래그 하나로 처리되고, 오류는 작은 재시도 루프로 처리되며, 동시성은 사용 중인 클라이언트가 이미 지원하는 방식 그대로 쓰면 된다.
위 스니펫들을 시작점으로 삼아 레지덴셜 게이트웨이에 연결하면, 오후 한나절이면 프로덕션 수준의 프록시 코드를 갖출 수 있다. 요금제와 GB당 요금은 가격 페이지에서 확인할 수 있으며, 전체 플래그 레퍼런스는 게이트웨이 문서에 있다.