본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

운영 클라이언트 설계 — 재시도·백오프·회로 차단기

~11 min · production, retry, backoff, circuit-breaker

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"운영용 HTTP 클라이언트는 단순한 httpx.get(url) 호출에서 끝나지 않아. 실제 httpx.get(url) 호출에는 시간 초과와 전체 요청 예산을 정하고, 재시도 가능한 실패만 백오프와 함께 다시 시도하며, Retry-After와 멱등성을 존중해야 해. 장애가 연쇄될 위험이 크다면 회로 차단기도 검토해야 하지."

운영 클라이언트 복원력의 세 층

1. 일시적일 가능성이 높은 실패만 재시도. 연결 실패, 일부 시간 초과, 429, 502·503·504 같은 응답은 재시도 후보가 될 수 있어. 하지만 모든 네트워크 오류와 5xx가 일시적인 것은 아니야. 최대 시도 횟수와 전체 기한을 정하고, 서버의 Retry-After가 있으면 해석해 존중하며, 지수 백오프와 지터로 재시도 폭주를 막아야 해.

2. 실제 요청의 멱등성을 판단. GET, HEAD, OPTIONS 같은 안전한 메서드와 PUT, DELETE 같은 멱등 메서드는 일반적으로 자동 재시도 후보야. 그래도 구현이 HTTP 의미를 제대로 지키는지, 요청 본문을 다시 보낼 수 있는지 확인해야 해. POST나 PATCH는 서버가 Idempotency-Key를 실제로 지원하고 같은 키의 중복 요청을 같은 작업으로 처리할 때만 안전하게 재시도할 수 있어. 헤더를 붙였다는 사실만으로 안전해지는 것은 아니야.

3. 지속적인 의존성 장애에는 회로 차단기. 상위 서비스의 반복 실패나 지연 급증이 연결 풀, 워커, 스레드를 고갈시켜 연쇄 장애를 일으킬 수 있다면 일정 기간 호출을 차단해. 회로가 열리면 빠르게 오류를 반환하고, 안전한 경우에만 캐시나 대체 결과를 사용해. 냉각 시간이 지나면 제한된 탐색 요청으로 복구를 확인한 뒤 정상 호출을 재개해.

지수 백오프 공식

기본적인 상한 지수 백오프는 delay = min(base * 2^attempt, max_delay)로 표현할 수 있어. 동기화를 피하는 전체 지터의 한 예는 delay = random_uniform(0, base * 2^attempt)야. 마지막 delay = base * 2^attempt + random(0, base)는 고정된 지수 지연에 임의 값을 더하는 방식이지, 일반적으로 말하는 equal jitter 공식과는 달라. 어떤 공식을 택하든 상한과 전체 기한을 함께 둬야 해.

# Attempt 0:  ~1s
# Attempt 1:  ~2s
# Attempt 2:  ~4s
# Attempt 3:  ~8s
# Attempt 4:  ~16s (max_delay 에 cap)
# 총 경과: 최종 실패 전 ~31s

지터가 없으면 같은 장애를 본 수많은 클라이언트가 같은 간격으로 재시도해 상위 서비스를 다시 덮칠 수 있어. 지터는 이 재시도 시점을 흩어 놓아 동시 부하를 줄여 줘. 위 동결 예제의 약 31초는 단순 지연 합계일 뿐이므로 실제 시간에는 지터, 요청 시간, 시도 횟수 정의가 더해져. 재시도는 별도의 추가 트래픽이므로 전역 재시도 예산도 고려해야 해.

재시도 판단표

