API 호출 실패, 언제 재시도하고 언제 포기할까

실패했다고 다시 부르면, 더 나빠집니다

외부 API를 호출하는 코드를 짜다 보면 자연스럽게 이런 생각이 듭니다. "실패하면 몇 번 더 해보면 되겠지." 그래서 for 문으로 세 번 감싸고, 사이에 sleep을 하나 넣습니다.

이게 위험합니다. 실패에는 다시 시도하면 성공하는 것과, 백 번을 해도 똑같은 것이 있습니다. 후자를 반복하면 응답 시간만 늘어나고, 최악의 경우 API 제공자로부터 호출량 제한에 걸립니다. 문제를 해결하려던 재시도가 새로운 문제를 만드는 셈입니다.

이 글은 블로그 자동 발행 서비스를 운영하면서 실제로 적용한 재시도 정책을 정리한 것입니다. Python과 httpx를 예로 들지만, 원리는 어떤 언어에서든 같습니다. HTTP 상태코드의 의미를 어느 정도 알고 있다고 가정합니다.

상태코드별 처리표

결론부터 놓겠습니다. 제가 실제로 쓰고 있는 정책입니다.

상태코드 의미 처리
400 요청이 잘못됨 재시도하지 않음
401 인증 실패 토큰 갱신 후 1회만 재시도
403 권한 없음 재시도하지 않음
404 대상이 없음 재시도하지 않음
429 호출량 초과 Retry-After 확인 후 짧으면 대기, 길면 포기
5xx 서버 쪽 문제 지수 백오프 + 지터로 재시도

표를 관통하는 기준은 하나입니다. "내가 똑같이 다시 보내면, 결과가 달라질 여지가 있는가." 있으면 재시도하고, 없으면 즉시 포기합니다.

왜 이렇게 나눴나

400, 403, 404는 내 요청이 문제입니다. 필드 이름을 틀렸거나, 권한이 없거나, 없는 글을 수정하려 한 것입니다. 똑같은 요청을 다시 보내면 똑같은 응답이 옵니다. 여기서 재시도하는 건 시간 낭비일 뿐 아니라, 실패의 원인을 로그에서 늦게 발견하게 만듭니다. 즉시 실패하는 편이 오히려 친절합니다.

401은 예외입니다. 액세스 토큰이 만료됐을 뿐이라면, 새 토큰을 받아 다시 보내면 성공합니다. 그래서 401은 딱 한 번, 토큰을 버리고 새로 받은 뒤 재시도합니다.

if status == 401 and not token_refreshed:
    invalidate_token()       # 캐시된 토큰 폐기
    token_refreshed = True   # 이 플래그가 핵심
    continue                 # 대기 없이 즉시 재시도

token_refreshed 플래그가 중요합니다. 새 토큰으로 보냈는데 또 401이라면, 그건 만료 문제가 아니라 계정 권한이나 설정 자체가 잘못된 것입니다. 이때 계속 재시도하면 무한 루프에 빠집니다. 그래서 한 번만 허용합니다.

5xx는 서버 쪽 문제입니다. 잠시 후에는 나아질 수 있으니 기다렸다 다시 보냅니다. 여기가 지수 백오프가 필요한 자리입니다.

429: 기다리라고 알려주는 경우가 있습니다

429는 호출량 제한에 걸렸다는 뜻입니다. 여기서 흔한 실수가 바로 다시 보내는 것입니다. 제한에 걸렸는데 또 두드리면 제한이 더 길어질 수 있습니다.

많은 API가 429 응답에 Retry-After 헤더를 함께 보냅니다. "몇 초 뒤에 다시 오라"는 안내입니다. 이걸 읽습니다.

retry_after = response.headers.get("Retry-After")

if retry_after and float(retry_after) <= MAX_WAIT:
    await asyncio.sleep(float(retry_after))
    continue          # 안내받은 만큼 기다렸다 재시도
else:
    raise QuotaExceeded()   # 너무 길거나 안내가 없으면 즉시 포기

상한(MAX_WAIT)을 두는 이유가 있습니다. 서버가 "10분 뒤에 오라"고 하면, 그 10분을 기다리는 건 재시도가 아니라 서비스가 멈춰 있는 것입니다. 저는 짧은 대기만 허용하고, 그보다 길면 실패로 처리해 로그를 남긴 뒤 다음 작업으로 넘어갑니다. 기다릴 수 없는 실패는, 빨리 실패하는 게 낫습니다.

