Published on

Decorator+Generator로 재시도·백오프 20줄 구현

Authors

서버/외부 API 호출에서 실패는 예외가 아니라 상수입니다. 네트워크 타임아웃, 일시적인 502/503, 레이트 리밋, DNS 지연 같은 문제는 "한 번 더" 시도하면 해결되는 경우가 많습니다. 하지만 재시도 로직을 여기저기 흩뿌리면 코드가 금방 지저분해지고, 백오프(대기 시간) 정책이 제각각이 되어 장애 때 트래픽을 더 악화시키기도 합니다.

이 글은 데코레이터 + 제너레이터 조합으로 재시도·백오프를 20줄 내외로 구현하는 방법을 다룹니다. 핵심은 다음 두 가지입니다.

  • 제너레이터: 시도 횟수에 따른 대기 시간을 순차적으로 만들어 주는 "정책" 레이어
  • 데코레이터: 어떤 함수든 감싸서 동일한 재시도 정책을 적용하는 "적용" 레이어

추가로, 운영에서 자주 겪는 502/503 추적 경험이 있다면 네트워크 계층의 일시 장애가 얼마나 흔한지 체감하실 텐데요. 비슷한 맥락으로 egress 경로에서만 502가 터질 때의 원인 추적은 EKS에서 Pod egress만 502? Envoy/NLB 추적기도 참고할 만합니다.

20줄 구현: 제너레이터로 백오프, 데코레이터로 적용

아래 코드는 다음을 만족합니다.

  • 최대 tries 번 시도
  • 실패 시 base 를 시작으로 지수 백오프(factor) 적용
  • 지터(jitter)로 동시 재시도 폭주(Thundering Herd) 완화
  • 특정 예외 타입만 재시도
import time, random

def backoff(tries, base=0.2, factor=2.0, cap=5.0, jitter=0.1):
    for n in range(tries - 1):
        t = min(cap, base * (factor ** n))
        yield max(0.0, t + random.uniform(-jitter, jitter))

def retry(tries=5, exc=(Exception,), **bo):
    def deco(fn):
        def wrapped(*a, **k):
            last = None
            for wait in list(backoff(tries, **bo)) + [None]:
                try:
                    return fn(*a, **k)
                except exc as e:
                    last = e
                    if wait is None: raise
                    time.sleep(wait)
            raise last
        return wrapped
    return deco

줄 수를 줄이기 위해 몇 가지를 단순화했습니다.

  • 마지막 시도는 wait is None 으로 구분했습니다.
  • list(backoff(...)) + [None] 패턴으로 "마지막은 대기 없이 종료"를 만들었습니다.

사용 예시

import requests

@retry(tries=4, exc=(requests.Timeout, requests.ConnectionError), base=0.3, cap=3.0, jitter=0.2)
def fetch_json(url: str):
    r = requests.get(url, timeout=1.0)
    r.raise_for_status()
    return r.json()

print(fetch_json("https://httpbin.org/json"))

왜 제너레이터가 좋은가: 정책을 분리하고 테스트하기 쉬움

백오프 정책을 제너레이터로 분리하면 장점이 큽니다.

  1. 정책을 독립적으로 테스트 가능
  2. 동일 정책을 여러 데코레이터/함수에서 재사용 가능
  3. "다음에 얼마나 기다릴지"를 외부로 노출해 로깅/메트릭에 활용 가능

예를 들어 백오프가 의도대로 증가하는지 빠르게 확인할 수 있습니다.

print(list(backoff(tries=6, base=0.1, factor=2.0, cap=1.0, jitter=0.0)))
# [0.1, 0.2, 0.4, 0.8, 1.0]

실전에서 자주 망하는 포인트 6가지

재시도는 "좋은 의도"로 넣었다가 장애를 키우는 경우도 많습니다. 아래는 운영에서 특히 자주 터지는 함정입니다.

1) 멱등성 없는 요청을 무작정 재시도

POST/PATCH 같은 변경 요청은 재시도 시 중복 결제가 나거나 상태가 꼬일 수 있습니다.

  • 서버가 Idempotency-Key 를 지원하는지 확인
  • 클라이언트에서 요청 ID를 만들어 중복 처리 방지
  • 가능하면 "조회" 계열에만 재시도 적용

