Published on

Python 데코레이터+컨텍스트로 리트라이·타임아웃

Authors

외부 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 버전까지 확장한 예제를 이어서 정리해 드릴 수 있습니다.