← 블로그로 돌아가기

파이썬을 위한 httpx의 비동기 프록시: 회전, HTTP/2 및 초보자의 5가지 실수

httpx에서 프록시 회전을 올바르게 설정하고 HTTP/2를 활성화하며 90%의 초보 파서 개발자들이 겪는 실수를 피하는 방법을 알아봅니다.

📅2026년 10월 17일

httpx 라이브러리는 Python에서 비동기 HTTP 요청의 표준이 되었지만, 프록시를 사용할 때는 프로덕션에서 파서를 망가뜨리는 몇 가지 미세한 점들이 있습니다. 비동기 프록시 회전을 설정하고 HTTP/2를 활성화하는 방법, 그리고 왜 귀하의 스크립트가 50-100개의 요청마다 중단되는지 살펴보겠습니다.

왜 httpx인가, requests나 aiohttp가 아닌

requests는 동기식이며 기본적으로 HTTP/2를 지원하지 않습니다. aiohttp는 비동기식이지만 프록시 설정이 덜 친숙하고 HTTP/2 지원이 전혀 없습니다. httpx는 두 가지 문제를 모두 해결합니다: 동기 및 비동기 클라이언트를 단일 API로 제공하며, h2 확장을 통해 HTTP/2를 네이티브로 지원하고, 전송 수준에서 프록시와 유연하게 작업할 수 있습니다.

마켓플레이스(예: Wildberries, Ozon, Avito)에서 파싱 작업, 소셜 미디어 API 데이터 수집 또는 경쟁사 가격 모니터링을 위한 비동기성은 매우 중요합니다 — 이는 순차 요청에 비해 속도를 크게 향상시킵니다. 그러나 적절한 프록시 회전과 연결 관리 없이 비동기성은 새로운 문제의 원인이 됩니다: 연결 풀 고갈, 중단된 코루틴, 파일 디스크립터 누수.

두 번째 중요한 점은 HTTP/2가 하나의 TCP 연결에서 여러 요청을 멀티플렉싱할 수 있어 대상 서버의 부하를 줄이고, 하나의 IP에서 TCP 핸드셰이크 수를 모니터링하는 일부 단순한 안티봇 시스템을 우회하는 데 도움이 됩니다.

httpx에서 프록시 기본 설정

httpx 0.26+ 버전부터 프록시 설정 구문이 변경되었습니다 — 이제 proxies 대신 proxy 매개변수를 사용합니다. 이는 오래된 튜토리얼을 참고할 경우 혼란의 주요 원인이 될 수 있습니다.

import httpx
import asyncio

PROXY_URL = "http://user:[email protected]:8000"

async def fetch(url: str):
    async with httpx.AsyncClient(proxy=PROXY_URL, timeout=15.0) as client:
        response = await client.get(url)
        return response.status_code, response.text[:200]

asyncio.run(fetch("https://httpbin.org/ip"))

HTTP 및 HTTPS 트래픽을 위한 프록시를 분리하거나 서로 다른 도메인에 대해 다른 프록시를 설정해야 하는 경우, httpx.Client(mounts=...)를 사용하세요 — 전송은 URL 패턴에 따라 마운트됩니다:

mounts = {
    "http://": httpx.AsyncHTTPTransport(proxy="http://user:[email protected]:8000"),
    "https://": httpx.AsyncHTTPTransport(proxy="http://user:[email protected]:8000"),
}

async with httpx.AsyncClient(mounts=mounts) as client:
    response = await client.get("https://example.com")

사용자 이름과 비밀번호로 인증할 경우, 형식 http://user:pass@host:port는 HTTP 프록시에서 안정적으로 작동합니다. SOCKS5의 경우 httpx[socks]를 추가로 설치해야 하며, 그렇지 않으면 런타임에서 ImportError가 발생합니다 — 이는 많은 사람들이 빠지는 함정입니다.

비동기 프록시 회전: 작동하는 코드

프록시 회전에서 가장 큰 오류는 각 요청마다 새로운 AsyncClient를 생성하는 것입니다. 이는 성능을 저하시킵니다. 클라이언트는 연결 풀을 유지하고 있으며, 이를 재생성하면 keep-alive 연결의 모든 이점이 사라집니다. 올바른 접근 방식은 미리 클라이언트 풀을 생성하고, 프록시마다 하나씩 만들어 작업을 분배하는 것입니다.

import httpx
import asyncio
import itertools
import random

PROXIES = [
    "http://user:[email protected]:8000",
    "http://user:[email protected]:8000",
    "http://user:[email protected]:8000",
]

