초보의 첫 재시도 로직: 리뷰에서 걸린 실수 모음

DART(전자공시) Open API 클라이언트에 재시도 로직을 처음 붙였다. “실패하면 몇 번 더 해보면 되지”라고 생각했는데, 리뷰를 네 차례 넘게 받으면서 생각보다 훨씬 많은 걸 몰랐다는 걸 알게 됐다.

이 글은 삽질 과정에서 배운 것과 리뷰에서 걸린 실수를 정리한 빌드로그다. 다루는 주제는 에러의 층 나누기, 재시도 대상 고르기, 지수 백오프, 그리고 가짜 데이터로 테스트 하기다.

1. API 응답 에러는 2개 층으로 존재

에러가 어느 층에서 왔는지에 따라 처리할 위치가 달라진다. DART API에는 에러가 두개의 층으로 존재한다.

층어떻게 오나예시처리 위치
HTTP/네트워크 층HTTP 상태코드, 예외429, 5xx, 타임아웃, 연결 실패_request
DART API 층HTTP body의 status{"status": "020", ...}get_json

DART는 요청이 에러나 한도 초과로 거절돼도 HTTP로는 200을 돌려준다. 진짜 결과는 body 안의 DART 응답 코드에 있다. 반면 429·5xx·타임아웃은 body를 열어보기 전에 걸러야 하는 문제다.

내가 선택한 구조: HTTP 층 문제는 _request에서, DART API 에러는 get_json에서 처리했다. _request가 돌려받는 response에서 실질적인 상태코드를 확인할 수 있기 때문이다.

2. 5xx 응답에 resp.json()을 부르면 생기는 일

결론: HTTP 상태코드를 먼저 확인하고, 그다음에 파싱해야 한다. 순서를 바꾸면 진짜 원인이 가려진다.

서버에 장애가 나면 body에는 JSON이 아니라 HTML 에러 페이지가 오거나 아무것도 없다. 여기에 resp.json()을 부르면 JSONDecodeError가 난다. 로그에는 “JSON 파싱 실패”만 남고, 진짜 원인인 500은 보이지 않는다.

# 나쁜 순서: 500이어도 일단 파싱 → JSONDecodeError
data = resp.json()

# 좋은 순서: HTTP 층 확인 → 그다음 body
resp.raise_for_status()
data = resp.json()

내가 겪은 것: HTTP 상태코드 말고는 body에 아무 내용이 없는데 파싱부터 하면, HTTP 오류인지 다른 오류인지 원인을 파악하기 힘들었다.

3. 재시도 대상 선별

재시도는 “기다리면 나아질 수 있는 실패”에만 한다. 요청 자체가 틀렸거나 한도가 끝난 경우는 몇 번을 다시 보내도 결과가 같다.

구분재시도 O재시도 X
HTTP 층429, 500/502/503/5044xx (429 제외)
네트워크Timeout, ConnectionError—
DART status(선택) 800, 900010, 011, 012, 020, 100, 101

특히 주의할 건 020(요청 한도 초과) 이다. 이미 일일 한도를 다 쓴 상태에서 재시도하면, 성공할 가능성은 없고 한도만 더 깎는다.

재시도가 안전한 이유는 이 요청들이 멱등(idempotent)한 GET이기 때문이다. 같은 조회를 여러 번 보내도 서버 상태가 바뀌지 않는다. 결제나 주문 같은 POST라면 이야기가 달라진다.

내가 겪은 것: 네트워크 지연이나 서버 응답 지연은 재시도하고, DART 상태 코드도 확인해서 재시도 여부를 정하도록 만들었다.

4. 지수 백오프 + Retry-After

재시도 간격은 1초 → 2초 → 4초처럼 두 배씩 늘린다. 공식은 base * 2 ** attempt 다.

내 실수: 제곱을 지수로 착각했다

처음엔 (attempt + 1) ** 2 로 썼다. 결과는 1, 4, 9다. 이건 제곱, 즉 다항식 증가다. 지수는 밑이 고정되고 지수가 변한다(2 ** attempt).

처음 몇 번은 비슷해 보여도 횟수가 늘수록 차이가 벌어진다. 100번째에서 제곱은 10,000이지만 지수는 2⁹⁹이다. 그래서 실무에서는 min(wait, 상한) 으로 상한을 둔다.

Retry-After 태그 조심!

  • Retry-After: 만약 429 응답에 이 헤더가 있으면 내 계산보다 서버가 알려준 값을 우선한다.

직접 구현할까, 라이브러리를 쓸까

방식장점단점
직접 구현동작을 완전히 이해하고 제어이 글의 실수들을 직접 밟는다
urllib3.Retry + HTTPAdapterrequests에 바로 붙고, Retry-After도 처리DART body status 같은 앱 층 코드는 못 봄
tenacity데코레이터로 조건·대기 전략을 자유롭게 조합의존성 추가, 설정 학습 필요

