httpx, aiohttp 및 프록시를 활용한 비동기 파이썬 스크래핑
비동기는 느린 스크래퍼를 빠르게 만들어주지만, 프록시, 세마포어 및 타임아웃을 올바르게 설정하지 않으면 빠른 스크래퍼가 차단될 수 있습니다. 여기 httpx 및 aiohttp를 위한 전체 패턴과 코드를 소개합니다.
스크래퍼가 대부분의 시간을 네트워크 대기 상태로 보낸다면, 비동기는 속도를 높이는 가장 큰 방법이며, 프록시는 그 속도를 차단당하지 않게 해줍니다. 이 가이드는 httpx 및 aiohttp와 프록시를 사용한 비동기 파이썬 스크래핑을 처음부터 끝까지 다룹니다: 각 라이브러리가 프록시를 설정하는 방법, 세마포어로 동시성을 제한하는 방법, 실제로 작동하는 타임아웃을 설정하는 방법, 세션을 손상시키지 않고 IP를 회전하는 방법을 설명합니다. 실행 가능한 코드와 교체 가능한 자격 증명도 포함되어 있습니다.
왜 비동기인가, 그리고 프록시는 어디에 적합한가
표준 requests는 블로킹 방식입니다: 각 호출은 다음 호출이 시작되기 전에 응답을 기다립니다. 500 페이지를 가져오면 500번의 왕복 시간을 지불해야 합니다. 비동기는 하나의 이벤트 루프에서 동시에 가져오므로 총 시간이 가장 느린 단일 요청에 가까워집니다. 문제는 한 IP에서 발생하는 동시 요청의 폭발이 바로 안티봇 시스템이 감시하는 시그니처라는 것입니다. 해결책은 속도를 느리게 하는 것이 아니라, 트래픽을 회전 프록시 풀에 분산시키고 세마포어로 의도적으로 조절하는 것입니다.
httpx에서 프록시 설정하기 (비동기)
httpx는 실용적인 기본값입니다. 하나의 클라이언트 모델이 동기와 비동기를 모두 처리하며, HTTP/2를 지원합니다. 현대적인 API를 주목하세요: 클라이언트에 단일 proxy= 인수로 설정하며, 이전의 proxies= 딕셔너리가 아닙니다 — 업그레이드 후 "내 프록시가 무시되는 이유"에 대한 혼란의 일반적인 원인입니다. 자격 증명은 프록시 URL에 직접 포함됩니다.
import asyncio, httpx
PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"
async def fetch(client, url):
r = await client.get(url, timeout=httpx.Timeout(20.0))
return url, r.status_code, r.text
async def main(urls):
async with httpx.AsyncClient(proxy=PROXY, http2=True) as client:
tasks = [fetch(client, u) for u in urls]
return await asyncio.gather(*tasks)
urls = ["https://httpbin.org/ip"] * 5
print(asyncio.run(main(urls)))
모든 요청에 재사용되는 하나의 클라이언트가 포인트입니다 — 이는 연결 풀을 따뜻하게 유지하여 프록시를 통한 반복적인 TLS 핸드셰이크를 건너뛰게 합니다. 요청마다 새로운 클라이언트를 생성하는 것은 가장 일반적인 비동기 성능 버그입니다: 이는 매 호출마다 연결 재사용과 쿠키 상태를 버리게 됩니다.
aiohttp에서 프록시 설정하기 (요청별)
aiohttp는 asyncio에 네이티브이며 가장 세밀한 동시성 제어를 제공합니다. 하지만 프록시 규칙이 다릅니다: 프록시는 세션이 아닌 session.get()에서 요청별로 전달됩니다. 이는 회전에 실제로 편리합니다. 여기서 사람들이 두 가지에 걸립니다 — 기본 User-Agent는 문자 그대로 Python/3.x aiohttp/3.x이며, 이는 반드시 재정의해야 하는 명백한 신호입니다. 연결 풀 크기를 명시적으로 설정해야 합니다.
import aiohttp, asyncio
PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"}
async def fetch(session, url):
timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
async with session.get(url, proxy=PROXY, timeout=timeout) as resp:
return url, resp.status, await resp.text()
async def main(urls):
conn = aiohttp.TCPConnector(limit=20, limit_per_host=8)
async with aiohttp.ClientSession(connector=conn, headers=HEADERS) as session:
return await asyncio.gather(*(fetch(session, u) for u in urls))
print(asyncio.run(main(["https://httpbin.org/ip"] * 5)))
TCPConnector(limit=20, limit_per_host=8)은 총 열린 연결과 단일 호스트에 대한 연결을 제한합니다 — 이는 하나의 대상이 전체 풀을 흡수하지 못하게 하는 첫 번째 예의입니다.

