python httpx requests 비교 — 마이그레이션하며 알게 된 동작 차이 7가지

목차

python httpx requests 비교를 검색하면 "API가 거의 같다"는 글이 많다. 첫인상은 맞다. 다만 FastAPI 프로젝트에 박혀 있던 requests 호출 80여 개를 걷어내며 보니, 표면적 호환과 실제 동작은 다른 지점이 꽤 있더라. 변경 전후 코드부터 본다.

# Before (requests 2.31)
import requests

def fetch_user(user_id: int) -> dict:
    r = requests.get(
        f"https://api.example.com/users/{user_id}",
        timeout=5,
    )
    r.raise_for_status()
    return r.json()
# After (httpx 0.27, async)
import httpx

async def fetch_user(client: httpx.AsyncClient, user_id: int) -> dict:
    r = await client.get(
        f"/users/{user_id}",
        timeout=5,
    )
    r.raise_for_status()
    return r.json()

프론트엔드에서 백엔드로 넘어온 지 2년 차다. axios의 인터셉터, baseURL, 자동 JSON 파싱이 너무 당연했는데 Python에서 requests를 처음 만났을 때는 좀 낡은 도구 같다는 인상을 받았다. httpx는 axios에 가까운 감각이라 적응이 빠르다. (개인 감상이다.)

오늘 한 것 — FastAPI 핸들러에서 requests 걷어내기

예를 들어, 처음 한 일은 단순했다. import requestsimport httpx로 바꾸고, requests.gethttpx.get으로 치환했다. 이게 진짜로 돌긴 돈다. 동기 함수 안에서는.

# 1단계 — 그냥 치환
import httpx

def fetch_user(user_id: int) -> dict:
    r = httpx.get(
        f"https://api.example.com/users/{user_id}",
        timeout=5,
    )
    r.raise_for_status()
    return r.json()

실제로, 문제는 이게 FastAPI의 async def 핸들러 안에서 호출되고 있었다는 점이다. 동기 httpx.get은 내부적으로 매번 새 클라이언트를 만들고, 그 안에서 이벤트 루프를 막는다. requests와 똑같이 블로킹이라는 뜻이다. 이러면 갈아탄 의미가 없다.

그래서 2단계로 AsyncClient를 lifespan에 붙였다.

# 2단계 — lifespan에서 클라이언트 재사용
from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(5.0, connect=2.0),
        limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
    )
    yield
    await app.state.http.aclose()

app = FastAPI(lifespan=lifespan)

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    r = await app.state.http.get(f"/users/{user_id}")
    r.raise_for_status()
    return r.json()

여기서 멈췄으면 좋았겠지만, 첫 배포에서 한 번 깨졌다. 일부 외부 API가 응답을 chunked로 흘려보내는데 코드는 r.json()만 호출하는 구조였다. 평소엔 잘 돌더라. 그런데 응답 본문이 비어 있는 케이스가 1%쯤 섞여 들어왔다.

원인은 크게 어렵지 않았다. 동일 클라이언트에 동시 요청이 몰릴 때 keepalive 한도와 부딪히는 케이스가 있었다. limits를 손보고 외부 API별로 클라이언트를 분리하니 사라졌다. 한 클라이언트에 모든 외부 호출을 묶어두면 편하지만, 호스트마다 동시성 특성이 다르면 분리하는 편이 안정적이다.

새로 알게 된 것 — API는 비슷한데 동작이 다른 7가지

이 부분이 진짜 정리하고 싶었던 내용이다. python httpx requests 비교 글 대부분이 "거의 호환된다"에서 끝나는데, 실제로 갈아타면 미묘하게 다른 지점들이 발목을 잡는다.

1. timeout 기본값과 의미

requests의 기본 timeout은 None이다. 즉 무한이다. 안 걸어두면 외부 API가 죽을 때 우리도 같이 죽는다. 이건 requests 공식 문서에 굵게 적혀 있다.

특히, httpx는 기본 timeout이 5초다 (httpx 0.27 기준). 안 걸어둬도 죽지는 않는다. 의미는 또 다르다. requests의 timeout=5는 "read timeout 5초"인데, httpx의 timeout=5는 connect, read, write, pool 모두 5초다. 같은 5를 줬는데 동작이 같지 않다.

항목 requests 2.31 httpx 0.27
기본 timeout None (무한) 5.0초
timeout=5 의미 read 5초 connect/read/write/pool 모두 5초
세분화 (connect, read) 튜플 httpx.Timeout(read, connect, write, pool)
HTTP/2 미지원 옵션 (http2=True)
async 없음 AsyncClient
Connection pool Session 단위 Client 단위 (HTTP/2면 1 connection)

특히, 세분화하려면 이렇게 쓴다.