2) 모든 예외를 재시도

위 예시는 기본이 exc=(Exception,) 이라 위험합니다. 실전에서는 반드시 좁히세요.

  • 네트워크 계열: TimeoutError, ConnectionError
  • 일시적 HTTP: 502, 503, 504, 429

HTTP 상태 코드를 기준으로 재시도하려면 예외를 변환하거나, 응답을 보고 예외를 던지는 형태가 깔끔합니다.

3) 지터 없이 동시 폭주

장애 순간에 수천 개의 워커가 동일한 백오프 스케줄로 동시에 재시도하면, 복구 직전에 트래픽이 몰려 더 오래 죽습니다. 지터는 거의 필수입니다.

4) 캡(cap) 없는 지수 백오프

지수 백오프는 금방 대기 시간이 커집니다. cap 이 없으면 워커가 장시간 블로킹되어 큐가 쌓이고 타임아웃이 연쇄적으로 발생합니다.

5) 타임아웃 없는 재시도

재시도는 "실패를 빨리 감지"한다는 전제가 있어야 합니다. 호출 함수 자체에 타임아웃이 없으면 재시도가 의미가 없습니다.

  • requests.get(..., timeout=...)
  • DB/Redis 클라이언트 타임아웃
  • gRPC deadline

6) 로그/메트릭이 없어 원인 분석 불가

재시도는 문제를 "가려" 버리기도 합니다. 최소한 다음은 남기세요.

  • 시도 횟수
  • 대기 시간
  • 예외 타입/메시지
  • 최종 실패 여부

로깅을 붙인 확장 버전(여전히 간결하게)

20줄 버전은 미니멀하지만, 운영에서는 관측성이 중요합니다. 아래는 on_retry 콜백을 추가한 형태입니다.

import time, random

def backoff(tries, base=0.2, factor=2.0, cap=5.0, jitter=0.1):
    for n in range(tries - 1):
        t = min(cap, base * (factor ** n))
        yield max(0.0, t + random.uniform(-jitter, jitter))

def retry(tries=5, exc=(Exception,), on_retry=None, **bo):
    def deco(fn):
        def wrapped(*a, **k):
            last = None
            for i, wait in enumerate(list(backoff(tries, **bo)) + [None], start=1):
                try:
                    return fn(*a, **k)
                except exc as e:
                    last = e
                    if wait is None: raise
                    if on_retry: on_retry(i, wait, e)
                    time.sleep(wait)
            raise last
        return wrapped
    return deco

사용:

def log_retry(i, wait, e):
    print(f"retry={i} wait={wait:.2f}s err={type(e).__name__}: {e}")

@retry(tries=5, exc=(OSError,), base=0.1, cap=1.0, jitter=0.05, on_retry=log_retry)
def flaky():
    raise OSError("temporary")

flaky()

데코레이터 기반 재시도 패턴을 더 확장하고 싶다면

이 글은 데코레이터와 제너레이터로 "짧고 강한" 구현을 목표로 했습니다. 더 복잡한 요구(컨텍스트 매니저로 타임박스, 리소스 정리, 단계별 타임아웃 등)가 있다면 Python 데코레이터+컨텍스트로 리트라이·타임아웃처럼 컨텍스트를 결합하는 방식도 좋은 선택입니다.

또한 인증/토큰 갱신이 얽힌 요청에서 재시도가 무한 루프처럼 보이는 케이스도 있는데, 리다이렉트/세션 문제를 동반한다면 NextAuth.js JWT 세션 401 반복 시 점검 7가지 같은 체크리스트가 도움이 됩니다.

정리

  • 제너레이터로 백오프 시퀀스를 만들고, 데코레이터로 함수에 주입하면 재시도를 매우 작은 코드로 표준화할 수 있습니다.
  • 실전에서는 예외 범위를 좁히고, 지터/캡/타임아웃/관측성을 반드시 챙기세요.
  • 재시도는 장애를 "숨기는" 도구가 아니라, 장애를 "완만하게" 만드는 도구입니다. 정책이 일관되게 적용될 때 효과가 큽니다.