누구나 한 번쯤 이런 가드 코드를 씁니다:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
그런데 이 코드를 상태 줄과 헤더는 보내고, 본문 전에 멈춰버리는 서버에 던져 보세요. 읽기 타임아웃이 납니다. 하지만 저 가드는 안 먹힙니다. 실제로 튀어나오는 예외는 ConnectionError이고, ConnectionError는 Timeout의 하위 클래스가 아닙니다.
같은 상황을 httpx에 걸면 ReadTimeout이 발생하고, 이 값은 TimeoutException의 하위 타입이라서 같은 가드로 잡힙니다.
스크래퍼를 망가뜨리는 차이점을 기준으로 httpx와 requests를 비교해 보려다가, 원래는 async까지 쓰려고 했습니다. 그런데 막상 보니 async는 목록에서 제일 재미없는 항목이었습니다.
무엇을 테스트했고, 어떻게 했는가
로컬 픽스처 서버를 상대로 8개의 프로브를 돌렸습니다. 클라이언트가 “무슨 일을 했다”고 말하는 건 증거가 아니기 때문입니다. 서버는 TCP 연결 수를 세고 있습니다. 즉, 어떤 요청 라인도 파싱하기 전에 수락된 소켓마다 1씩 올립니다. 또 실제로 가져간 경로(path) 도 기록합니다. 연결 재사용과 리다이렉트 추적은 모두 네트워크 상에서 검증해야 하는 주장이고, 실제 검증은 바로 그 네트워크 위에서 이뤄집니다.
httpx 0.28.1 (http2 extra 포함), requests 2.34.2, Python 3.14.2, macOS arm64 환경에서 테스트했습니다. 둘 다 완전히 새로 만든 virtualenv에서 돌려서 서로의 흔적을 물려받지 않게 했습니다. 원시 출력은 여기 있습니다: httpx-probes.json.
첫 실행 전에 하네스에 6개의 예측을 미리 넣어 두었고, 끝난 뒤에도 그대로 남겨 뒀습니다. 3개는 맞았고, 2개는 틀렸고, 1개는 제가 생각한 상황에서는 맞았지만 정작 중요한 상황은 놓쳤습니다. 내역은 prediction-scorecard.json에 있습니다.
당신 모르게 바뀌는 기본값들

| 동작 | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| 기본으로 리다이렉트 추적 | 예 | 아니오 |
| 모듈 수준 호출 시 연결 재사용 | 아니오 | 아니오 |
| 헤더 전 단계에서 멈춤 | ReadTimeout | ReadTimeout |
| 본문 중간에서 멈춤 | ConnectionError | ReadTimeout |
| 문자셋 미지정 | ISO-8859-1 | utf-8 |
| HTTP/2 | 제공되지 않음 | 선택적으로 사용 가능 |
| connect/read/write/pool 타임아웃 분리 | 아니오 | 예 |
리다이렉트, 소켓 재사용, 예외 결과, 디코딩, 프로토콜 협상은 프로브로 관찰했습니다. 타임아웃 API 형태와 requests에 HTTP/2 플래그가 없다는 점은 API 기능 차원에서 확인한 내용입니다. httpx-probes.json.
이 표의 세 줄만으로도, 라이브러리를 바꾸는 날 조용히 코드를 틀어놓을 수 있습니다.
리다이렉트: 기본값은 꺼져 있고, 서버가 그걸 증명합니다
/ok로 끝나는 4단계 리다이렉트 체인을 테스트했습니다:
| 클라이언트 | 서버가 본 요청 수 | 반환된 상태 코드 |
|---|---|---|
| requests | 5회 | 200 |
| httpx | 1회 | 302 |
httpx, follow_redirects=True | 5회 | 200 |
5회는 4번 이동에 도착지 1번을 더한 값입니다. 제 예측은 4였는데, 그건 계산을 잘못한 것이었고, 여기서는 그냥 넘어가지 않고 바로 고쳤습니다.
이 동작은 httpx의 공식 기본값이고, 설계 자체도 충분히 납득할 만합니다. 리다이렉트는 호출자가 알아야 할 수도 있는 정보니까요. 하지만 마이그레이션이 아무 경고 없이 깨지는 가장 흔한 이유이기도 합니다. 코드가 302를 받으면 response.text는 비어 있고, 파서는 행을 찾지 못하고, 로그는 200 OK라고 말하지 않습니다… 사실은 302라고 말합니다. 그리고 requests를 쓸 때는 애초에 상태 코드를 볼 이유가 없었기 때문에 아무도 그걸 보고 있지 않았던 겁니다.
제가 거꾸로 예측했던 타임아웃 이야기
저는 httpx가 실패한 단계의 이름을 더 정확히 드러내고, requests는 둘을 한 클래스에 뭉뚱그릴 거라고 예상했습니다. 실제로는 반대였습니다.
공식 문서: Requests timeout documentation.

