파이썬 데코레이터로 재시도 로직 짜는 법, tenacity 실전 설정

파이썬 데코레이터로 재시도

결론부터 말씀드리면, 파이썬 데코레이터로 재시도 로직을 직접 만들기보다 tenacity 라이브러리를 쓰는 편이 훨씬 안전합니다. 지수 백오프, 예외 필터링, 재시도 로깅까지 옵션 몇 줄로 끝나고, 직접 짠 재시도 로직에서 흔히 나는 버그(무한 루프, 예외 삼킴, 스레드 폭주)를 피할 수 있기 때문입니다. 이 글에서는 @retry 데코레이터를 실전에 쓸 때 꼭 챙겨야 할 설정과, 반대로 재시도를 걸면 안 되는 상황까지 코드와 함께 정리했습니다.

파이썬 데코레이터로 재시도, 왜 직접 짜지 말고 tenacity를 써야 할까요

데코레이터는 원본 함수를 감싸서 동작을 덧붙이는 파이썬 문법인데, 재시도처럼 반복되는 로직을 함수 밖으로 빼낼 때 특히 유용합니다. 그래서 많은 분들이 while 루프와 try/except로 직접 재시도 데코레이터를 만들곤 합니다.

문제는 이런 자작 로직이 세 가지를 놓치기 쉽다는 점입니다. 첫째로 except Exception처럼 모든 예외를 잡아버려서 진짜 버그까지 재시도로 덮어버리고, 둘째로 대기 시간을 고정값으로만 넣어서 서버가 복구되기 전에 계속 두드리는 경우가 많습니다. 셋째로 최대 시도 횟수를 셀 때 off-by-one 실수가 자주 나옵니다.

PyPI에 배포된 tenacitypip install tenacity로 설치하면 이런 부분을 옵션으로 명시해서 데코레이터 한 줄로 처리할 수 있습니다. 이 글은 tenacity 8.x 계열 API(stop, wait, retry, reraise 등)를 기준으로 설명합니다.

tenacity 재시도 로직, 데코레이터 세 줄이면 충분합니다

가장 단순한 형태는 stop_after_attemptwait_fixed 두 옵션만 조합하는 것입니다. 아래 코드는 처음 두 번은 일부러 실패하고 세 번째에 성공하도록 만든 예시입니다.

import time
from tenacity import retry, stop_after_attempt, wait_fixed

attempt_counter = {"count": 0}

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def flaky_call():
    attempt_counter["count"] += 1
    print(f"시도 {attempt_counter['count']}번째")
    if attempt_counter["count"] < 3:
        raise ConnectionError("일시적 네트워크 오류")
    return "성공"

result = flaky_call()
print(result)

이 코드를 실행하면 다음과 같은 흐름으로 출력됩니다.

시도 1번째
시도 2번째
시도 3번째
성공

stop_after_attempt(3)은 최초 호출을 포함해 총 3번까지 시도를 허용한다는 뜻이고, wait_fixed(2)는 실패한 시도 사이에 2초씩 고정으로 대기합니다. 함수 본문은 재시도 여부를 전혀 신경 쓰지 않아도 되므로, 원래 로직과 재시도 로직이 깔끔하게 분리됩니다.

지수 백오프와 예외 필터링을 같이 걸어야 하는 이유

wait_fixed만 쓰면 서버 장애처럼 복구까지 시간이 걸리는 상황에서 똑같은 간격으로 계속 요청을 보내게 됩니다. 이럴 때는 wait_exponential로 대기 시간을 점점 늘리고, retry_if_exception_type으로 재시도할 예외 범위를 좁히는 조합이 실전에서 자주 쓰입니다.

from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
)
import requests

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=2, max=30),
    retry=retry_if_exception_type(requests.exceptions.RequestException),
)
def call_api():
    response = requests.get("https://api.example.com/data", timeout=5)
    response.raise_for_status()
    return response.json()

자주 쓰는 옵션을 정리하면 아래와 같습니다.

