- Published on
Python 데코레이터+컨텍스트로 리트라이·타임아웃
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
외부 API 호출, DB 질의, 서드파티 SDK 연동 같은 I/O 작업은 실패가 “예외적인 사건”이 아니라 “정상적인 가능성”입니다. 네트워크 지연, 일시적 5xx, 레이트 리밋, DNS 문제, 커넥션 재설정 등은 언제든 발생합니다. 문제는 이런 실패를 매번 호출부에서 try/except 로 처리하기 시작하면 코드가 빠르게 지저분해지고, 팀마다 재시도 정책이 달라져 운영 시 예측 가능성이 사라진다는 점입니다.
이 글에서는 Python의 데코레이터와 컨텍스트 매니저를 함께 사용해 다음을 만족하는 구현을 소개합니다.
- 호출부는 깔끔하게 유지
- 자동 리트라이(지수 백오프 + 지터)
- 타임아웃(전체 작업 시간 제한)
- 실패 사유 분류(재시도 가능한 예외 vs 즉시 실패)
- 로깅/메트릭을 끼워 넣기 쉬운 구조
관련해서 “어떤 작업이 끝나지 않고 멈춘 것처럼 보일 때” 관측 지점을 체크리스트화하는 접근이 중요합니다. 비슷한 맥락으로 아래 글도 함께 참고하면 운영 시 디버깅에 도움이 됩니다.
왜 데코레이터와 컨텍스트 매니저를 같이 쓰나
데코레이터의 장점
- “이 함수는 재시도 정책을 가진다”를 선언적으로 표현
- 여러 함수에 동일 정책을 빠르게 적용
- 호출부 변경 최소화
컨텍스트 매니저의 장점
- 특정 블록 범위에 공통 정책을 적용
- 리소스 세팅/정리(타이머 시작/종료, 트레이싱 스팬 등)를 구조적으로 보장
- 중첩 조합이 쉬움
결론적으로 함수 단위에는 데코레이터, 블록 단위에는 컨텍스트 매니저가 잘 맞습니다. 그리고 둘을 조합하면 “기본은 데코레이터로 통일하되, 일부 구간만 정책을 다르게” 같은 실무 요구를 자연스럽게 처리할 수 있습니다.
설계 목표와 주의점
1) 타임아웃의 의미를 명확히
타임아웃은 크게 두 종류가 있습니다.
- 시도 1회당 타임아웃: 각 요청은
n초 안에 끝나야 한다 - 전체 작업 타임아웃: 재시도 포함 전체가
N초를 넘기면 실패
실무에서는 “재시도하다가 총 2분이 넘어가서 스레드/워커가 잠김” 같은 문제가 더 치명적입니다. 그래서 이 글의 구현은 전체 작업 타임아웃을 1급으로 다룹니다.
2) 재시도 가능한 오류만 재시도
- 네트워크 계층 예외, 일시적 5xx, 429 등은 재시도 후보
- 4xx(인증 실패, 파라미터 오류 등)는 대부분 즉시 실패
예외를 무조건 재시도하면 장애를 악화시키거나, 동일한 잘못된 요청을 폭격하게 됩니다.
3) 백오프 + 지터는 사실상 필수
동시에 다수 인스턴스가 실패하면, 고정 딜레이 재시도는 “동시 재시도 폭주”를 유발합니다. 지수 백오프에 랜덤 지터를 섞어야 합니다.
핵심 구현: 전체 타임아웃 컨텍스트 매니저
Python 표준 라이브러리만으로 “블록 전체 시간 제한”을 만들 때 가장 이식성 높은 방법은 time.monotonic() 기반으로 남은 시간(deadline) 을 계산하는 방식입니다.
아래 컨텍스트 매니저는 “지금부터 timeout 초 안에 끝나야 한다”는 데드라인을 만들고, 루프에서 남은 시간을 계산할 수 있게 도와줍니다.
from __future__ import annotations
import time
from contextlib import contextmanager
from dataclasses import dataclass
class DeadlineExceeded(TimeoutError):
pass
@dataclass(frozen=True)
class Deadline:
end: float
@staticmethod
def after(seconds: float) -> "Deadline":
return Deadline(end=time.monotonic() + seconds)
def remaining(self) -> float:
return self.end - time.monotonic()
def ensure(self) -> None:
if self.remaining() <= 0:
raise DeadlineExceeded("deadline exceeded")
@contextmanager
def deadline(timeout: float):
d = Deadline.after(timeout)
try:
yield d
finally:
# 여기서 로깅/트레이싱 종료 같은 후처리를 넣기 좋습니다.
pass
이 패턴의 장점은 다음과 같습니다.
- OS 시그널에 의존하지 않아 Windows에서도 동작
- 스레드/이벤트루프와 강하게 결합되지 않음
- 남은 시간을 각 시도에 전달해 “시도당 타임아웃”도 자연스럽게 연동 가능
핵심 구현: 재시도 데코레이터(백오프+지터+예외 분류)
다음은 동기 함수에 적용 가능한 재시도 데코레이터입니다.
- 최대 시도 횟수
- 재시도 가능한 예외 타입
- 백오프(지수) + 지터
- 전체 데드라인과 연동
from __future__ import annotations
import functools
import random
import time
from dataclasses import dataclass
from typing import Callable, Iterable, Optional, Type
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 5
base_delay: float = 0.2
max_delay: float = 3.0
jitter: float = 0.2 # 0.2면 +-20% 랜덤
retry_on: tuple[Type[BaseException], ...] = (OSError, TimeoutError)
def _sleep_with_deadline(seconds: float, d) -> None:
# d는 Deadline 또는 None
if d is None:
time.sleep(seconds)
return
remaining = d.remaining()
if remaining <= 0:
raise DeadlineExceeded("deadline exceeded before sleep")
time.sleep(min(seconds, remaining))
def retry(policy: RetryPolicy, *, deadline_arg: str = "_deadline"):
"""
`deadline_arg` 이름으로 Deadline을 키워드 인자로 받을 수 있게 해 둡니다.
호출부에서 전체 타임아웃 컨텍스트와 조합할 때 유용합니다.
"""
def deco(fn: Callable):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
d = kwargs.get(deadline_arg)
last_exc: Optional[BaseException] = None
for attempt in range(1, policy.max_attempts + 1):
if d is not None:
d.ensure()
try:
return fn(*args, **kwargs)
except policy.retry_on as e:
last_exc = e
if attempt >= policy.max_attempts:
raise
# 지수 백오프
delay = min(policy.base_delay * (2 ** (attempt - 1)), policy.max_delay)
# 지터 적용: delay * (1 - jitter) ~ delay * (1 + jitter)
if policy.jitter > 0:
factor = 1 + random.uniform(-policy.jitter, policy.jitter)
delay = max(0.0, delay * factor)
_sleep_with_deadline(delay, d)
# 논리적으로 도달하지 않지만 타입 안정성을 위해
if last_exc is not None:
raise last_exc
raise RuntimeError("retry wrapper: unreachable")
return wrapper
return deco
여기서 중요한 포인트는 deadline_arg 입니다. 데코레이터는 보통 함수 시그니처를 바꾸지 않고 쓰고 싶지만, “전체 타임아웃”을 재시도 로직과 공유하려면 어딘가로 데드라인을 흘려보내야 합니다. 가장 단순하고 명시적인 방법이 키워드 인자로 전달하는 방식입니다.
사용 예제: 외부 API 호출에 자동 리트라이·전체 타임아웃 적용
아래 예제는 requests 를 사용합니다. requests.get(..., timeout=...) 의 timeout 은 “연결/읽기 타임아웃” 성격이므로, 우리는 남은 데드라인을 기반으로 시도당 timeout을 동적으로 계산합니다.
주의: 본문에서 부등호 기호는 MDX 빌드 에러를 유발할 수 있어, 비교 연산은 코드 블록 안에서만 사용합니다.
import requests
policy = RetryPolicy(
max_attempts=5,
base_delay=0.3,
max_delay=2.0,
jitter=0.3,
retry_on=(requests.Timeout, requests.ConnectionError),
)
@retry(policy)
def fetch_json(url: str, *, _deadline=None) -> dict:
# 남은 시간 중 일부를 시도 1회 timeout으로 사용
per_attempt_timeout = 3.0
if _deadline is not None:
remaining = _deadline.remaining()
_deadline.ensure()
per_attempt_timeout = max(0.1, min(per_attempt_timeout, remaining))
r = requests.get(url, timeout=per_attempt_timeout)
# HTTP 상태코드 기반 재시도는 예외로 변환해서 retry_on에 포함시키는 방식이 깔끔합니다.
if r.status_code in (429, 500, 502, 503, 504):
raise requests.Timeout(f"retryable status: {r.status_code}")
r.raise_for_status()
return r.json()
def main():
url = "https://httpbin.org/json"
with deadline(8.0) as d:
data = fetch_json(url, _deadline=d)
print(data)
if __name__ == "__main__":
main()
이 구조의 장점은 다음과 같습니다.
fetch_json은 “재시도 정책이 적용된 함수”로 재사용 가능- 호출부는
with deadline(...)한 줄로 전체 타임아웃을 강제 - 각 시도는 남은 시간을 넘지 않도록 자동으로 제한
컨텍스트 매니저로 관측 가능성(로깅/메트릭)까지 묶기
재시도는 운영에서 특히 “왜 재시도했는지, 몇 번 했는지, 총 지연이 얼마인지”가 중요합니다. 컨텍스트 매니저는 이런 공통 후처리를 넣기에 좋습니다.
예를 들어, 간단한 타이밍 측정 컨텍스트를 추가해 봅니다.
import time
from contextlib import contextmanager
@contextmanager
def timed(name: str, logger=print):
start = time.monotonic()
try:
yield
finally:
elapsed = time.monotonic() - start
logger(f"{name} took {elapsed:.3f}s")
사용은 이렇게 조합합니다.
with timed("fetch profile"):
with deadline(5.0) as d:
profile = fetch_json("https://httpbin.org/json", _deadline=d)
실무에서는 logger 를 표준 로거로 바꾸고, 메트릭(예: Prometheus counter/histogram)도 같은 위치에 넣으면 됩니다.
예외 분류 전략: 무엇을 retry_on에 넣을까
대략적인 가이드는 다음과 같습니다.
- 재시도 후보
TimeoutError계열- 커넥션 오류(
ConnectionError,OSError일부) - 429, 500, 502, 503, 504
- 즉시 실패 후보
- 인증/인가 실패(401, 403)
- 잘못된 요청(400, 404)
- 도메인 로직 예외(검증 실패 등)
레이트 리밋은 특히 중요합니다. 서버가 429와 함께 Retry-After 헤더를 주는 경우가 많으니, 가능하면 그 값을 우선하는 로직을 추가하세요.
흔한 함정 5가지
1) 전체 타임아웃 없이 재시도만 넣기
재시도는 실패를 완화하지만, 전체 타임아웃이 없으면 워커가 장시간 점유될 수 있습니다. “멈춘 것처럼 보이는 작업”이 늘어납니다.
2) 모든 예외를 재시도
버그나 잘못된 파라미터까지 재시도하면 장애가 장기화됩니다. 반드시 예외/상태코드 분류가 필요합니다.
3) 고정 딜레이 재시도
동시 장애에서 재시도 타이밍이 동기화되어 트래픽 스파이크를 만듭니다. 지터를 넣으세요.
4) 타임아웃을 너무 짧게 잡고 재시도만 늘리기
짧은 타임아웃은 실패율을 올리고 재시도를 폭증시킬 수 있습니다. “시도당 타임아웃”과 “전체 타임아웃”을 함께 설계해야 합니다.
5) 관측 지점 없이 재시도
재시도가 얼마나 발생하는지 모르면, 실제로는 장애를 숨기고 있을 수 있습니다. 최소한 “시도 횟수, 총 소요, 마지막 예외”는 로그/메트릭으로 남겨야 합니다.
비동기(async) 환경에서는 어떻게 확장하나
이 글의 코드는 동기 함수 기준이지만, 아이디어는 동일합니다.
async def용 데코레이터는await fn(...)형태로 wrapper를 만들고time.sleep대신asyncio.sleep을 사용- HTTP 클라이언트는
httpx.AsyncClient같은 것으로 교체
다만 async에서는 “취소(cancellation)”가 중요한 제어 수단이므로, 전체 데드라인 초과 시 asyncio.TimeoutError 와 태스크 취소가 어떻게 전파되는지까지 포함해 설계하는 것이 안전합니다.
마무리: 재시도는 기능이 아니라 정책이다
자동 리트라이는 단순 편의 기능이 아니라, 시스템의 실패 모델을 코드로 고정하는 “정책”입니다. 데코레이터로 정책을 선언하고, 컨텍스트 매니저로 범위와 관측을 묶으면 다음 효과가 생깁니다.
- 호출부가 단순해지고 일관성이 생김
- 운영 중 장애에서 “왜 느려졌는지/왜 멈췄는지”를 추적하기 쉬움
- 타임아웃과 재시도를 함께 다루며 워커 고갈을 예방
CI 환경에서 재시도 테스트가 불안정해지거나, 캐시/네트워크 이슈로 빌드 시간이 늘어지는 경우도 종종 비슷한 패턴으로 접근합니다. 관련해서는 아래 글도 참고할 만합니다.
원한다면 다음 단계로, Retry-After 지원, 상태코드별 정책 테이블, 서킷 브레이커(연속 실패 시 빠른 실패), 그리고 httpx 기반 async 버전까지 확장한 예제를 이어서 정리해 드릴 수 있습니다.