
결론부터 말씀드리면, 파이썬 데코레이터로 재시도 로직을 직접 만들기보다 tenacity 라이브러리를 쓰는 편이 훨씬 안전합니다. 지수 백오프, 예외 필터링, 재시도 로깅까지 옵션 몇 줄로 끝나고, 직접 짠 재시도 로직에서 흔히 나는 버그(무한 루프, 예외 삼킴, 스레드 폭주)를 피할 수 있기 때문입니다. 이 글에서는 @retry 데코레이터를 실전에 쓸 때 꼭 챙겨야 할 설정과, 반대로 재시도를 걸면 안 되는 상황까지 코드와 함께 정리했습니다.
파이썬 데코레이터로 재시도, 왜 직접 짜지 말고 tenacity를 써야 할까요
데코레이터는 원본 함수를 감싸서 동작을 덧붙이는 파이썬 문법인데, 재시도처럼 반복되는 로직을 함수 밖으로 빼낼 때 특히 유용합니다. 그래서 많은 분들이 while 루프와 try/except로 직접 재시도 데코레이터를 만들곤 합니다.
문제는 이런 자작 로직이 세 가지를 놓치기 쉽다는 점입니다. 첫째로 except Exception처럼 모든 예외를 잡아버려서 진짜 버그까지 재시도로 덮어버리고, 둘째로 대기 시간을 고정값으로만 넣어서 서버가 복구되기 전에 계속 두드리는 경우가 많습니다. 셋째로 최대 시도 횟수를 셀 때 off-by-one 실수가 자주 나옵니다.
PyPI에 배포된 tenacity를 pip install tenacity로 설치하면 이런 부분을 옵션으로 명시해서 데코레이터 한 줄로 처리할 수 있습니다. 이 글은 tenacity 8.x 계열 API(stop, wait, retry, reraise 등)를 기준으로 설명합니다.
tenacity 재시도 로직, 데코레이터 세 줄이면 충분합니다
가장 단순한 형태는 stop_after_attempt와 wait_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는 기본적으로 모든 예외를 재시도 대상으로 봅니다. ValueError나 TypeError처럼 코드 버그로 나는 예외까지 재시도해버리면 실패 원인을 알아채는 시간만 늦어지므로, 네트워크·타임아웃 계열 예외로 범위를 좁혀두는 편이 낫습니다.
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_exponential의 max 값과 상위 타임아웃 값이 안 맞는 것도 자주 놓치는 부분입니다. 예를 들어 전체 요청 제한 시간이 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 버전을 고정해두고, 재시도 대상 예외를 프로젝트에 맞게 좁히는 작업부터 바로 적용해 보시기 바랍니다.