응답재시도?주의
네트워크 오류(시간 초과, 연결 거부)조건부연결 거부는 전송 전 실패일 수 있지만 읽기 시간 초과는 서버가 이미 처리한 뒤일 수도 있어. 멱등성과 전송 상태를 함께 판단해
2xx보통 아니야프로토콜상 성공이야. 본문 검증 실패를 재시도할지는 별도 정책이 필요해
3xx재시도와 달라리디렉션 정책에 따라 따라갈지 결정하고, 호스트가 바뀔 때 인증 정보 전달을 주의해
4xx(408, 429 제외)대부분 아니야같은 요청을 반복해도 해결되지 않는 경우가 많아. 일부 409 등은 API 계약에 따른 별도 정책이 있을 수 있어
408 Request Timeout조건부요청의 멱등성과 서버 처리 가능성을 확인하고 전체 기한 안에서만 재시도해
429 Too Many Requests조건부Retry-After와 서비스의 요청률 정책을 존중하고 동시성이나 호출량 자체를 줄여야 해
5xx일부만500 전체를 무조건 반복하지 말고 502·503·504 등 복구 가능성이 있는 상태와 API 정책을 선별해

회로 차단기의 상태

  • CLOSED — 정상 상태야. 요청을 보내면서 정해진 창 안의 실패율이나 연속 실패를 측정해.
  • OPEN — 실패 기준을 넘은 상태야. 상위 서비스를 호출하지 않고 빠르게 거절하며, 안전한 캐시·대체 경로·지연 큐가 있을 때만 사용해.
  • HALF-OPEN — 냉각 시간이 지난 뒤 복구를 시험하는 상태야. 제한된 수의 요청이 성공하면 CLOSED로 돌아가고, 다시 실패하면 OPEN으로 전환해.

실패 기준과 냉각 시간에 보편적인 정답은 없어. 호출량이 적으면 짧은 구간의 비율이 쉽게 흔들리고, 호출량이 많으면 연속 실패만으로는 반응이 늦을 수 있어. 의존성마다 오류 비용, 평소 지연, 최소 호출 수, 실패율, 동시 탐색 요청 수를 따로 정하고 관측 자료로 조정해야 해. 아래 동결 pybreaker 예제의 exclude 목록은 모든 httpx.HTTPStatusError를 제외하므로 주석과 달리 5xx도 회로 실패로 세지 않아. 4xx만 제외하려면 상태를 분류해 5xx용 예외를 따로 발생시키거나 라이브러리의 제외 정책을 더 정밀하게 구성해야 해.

재시도는 짧은 일시 장애를 흡수하고, 회로 차단기는 지속 장애가 시스템 안쪽으로 번지는 것을 막아. 둘은 서로를 대신하지 않으며 모든 클라이언트에 의무적으로 필요한 것도 아니야. 재시도는 멱등성·기한·예산과 함께 설계하고, 회로 차단기는 장애가 자원 고갈로 이어질 때 도입해. tenacity, Polly, resilience4j 같은 라이브러리도 정책을 대신 결정해 주지는 않아.

cwkPippa의 복원력 관점

cwkPippa의 LLM 연동처럼 응답 시간이 길고 공급자별 요청률 제한이 있는 호출에서는 연결·읽기 시간 초과, 스트리밍 중단, 429, 5xx를 서로 구분해야 해. 대체 공급자 경로는 가용성을 높일 수 있지만 회로 차단기와 같지는 않아. 요청 형식, 모델 능력, 비용, 부작용이 달라질 수 있기 때문이야. 실제 정책은 각 공급자의 Retry-After 의미와 멱등성, 전체 사용자 대기 시간, 중복 생성 위험을 기준으로 설계하고 관측 자료로 검증해야 해.

Code

Tenacity — 지수 백오프와 지터를 적용한 재시도·python
# Backoff 와 jitter 가진 retry — tenacity 라이브러리
from tenacity import retry, wait_exponential_jitter, stop_after_attempt, retry_if_exception_type
import httpx

# Idempotent method — transient 실패에 retry 안전
@retry(
    wait=wait_exponential_jitter(initial=1, max=30, jitter=1),
    stop=stop_after_attempt(5),
    retry=retry_if_exception_type((httpx.TransportError, httpx.HTTPStatusError)),
)
def fetch_user(uid: str) -> dict:
    resp = httpx.get(f'https://api.example.com/users/{uid}')
    if resp.status_code >= 500:
        resp.raise_for_status()  # retry trigger
    if resp.status_code == 429:
        # Retry 전 Retry-After 존중
        import time
        wait = int(resp.headers.get('Retry-After', 5))
        time.sleep(wait)
        raise httpx.HTTPStatusError('rate limited', request=resp.request, response=resp)
    resp.raise_for_status()
    return resp.json()