공식 문서: HTTPX timeout documentation.
| 멈춤 지점 | requests | httpx |
|---|---|---|
| 상태 줄 이전 | ReadTimeout | ReadTimeout |
| 헤더 전송 후 본문 중간 | ConnectionError | ReadTimeout |
httpx는 두 경우 모두 같은 정확한 이름을 돌려줍니다. requests는 둘을 다르게 나누는데, 그 경계선이 바로 retry 코드가 기준으로 삼는 지점입니다.
이 결과는 클래스 계층만 보고 추론한 게 아닙니다. 직접 가드를 돌려 봤습니다:
| 멈춤 지점 | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| 상태 줄 이전 | 잡음 | 잡음 |
| 본문 중간 | ConnectionError로 빠져나감 | 잡음 |
timeout-retry-guard.json. requests.exceptions.ConnectionError는 requests.exceptions.Timeout의 하위 클래스가 아니고, httpx.ReadTimeout은 httpx.TimeoutException의 하위 클래스입니다.
requests의 예외 메시지에는 ConnectionError 안쪽에 Read timed out.라고 적혀 있습니다. 라이브러리는 무슨 일이 일어났는지 알고 있습니다. 다만 타입 시스템에는 그 사실을 전달하지 않을 뿐이고, except 절이 보는 건 바로 그 타입 시스템입니다.
이 측정 사례는 꽤 구체적입니다. 헤더는 도착했지만, 본문 진행이 읽기 타임아웃을 넘길 만큼 멈춘 경우입니다. 의도적으로 스트리밍하는 경우처럼 타임아웃 창 안에서 청크를 계속 보내는 응답은 다르게 동작할 수 있고, 이번 테스트에서는 다루지 않았습니다.
커넥션 풀링: 차이는 결국 클라이언트 API에 있습니다
GET 10번을 네 가지 방식으로 돌리고, 서버 쪽에서 소켓 수를 셌습니다:
| 방식 | 열린 소켓 수 |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
이건 같은 결과이고, 굳이 적을 가치가 있습니다. 흔히 “httpx는 풀링하고 requests는 안 한다”는 식으로 알고 있기 때문입니다. 사실 둘 다 모듈 레벨에서는 풀링하지 않습니다. 풀링은 둘 다 클라이언트 객체를 통해서만 일어납니다. 지금 requests.get()을 루프에서 쓰고 있다면, httpx.get()으로 바꾼다고 소켓 churn이 줄어들지는 않습니다.