timeout = httpx.Timeout(
    connect=2.0,   # TCP 연결 자체
    read=10.0,     # 응답 읽는 시간
    write=5.0,     # 요청 본문 보내는 시간
    pool=1.0,      # 풀에서 커넥션 받는 시간
)
client = httpx.AsyncClient(timeout=timeout)

pool timeout이 따로 있다는 점이 흥미로웠다. 동시 요청이 max_connections를 넘으면 대기 큐에 들어가는데, 그 큐에서 기다리는 시간이다. 이걸 짧게 잡으면 부하 폭증 시 빠르게 실패하면서 회로 차단기 패턴을 쉽게 구현할 수 있다.

2. Session vs Client — 재사용 단위

한편, requests의 Session은 쿠키와 커넥션 풀을 공유한다. 안 쓰면 매 요청마다 TCP 핸드셰이크가 일어난다. 알고 있었지만 레거시 코드를 보면 대부분 그냥 requests.get()을 직접 부른다.

즉, httpx는 더 노골적이다. httpx.get()은 내부적으로 매번 임시 Client를 만든다. 공식 문서에도 "production에서는 Client를 직접 만들어 재사용하라"고 명시되어 있다. 무시하면 keepalive와 HTTP/2의 이점이 다 날아간다.

3. content vs data vs json

requests에서는 보통 data=, json= 정도만 신경 쓴다. httpx는 content=가 따로 있다.

  • content=: raw bytes 또는 str. 그대로 보낸다.
  • data=: dict면 form-encoded, str/bytes면 raw로 처리.
  • json=: dict를 JSON으로 직렬화.
  • files=: multipart.

뭐가 다른가 하면, data에 str을 넣었을 때 requests는 그대로 보낸다. httpx에서는 form 파싱을 시도하는 케이스가 있어서 미묘하게 깨진다. raw 본문이라면 content=로 명시하는 편이 안전하다.

4. raise_for_status() 시점

반면, 둘 다 같은 이름의 메서드가 있다. 그런데 httpx에서는 응답을 다 읽기 전에도 호출할 수 있다. streaming 응답을 받을 때 status만 먼저 확인하고 본문을 끊고 싶다면 client.stream()을 쓴다.

async with client.stream("GET", url) as r:
    r.raise_for_status()
    async for chunk in r.aiter_bytes():
        process(chunk)

한편, requests로는 stream=True로 처리하던 패턴인데, 컨텍스트 매니저로 바뀌어서 자원 해제가 명확해졌다.

5. 프록시 환경변수

물론, requests는 HTTP_PROXY, HTTPS_PROXY를 자동으로 읽는다. httpx는 0.26 이후로 기본적으로 환경변수를 읽지 않는다. trust_env=True를 명시하거나, proxies=를 직접 넘겨야 한다. 회사 망에서 갑자기 502가 떠서 한참 봤다.

6. retry — 기본 제공이 없다

이처럼, requests는 urllib3의 Retry를 끼워 쓰는 게 사실상 표준이다. httpx는 공식적으로 retry를 제공하지 않는다. transport 계층에서 직접 만들거나, tenacity 같은 외부 라이브러리를 붙여야 한다.

import httpx
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
async def fetch(client: httpx.AsyncClient, url: str):
    r = await client.get(url)
    r.raise_for_status()
    return r.json()

결국, requests + urllib3 Retry는 status 코드 기반 재시도가 라이브러리 안에서 끝났는데, httpx는 그걸 밖으로 꺼낸 셈이다. 처음엔 불편했지만 retry 로직이 보이는 곳에 있어서 디버깅은 더 쉽다.

7. cert / verify 동작

verify=False로 자체 서명 인증서를 우회하는 패턴은 둘 다 된다. 차이는, httpx가 ssl.SSLContext를 그대로 받는다는 점이다. 사내 CA 번들을 적용할 때 코드가 깔끔해진다.

import ssl
import httpx

ctx = ssl.create_default_context(cafile="/etc/ssl/internal-ca.pem")
client = httpx.AsyncClient(verify=ctx)

특히, requests에서는 verify="/path/to/cert.pem" 식으로 경로만 받았다. SSLContext를 직접 다루려면 transport adapter를 따로 구성해야 했다.

코드 — 같은 일을 4가지로 써본 결과

게다가, 100개 URL을 동시에 가져오는 시나리오로 비교했다.

# 1) requests 단순 루프 (직렬)
import requests

def fetch_all_sync(urls):
    with requests.Session() as s:
        return [s.get(u, timeout=5).json() for u in urls]
# 2) requests + ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
import requests

def fetch_all_threads(urls):
    with requests.Session() as s, ThreadPoolExecutor(max_workers=20) as ex:
        return list(ex.map(lambda u: s.get(u, timeout=5).json(), urls))
# 3) httpx 동기 클라이언트
import httpx