class ProxyPool:
    def __init__(self, proxies: list[str]):
        self.clients = [
            httpx.AsyncClient(proxy=p, timeout=httpx.Timeout(15.0, connect=5.0))
            for p in proxies
        ]
        self._cycle = itertools.cycle(self.clients)

    def get_client(self) -> httpx.AsyncClient:
        return next(self._cycle)  # 라운드 로빈
        # 무작위 선택: random.choice(self.clients)

    async def close_all(self):
        for client in self.clients:
            await client.aclose()

async def fetch_with_retry(pool: ProxyPool, url: str, retries: int = 3):
    for attempt in range(retries):
        client = pool.get_client()
        try:
            response = await client.get(url)
            if response.status_code == 200:
                return response.text
        except (httpx.ConnectTimeout, httpx.ProxyError, httpx.ReadTimeout):
            await asyncio.sleep(0.5 * (attempt + 1))
            continue
    return None

async def main():
    pool = ProxyPool(PROXIES)
    urls = [f"https://example.com/page/{i}" for i in range(100)]

    sem = asyncio.Semaphore(20)  # 병렬성 제한

    async def bound_fetch(url):
        async with sem:
            return await fetch_with_retry(pool, url)

    results = await asyncio.gather(*(bound_fetch(u) for u in urls))
    await pool.close_all()
    return results

asyncio.run(main())

이 패턴은 세 가지 문제를 동시에 해결합니다: keep-alive를 통해 TCP 연결을 재사용하고, 라운드 로빈 방식으로 프록시에 부하를 분산시키며, 세마포어를 통해 병렬성을 제한하여 대상 서버를 과부하시키거나 속도 제한으로 인해 차단되는 것을 방지합니다.

Wildberries, Ozon 또는 Instagram API와 함께 작동하는 프로덕션 파서의 경우, 프록시 풀의 크기와 지리적 다양성이 충분히 커야 합니다 — 그렇지 않으면 동일한 서브넷 내에서 회전해도 차단을 피할 수 없습니다. 주거용 프록시가 적합합니다. 이들은 실제 사용자 IP를 가지고 있어 안티봇 시스템의 필터에 덜 걸립니다.

httpx에서 HTTP/2: 왜 필요하고 어떻게 활성화하는가

기본적으로 httpx는 HTTP/1.1을 사용합니다. HTTP/2를 활성화하려면 확장을 설치하고 플래그를 명시적으로 전달해야 합니다:

# pip install httpx[http2]

async with httpx.AsyncClient(http2=True, proxy=PROXY_URL) as client:
    response = await client.get("https://example.com")
    print(response.http_version)  # "HTTP/2"를 기대합니다

중요한 점: 모든 프록시가 HTTP/2 터널링을 올바르게 지원하는 것은 아닙니다. 일부 저렴한 데이터 센터 프록시는 최종 서버가 HTTP/2를 지원하더라도 HTTP/1.1 프록시로만 작동합니다 — 이 경우 httpx는 CONNECT 터널을 통해 조용히 HTTP/1.1로 롤백하며, 명시적인 확인 없이 이를 알아차리기 어려울 수 있습니다 response.http_version을 통해 확인해야 합니다.

HTTP/2는 세 가지 시나리오에서 실제 이점을 제공합니다: 하나의 도메인에 대해 많은 요청을 할 때 (하나의 연결에서 멀티플렉싱이 지연을 줄임), 안티봇 보호를 위해 요청의 가시성을 줄여야 할 때 (하나의 IP에서 TCP 핸드셰이크 수가 적어 보이는 것이 더 자연스러움), 그리고 대상 사이트가 HTTP/2를 요구하고 HTTP/1.1 클라이언트에게 오류나 제한된 콘텐츠를 반환할 때 — 이는 Cloudflare에서 운영되는 사이트에서 가끔 발생합니다.

프록시를 통한 HTTP/2 연결의 속도와 안정성이 중요한 작업에는 전용 데이터 센터 프록시가 더 적합합니다 — 이들은 주거용 프록시보다 빠르고 현대 프로토콜의 터널링을 올바르게 지원합니다.

httpx에서 프록시와 관련된 5가지 일반적인 오류

1. 각 요청마다 클라이언트 재생성

async with httpx.AsyncClient(...) as client: await client.get(url)와 같은 코드는 안티 패턴입니다. 클라이언트를 생성할 때마다 새로운 TCP 연결, 새로운 TLS 핸드셰이크가 발생하며, 연결 풀링을 완전히 무시합니다. 1000개의 요청에서 올바른 접근 방식과 잘못된 접근 방식 간의 속도 차이는 5-10배에 이를 수 있습니다.

2. 타임아웃이 없거나 기본 타임아웃이 너무 큼

