파이썬으로 API를 부르거나 웹페이지를 가져오는 스크립트가 아무 오류 없이 멈춰 있는 경우가 있습니다. 대부분 requests timeout을 지정하지 않았거나, 지정했더라도 그 의미를 잘못 알고 있어서 생깁니다. 이 노트북(윈도우 11, Python 3.12, requests 2.34.2, urllib3 2.7.0)에 일부러 느리게 응답하는 테스트 서버를 띄워 놓고 직접 재 봤습니다. 가장 의외였던 결과는 timeout=5를 줬는데도 요청이 19.01초 동안 끝나지 않은 것이었습니다. 재시도를 붙였더니 예외 이름이 ReadTimeout에서 ConnectionError로 바뀌는 것도 확인했습니다. 실측 순서대로 정리하고, 마지막에 그대로 가져다 쓸 수 있는 함수를 붙였습니다.
실험 준비: 일부러 느린 서버 만들기
외부 사이트로는 “느린 상황”을 마음대로 만들 수 없어서, 파이썬 표준 라이브러리만으로 테스트 서버를 만들었습니다. 경로마다 다르게 행동합니다. /slow는 30초 동안 아무것도 보내지 않다가 응답하고, /drip은 1초에 한 글자씩 20초 동안 흘려보내고, /flaky는 처음 두 번은 503(서버 바쁨)을 돌려준 뒤 세 번째에 성공합니다. /gzip은 압축된 응답 확인용입니다.
"""일부러 느리거나 불안정하게 응답하는 테스트 서버 (포트 8765)
/slow?s=30 s초 동안 아무것도 보내지 않다가 응답
/drip?n=20 1초에 한 글자씩 n초 동안 흘려보냄
/flaky?k=a 같은 k로 처음 2번은 503, 3번째부터 200
"""
import gzip
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
hits = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlparse(self.path)
q = parse_qs(url.query)
if url.path == "/slow":
time.sleep(float(q.get("s", ["30"])[0]))
self._send(200, b"slow ok\n")
elif url.path == "/drip":
n = int(q.get("n", ["20"])[0])
self.send_response(200)
self.send_header("Content-Length", str(n))
self.end_headers()
for _ in range(n):
self.wfile.write(b".")
self.wfile.flush()
time.sleep(1)
elif url.path == "/flaky":
key = q.get("k", ["x"])[0]
hits[key] = hits.get(key, 0) + 1
if hits[key] <= 2:
self._send(503, b"busy\n")
else:
self._send(200, b"flaky ok\n")
elif url.path == "/gzip":
body = gzip.compress(("gzip 본문 " * 500).encode())
self.send_response(200)
self.send_header("Content-Encoding", "gzip")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self._send(200, b"ok\n")
def _send(self, status, body):
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(time.strftime("%H:%M:%S"), self.address_string(), fmt % args, flush=True)
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
터미널 하나에서 python slow_server.py로 서버를 띄워 두고, 다른 터미널에서 아래 실험 스크립트들을 실행했습니다.
timeout이 없으면 서버가 놓아줄 때까지 기다립니다
먼저 timeout 없이 30초짜리 경로를 불렀습니다.
import time
import requests
t = time.perf_counter()
r = requests.get("http://127.0.0.1:8765/slow?s=30")
print(r.status_code, r.text.strip(), f"{time.perf_counter() - t:.2f}초")
처음 실행했을 때는 결과의 한글이 30.01��처럼 깨져 나왔습니다. 윈도우 콘솔의 출력 인코딩 문제라서 python -X utf8로 다시 실행하니 제대로 보였습니다. 결과는 200 slow ok 30.01초였습니다. 이 서버는 30초 뒤에 응답하도록 만들었으니 30초에서 끝났지만, 응답을 끝내 보내지 않는 서버였다면 그만큼 계속 기다렸을 것입니다. requests는 기본값으로 timeout이 없기 때문입니다. 정기 실행 스크립트가 이렇게 멈추면 다음 회차까지 밀리고, 로그에도 오류가 남지 않아 원인을 찾기 어렵습니다.
timeout=5는 “전체 5초”가 아닙니다
다음은 timeout을 준 세 가지 경우입니다. 응답이 늦는 서버, 연결 자체가 안 되는 주소(10.255.255.1, 응답하지 않는 사설 IP), 그리고 조금씩 흘려보내는 서버입니다.
$ python -X utf8 t2_timeout.py
읽기 지연 /slow timeout=5 -> ReadTimeout, 5.03초
연결 불가 10.255.255.1 timeout=(3, 5) -> ConnectTimeout, 3.00초
조금씩 흘림 /drip timeout=5 -> 200 본문 20바이트, 19.01초
앞의 두 개는 예상대로 끊겼습니다. 읽기 지연은 5.03초에 ReadTimeout, 연결 불가는 튜플 앞 값인 3초에 ConnectTimeout이 났습니다. 문제는 세 번째입니다. timeout=5를 줬는데 19.01초 뒤에 정상 응답으로 끝났습니다.
이유는 requests의 timeout이 전체 소요 시간이 아니라 “다음 바이트가 올 때까지 기다리는 시간”이기 때문입니다. 서버가 1초마다 한 글자씩 보내면 5초 타이머가 매번 다시 시작되므로 끝없이 길어질 수 있습니다. 정리하면 이렇습니다.
| 설정 | 의미 | 실측 |
|---|---|---|
| timeout 없음 | 무한 대기 | 서버가 응답할 때까지 30.01초 |
| timeout=5 | 연결 5초, 바이트 사이 간격 5초 | /slow 5.03초 / /drip 19.01초 |
| timeout=(3, 5) | 연결 3초, 읽기 5초를 따로 | 연결 불가 주소 3.00초 |
| 전체 시간 제한 | requests에 옵션 없음 → 직접 구현 | 아래 safe_get에서 8.01초 |
재시도 붙이기: Retry + HTTPAdapter
일시적인 503이나 순간적인 지연은 다시 시도하면 해결되는 경우가 많습니다. requests는 내부에서 쓰는 urllib3의 Retry를 세션에 연결하는 방식으로 재시도를 지원합니다.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=3, # 최대 재시도 3번 (첫 시도 포함 4번)
backoff_factor=1, # 재시도 사이 대기: 0초, 2초, 4초 ...
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD"],
)
session = requests.Session()
session.mount("http://", HTTPAdapter(max_retries=retry))
session.mount("https://", HTTPAdapter(max_retries=retry))
r = session.get("http://127.0.0.1:8765/flaky?k=test", timeout=(3, 5))
print(r.status_code, r.text)
urllib3 디버그 로그를 켜고 /flaky와 /slow를 차례로 불렀습니다. 시간은 스크립트 시작부터 잰 밀리초입니다.
154ms "GET /flaky?k=a6c4... HTTP/1.1" 503 5
155ms Retry: /flaky?k=a6c4...
157ms "GET /flaky?k=a6c4... HTTP/1.1" 503 5
2160ms Retry: /flaky?k=a6c4...
2177ms "GET /flaky?k=a6c4... HTTP/1.1" 200 9
== 503 두 번 후 성공 /flaky -> 200 flaky ok, 2.03초
7186ms Retrying (Retry(total=2, ...)) after ... ReadTimeoutError ... (read timeout=5)
14198ms Retrying (Retry(total=1, ...)) after ... ReadTimeoutError ...
23210ms Retrying (Retry(total=0, ...)) after ... ReadTimeoutError ...
== 계속 느림 /slow -> ConnectionError, 26.04초
503이 두 번 난 뒤 세 번째에 성공했고 2.03초 걸렸습니다. 로그를 보면 첫 재시도는 바로, 두 번째는 2초 쉬고 나갔습니다. 계속 느린 /slow는 5초씩 4번 시도하고 그 사이 0초·2초·4초를 쉬어서 모두 26.04초 걸렸습니다. backoff_factor를 키우면 대기 시간이 늘어나므로, 재시도 횟수 × 읽기 timeout + 대기 시간 합계를 미리 계산해 두는 편이 좋습니다.
allowed_methods에 POST를 넣지 않은 것은 의도한 것입니다. 결제나 글 등록처럼 같은 요청이 두 번 처리되면 안 되는 작업은 자동 재시도 대상에서 빼야 합니다.
주의: 재시도 후에는 ConnectionError로 바뀝니다
위 로그의 마지막 줄을 보면 /slow가 결국 실패했는데 예외 이름이 ReadTimeout이 아니라 ConnectionError입니다. 정말 그런지 따로 확인했습니다.
$ python -X utf8 t4_except.py
ConnectionError로 잡힘
Timeout의 하위 클래스인가: False
안쪽 원인: ReadTimeoutError
재시도를 붙인 세션에서는 횟수를 다 쓰면 urllib3가 MaxRetryError를 내고, requests가 이것을 ConnectionError로 바꿔 던집니다. 그리고 ConnectionError는 Timeout의 하위 클래스가 아닙니다. 그래서 timeout만 처리하려고 except requests.exceptions.Timeout:이라고 써 두면 재시도를 붙인 순간 이 예외가 그대로 빠져나가 스크립트가 죽습니다. 두 예외를 함께 잡거나, 상위 클래스인 requests.exceptions.RequestException으로 잡아야 합니다.
전체 시간 제한 만들기 — 첫 시도는 실패했습니다
남은 문제는 /drip처럼 조금씩 흘리는 서버입니다. requests에는 전체 시간 제한 옵션이 없어서, stream=True로 받으면서 조각을 받을 때마다 경과 시간을 확인하기로 했습니다. 처음에는 흔히 쓰는 iter_content(chunk_size=8192)로 짰고 제한은 8초로 줬습니다. 그런데 결과가 이랬습니다.
/drip?n=20 -> DeadlineExceeded: 8초 안에 응답을 다 받지 못함: http://127.0.0.1:8765/drip?n=20 (19.01초)
예외는 났는데 8초가 아니라 19.01초 뒤에 났습니다. 다 받고 나서야 시간을 검사한 것입니다. 첫 조각이 언제 돌아오는지 읽는 방식별로 재 봤습니다.
iter_content(8192) 첫 조각 20바이트, 19.04초
iter_content(None) 첫 조각 20바이트, 19.02초
raw.read1(8192) 첫 조각 1바이트, 0.00초
iter_content는 크기를 8192로 주든 None으로 주든 조각이 다 차거나 응답이 끝날 때까지 기다렸습니다. 반면 urllib3 응답 객체의 read1()은 도착한 1바이트를 바로 돌려줬습니다. 그래서 반복문을 r.raw.read1(8192, decode_content=True)로 바꿨습니다. decode_content=True는 gzip 같은 압축 응답을 풀어서 받기 위한 옵션입니다.
완성 코드: safe_get
연결·읽기 timeout, 재시도, 전체 시간 제한을 한데 묶은 최종 코드입니다. 그대로 저장해서 python -X utf8 safe_get.py로 실행하면 테스트 서버를 상대로 세 경우를 확인합니다.
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class DeadlineExceeded(requests.exceptions.Timeout):
"""전체 제한 시간을 넘김"""
def make_session(retries=3):
retry = Retry(
total=retries,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD"],
)
s = requests.Session()
s.mount("http://", HTTPAdapter(max_retries=retry))
s.mount("https://", HTTPAdapter(max_retries=retry))
return s
def safe_get(session, url, timeout=(3, 10), deadline=30):
"""연결·읽기 timeout에 더해, 본문을 다 받을 때까지 전체 시간도 제한"""
start = time.monotonic()
with session.get(url, timeout=timeout, stream=True) as r:
r.raise_for_status()
chunks = []
while True:
# read1: 도착한 만큼만 바로 돌려줌 (iter_content는 8KB가 찰 때까지 기다림)
chunk = r.raw.read1(8192, decode_content=True)
if not chunk:
break
chunks.append(chunk)
if time.monotonic() - start > deadline:
raise DeadlineExceeded(f"{deadline}초 안에 응답을 다 받지 못함: {url}")
return b"".join(chunks)
if __name__ == "__main__":
session = make_session()
base = "http://127.0.0.1:8765"
for url in [f"{base}/", f"{base}/drip?n=20", f"{base}/slow?s=30"]:
t = time.perf_counter()
try:
body = safe_get(session, url, timeout=(3, 5), deadline=8)
result = f"성공 {len(body)}바이트"
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
result = f"{type(e).__name__}: {str(e)[:60]}"
print(f"{url.replace(base, ''):<12} -> {result} ({time.perf_counter() - t:.2f}초)")
실행 결과입니다. 이번에는 /drip이 설정한 8초에 맞춰 8.01초에 끊겼습니다.
$ python -X utf8 safe_get.py
/ -> 성공 3바이트 (0.02초)
/drip?n=20 -> DeadlineExceeded: 8초 안에 응답을 다 받지 못함: http://127.0.0.1:8765/drip?n=20 (8.01초)
/slow?s=30 -> ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=8765): Max retries (26.09초)
압축 응답도 제대로 풀리는지 확인하려고 처음에는 실제 사이트(python.org)를 불렀는데, 이 노트북에서는 SSLError ... CERTIFICATE_VERIFY_FAILED가 나서 실패했습니다. 이 글의 주제와는 다른 인증서 환경 문제라서, 테스트 서버에 /gzip 경로를 추가해 비교했습니다. 64바이트로 압축되어 온 응답을 requests.get과 safe_get 모두 6000바이트로 똑같이 풀었습니다.
Content-Encoding: gzip / 전송 크기: 64 바이트
requests.get : 6000 바이트
safe_get : 6000 바이트, 같은 내용: True
남은 한계와 적용 순서
safe_get의 전체 시간 제한은 응답 헤더를 받은 뒤부터 검사됩니다. 그래서 위 결과처럼 헤더조차 오지 않는 /slow는 재시도까지 포함해 26.09초가 걸렸습니다. 이 구간은 timeout과 재시도 횟수로 상한을 정해야 합니다. 이번 설정이라면 대략 “(읽기 5초 × 4번) + 대기 6초”입니다. 그보다 짧아야 한다면 재시도 횟수나 읽기 timeout을 줄이면 됩니다.
기존 스크립트에 적용할 때는 아래 순서를 권합니다.
- 모든
requests.get/post에timeout=(3, 10)처럼 값을 넣습니다. 이것만으로 무한 대기는 사라집니다. - GET처럼 다시 보내도 안전한 요청에만
Retry를 붙인 세션을 씁니다. - 예외 처리는
Timeout과ConnectionError를 함께, 또는RequestException으로 잡습니다. - 큰 파일을 받거나 느린 서버를 상대할 때만
safe_get같은 전체 시간 제한을 추가합니다.
timeout은 “다음 바이트를 기다리는 최대 시간”입니다. 전체 시간을 제한하려면 직접 재야 하고, 그때도 iter_content는 조각이 찰 때까지 기다린다는 점을 기억해 두세요.
마무리
가장 작은 첫걸음은 지금 쓰는 스크립트에서 requests.를 검색해 timeout이 빠진 줄을 찾는 것입니다. 한 줄에 timeout=10만 붙여도 “아무 오류 없이 멈춘 스크립트”는 적어도 오류를 남기고 끝나게 됩니다. 다음 글에서는 CSV 데이터로 매일 요약 리포트(HTML)를 자동으로 만드는 스크립트를 실제로 돌려 보며 정리하겠습니다.