HTTP/2는 명시적으로 켜야 하고, extra가 필요합니다
아티팩트에 기록된 공개 HTTP/2 엔드포인트를 상대로 확인했습니다:
공식 문서: RFC 9113: HTTP/2.
| 클라이언트 | 협상 결과 |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, 플래그 없음 |
httpx[http2] extra가 필요합니다. 저는 그냥 pip install httpx만 해도 조용히 1.1로 협상할 거라고 생각했고, 적기 전에 직접 확인해 봤습니다:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
Client를 만드는 순간, 첫 요청도 보내기 전에 에러가 납니다. 그리고 메시지가 해결 방법까지 알려 줍니다. 이런 실패는 좋은 실패이고, 저는 그걸 거꾸로 예상하고 있었습니다 (http2-extra-missing.json).
이 프로브는 해당 엔드포인트에서 프로토콜 협상이 성공했다는 사실만 보여 줍니다. 스크래핑 속도 향상을 입증하는 건 아닙니다. 매칭되는 HTTP/1.1 워크로드는 테스트하지 않았습니다.
순차 처리와 동시 처리의 처리량 비교
0.3초씩 자는 엔드포인트에 요청 20개를 보냈습니다:
| 모드 | 전체 경과 시간 | 소켓 수 |
|---|---|---|
동기, Client 1개 | 6.138초 | 1 |
비동기, AsyncClient 1개 | 0.357초 | 20 |
동시 실행은 순차 실행의 6.138초에 비해 0.357초에 끝났습니다. 대신 20개의 연결을 열었고, 동기 클라이언트는 1개만 재사용했습니다. 즉, 이 실험은 라이브러리 속도라기보다 실행 모델과 실효 동시성을 바꾼 것입니다.
이 숫자를 해석하는 솔직한 방식이 바로 그겁니다. httpx 자체를 측정한 게 아니라, 일부러 느린 엔드포인트에 대한 동시성을 측정한 겁니다. async가 잘 되는 다른 클라이언트라도 비슷한 구간에 들어가고, 빠른 엔드포인트에서는 그 차이도 줄어듭니다.
제가 예측하지 못했던 문자셋 사례
헤더가 거짓말하는 응답 — UTF-8 바이트에 charset=iso-8859-1 — 는 둘 다 같은 깨진 문자열을 만들 거라고 예상했습니다. 실제로는 그렇습니다. 둘 다 원문이 Café Ubersetzung — naïve résumé인데도 Café Ubersetzung â naïve résumé를 돌려줍니다.
하지만 제가 예측하지 못했던, 그리고 정말 중요한 사례는 따로 있었습니다:
| 응답 | requests 디코딩 | httpx 디코딩 |
|---|---|---|
charset=utf-8, utf-8 바이트 | 정상 | 정상 |
charset=iso-8859-1, utf-8 바이트 | 깨진 문자열 | 깨진 문자열 |
| 문자셋이 전혀 없음 | 깨진 문자열 | 정상 |
requests는 헤더에 아무 정보도 없으면 ISO-8859-1로 떨어지고, httpx는 utf-8을 기본값으로 씁니다. 그래서 문자셋이 없는 픽스처에서는 .text로 디코딩된 결과가 서로 달랐습니다. 반면 response.content를 쓰는 쪽은 같은 원본 바이트를 그대로 받습니다.
메모리, 측정이 쉬우니 확인해 봤습니다
피크 RSS, /usr/bin/time -l, 각 셀마다 새 프로세스 1개씩:
| 셀 | requests | httpx |
|---|---|---|
| import만 수행 | 36.0 MiB | 30.6 MiB |
| import + GET 1회 | 35.8 MiB | 40.7 MiB |
이 값들은 단일 프로세스 스냅샷입니다. requests의 GET 1회 값이 import-only 값보다 약간 낮게 나온 건 측정 노이즈가 있다는 뜻입니다. 방향성 있는 메모리 결론은 내릴 수 없고, 반복 샘플과 범위가 필요합니다.
선택할 때 이 결과를 어떻게 읽어야 하나
이미 있는 requests 코드의 버그를 찾고 있다면? 리다이렉트 가정, 본문 중간 멈춤까지 잡힐 거라고 믿으면서 requests.exceptions.Timeout만 받는 핸들러, 그리고 문자셋 없는 응답을 받는 .text 소비자를 점검하세요.
기계적으로 httpx로 옮기려는 중이라면? 예외 네임스페이스를 httpx.TimeoutException 또는 더 좁은 phase 클래스들로 바꾸고, follow_redirects를 켤지 정하고, 디코딩 가정을 다시 테스트하세요. 이미 있는 requests 핸들러는 데모에서 보인 본문 중간 케이스를 놓치고 있었습니다. 마이그레이션이 그 버그를 새로 만들지는 않습니다.
여러 URL을 가져오는 새 도구를 만들고 있다면? 연결, 읽기, 쓰기, 풀 획득 타임아웃을 따로 보고 싶다면 httpx가 후보입니다. 이 단계들은 “왜” 그런 일이 일어났는지보다, 어디서 기다렸는지 — 연결 수립인지, 응답 본문 진행인지, 요청 업로드인지, 로컬 풀 획득인지 — 를 알려 줍니다.
작고 동기적인 도구를 만든다면? requests도 충분하고, 어디에나 있습니다. 바꿀 이유는 속도가 아닙니다.
무엇을 선택하든, 모듈 수준 함수보다 클라이언트 객체를 쓰세요. 이 목록에서 양쪽 라이브러리 모두에 대해 순수하게 이득인 변화는 그 하나입니다.
관리형 API는 어디에 맞는가
위에서 다룬 건 모두 fetch 계층이고, 사실 fetch 계층이 제일 쉽습니다. 여기에는 JavaScript 렌더링도 없고, 안티봇 챌린지 처리도 없고, HTML을 원하는 행으로 바꾸는 작업도 없습니다.
작성자 메모: Thunderbit은 URL을 넣으면 렌더링과 추출까지 맡겨주는 관리형 옵션입니다. 이번 HTTP 클라이언트 하네스에서는 테스트하지 않았습니다. 페이지 확보나 구조화 추출이 문제일 때, 즉 HTTP 클라이언트의 의미론이 아니라 그 이전 단계의 문제가 핵심일 때만 이런 범주를 고려하세요.
일반 웹페이지를 가져와서 직접 파싱하는 경우라면, 두 클라이언트 모두 후보입니다. 관리형 서비스는 사느냐 만드느냐의 별도 판단이지, 이 두 라이브러리 중 하나를 고르는 근거는 아닙니다.
더 넓은 범위에서는, 웹 스크래핑 API 총정리에서 호스팅 옵션을, 오픈소스 스크래퍼 종합 가이드에서 자체 호스팅 옵션을 다뤘습니다.
결론
비동기 동시성, 단계별 타임아웃, UTF-8 폴백이 필요한 새로운 Python fetch 계층이라면, 이번 테스트 범위에서는 httpx가 기본 선택입니다. 이미 성숙한 동기 코드이고 마이그레이션 리스크가 장점보다 크다면 requests도 여전히 유효합니다. 프록시, 재시도 정책, TLS 지문, 스트리밍, 업로드, 그리고 현실적인 네트워크 변동성은 테스트하지 않았으므로, 이것이 모든 스크래핑 클라이언트에 대한 보편적 순위표는 아닙니다.
조심해야 할 가장 큰 이유는 리다이렉트 기본값입니다. 그리고 그게 정말 위험한 이유는, 그게 좋은 설계이기 때문입니다. 명시가 암시보다 낫다는 말은, 그 암시적인 동작이 이미 배포한 코드의 핵심 부품이 되기 전까지만 성립합니다.
사전 등록한 스코어카드는 최종적으로 3개는 맞고 2개는 틀렸으며 1개는 불완전했습니다. 유용한 수정 사항은 본문 중간 예외 클래스였고, 나머지 판단은 스코어카드 서사보다 관찰된 동작에서 가져와야 합니다.
웹 데이터 추출용 Thunderbit 체험하기 Get Started Free
자주 묻는 질문
httpx는 정말 리다이렉트를 따라가지 않나요?
기본값으로는 아닙니다. 서버는 4단계 체인에 대해 요청 1번만 세었고, 응답은 302로 돌아왔습니다. 호출마다 follow_redirects=True를 넣거나, Client에 한 번 설정하세요. 이 동작은 문서화되어 있고 의도된 것입니다. 그래도 마이그레이션에서 조용히 깨지기 가장 쉬운 부분인데, 예외가 아니라 빈 파싱으로 끝나기 때문입니다.
except requests.exceptions.Timeout만으로는 정말 부족한가요?
헤더를 보낸 뒤 멈추는 서버라면 부족합니다. 그 경우 ConnectionError가 발생하고, 이는 Timeout의 하위 클래스가 아니므로 가드가 놓칩니다. 이건 추론이 아니라 직접 확인한 결과입니다. 둘 다 잡고 싶다면 requests.exceptions.RequestException을 잡을 수는 있지만, 타임아웃이 아닌 것도 같이 잡게 된다는 점은 감수해야 합니다.
httpx가 requests보다 빠른가요? 한 번에 한 요청을 보내는 경우엔 의미 있게 빠르지 않습니다. 그 용도가 아닙니다. 이번 테스트의 17.2배는 0.3초짜리 엔드포인트에 20개의 동시 요청을 보낸 결과로, 동시성 측정입니다. 워크로드가 순차적이라면 속도 향상은 기대하지 말고, 기본값을 기준으로 선택하세요.
http2 extra가 꼭 필요한가요?
HTTP/2를 쓰고 싶을 때만 필요합니다. 그리고 그것 없이 http2=True를 주면, Client를 만드는 순간 httpx가 ImportError를 내고 httpx[http2]를 설치하라고 알려 줍니다. 몰래 1.1로 떨어질 걱정은 없습니다. 저는 그렇게 될 거라 예상했지만, 대신 직접 확인했습니다.
여기서 테스트하지 않은 것은 무엇인가요? 프록시 동작입니다. 스크래핑에서 매우 중요하고 별도의 하네스가 필요합니다. 재시도는 httpx에는 내장 로직이 없고 requests는 urllib3에서 가져오므로, 공정 비교라면 사실상 두 재시도 라이브러리의 비교가 됩니다. TLS 지문은 안티봇 시스템이 실제로 보는 축이지만, 어느 쪽도 그걸 직접 다루지 않습니다. 스트리밍과 파일 업로드도 테스트하지 않았습니다. 그리고 여기의 8개 프로브 중 6개는 localhost에서 돌렸고, 한 대의 머신과 한 버전의 Python만 사용했습니다. 픽스처 서버에서 얻은 지연 시간은 네트워크가 아니라 설계에 대한 측정입니다.