Retry-After 헤더가 아예 없는 경우도 마찬가지로 즉시 포기합니다. 얼마를 기다려야 할지 모르는 상태에서 짐작으로 기다리는 건 근거가 없습니다.

지수 백오프와 지터

5xx 재시도에 쓰는 두 가지 장치입니다. 이름은 거창한데, 하는 일은 단순합니다.

지수 백오프(exponential backoff)는 재시도할 때마다 대기 시간을 늘리는 것입니다. 0.5초 → 1초 → 2초 하는 식입니다. 서버가 힘들어하고 있는데 같은 간격으로 계속 두드리면 회복을 방해합니다. 갈수록 뜸하게 두드려서 숨 쉴 틈을 줍니다.

지터(jitter)는 대기 시간에 약간의 무작위값을 섞는 것입니다. 이게 왜 필요하냐면, 여러 요청이 동시에 실패했을 때 문제가 생기기 때문입니다. 모두가 정확히 1초 뒤에 재시도하면, 1초 뒤에 또 한꺼번에 몰려갑니다. 겨우 일어서던 서버가 다시 넘어집니다. 대기 시간을 조금씩 흩뿌려서 이걸 막습니다.

delay = min(BASE * (2 ** attempt), MAX_DELAY)
delay += random.uniform(0, JITTER)   # 이 한 줄이 지터
await asyncio.sleep(delay)

대기 시간에 상한(MAX_DELAY)을 두는 것도 잊지 마세요. 지수는 생각보다 빨리 커집니다.

재시도 횟수에 상한을 두는 이유

저는 총 시도 횟수를 세 번으로 제한했습니다. 왜 무한히 하지 않느냐면, 재시도는 공짜가 아니기 때문입니다.

재시도하는 동안 그 작업은 자원을 붙들고 있습니다. 실패가 계속 쌓이면 대기 중인 작업이 늘어나고, 결국 전체 서비스가 느려집니다. 한 번의 실패를 끝까지 살려보려다 나머지 전부를 망치는 구조입니다.

그래서 정해진 횟수 안에 안 되면 깨끗하게 실패시키고, 대신 로그를 잘 남깁니다. 어떤 요청이 어떤 상태코드로 몇 번 시도 후 실패했는지가 남아 있으면, 나중에 원인을 찾을 수 있습니다. 조용히 무한 재시도하는 코드는 아무것도 남기지 않습니다.

타임아웃도 같은 맥락입니다. 응답이 안 오는 요청을 하염없이 기다리지 않도록, 호출마다 시간 제한을 걸어둡니다.

async with httpx.AsyncClient(timeout=10.0) as client:
    response = await client.post(url, json=payload)

정리

재시도 로직을 만들 때 확인할 것들입니다.

  • □ 모든 실패를 똑같이 재시도하고 있지 않은가 — 4xx는 대부분 포기해야 한다
  • □ 401 재시도에 횟수 제한이 있는가 — 없으면 무한 루프
  • □ 429에서 Retry-After를 읽고 있는가
  • □ 대기 시간이 고정값은 아닌가 — 지수 백오프 + 지터
  • □ 최대 시도 횟수와 타임아웃이 정해져 있는가
  • □ 실패했을 때 상태코드와 시도 횟수가 로그에 남는가

좋은 재시도 로직의 목표는 "어떻게든 성공시키는 것"이 아닙니다. 성공할 수 있는 실패만 다시 시도하고, 나머지는 빠르게 포기하는 것입니다. 포기를 잘하는 코드가 결국 더 안정적으로 돌아갑니다.

관련 글: Blogger API와 OAuth 2.0으로 블로그 글 자동 발행하기, httpx AsyncClient로 외부 API 비동기 호출하기, Docker .env를 고쳤는데 왜 안 바뀔까

비슷한 문제로 고생하고 계셨다면, 구독이나 공유로 알려 주세요.

댓글

이 블로그의 인기 게시물

UGREEN DXP4800PLUS NAS로 유튜브 쇼츠 자동화 시스템 구축기 (Docker · FastAPI · PostgreSQL · Edge-TTS)

YouTube Data API 쿼터 초과를 피하는 방법 (700개 쇼츠 수집 사례)

FastAPI app.mount 사용 후 API가 404가 되는 이유와 해결 방법