처음엔 직접 짜보면서 원리를 익히고, 이후엔 라이브러리로 옮기는 게 좋다고 느꼈다.

4-1. 울타리 기둥 문제: 시도 3번이면 대기는 2번

테스트 기대값을 waits == [1, 2, 4] 로 썼는데 실패했다. 실제 값은 [1, 2] 였다.

대기는 시도와 시도 사이에만 있다. 마지막 시도가 실패하면 기다리지 않고 바로 예외를 던진다. 그래서 대기 횟수는 항상 시도 횟수 빼기 1이다.

시도1 ─(1초)─ 시도2 ─(2초)─ 시도3 → raise

더 근본적인 문제는 요구사항 문장이었다.

  • “최대 3회 재시도” → 시도 4번, 대기 3번
  • “최대 3회 시도” → 재시도 2번, 대기 2번

이 테스트는 코드 버그가 아니라 내 머릿속 모델과 요구사항의 모호함을 드러냈다. 테스트가 실패했을 때 코드보다 기대값을 먼저 의심해볼 때도 있다는 걸 배웠다.

5. 재시도 로직 테스트하기: 기다리지 않고 바로 테스트

재시도 테스트에서 진짜로 sleep하면 테스트 하나가 몇 초씩 걸린다(1+2+4 = 7초). 그래서 두 가지를 가짜로 바꾼다.

  • time.sleep → monkeypatch 로 대체해서, 실제로 자는 대신 “몇 초를 자려 했는지”만 기록한다.
  • 응답 → FakeResponse 로 “실패, 실패, 성공” 시퀀스를 흉내 내고 호출 횟수를 검증한다.
def test_retry_then_success(monkeypatch, client):
    waits = []
    monkeypatch.setattr("time.sleep", lambda s: waits.append(s))

    responses = iter([FakeResponse(503), FakeResponse(503), FakeResponse(200)])
    calls = []
    def fake_get(*args, **kwargs):
        calls.append(1)
        return next(responses)
    monkeypatch.setattr(client.session, "get", fake_get)

    client._request("list.json")  # 실행 

    assert len(calls) == 3
    assert waits == [1, 2]   # 4-1장: 시도 3번 = 대기 2번

이렇게 “재시도 경로”를 일부러 태우는 테스트가 있어야, 다음 장의 NameError 같은 실수가 잡힌다.

로직 : 고친 뒤의 _request

import time
from requests.exceptions import HTTPError, Timeout
from requests.exceptions import ConnectionError as ReqConnectionError

RETRY_STATUS = {429, 500, 502, 503, 504}

def _request(self, path, params=None, max_attempts=3):
    url = f"{self.base_url}/{path}"
    for attempt in range(max_attempts):
        try:
            resp = self.session.get(url, params=params, timeout=10)
            resp.raise_for_status()          # HTTP Error 던짐
            return resp                      # 성공 데이터 return
        except HTTPError as e:
            code = e.response.status_code    #  HTTP 상태코드
            if code not in RETRY_STATUS or attempt == max_attempts - 1:
                raise
            reason = f"HTTP {code}"
        except (Timeout, ReqConnectionError) as e:   # 타임아웃
            if attempt == max_attempts - 1:
                raise
            reason = type(e).__name__        #  code 사용 안 함
        wait = backoff(attempt)
        self.logger.warning("%s, %.1f초 후 재시도", reason, wait)
        time.sleep(wait)

Retry-After 처리는 여기서 빠져 있다. 429일 때 e.response.headers.get("Retry-After") 를 backoff() 보다 우선하면 된다.

마무리: 다음 재시도 코드를 쓸 때 볼 체크리스트

재시도 로직은 단순히 다시 돌리기라 생각했는데, 하다보니 “어떤 실패를, 어느 층에서, 몇 번, 얼마나 기다려 다시 보낼지” 정하는 일이었다.

  • HTTP 층 에러와 API body 에러를 다른 함수에서 처리하는가
  • 상태코드를 확인한 뒤에 파싱하는가
  • 재시도해도 소용없는 에러(4xx, 한도 초과)는 바로 던지는가
  • 성공하면 즉시 빠져나오는가
  • 대기는 2 ** attempt + 상한 인가, 429에서 Retry-After 태그 확인해서 재시도 시간 설정
  • 재시도 경로를 sleep 없이 태우는 테스트가 있는가

정상 경로만 생각하고 짠 코드는 실패 시 무너진다. 재시도 로직은 애초에 실패 경로를 위한 코드이고 안전하게 수집할 수 있게 해준다.