def fetch_all_httpx_sync(urls):
    with httpx.Client(timeout=5.0) as c:
        return [c.get(u).json() for u in urls]
# 4) httpx 비동기 클라이언트
import asyncio
import httpx

async def fetch_all_async(urls):
    async with httpx.AsyncClient(timeout=5.0) as c:
        tasks = [c.get(u) for u in urls]
        responses = await asyncio.gather(*tasks)
        return [r.json() for r in responses]

특히, 체감상 1번이 가장 느리고 4번이 가장 빨랐다. 정확한 벤치는 외부 API의 응답 시간 변동이 커서 의미 있는 수치를 뽑기 어렵다. 1번과 4번의 격차가 한 자릿수 초와 1초 미만으로 갈리는 수준이라 굳이 측정하지 않아도 차이가 보인다.

즉, 2번이 의외로 4번에 근접한다. CPU 바운드가 아니고 I/O 바운드라면 thread pool도 충분히 빠르다. async가 무조건 답은 아니라는 뜻이다.

한 가지 함정 — 같은 host에 몰리면

특히, 같은 도메인에 100개를 동시에 던지면 4번이 항상 빠르진 않다. AsyncClient의 max_keepalive_connectionsmax_connections에 묶여서 대기가 생긴다. 기본값(20/100)에서 host당 keepalive가 좁다고 느끼면 limits를 키운다.

limits = httpx.Limits(
    max_keepalive_connections=50,
    max_connections=200,
    keepalive_expiry=30.0,
)
client = httpx.AsyncClient(limits=limits, http2=True)

http2=True를 켜면 동일 호스트에 대한 동시 요청이 단일 connection 위로 다중화된다. 외부 API가 HTTP/2를 지원해야 효과가 있다. CloudFront, GCP LB 같은 대부분의 상용 엔드포인트는 지원한다. (httpx에서 http2를 쓰려면 pip install httpx[http2]로 h2 의존성을 같이 깔아야 한다. 이거 빠뜨리면 옵션을 켜도 조용히 HTTP/1.1로 떨어진다.)

메모 — 언제 갈아타고 언제 그대로 둘지

특히, 마이그레이션이 단순 import 치환이 아니라는 게 핵심 교훈이다. 상황별 기준을 메모로 남긴다.

httpx로 갈아타는 게 맞는 상황

  • FastAPI/Starlette처럼 async가 기본인 프레임워크 위에서 외부 API를 호출하는 경우. requests를 async 함수 안에서 그대로 쓰는 건 이벤트 루프를 막는다. 이거 하나만으로도 충분한 이유다.
  • 동일 호스트에 동시에 수십~수백 요청을 보내는 자동화 스크립트. HTTP/2 다중화로 connection 수를 줄일 수 있다.
  • 타입 힌트와 IDE 자동완성을 적극 활용하는 프로젝트. httpx는 type stub이 잘 정리되어 있다 (httpx 0.27 기준).

반면, requests를 그대로 두는 게 맞는 상황

  • 동기 코드 위주의 짧은 스크립트. 새로 배울 비용이 이득보다 크다.
  • urllib3 Retry, 인증서 처리, 프록시 자동 감지 등 기존 인프라가 requests 가정으로 굳어 있는 경우. 옮기면 깨지는 곳이 많다.
  • 외부 SDK가 내부적으로 requests를 강하게 의존하는 경우. 같이 살게 두는 편이 낫다.

그런데, 섞어 쓰는 경우의 주의점

같은 프로세스 안에 requests와 httpx가 공존하는 건 가능하다. connection pool이 따로 굴러간다는 점은 인지하고 있어야 한다. 둘 다 keepalive를 잡고 있으면 file descriptor가 두 배로 늘어난다. 컨테이너 ulimit이 빡빡한 환경에서는 문제가 된다.

당장 할 수 있는 액션 세 가지로 마무리한다.

  1. 코드에서 requests.get(...)Session 없이 직접 호출하는 곳을 grep해 본다. 거기가 가장 손쉬운 1차 이득 지점이다. Session만 붙여도 latency가 줄어든다.
  2. async def 안에서 sync requests를 부르는 코드가 있는지 본다. 있으면 그 핸들러부터 httpx.AsyncClient로 옮긴다.
  3. timeout이 None인 채로 외부 API를 부르는 곳이 있다면, requests를 유지하더라도 timeout부터 박는다. 갈아타는 것과는 별개 작업이다.

axios에서 fetch로 갈아탈 때도 비슷한 인상이었다. API는 비슷해 보이는데 동작은 미묘하게 다르고, 그 미묘함이 production에서 한 번씩 사고를 낸다. httpx는 requests의 자연스러운 후속이지만 1:1 대체는 아니다. 이 정도가 마이그레이션을 끝낸 시점의 정직한 평가다.

관련 글