- Published on
실무형 Python 데코레이터로 리트라이·캐시·타임아웃 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드에서 외부 API, DB, 메시지 브로커 같은 I/O 호출은 언제든 느려지거나 실패합니다. 문제는 “실패 자체”보다도, 호출부마다 제각각으로 try/except 와 재시도 루프를 붙이면서 코드가 산만해지고 정책이 분산된다는 점입니다.
이 글에서는 Python 데코레이터로 리트라이(retry), 캐시(cache), 타임아웃(timeout) 을 정책 단위로 캡슐화하고, 함수 위에 “스티커처럼” 붙여 재사용하는 방식을 다룹니다. 특히 다음을 목표로 합니다.
- 어떤 예외는 재시도하고, 어떤 예외는 즉시 실패시키기
- 지수 백오프, 지터(jitter)로 동시 재시도 폭주 완화하기
- 캐시 키를 안전하게 만들고 TTL을 적용하기
- 타임아웃을 “호출자 관점”에서 강제하기
- 세 기능을 한 번에 조합하되 부작용(중복 호출, 캐시 오염)을 줄이기
실무에서 캐시 갱신/만료 타이밍이 인증 장애를 좌우하는 사례는 흔합니다. 예를 들어 JWK 키를 캐시하다가 kid 불일치로 JWT 검증이 깨지는 상황도 결국 “캐시 정책” 문제입니다. 관련해서는 JWT 검증 실패 - kid 불일치와 JWK 캐시 갱신법 글도 함께 참고하면 좋습니다.
1) 설계 원칙: 데코레이터는 정책, 함수는 도메인
데코레이터로 감쌀 때 가장 중요한 원칙은 간단합니다.
- 함수는 “무엇을 할지”만 표현한다
- 데코레이터는 “어떻게 안전하게 실행할지”를 표현한다
즉, fetch_user() 는 사용자 정보를 가져오는 도메인 로직만 갖고, 재시도/타임아웃/캐시는 함수 바깥에서 주입합니다.
또 하나의 원칙은 관측 가능성입니다. 재시도는 조용히 성공하면 더 위험합니다. 최소한 다음은 로그/메트릭으로 남길 수 있게 훅을 열어두는 편이 좋습니다.
- 몇 번 만에 성공했는지
- 어떤 예외로 재시도했는지
- 타임아웃 발생 빈도
- 캐시 히트율
2) 리트라이 데코레이터: 예외 분류 + 백오프 + 지터
재시도는 “실패하면 다시”가 아니라, 재시도해도 되는 실패만 다시여야 합니다.
- 재시도 적합: 일시적 네트워크 오류, 5xx, 타임아웃
- 재시도 부적합: 4xx(대부분), 파라미터 검증 오류, 인증 실패(상황에 따라)
아래는 동기 함수 기준의 리트라이 데코레이터 예시입니다.
import time
import random
import functools
from typing import Callable, Iterable, Tuple, Type
def retry(
*,
retries: int = 3,
base_delay: float = 0.2,
max_delay: float = 3.0,
jitter: float = 0.1,
retry_on: Tuple[Type[BaseException], ...] = (TimeoutError, ConnectionError),
on_retry: Callable[[int, BaseException, float], None] | None = None,
):
"""동기 함수용 리트라이 데코레이터.
- retries: 총 시도 횟수(최초 1회 포함)
- base_delay: 첫 백오프 기준
- jitter: 랜덤 지터 범위(초)
"""
def decorator(func: Callable):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc: BaseException | None = None
for attempt in range(1, retries + 1):
try:
return func(*args, **kwargs)
except retry_on as exc:
last_exc = exc
if attempt >= retries:
raise
# 지수 백오프 + 지터
delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
delay = max(0.0, delay + random.uniform(-jitter, jitter))
if on_retry:
on_retry(attempt, exc, delay)
time.sleep(delay)
# 이 지점에 오면 로직상 이상하지만, 타입 체커를 위한 안전장치
assert last_exc is not None
raise last_exc
return wrapper
return decorator
실무 팁: 재시도 폭주(Thundering Herd) 완화
동일한 장애가 발생했을 때 여러 워커가 동시에 재시도하면, 대상 시스템을 더 죽입니다. 그래서 지터는 옵션이 아니라 필수에 가깝습니다.
- 고정 딜레이 반복은 피하기
- 지수 백오프와 랜덤 지터를 같이 쓰기
- 가능하면 “서킷 브레이커”까지 고려(이 글 범위 밖)
3) TTL 캐시 데코레이터: 키 설계가 전부다
캐시는 성능뿐 아니라 안정성을 위해서도 씁니다. 외부 API가 흔들릴 때 캐시가 완충재가 됩니다.
하지만 캐시 데코레이터의 핵심은 TTL보다 캐시 키입니다.
args,kwargs가 해시 가능하지 않으면 그대로 키를 만들 수 없음dict는 순서가 달라질 수 있음- 민감 정보(토큰 등)를 키에 그대로 넣으면 위험
아래는 “간단하지만 실무에서 쓸 만한” 메모리 TTL 캐시 예시입니다. 프로세스 단위 캐시이므로 멀티 인스턴스 환경에서는 Redis 같은 외부 캐시로 확장해야 합니다.
import time
import functools
import hashlib
import json
from dataclasses import is_dataclass, asdict
from typing import Any, Callable
def _stable_json(obj: Any) -> str:
if is_dataclass(obj):
obj = asdict(obj)
return json.dumps(obj, sort_keys=True, default=str, separators=(",", ":"))
def default_cache_key(func_name: str, args: tuple, kwargs: dict) -> str:
payload = {
"func": func_name,
"args": args,
"kwargs": kwargs,
}
raw = _stable_json(payload).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def ttl_cache(
*,
ttl_seconds: float = 30.0,
key_fn: Callable[[str, tuple, dict], str] = default_cache_key,
cache_none: bool = True,
):
"""동기 함수용 TTL 캐시 데코레이터(프로세스 메모리).
- cache_none: 결과가 None일 때도 캐시할지 여부
"""
store: dict[str, tuple[float, Any]] = {}
def decorator(func: Callable):
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = key_fn(func.__name__, args, kwargs)
now = time.time()
if key in store:
expires_at, value = store[key]
if now < expires_at:
return value
store.pop(key, None)
value = func(*args, **kwargs)
if value is None and not cache_none:
return value
store[key] = (now + ttl_seconds, value)
return value
return wrapper
return decorator
실무 팁: “부정 캐시(negative caching)”는 신중히
None 이나 “없음” 결과를 캐시하면 DB/외부 API 부하를 줄이지만, 데이터가 곧 생기는 케이스(예: 방금 생성된 리소스)에서는 사용자 경험을 망칠 수 있습니다. 그래서 cache_none 같은 스위치를 두는 편이 안전합니다.
4) 타임아웃 데코레이터: 호출자 관점에서 강제하기
Python에서 “모든 함수에 대해” 타임아웃을 강제하는 건 생각보다 까다롭습니다.
- 순수 CPU 작업은 스레드로 감싸도 중단이 어렵습니다(스레드는 강제 종료가 불가)
- I/O 작업은 라이브러리 자체 타임아웃을 쓰는 게 가장 안전합니다(예:
requests의timeout)
그럼에도 “호출자 레벨”에서 타임아웃을 통일하고 싶을 때는, 스레드 풀에서 실행하고 결과 대기를 타임아웃으로 거는 방식이 현실적인 절충안입니다.
import functools
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
from typing import Callable, TypeVar
T = TypeVar("T")
def timeout(*, seconds: float, executor: ThreadPoolExecutor | None = None):
"""동기 함수용 타임아웃 데코레이터.
주의: 타임아웃이 나도 작업 스레드가 백그라운드에서 계속 실행될 수 있습니다.
I/O 라이브러리의 자체 타임아웃과 병행하는 것을 권장합니다.
"""
def decorator(func: Callable[..., T]):
@functools.wraps(func)
def wrapper(*args, **kwargs) -> T:
local_executor = executor or ThreadPoolExecutor(max_workers=1)
owns = executor is None
try:
fut = local_executor.submit(func, *args, **kwargs)
return fut.result(timeout=seconds)
except FutureTimeout as exc:
raise TimeoutError(f"Timed out after {seconds} seconds") from exc
finally:
if owns:
local_executor.shutdown(wait=False, cancel_futures=True)
return wrapper
return decorator
실무 팁: 타임아웃은 “전파”가 중요
상위 요청의 타임아웃이 2초인데 내부 함수가 5초 타임아웃이면 의미가 없습니다. 가능하면 다음 중 하나를 권장합니다.
- 상위에서
deadline을 계산해 하위로 전달 - 전체 호출 체인에서 동일한
seconds정책을 공유
네트워크 타임아웃 이슈는 애플리케이션 레벨뿐 아니라 인프라 설정에서도 발생합니다. 예를 들어 SSH 타임아웃도 결국 “어디서 끊기는지”를 계층별로 봐야 합니다. 관련 진단 관점은 Azure VM SSH 타임아웃 - NSG·UDR 진단 체크리스트 도 참고할 만합니다.
5) 한 번에 조합하기: 순서가 동작을 바꾼다
데코레이터는 쌓는 순서에 따라 의미가 달라집니다. 아래 두 케이스는 결과가 크게 다릅니다.
cache가 바깥: 캐시 히트면 재시도/타임아웃이 아예 실행되지 않음(대부분 이게 바람직)retry가 바깥: 실패 시 재시도할 때마다 캐시 로직도 반복(키 생성 비용, 락 등이 있으면 손해)
일반적으로 추천하는 순서는 다음입니다.
ttl_cache(가장 바깥)retrytimeout(가장 안쪽, 실제 실행을 제한)
즉 “캐시가 없을 때만” 타임아웃을 걸고, 타임아웃/일시 오류면 재시도하는 구조입니다.
import urllib.request
def log_retry(attempt: int, exc: BaseException, delay: float) -> None:
print(f"retry attempt={attempt} exc={type(exc).__name__} delay={delay:.2f}s")
@ttl_cache(ttl_seconds=10.0)
@retry(retries=4, base_delay=0.2, max_delay=2.0, jitter=0.2, on_retry=log_retry)
@timeout(seconds=1.0)
def fetch_text(url: str) -> str:
# 라이브러리 자체 타임아웃도 함께 거는 게 더 안전합니다.
with urllib.request.urlopen(url, timeout=0.8) as resp:
return resp.read().decode("utf-8")
조합 시 주의점 1: “재시도 가능한 함수”만 캐시해라
POST 같은 비멱등 요청을 캐시하거나 재시도하면 중복 처리 위험이 있습니다. 최소한 다음을 점검하세요.
- 호출이 멱등인지(같은 입력이면 같은 결과인지)
- 서버가
Idempotency-Key를 지원하는지 - 재시도 시 중복 부작용(결제, 발송, 생성)이 없는지
조합 시 주의점 2: 캐시가 장애를 숨길 수 있다
캐시 TTL이 길면 장애가 가려져서 “문제 없는데요?” 상태가 됩니다. 캐시 히트율이 급등하거나 원본 호출량이 급감하면, 오히려 알람을 걸어야 하는 경우도 있습니다.
6) 실무 확장: 비동기, 외부 캐시, 락, 스탬피드 방지
여기서 만든 구현은 “패턴”을 보여주기 위한 최소 버전입니다. 실무에서는 다음 확장이 자주 필요합니다.
async def지원:asyncio.wait_for로 타임아웃,asyncio.sleep로 백오프- Redis 캐시: 멀티 인스턴스에서 캐시 공유
- 캐시 스탬피드 방지: 키별 락을 잡고 한 번만 원본 호출
- 예외 분류 고도화: HTTP 상태 코드별 정책, SDK 예외 타입별 정책
특히 캐시는 인증/키 로테이션과 결합될 때 장애를 만들기 쉽습니다. JWK 같은 “주기적으로 바뀌는 데이터”는 TTL과 강제 갱신 트리거(예: 검증 실패 시 즉시 갱신)를 함께 설계하는 편이 안전합니다. 이 관점은 앞서 언급한 JWT 검증 실패 - kid 불일치와 JWK 캐시 갱신법 과도 연결됩니다.
7) 체크리스트: 운영에 넣기 전 점검
마지막으로, 데코레이터 기반 안정화 패턴을 운영에 넣기 전에 아래를 확인해두면 시행착오가 줄어듭니다.
- 재시도 대상 예외가 과하지 않은가(모든 예외 재시도 금지)
- 백오프와 지터가 있는가
- 전체 요청 SLA 대비 타임아웃이 합리적인가(상위 타임아웃보다 길지 않은가)
- 캐시 키에 민감 정보가 포함되지 않는가
- 캐시 TTL이 너무 길어 장애를 숨기지 않는가
- 재시도/타임아웃/캐시 히트에 대한 로그 또는 메트릭 훅이 있는가
리트라이·캐시·타임아웃은 각각 보면 단순하지만, 팀 코드베이스 전체에 일관되게 적용하기는 어렵습니다. 데코레이터로 정책을 표준화하면 “호출부의 복잡도”를 줄이면서도 운영 안정성을 끌어올릴 수 있고, 무엇보다 장애가 났을 때 원인을 추적하기 쉬워집니다.