Published on

Python 데코레이터로 재시도·백오프 구현 5패턴

Authors

외부 API, 메시지 브로커, DB, 파일 스토리지 같은 의존성은 “가끔” 실패합니다. 문제는 그 “가끔”이 사용자 경험과 장애 전파를 좌우한다는 점입니다. 재시도와 백오프(backoff)는 가장 비용 대비 효과가 큰 안정화 기법이지만, 아무 호출에나 무작정 붙이면 오히려 트래픽 폭주(thundering herd), 지연 증가, 중복 처리 같은 2차 문제를 만듭니다.

이 글에서는 Python 데코레이터로 재시도·백오프를 구현할 때 자주 쓰는 5가지 패턴을 코드로 정리합니다. 예시는 표준 라이브러리만으로 동작하도록 구성했고, 필요 시 requests 같은 라이브러리로 쉽게 확장할 수 있게 만들었습니다.

장애가 애플리케이션 밖에서 시작되어 쿠버네티스까지 번질 때는 재시도만으로는 부족할 수 있습니다. 운영 관점에서 크래시 루프를 함께 점검하는 글도 참고하세요: Kubernetes CrashLoopBackOff 원인별 로그·해결 9가지

재시도·백오프 설계 체크리스트

데코레이터 코드를 보기 전에, 실전에서 꼭 확인해야 하는 조건을 먼저 정리합니다.

  • 무엇을 재시도할 것인가: 타임아웃, 5xx, 커넥션 리셋 같은 일시적(transient) 오류만 대상이어야 합니다.
  • 무엇을 재시도하지 말 것인가: 4xx(특히 400, 401, 403, 404)처럼 요청이 잘못된 경우는 재시도해도 결과가 바뀌지 않습니다.
  • 멱등성(idempotency): POST/결제/주문 같은 요청은 재시도 시 중복 처리 위험이 있습니다. 서버가 idempotency key를 지원하는지 확인해야 합니다.
  • 백오프와 지터(jitter): 동일한 백오프를 쓰면 클라이언트들이 같은 타이밍에 몰립니다. 지터는 사실상 필수입니다.
  • 최대 대기 시간/시도 횟수: 재시도는 “무한히”가 아니라 “정해진 예산” 안에서만.
  • 관측 가능성(Observability): 몇 번 재시도했는지, 어떤 예외였는지, 총 지연이 얼마였는지 로그/메트릭으로 남겨야 합니다.

공통 유틸: 백오프 계산기와 예외 분류

먼저 패턴들에서 공통으로 쓰는 구성요소를 준비합니다.

import random
import time
from dataclasses import dataclass
from typing import Callable, Iterable, Optional, Tuple, Type


def exp_backoff(
    attempt: int,
    base: float = 0.2,
    factor: float = 2.0,
    max_delay: float = 5.0,
) -> float:
    """attempt는 1부터 시작한다고 가정."""
    delay = base * (factor ** (attempt - 1))
    return min(delay, max_delay)


def full_jitter(delay: float) -> float:
    """AWS 아키텍처 블로그에서 유명한 full jitter: [0, delay] 균등 분포."""
    return random.uniform(0.0, delay)


@dataclass
class RetryState:
    attempt: int
    delay: float
    elapsed: float
    last_exc: Optional[BaseException] = None
  • exp_backoff는 지수 백오프를 계산합니다.
  • full_jitter는 지터를 적용합니다.
  • RetryState는 로깅/콜백에 넘길 상태 객체입니다.

이제부터는 이 공통 유틸을 기반으로 5가지 패턴을 구현합니다.

패턴 1) 기본형: 예외 기반 재시도 + 지수 백오프

가장 흔한 형태입니다. 특정 예외 타입만 재시도하고, 시도 횟수 제한과 백오프를 둡니다.

from functools import wraps