pybreaker — 상위 서비스 호출을 감싸는 회로 차단기·python
# 단순 circuit breaker — pybreaker 라이브러리
import pybreaker
import httpx

breaker = pybreaker.CircuitBreaker(
    fail_max=5,           # 5 연속 실패 후 OPEN
    reset_timeout=60,     # HALF-OPEN probe 전 60s cooldown
    exclude=[httpx.HTTPStatusError],  # 4xx 를 실패로 안 셈 (client 이슈)
)

@breaker
def call_upstream(url: str) -> dict:
    resp = httpx.get(url, timeout=10.0)
    resp.raise_for_status()
    return resp.json()

# 사용
try:
    data = call_upstream('https://api.example.com/users/42')
except pybreaker.CircuitBreakerError:
    # Circuit 이 OPEN — fail fast, cached 돌려줌, 나중 큐
    data = cached_or_fallback()
except httpx.HTTPError as e:
    # Upstream error 가 circuit threshold 향해 카운트
    raise
운영형 구조 — 재시도·백오프·지터·멱등성·Retry-After·python
# Idempotency 인식 retry decorator (retry + idempotency 체크 결합)
import httpx
import time, random

IDEMPOTENT_METHODS = {'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'}

def call_with_resilience(method: str, url: str, idempotency_key: str | None = None, **kwargs):
    """Retry + backoff + idempotency 인식 + Retry-After 존중 결합."""
    method = method.upper()
    # POST/PATCH 는 caller 가 Idempotency-Key 제공할 때만 retry
    can_retry = method in IDEMPOTENT_METHODS or idempotency_key is not None
    if idempotency_key:
        kwargs.setdefault('headers', {})['Idempotency-Key'] = idempotency_key

    max_attempts = 5 if can_retry else 1
    for attempt in range(max_attempts):
        try:
            with httpx.Client(timeout=10.0) as c:
                resp = c.request(method, url, **kwargs)
            if resp.status_code == 429:
                wait = int(resp.headers.get('Retry-After', 1))
                time.sleep(wait)
                continue
            if 500 <= resp.status_code < 600:
                if not can_retry:
                    return resp
                wait = min(2 ** attempt, 30) + random.random()
                time.sleep(wait)
                continue
            return resp
        except httpx.TransportError:
            if not can_retry:
                raise
            wait = min(2 ** attempt, 30) + random.random()
            time.sleep(wait)
    return resp  # 모든 시도 소진

External links

Exercise

로컬 테스트 서버의 GET 호출을 tenacity로 감싸고, 선별한 5xx와 TransportError에만 재시도·백오프·지터를 적용해 봐. 사용하지 않는 localhost 포트로 연결 실패를 만들고 각 시도의 시각과 전체 경과 시간을 기록해. 이어서 fail_max=3, reset_timeout=10으로 구성한 pybreaker를 호출 주위에 추가해 연속 실패 뒤 OPEN 상태에서 네트워크 호출이 빠르게 차단되는지 확인해. 냉각 시간이 지난 뒤 로컬 서버를 정상화하고 탐색 요청이 성공해 CLOSED로 돌아오는 과정도 관찰해.
Hint
wait_exponential_jitter는 대기 시간을 계산하지만 어떤 예외와 응답을 재시도할지는 별도로 정확히 정해야 해. 지터가 있으므로 간격이 정확히 1초, 2초, 4초가 될 것이라고 단정하지 말고 범위와 상한을 확인해. pybreaker에서는 5xx를 회로 실패로 세려면 동결 예제처럼 HTTPStatusError 전체를 exclude하지 말고 상태별 예외 분류를 구성해야 해. CLOSED, OPEN, HALF-OPEN 전환과 실제 상위 호출 횟수를 함께 기록하면 동작을 분명히 볼 수 있어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.