본문 중간에서 멈추는 응답이 Requests의 흔한 타임아웃 처리 로직을 빠져나간다면

최종 업데이트: August 17, 2026
본문 중간에서 멈추는 응답이 Requests의 흔한 타임아웃 처리 로직을 빠져나간다면
AI 요약
기존 requests 코드의 버그를 찾고 있나요? 리다이렉트 가정, 본문 중간 멈춤까지 잡을 거라고 생각하며 requests.exceptions.Timeout만 받는 핸들러, 그리고 문자셋이 없는 응답을 받는 .text 소비자를 점검하세요. httpx로 기계적으로 옮기려는 중인가요? 예외 네임스페이스를 httpx.TimeoutException 또는 더 좁은 phase 클래스들로 바꾸고, follow_redirects 사용 여부를 결정한 뒤, 디코딩 가정을 다시 테스트하세요. 데모에서 보인 본문 중간 케이스는 기존 requests 핸들러가 이미 놓치고 있으며, 마이그레이션이 그 버그를 새로 만들지는 않습니다. 여러 URL을 가져오는 새 기능을 만들고 있나요? connect, read, write, pool 타임아웃을 분리해서 쓰고 싶다면 httpx의 AsyncClient가 후보입니다.

누구나 한 번쯤 이런 가드 코드를 씁니다:

try:
    r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
    retry()

그런데 이 코드를 상태 줄과 헤더는 보내고, 본문 전에 멈춰버리는 서버에 던져 보세요. 읽기 타임아웃이 납니다. 하지만 저 가드는 안 먹힙니다. 실제로 튀어나오는 예외는 ConnectionError이고, ConnectionErrorTimeout의 하위 클래스가 아닙니다.

같은 상황을 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에 있습니다.

당신 모르게 바뀌는 기본값들

Measured results chart: Defaults that differ between clients

동작requests 2.34.2httpx 0.28.1
기본으로 리다이렉트 추적아니오
모듈 수준 호출 시 연결 재사용아니오아니오
헤더 전 단계에서 멈춤ReadTimeoutReadTimeout
본문 중간에서 멈춤ConnectionErrorReadTimeout
문자셋 미지정ISO-8859-1utf-8
HTTP/2제공되지 않음선택적으로 사용 가능
connect/read/write/pool 타임아웃 분리아니오

리다이렉트, 소켓 재사용, 예외 결과, 디코딩, 프로토콜 협상은 프로브로 관찰했습니다. 타임아웃 API 형태와 requests에 HTTP/2 플래그가 없다는 점은 API 기능 차원에서 확인한 내용입니다. httpx-probes.json.

이 표의 세 줄만으로도, 라이브러리를 바꾸는 날 조용히 코드를 틀어놓을 수 있습니다.

리다이렉트: 기본값은 꺼져 있고, 서버가 그걸 증명합니다

/ok로 끝나는 4단계 리다이렉트 체인을 테스트했습니다:

클라이언트서버가 본 요청 수반환된 상태 코드
requests5회200
httpx1회302
httpx, follow_redirects=True5회200

5회는 4번 이동에 도착지 1번을 더한 값입니다. 제 예측은 4였는데, 그건 계산을 잘못한 것이었고, 여기서는 그냥 넘어가지 않고 바로 고쳤습니다.

이 동작은 httpx의 공식 기본값이고, 설계 자체도 충분히 납득할 만합니다. 리다이렉트는 호출자가 알아야 할 수도 있는 정보니까요. 하지만 마이그레이션이 아무 경고 없이 깨지는 가장 흔한 이유이기도 합니다. 코드가 302를 받으면 response.text는 비어 있고, 파서는 행을 찾지 못하고, 로그는 200 OK라고 말하지 않습니다… 사실은 302라고 말합니다. 그리고 requests를 쓸 때는 애초에 상태 코드를 볼 이유가 없었기 때문에 아무도 그걸 보고 있지 않았던 겁니다.

제가 거꾸로 예측했던 타임아웃 이야기

저는 httpx가 실패한 단계의 이름을 더 정확히 드러내고, requests는 둘을 한 클래스에 뭉뚱그릴 거라고 예상했습니다. 실제로는 반대였습니다.

공식 문서: Requests timeout documentation.

System diagram: Where the Timeout Lands

공식 문서: HTTPX timeout documentation.