기본적으로 httpx는 모든 요청에 대해 5초의 타임아웃을 설정합니다 — 연결, 읽기, 쓰기, 풀 모두 포함됩니다. 불안정한 프록시를 사용할 때는 연결에 대해 이 시간이 부족하지만, 프록시가 단순히 "멈춰있을" 경우 읽기에는 너무 많습니다. 타임아웃을 명확하게 나누어 설정하세요:

timeout = httpx.Timeout(
    connect=5.0,
    read=10.0,
    write=10.0,
    pool=5.0
)
client = httpx.AsyncClient(proxy=PROXY_URL, timeout=timeout)

3. 연결 풀의 한계 무시

많은 수의 병렬 작업이 있을 경우 (500개 이상의 코루틴을 세마포어 없이 gather) httpx는 PoolTimeout을 발생시킵니다. 이는 풀의 연결 수 한도(기본적으로 100)가 초과되었기 때문입니다. httpx.Limits(max_connections=200, max_keepalive_connections=50)를 통해 한계를 명확하게 설정하고 항상 병렬성을 제어하기 위해 세마포어를 사용하세요.

4. 이해 없이 verify 비활성화

많은 사람들이 Stack Overflow에서 verify=False를 복사하여 MITM 인증서가 있는 프록시를 사용할 때 SSL 오류를 피하려고 합니다. 이는 중간자 공격에 대한 길을 열어주며, 손상된 프록시를 통해 데이터 유출로 이어질 수 있습니다. 프록시가 특별한 인증서를 요구하는 경우, verify="/path/to/cert.pem"를 통해 명시적으로 추가하고, 전체 검증을 비활성화하지 마세요.

5. 클라이언트를 닫지 않아 파일 디스크립터 누수

클라이언트 풀을 수동으로 생성하는 경우 (위의 예제와 같이), 작업이 끝날 때마다 await client.aclose()를 호출하는 것을 잊지 마세요. 명시적인 연결 종료 없이 오랜 시간 작동하는 스크립트는 운영 체제의 열린 파일 디스크립터 한도(일반적으로 Linux에서 기본값 1024)에 도달하며, 파서는 몇 시간 후에 Too many open files라는 신비로운 오류로 중단됩니다.

파싱에 적합한 프록시 유형 선택하기

프록시 선택은 위의 오류 목록에서 얼마나 많은 오류를 경험할지를 직접적으로 영향을 미칩니다 — 불안정한 프록시는 타임아웃과 연결 끊김을 유발합니다, 코드가 완벽하게 작성되었더라도 말입니다.

프록시 유형 속도 차단 위험 사용 시기
데이터 센터 높음 보안 사이트에서 높은 위험 Open API, 약한 안티봇 보호
주거용 중간 낮음 Wildberries, Ozon, 소셜 미디어
모바일 중간-낮음 최소 Instagram, TikTok API, 강력한 보호

API에 대한 높은 속도의 비동기 파싱을 위해서는 데이터 센터 프록시가 경제적으로 유리한 선택입니다 — 중요한 것은 위의 코드에서 보여준 것처럼 부하를 균등하게 분산시키는 것입니다.

프로덕션 전 체크리스트

  • httpx 클라이언트는 한 번 생성되어 재사용되며, 각 요청마다 생성되지 않습니다.
  • 타임아웃은 connect/read/write/pool로 나누어져 있으며, 기본값이 사용되지 않습니다.
  • 병렬성은 asyncio.Semaphore를 통해 제한됩니다.
  • 연결 풀의 한계는 실제 부하에 맞게 설정됩니다.
  • ProxyError, ConnectTimeout, ReadTimeout을 처리하는 재시도 로직이 있습니다.
  • 모든 클라이언트는 종료 시 aclose()를 통해 명시적으로 닫힙니다.
  • 극단적인 필요가 아닌 한 verify=False는 사용되지 않습니다.
  • HTTP/2는 의도적으로 활성화되며, response.http_version를 통해 확인됩니다.

결론

httpx는 프록시 및 HTTP/2와 함께 비동기 작업을 위한 강력한 도구를 제공하지만, 클라이언트 풀, 타임아웃 및 연결 한계를 신중하게 설정해야 합니다. 프로덕션에서 파서의 대부분의 중단은 라이브러리와 관련된 것이 아니라 아키텍처적 오류에서 발생합니다: 클라이언트 재생성, 병렬성 제한 부족 및 프록시 예외 처리의 잘못된 처리입니다.

마켓플레이스 또는 소셜 미디어 API를 위한 파서를 구축하는 경우, 의심스러운 활동을 적극적으로 모니터링하는 경우 프록시 인프라의 안정성이 코드만큼 중요합니다. 이러한 작업에는 주거용 프록시를 고려하는 것이 좋습니다 — 이들은 저렴한 데이터 센터 풀에 비해 IP 주소의 평판이 더 높아 ProxyError 및 ConnectTimeout 오류를 줄여줍니다.