def retry(
    *,
    retries: int = 3,
    retry_on: Tuple[Type[BaseException], ...] = (TimeoutError, ConnectionError),
    base: float = 0.2,
    factor: float = 2.0,
    max_delay: float = 5.0,
    jitter: Callable[[float], float] = full_jitter,
    on_retry: Optional[Callable[[RetryState], None]] = None,
):
    """동기 함수용 재시도 데코레이터."""

    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            start = time.monotonic()
            last_exc: Optional[BaseException] = None

            for attempt in range(1, retries + 2):  # 최초 1회 + retries
                try:
                    return fn(*args, **kwargs)
                except retry_on as exc:
                    last_exc = exc
                    if attempt > retries + 1:
                        break

                    raw = exp_backoff(attempt, base=base, factor=factor, max_delay=max_delay)
                    delay = jitter(raw) if jitter else raw

                    state = RetryState(
                        attempt=attempt,
                        delay=delay,
                        elapsed=time.monotonic() - start,
                        last_exc=exc,
                    )
                    if on_retry:
                        on_retry(state)

                    time.sleep(delay)

            assert last_exc is not None
            raise last_exc

        return wrapper

    return decorator

사용 예시:

@retry(retries=4, retry_on=(TimeoutError,))
def fetch_user_profile(user_id: str) -> dict:
    # 실제로는 requests.get(...) 같은 호출이 들어간다고 가정
    raise TimeoutError("upstream timeout")


def log_retry(state: RetryState) -> None:
    print(f"retry attempt={state.attempt} delay={state.delay:.2f}s elapsed={state.elapsed:.2f}s exc={state.last_exc}")


@retry(retries=3, on_retry=log_retry)
def flaky() -> str:
    raise ConnectionError("tcp reset")

핵심 포인트:

  • retries=3이면 총 시도는 4번입니다(최초 1회 + 재시도 3회).
  • 지터는 기본으로 켜는 것을 권장합니다.
  • on_retry 콜백으로 로깅/메트릭을 연결할 수 있습니다.

패턴 2) 결과 기반 재시도: 예외가 아니라 반환값을 보고 판단

외부 라이브러리/SDK가 실패를 예외가 아니라 “상태 코드”나 “성공 플래그”로 돌려주는 경우가 있습니다. 또는 예외는 없지만 결과가 비정상(빈 리스트, None)인 케이스도 있습니다.

from functools import wraps


def retry_if(
    *,
    retries: int = 3,
    should_retry: Callable[[object], bool],
    base: float = 0.2,
    factor: float = 2.0,
    max_delay: float = 5.0,
    jitter: Callable[[float], float] = full_jitter,
    on_retry: Optional[Callable[[RetryState], None]] = None,
):
    """반환값을 검사해 재시도하는 데코레이터."""

    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            start = time.monotonic()
            last_result = None

            for attempt in range(1, retries + 2):
                result = fn(*args, **kwargs)
                last_result = result

                if not should_retry(result):
                    return result

                if attempt == retries + 1:
                    return result

                raw = exp_backoff(attempt, base=base, factor=factor, max_delay=max_delay)
                delay = jitter(raw) if jitter else raw

                if on_retry:
                    on_retry(RetryState(attempt=attempt, delay=delay, elapsed=time.monotonic() - start))

                time.sleep(delay)

            return last_result

        return wrapper

    return decorator

사용 예시(HTTP 상태 코드 기반):

@dataclass
class HttpResult:
    status: int
    body: str


def is_retryable_status(res: HttpResult) -> bool:
    return res.status in (429, 500, 502, 503, 504)


@retry_if(retries=5, should_retry=is_retryable_status)
def call_upstream() -> HttpResult:
    return HttpResult(status=503, body="temporary unavailable")

이 패턴은 특히 429(레이트 리밋) 대응에 유용합니다. 단, 429는 Retry-After 헤더를 존중하는 게 더 좋으므로 다음 패턴과 함께 쓰는 경우가 많습니다.

패턴 3) 서버 힌트 기반 백오프: Retry-After 우선 + 캡(cap)

레이트 리밋이나 점검 상황에서 서버가 “몇 초 뒤에 다시 와라”를 알려줄 때가 있습니다. 이 힌트를 무시하고 지수 백오프만 적용하면, 서버 정책과 충돌하거나 불필요한 재시도를 하게 됩니다.

여기서는 “서버가 준 대기 시간”을 우선하되, 클라이언트 측에서 최대 대기 시간을 캡으로 제한합니다.

from functools import wraps


class RateLimited(Exception):
    def __init__(self, retry_after: Optional[float] = None, message: str = "rate limited"):
        super().__init__(message)
        self.retry_after = retry_after