멈춤 지점requestshttpx
상태 줄 이전ReadTimeoutReadTimeout
헤더 전송 후 본문 중간ConnectionErrorReadTimeout

httpx는 두 경우 모두 같은 정확한 이름을 돌려줍니다. requests는 둘을 다르게 나누는데, 그 경계선이 바로 retry 코드가 기준으로 삼는 지점입니다.

이 결과는 클래스 계층만 보고 추론한 게 아닙니다. 직접 가드를 돌려 봤습니다:

멈춤 지점except requests.exceptions.Timeoutexcept httpx.TimeoutException
상태 줄 이전잡음잡음
본문 중간ConnectionError로 빠져나감잡음

timeout-retry-guard.json. requests.exceptions.ConnectionErrorrequests.exceptions.Timeout의 하위 클래스가 아니고, httpx.ReadTimeouthttpx.TimeoutException의 하위 클래스입니다.

requests의 예외 메시지에는 ConnectionError 안쪽에 Read timed out.라고 적혀 있습니다. 라이브러리는 무슨 일이 일어났는지 알고 있습니다. 다만 타입 시스템에는 그 사실을 전달하지 않을 뿐이고, except 절이 보는 건 바로 그 타입 시스템입니다.

이 측정 사례는 꽤 구체적입니다. 헤더는 도착했지만, 본문 진행이 읽기 타임아웃을 넘길 만큼 멈춘 경우입니다. 의도적으로 스트리밍하는 경우처럼 타임아웃 창 안에서 청크를 계속 보내는 응답은 다르게 동작할 수 있고, 이번 테스트에서는 다루지 않았습니다.

커넥션 풀링: 차이는 결국 클라이언트 API에 있습니다

GET 10번을 네 가지 방식으로 돌리고, 서버 쪽에서 소켓 수를 셌습니다:

방식열린 소켓 수
httpx.get() × 1010
requests.get() × 1010
httpx.Client()1
requests.Session()1

이건 같은 결과이고, 굳이 적을 가치가 있습니다. 흔히 “httpx는 풀링하고 requests는 안 한다”는 식으로 알고 있기 때문입니다. 사실 둘 다 모듈 레벨에서는 풀링하지 않습니다. 풀링은 둘 다 클라이언트 객체를 통해서만 일어납니다. 지금 requests.get()을 루프에서 쓰고 있다면, httpx.get()으로 바꾼다고 소켓 churn이 줄어들지는 않습니다.

System diagram: Pooling Lives in the Client

HTTP/2는 명시적으로 켜야 하고, extra가 필요합니다

아티팩트에 기록된 공개 HTTP/2 엔드포인트를 상대로 확인했습니다:

공식 문서: RFC 9113: HTTP/2.

클라이언트협상 결과
httpx.Client(http2=True)HTTP/2
httpx.Client(http2=False)HTTP/1.1
requestsHTTP/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개씩:

requestshttpx
import만 수행36.0 MiB30.6 MiB
import + GET 1회35.8 MiB40.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 총정리에서 호스팅 옵션을, 오픈소스 스크래퍼 종합 가이드에서 자체 호스팅 옵션을 다뤘습니다.

웹 데이터 추출용 Thunderbit 체험하기

결론

비동기 동시성, 단계별 타임아웃, 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만 사용했습니다. 픽스처 서버에서 얻은 지연 시간은 네트워크가 아니라 설계에 대한 측정입니다.

Ke
Ke
Thunderbit CTO | 시니어 데이터 사이언티스트 & ML 전문가 머신러닝과 데이터 과학 분야에서 약 10년에 가까운 경험을 쌓아온 Ke Shen은 컬럼비아 대학교 출신이며, 전 Walmart Labs의 시니어 데이터 사이언티스트였습니다. Python, R, Java, 통계 분야에서 동료들에게도 인정받는 깊은 전문성을 바탕으로, 복잡한 AI 알고리즘을 이론에서 실제 운영 수준의 아키텍처로 전환하는 데 필요한 실전 인사이트를 공유합니다.
목차
Thunderbit · AI 웹 데이터 에이전트

1회 클릭 안에 어떤 페이지든 데이터 추출

250,000명+ 사용자가 신뢰
무료 플랜 제공
웹페이지에서 스프레드시트까지
필요한 내용을 설명하세요 — Thunderbit의 AI Agent가 수집하고 Excel, Google Sheets, Airtable, Notion으로 내보냅니다. 시작은 무료입니다.
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week