세마포어로 동시성 제한하기
무제한 동시 요청을 발사하는 것은 프록시 풀을 태우고 속도 제한을 초과하는 가장 빠른 방법입니다. asyncio.Semaphore는 조절 장치입니다: 이는 한 번에 비행 중인 요청 수를 제한합니다, 큐에 있는 작업 수와 상관없이. 전역 제한을 설정하고, 공격적인 작업에는 도메인별 제한도 설정하세요.
sem = asyncio.Semaphore(15) # never more than 15 requests in flight
async def guarded_fetch(client, url):
async with sem:
return await fetch(client, url)
async def main(urls):
async with httpx.AsyncClient(proxy=PROXY) as client:
return await asyncio.gather(*(guarded_fetch(client, u) for u in urls))
적절한 숫자는 대상의 허용 범위와 풀 크기에 따라 다르며, 기계의 속도에 따라 다르지 않습니다. 보수적으로 시작하세요 (10–20), 차단 비율을 관찰하고 성공률이 높은 동안에만 올리세요. 하나의 고정 IP에서는 한 자릿수로 유지하세요.
타임아웃: 각 단계를 모델링하기
프록시된 요청은 직접 요청보다 더 많은 곳에서 실패합니다 — DNS 해석, 프록시 연결, 터널 설정, 대상 연결, 헤더 대기, 본문 읽기는 모두 별개의 지연입니다. 단일 포괄적 타임아웃은 어느 단계가 중단되었는지 숨깁니다. aiohttp의 ClientTimeout(total=, connect=, sock_read=) 및 httpx의 Timeout()은 각각을 별도로 제한할 수 있게 해줍니다. 예외 없는 한 가지 규칙: 타임아웃 없이 요청을 발행하지 마세요, 그렇지 않으면 한 번의 죽은 종료가 코루틴을 영원히 중단시키고 이벤트 루프를 조용히 굶주리게 할 것입니다.
세션을 깨지 않고 프록시 회전하기
두 가지 회전 전략이 있으며 잘못된 것을 선택하면 데이터가 손상됩니다. 요청별 무작위 회전은 상태 없는 페이지 가져오기에 완벽합니다. 그러나 쿠키, 로그인 또는 지역화에 의존하는 흐름을 파괴합니다, 왜냐하면 두 번째 요청이 첫 번째 요청과 다른 IP에 도착하기 때문입니다. 깨끗한 분할: 논리적 단위 경계에서 회전하세요 — 크롤 세그먼트당 또는 계정당 하나의 IP — 그리고 회전 게이트웨이를 사용하여 자동으로 새 출구를 제공받아 코드가 목록을 관리하지 않도록 하세요.
# rotating gateway: one endpoint, new exit IP per request
ROT = "http://USER:PASS@rotating.quantumproxies.io:8000"
# sticky session: same IP for a multi-step flow, tag the session id
STICKY = "http://USER-session-a1b2:PASS@gate.quantumproxies.io:8000"
async def crawl_segment(urls):
async with httpx.AsyncClient(proxy=ROT) as client: # rotates per call
return await asyncio.gather(*(fetch(client, u) for u in urls))
회전 주거 게이트웨이는 고동시성 스크래핑에 대한 실용적인 기본값입니다: 200개 이상의 국가에서 90M+ IP를 통한 요청별 회전, 장바구니 또는 로그인이 몇 분 동안 동일한 출구를 필요로 할 때 고정 세션을 제공합니다. 작업에 대해 주거용과 ISP 또는 데이터 센터를 비교하고 있다면, 2026년에 사용할 프록시 유형에 대한 가이드가 트레이드오프를 설명합니다.
재시도, 백오프 및 지터
죽은 출구와 일시적인 차단은 규모에서 정상적이며 예외적이지 않습니다. 그러나 단순한 재시도는 상황을 악화시킵니다: 50개의 비동기 작업이 동시에 실패하고 모두 즉시 재시도하면, 원래 실행보다 더 강하게 대상을 두드리는 동기화된 폭발을 발사합니다. 지연된 백오프와 무작위 지터를 추가하여 재시도가 분산되게 하고, 시도를 제한하며, 실패 시 소진된 IP를 재사용하는 대신 회전하세요.
import random
from httpx import HTTPError
async def robust_fetch(client, url, tries=3):
for attempt in range(tries):
try:
r = await client.get(url, timeout=httpx.Timeout(20.0))
if r.status_code < 400 and looks_real(r.text):
return r
except HTTPError:
pass
# exponential backoff + jitter before the next attempt
await asyncio.sleep((2 ** attempt) + random.uniform(0, 1))
return None