def retry_with_retry_after(
    *,
    retries: int = 5,
    base: float = 0.5,
    factor: float = 2.0,
    max_delay: float = 10.0,
    max_retry_after: float = 30.0,
    jitter: Callable[[float], float] = full_jitter,
):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(1, retries + 2):
                try:
                    return fn(*args, **kwargs)
                except RateLimited as exc:
                    if attempt == retries + 1:
                        raise

                    # 1) 서버 힌트 우선
                    if exc.retry_after is not None:
                        raw = min(float(exc.retry_after), max_retry_after)
                    else:
                        raw = exp_backoff(attempt, base=base, factor=factor, max_delay=max_delay)

                    delay = jitter(raw) if jitter else raw
                    time.sleep(delay)

        return wrapper

    return decorator

사용 예시:

@retry_with_retry_after(retries=4)
def call_api_with_limit():
    # 예: 응답 헤더의 Retry-After를 파싱했다고 가정
    raise RateLimited(retry_after=12)

운영 팁:

  • max_retry_after를 두지 않으면 서버가 과도한 값을 주는 경우(또는 파싱 오류) 요청이 장시간 멈출 수 있습니다.
  • 레이트 리밋은 “재시도”라기보다 “조절(throttle)”에 가깝습니다. 호출자 레벨에서 동시성 제한(세마포어)도 같이 고려하세요.

패턴 4) 비동기 async 지원: asyncio.sleep로 동일한 UX 제공

요즘 Python 서비스는 asyncio 기반인 경우가 많습니다. 동기용 데코레이터를 그대로 쓰면 time.sleep이 이벤트 루프를 막아 전체 처리량이 떨어집니다.

import asyncio
from functools import wraps


def async_retry(
    *,
    retries: int = 3,
    retry_on: Tuple[Type[BaseException], ...] = (TimeoutError, ConnectionError),
    base: float = 0.2,
    factor: float = 2.0,
    max_delay: float = 5.0,
    jitter: Callable[[float], float] = full_jitter,
):
    def decorator(fn):
        @wraps(fn)
        async def wrapper(*args, **kwargs):
            last_exc: Optional[BaseException] = None

            for attempt in range(1, retries + 2):
                try:
                    return await fn(*args, **kwargs)
                except retry_on as exc:
                    last_exc = exc
                    if attempt == retries + 1:
                        break

                    raw = exp_backoff(attempt, base=base, factor=factor, max_delay=max_delay)
                    delay = jitter(raw) if jitter else raw
                    await asyncio.sleep(delay)

            assert last_exc is not None
            raise last_exc

        return wrapper

    return decorator

사용 예시:

@async_retry(retries=5, retry_on=(TimeoutError,))
async def async_fetch():
    raise TimeoutError("async upstream timeout")

실전에서는 httpx.AsyncClient 같은 비동기 HTTP 클라이언트와 결합하는 경우가 많습니다. 이때도 핵심은 동일합니다: “재시도 대상 예외를 좁게”, “지터 포함”, “최대 지연 제한”.

패턴 5) 서킷 브레이커 결합: 재시도 대신 빠른 실패로 보호

재시도는 일시 장애에 강하지만, 의존성이 지속적으로 죽어 있는 상황에서는 오히려 시스템을 더 괴롭힙니다.

  • 실패한 호출이 쌓이며 워커/스레드가 잠김
  • 백오프로 인해 요청 지연이 길어져 타임아웃 폭발
  • 트래픽이 계속 유입되며 장애가 회복될 틈이 없음

이때 필요한 게 서킷 브레이커(circuit breaker) 입니다. 일정 실패율/연속 실패를 넘으면 “열림(open)” 상태로 전환해 즉시 실패시키고, 일정 시간이 지난 뒤 일부 요청만 “반열림(half-open)”으로 흘려보내 회복 여부를 확인합니다.

간단한 구현 예시입니다.

from dataclasses import dataclass


class CircuitOpen(Exception):
    pass


@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: float = 10.0

    _failures: int = 0
    _opened_at: Optional[float] = None

    def allow_request(self) -> bool:
        if self._opened_at is None:
            return True
        # open 상태: timeout 지나면 half-open으로 1회 시도 허용
        if (time.monotonic() - self._opened_at) >= self.recovery_timeout:
            return True
        return False

    def on_success(self) -> None:
        self._failures = 0
        self._opened_at = None

    def on_failure(self) -> None:
        self._failures += 1
        if self._failures >= self.failure_threshold:
            self._opened_at = time.monotonic()


