DART 공시 데이터를 자동으로 긁어오는 크롤러를 돌리던 어느 날, 갑자기 모든 요청이 실패하기 시작했다. 처음엔 코드 버그인 줄 알았다. 로그를 뒤져보니 응답 코드가 전부 429였고 조금 뒤엔 아예 연결조차 안 됐다. 내 서버 IP가 약 하루 동안 밴(ban, 접속 차단) 목록에 올라간 것이다. 원인은 단순했다. 분당 허용 요청 수를 넘겨버린 것. 이 글은 그날의 사고를 처음부터 복기하고, 무엇이 밴을 불렀고 어떻게 복구했으며 다시는 안 당하려면 뭘 바꿔야 하는지 정리한 기록이다.

면책: 공개 DART/더미 데이터 기준이며 회계판단·투자권유가 아니다. 아래 코드는 방법론 예시(합성 입력)다.

그날 무슨 일이 있었나?

먼저 사고 전체를 한 장으로 보면 이렇다.

flowchart TD
    A[크롤러 실행<br/>공시목록 순회] --> B[요청 폭주<br/>분당 한도 초과]
    B --> C[서버 응답 429<br/>Too Many Requests]
    C --> D{내 대응은?}
    D -->|무시하고 즉시 재시도| E[요청 더 폭주]
    E --> F[IP 밴<br/>약 24시간 접속 거부]
    F --> G[복구 대기 + 원인 분석]
    G --> H[레이트리밋 설계 후 재개]
    classDef bad fill:#ffe3e3,stroke:#e03131,color:#a01212
    classDef good fill:#e7f5ff,stroke:#1c7ed6,color:#10548f
    class B,E,F bad
    class G,H good

핵심은 밴 자체가 아니라 밴 직전의 내 대응이었다. 서버가 “너무 많다”고 429(Too Many Requests, 요청 과다)로 분명히 경고했는데 나는 그걸 일반 에러로 착각하고 곧바로 재시도를 때렸다. 경고를 무시한 재시도가 밴을 확정지었다.

왜 하필 24시간이나 막혔나?

밴에는 종류가 있다. 내가 겪은 건 짧게 풀리는 소프트 스로틀(soft throttle, 일시적 속도 제한)이 아니라 IP 단위 하드 밴에 가까웠다. 정상일 때와 밴일 때 서버가 어떻게 반응하는지 시간순으로 보면 차이가 뚜렷하다.

sequenceDiagram
    participant C as 크롤러
    participant S as 공시 서버
    Note over C,S: ① 정상 구간
    C->>S: GET 공시목록
    S-->>C: 200 OK
    Note over C,S: ② 한도 근접
    C->>S: GET (분당 한도 초과)
    S-->>C: 429 Too Many Requests
    Note over C,S: ③ 경고 무시·재시도
    C->>S: GET 즉시 재시도 반복
    S-->>C: 연결 거부 (IP 밴)
    Note over C,S: ④ 밴 유지 약 24시간
    C->>S: GET
    S--xC: 응답 없음

429 응답에는 보통 Retry-After라는 헤더가 붙는다. “몇 초 뒤에 다시 오라”는 서버의 안내다. 나는 이 헤더를 읽지도 않고 재시도했다. 서버 입장에선 “기다리라 했는데 계속 두드리는 클라이언트”로 보였을 것이고, 그래서 짧은 제한이 긴 차단으로 승격된 것이다. 경고는 응답 안에 다 적혀 있었는데 내 코드가 그걸 안 봤다.

밴을 부른 세 가지 함정은?

복기해보니 나는 초보가 흔히 밟는 지뢰를 세 개나 밟았다.

함정내가 한 일올바른 방향
무제한 동시 요청반복문에서 쉼 없이 GET요청 사이 간격(딜레이) 삽입
429를 일반 에러 취급즉시 무한 재시도Retry-After 읽고 대기
실패 시 즉시 재시도간격 없이 반복지수 백오프(재시도 간격을 점점 늘림)

특히 세 번째, 지수 백오프(exponential backoff)를 몰랐던 게 컸다. 실패할 때마다 대기 시간을 1초, 2초, 4초처럼 두 배씩 늘리면 서버가 회복할 틈을 준다. 나는 반대로 실패하자마자 더 세게 두드렸으니 불난 집에 부채질을 한 셈이다.

다음엔 어떻게 막나?

재발 방지의 뼈대는 “요청 속도를 내가 먼저 통제한다”는 것이다. 서버가 막기 전에 스스로 느려지는 구조를 짰다.

import time
import random
import requests
 
# 공개 DART/더미 엔드포인트 기준 예시 (실제 키·URL 아님)
BASE = "https://example-dart.local/api/list"
 
def polite_get(url, params, max_retry=5):
    delay = 1.0
    for attempt in range(max_retry):
        resp = requests.get(url, params=params, timeout=10)
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 429:
            # 서버가 알려준 대기 시간을 우선 존중
            wait = int(resp.headers.get("Retry-After", delay))
            time.sleep(wait + random.uniform(0, 0.5))  # 지터로 동시성 분산
            delay *= 2  # 지수 백오프
            continue
        resp.raise_for_status()
    raise RuntimeError("재시도 한도 초과 - 크롤러 정지")
 
# 요청 사이 기본 간격: 분당 한도 아래로 스스로 제한
for page in range(1, 50):
    data = polite_get(BASE, {"page": page})
    time.sleep(1.2)  # 최소 간격 확보

포인트는 세 가지다. 429가 오면 Retry-After를 먼저 읽어 그만큼 쉰다. 재시도 간격을 두 배씩 늘리고 거기에 지터(jitter, 약간의 랜덤 흔들림)를 더해 요청이 한꺼번에 몰리지 않게 한다. 무한 재시도 대신 한도를 정해두고 넘으면 크롤러를 아예 멈춘다. 멈추는 게 밴당하는 것보다 훨씬 싸다.

여기에 더해 운영 습관도 바꿨다.

flowchart LR
    A[크롤 시작 전] --> B[robots.txt 확인]
    B --> C[분당 한도 파악]
    C --> D[요청 간격·백오프 설정]
    D --> E[야간 저부하 시간대 실행]
    E --> F[로그로 429 모니터링]
    classDef step fill:#ebfbee,stroke:#2f9e44,color:#1a6b2e
    class A,B,C,D,E,F step

특히 robots.txt(사이트가 크롤러에게 허용·금지 경로를 알려주는 파일)와 이용약관, 레이트리밋 정책을 시작 전에 확인하는 습관이 생겼다. 데이터를 얻는 것보다 접근 권한을 잃지 않는 게 먼저다.

그래서 남은 교훈은?

밴에서 배운 건 기술보다 태도였다. 서버가 429로 “천천히”라고 말할 때 그건 에러가 아니라 협조 요청이다. 그걸 무시하면 협조가 차단으로 바뀐다. 크롤러를 짤 때 나는 이제 “얼마나 빨리 긁을까”보다 “얼마나 예의 바르게 긁을까”를 먼저 설계한다. 속도를 조금 양보하면 24시간을 통째로 날리는 일은 없다.

다음엔 이 백오프 로직을 공통 모듈로 빼서 여러 크롤러가 같은 레이트리밋을 공유하도록 만들 계획이다. 한 IP에서 도는 크롤러가 여럿이면 각자 조심해도 합쳐서 한도를 넘길 수 있으니 말이다.