200은 성공이 아닙니다
가장 미묘한 비동기 스크래핑 버그는 HTTP 200을 완료로 취급하는 것입니다. 안티봇 시스템은 CAPTCHA 페이지, 접근 거부 공지, 빈 결과 집합 또는 JS 도전과 함께 200을 반환합니다 — 따라서 상태 코드만으로 "성공"을 기록하는 프록시는 조용히 차단된 페이지를 제공하고 있습니다. 내용을 검증하세요: 응답을 신뢰하기 전에 알려진 요소, 최소 길이 또는 도전 마커의 부재를 확인하세요. 이는 위의 재시도 루프에서 looks_real() 게이트입니다.
스택을 직접 롤링하는 것을 멈춰야 할 때
위의 패턴 — 비동기 클라이언트, 세마포어, 타임아웃, 회전, 콘텐츠 검증 — 대부분의 대상을 깨끗하게 처리합니다. 그러나 사이트가 Cloudflare, TLS 지문 인식 또는 무거운 클라이언트 측 렌더링을 추가하면, 비록 프록시가 있어도 원시 비동기 HTTP는 점점 더 실패합니다, 왜냐하면 파이썬 TLS 핸드셰이크는 Chrome과 전혀 다르기 때문입니다. 그 선에서 실제 브라우저 지문을 가지고 IP를 회전하고 JavaScript를 필요에 따라 렌더링하는 Scraper API는 모든 것을 수동으로 유지하는 것보다 더 적은 코드와 더 높은 성공률을 제공합니다. 헤드리스와 HTTP 비용에 대한 우리의 게시물은 그 에스컬레이션이 가치가 있는 곳을 다룹니다.
자주 묻는 질문
aiohttp에서 프록시를 어떻게 사용하나요?
요청별로 프록시 URL을 전달하세요: session.get(url, proxy="http://user:pass@host:port"). requests와 달리, aiohttp는 세션에서 프록시 딕셔너리를 받지 않습니다. 항상 기본 User-Agent를 재정의하고 (Python/3.x aiohttp/3.x는 명백한 봇 신호입니다) ClientTimeout을 설정하여 죽은 출구가 코루틴을 중단시키지 않도록 하세요.
비동기 스크래핑에 httpx와 aiohttp 중 어느 것이 더 나은가요?
기본값으로 httpx를 선택하세요: 하나의 클라이언트가 동기와 비동기를 모두 처리하며, HTTP/2가 내장되어 있고, 프록시는 단일 인수입니다. 최대 동시성 제어를 원할 때 aiohttp를 선택하세요 — 명시적인 연결 풀 제한과 요청별 프록시는 대규모, 빠른 크롤에 적합합니다. 둘 다 괜찮습니다; 프록시 전략이 라이브러리보다 더 중요합니다.
얼마나 많은 동시 요청을 실행해야 하나요?
기계가 허용하는 만큼이 아니라, 대상과 풀이 허용하는 만큼입니다. 비행 중인 세마포어 10–20으로 시작하고, 차단 및 오류 비율을 관찰하며, 성공률이 높은 동안에만 올리세요. 하나의 고정 IP에서는 한 자릿수로 유지하세요. 더 넓은 IP 회전은 더 높은 총 동시성을 안전하게 실행할 수 있게 해줍니다.
왜 내 비동기 스크래퍼가 동기 스크래퍼가 차단되지 않았을 때 차단되나요?
왜냐하면 동시성이 신호를 집중시키기 때문입니다: 하나의 IP에서 많은 동시 요청은 고전적인 봇 패턴입니다. 회전 풀에 부하를 분산시키고, 세마포어로 조절하며, 재시도 시 지터 백오프를 추가하고, 응답 내용을 검증하세요 — 200은 여전히 도전 페이지일 수 있습니다. 회전 없는 속도가 차단된 이유입니다.
그것이 전체 비동기 패턴입니다: 클라이언트를 선택하고, 프록시를 올바르게 설정하고, 세마포어로 동시성을 제한하고, 모든 타임아웃 단계를 경계하고, 논리적 경계에서 회전하고, 단순한 200을 절대 신뢰하지 마세요. 프록시 레이어를 먼저 올바르게 설정하면 대부분의 차단 목록이 도달하기 전에 사라집니다.