def retry_with_circuit(
    breaker: CircuitBreaker,
    *,
    retries: int = 2,
    retry_on: Tuple[Type[BaseException], ...] = (TimeoutError, ConnectionError),
):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            if not breaker.allow_request():
                raise CircuitOpen("circuit is open")

            try:
                # 내부적으로는 짧게만 재시도
                result = retry(retries=retries, retry_on=retry_on)(fn)(*args, **kwargs)
                breaker.on_success()
                return result
            except retry_on:
                breaker.on_failure()
                raise

        return wrapper

    return decorator

사용 예시:

breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=15.0)


@retry_with_circuit(breaker, retries=1, retry_on=(TimeoutError,))
def call_payment_gateway():
    raise TimeoutError("gateway timeout")

이 패턴의 의의:

  • “재시도”는 짧게(예: 1~2회)만 하고
  • 지속 장애는 서킷 브레이커가 막아
  • 시스템 전체의 스레드/커넥션/큐 적체를 줄입니다

쿠버네티스에서 파드가 계속 재시작되는 상황이라면, 애플리케이션 레벨의 재시도뿐 아니라 런타임/리소스/권한 문제까지 같이 봐야 합니다. 진단 흐름은 다음 글이 도움이 됩니다: EKS Pod가 ContainerCreating에 멈출 때 10분 진단

보너스: 멱등성 키와 함께 쓰는 방법(중복 처리 방지)

재시도는 “같은 요청을 여러 번 보냄”을 전제로 합니다. 따라서 멱등하지 않은 작업(주문 생성, 결제 승인, 포인트 차감 등)에 재시도를 걸려면 다음 중 하나가 필요합니다.

  • 서버가 idempotency key를 지원하고, 클라이언트가 요청마다 고유 키를 붙인다
  • 클라이언트가 작업을 로컬 트랜잭션/아웃박스(outbox)로 관리한다

간단한 예시(헤더에 키를 넣는 형태)만 보면:

import uuid


def with_idempotency_key(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        kwargs.setdefault("idempotency_key", str(uuid.uuid4()))
        return fn(*args, **kwargs)

    return wrapper


@with_idempotency_key
@retry(retries=3, retry_on=(TimeoutError, ConnectionError))
def create_order(user_id: str, amount: int, idempotency_key: str):
    # requests.post(url, headers={"Idempotency-Key": idempotency_key}, json=...)
    # 같은 키로 재시도하면 서버는 중복 생성 대신 동일 결과를 반환해야 안전
    raise TimeoutError("timeout before response")

이 주제는 분산 트랜잭션/보상 트랜잭션과도 연결됩니다. 더 큰 설계 관점이 필요하면 MSA 사가 패턴 보상 트랜잭션 설계 7단계도 함께 보면 좋습니다.

마무리: 어떤 패턴을 언제 쓰나

  • 패턴 1(예외 기반): 대부분의 네트워크 호출 기본값. 지터 포함은 사실상 필수.
  • 패턴 2(결과 기반): 예외 대신 상태 코드/결과 객체로 실패를 표현하는 SDK에 적합.
  • 패턴 3(Retry-After): 429/레이트 리밋 대응. 서버 정책을 존중하면서도 캡으로 안전장치.
  • 패턴 4(async): 이벤트 루프 환경에서 처리량을 지키는 필수 형태.
  • 패턴 5(서킷 브레이커): 지속 장애에서 재시도가 시스템을 망가뜨리는 것을 방지.

현업에서는 보통 패턴 1 또는 4를 기본으로 깔고, 레이트 리밋이 보이면 패턴 3, 장애 전파를 막아야 하면 패턴 5, 그리고 멱등성 문제가 있으면 idempotency key까지 같이 엮는 식으로 조합합니다.

재시도는 “성공 확률을 올리는 도구”이지만, 동시에 “지연을 늘리는 도구”이기도 합니다. 따라서 호출 경로의 타임아웃 예산(예: 전체 2초) 안에서 retries, max_delay, timeout을 함께 설계하는 것이 가장 중요합니다.