옵션 역할 실전에서 자주 쓰는 값
stop_after_attempt(n) 총 시도 횟수 제한 3~5
stop_after_delay(sec) 누적 대기 시간 제한 10~30초
wait_fixed(sec) 매번 동일한 간격 대기 1~2초
wait_exponential(...) 대기 시간을 지수적으로 증가 multiplier=1, min=2, max=30
wait_random_exponential(...) 지수 백오프에 지터 추가 multiplier=1, max=60
retry_if_exception_type(Exc) 지정한 예외만 재시도 requests.exceptions.RequestException

retry_if_exception_type을 넣지 않으면 tenacity는 기본적으로 모든 예외를 재시도 대상으로 봅니다. ValueErrorTypeError처럼 코드 버그로 나는 예외까지 재시도해버리면 실패 원인을 알아채는 시간만 늦어지므로, 네트워크·타임아웃 계열 예외로 범위를 좁혀두는 편이 낫습니다.

reraise 안 쓰면 원본 예외 대신 RetryError가 올라옵니다

서버 네트워크 연결 오류와 재시도를 나타내는 추상적 이미지

tenacity를 처음 쓸 때 가장 자주 걸리는 부분이 이것입니다. 재시도를 모두 소진하고도 실패하면, 기본 설정(reraise=False)에서는 원본 예외가 아니라 tenacity.RetryError가 발생합니다. 상위 코드에서 원본 예외 타입으로 except를 걸어두면 이 에러를 못 잡습니다.

from tenacity import retry, stop_after_attempt, RetryError

@retry(stop=stop_after_attempt(2))
def always_fail():
    raise ValueError("항상 실패")

try:
    always_fail()
except ValueError:
    print("ValueError로 잡힘")
except RetryError:
    print("RetryError로 잡힘")

이 코드는 RetryError로 잡힘을 출력합니다. ValueError를 그대로 잡고 싶다면 데코레이터에 reraise=True를 추가하면 됩니다.

@retry(stop=stop_after_attempt(2), reraise=True)
def always_fail():
    raise ValueError("항상 실패")

이렇게 바꾸면 최종 실패 시 RetryError 대신 원본 ValueError가 그대로 올라오므로, 기존 예외 처리 코드를 수정하지 않고도 tenacity를 끼워 넣을 수 있습니다. 로깅이 필요하면 before_sleep=before_sleep_log(logger, logging.WARNING)을 같이 넣어서 재시도할 때마다 경고 로그를 남기는 방법도 있습니다.

재시도가 위험해지는 경우를 먼저 확인하고 다섯 줄 설정부터 시작하세요

재시도 로직이 항상 안전한 것은 아닙니다. 결제나 주문 생성처럼 같은 요청이 두 번 실행되면 실제로 데이터가 중복되는 비멱등(non-idempotent) 작업에 @retry를 그대로 붙이면, 네트워크 오류로 응답만 못 받았을 뿐 서버는 이미 처리를 끝낸 상황에서 중복 결제가 발생할 수 있습니다. 이런 API에는 서버 쪽에 idempotency key가 있는지 먼저 확인해야 합니다.

wait_exponentialmax 값과 상위 타임아웃 값이 안 맞는 것도 자주 놓치는 부분입니다. 예를 들어 전체 요청 제한 시간이 10초인데 wait_exponential(max=30)을 그대로 쓰면 재시도 대기만으로 제한 시간을 넘겨버릴 수 있으므로, stop_after_delay(10)처럼 누적 대기 시간을 같이 제한해 주는 편이 안전합니다.

여러 데코레이터를 함께 쓸 때는 순서도 중요합니다. @retry를 캐싱 데코레이터보다 위에 두면 캐시된 값을 반환하는 경우까지 재시도 대상에 걸릴 수 있으므로, 실제로 실패할 수 있는 호출(네트워크 요청 등)을 감싸는 가장 안쪽 함수에 @retry를 붙이는 편이 의도한 대로 동작합니다.

정리하면, 새 프로젝트에 재시도 로직을 넣을 때는 stop_after_attempt, wait_exponential, retry_if_exception_type, reraise=True 네 가지로 시작해서 필요할 때 before_sleep으로 로깅을 더하는 순서를 권합니다. requirements.txt에 tenacity 버전을 고정해두고, 재시도 대상 예외를 프로젝트에 맞게 좁히는 작업부터 바로 적용해 보시기 바랍니다.

파이썬 asyncio.gather에서 하나 실패하면 전체가 죽는 이유와 해결법

Leave a Comment