429 차단, 원인은 요청 빈도가 아니었습니다
축은 '얼마나 자주'가 아니라 '한 번에 몇 건'이었습니다. 분당 26건에서 차단당하고 분당 110건은 무사했던 로그를 세어 다시 찾은 기준.
외부 API를 주기적으로 조회하는 서비스를 운영하다 429를 맞았습니다.
대응은 명확해 보였습니다. 너무 자주 불렀으니 주기를 늘리면 되겠지.
조회 간격을 늘리고 재배포했습니다.
그리고 얼마 뒤 또 맞았습니다.
결론부터 말하면 제가 잡은 축이 틀렸습니다.
그리고 축이 틀리면 대응은 정확히 그만큼 빗나갑니다.
간격을 늘리는 것은 애초에 문제가 아니었던 것을 고치는 일이었습니다.
가설이 틀렸다는 것을 어떻게 알았나
두 번째 차단 뒤에야 로그를 제대로 봤습니다.
그런데 로그가 제 가설을 정면으로 반증하고 있었습니다.

빈도가 축이라면 나올 수 없는 결과입니다.
분당 110건 쪽이 4배 넘게 많은데 그쪽이 멀쩡했으니까요.
"자주 부르면 차단당한다"는 것은 검증한 사실이 아니라 제 짐작이었습니다.
그 짐작 위에서 대응을 설계했으니 두 번째 차단은 예정된 결과였습니다.
표본을 세어 축을 다시 찾았다
짐작을 버리고 로그에서 조회 구간 81곳을 뽑아 세어봤습니다.
차단된 구간과 무사한 구간을 가른 것은 한 번 깨어날 때 보낸 요청 수였습니다.
| 회당 요청 수 | 표본 | 차단 |
|---|---|---|
| 1~3건 | 다수 | 0건 |
| 15~31건 | 5곳 | 4곳 |
같은 대상, 같은 IP, 같은 계정인데 갈렸습니다.
결정적인 증거는 같은 저장소 안에 있었습니다.
3초 간격으로 한 건씩 조회하는 스크립트는 3개월 동안 무사했습니다.
반면 20일치를 한 번에 연속 조회하는 쪽만 차단됐습니다.
간격은 3초로 훨씬 짧은데 그쪽이 살아남았습니다.
서버가 보는 것은 "얼마나 자주 오는가"가 아니라 "한 번에 얼마나 몰려 오는가"에
가까웠습니다.
버스트 테스트가 통과한 것을 안전하다고 읽었다
여기에 앞선 실수가 하나 더 있었습니다.
도입 전에 버스트 테스트를 돌렸습니다.
간격 1.0초, 0.5초, 0.3초로 각 30회씩. 0.3초 간격으로 60초 지속까지.
전부 200이 왔고, 그래서 문서에 이렇게 적었습니다.
이 엔드포인트는 연타해도 200이다
틀렸습니다. 그 테스트가 잰 것은 순간 요청률 하나뿐이었습니다.
실제 차단은 24시간 누적에 걸렸습니다.
레이트리밋은 창(window)이 여러 개인데, 저는 그중 하나만 재고
"안전하다"로 읽었습니다.
짧은 버스트가 통과했다는 것은 짧은 버스트가 통과했다는 뜻일 뿐입니다.
그 이상을 읽으면 안 됐습니다.
요청량을 실제로 세어봤다
축을 알고 나서 우리가 실제로 얼마나 보내는지 세어봤습니다.
| 항목 | 값 |
|---|---|
| 폴링 주기 | 40초 + 지터 0~10초 = 평균 45초 |
| 구독당 요청 | 1건 |
| 실제 구독 수 | 운영 2 + 스테이징 1 = 3개 |
| 합계 | 시간당 240건 · 하루 5,760건 |
초당 0.07건이니 순간 폭발은 아닙니다.
그런데 24시간 무중단입니다.
여기서 눈에 걸린 것이 스테이징이었습니다.
스테이징이 운영과 같은 API를 같은 주기로 두드리고 있어서, 요청량이 그대로 1.5배가 됐습니다.
QA용 환경이 운영과 같은 예산을 나눠 쓰고 있다는 것을 그때까지 아무도
세어보지 않았습니다.
고친 것은 간격이 아니라 회당 부하
축을 알고 나니 고칠 곳이 달라졌습니다.
조회 주기는 그대로 두고, 한 번 깨어날 때 보내는 요청 수를 잘랐습니다.
# 한 번에 20일치를 훑던 것을 나눠 보낸다.
# 총량은 같지만 한 번의 봉우리가 낮아진다.
CHUNK = 3
for group in chunked(targets, CHUNK):
await fetch_all(group)
await asyncio.sleep(interval)차단 응답은 답을 들고 온다
부끄러운 이야기를 하나 더 하면, 차단당한 응답의 헤더를 읽지 않았습니다.
retry-after: 63160
17.5시간입니다.
이 값이 응답에 들어 있었는데 한 번도 읽지 않고, 5시간 동안 "몇 분이면 풀릴까"
하면서 5분마다 다시 두드렸습니다.
Retry-After는 표준 헤더이고 대개 정확합니다.
다만 그 값이 창의 잔여일 수 있어 실제보다 길게 나오기도 합니다.
아주 드물게(예: 30분) 탐침 한 번을 허용해 진짜로 풀렸는지 확인하는 편이
좋습니다. 서킷 브레이커의 반열림과 같은 발상입니다.
계속 두드리는 것이 차단을 연장시켰다
더 나쁜 것이 있었습니다.
차단당한 상태에서 581회 연속으로 실패하면서 계속 요청을 보냈습니다.
멈추는 장치가 없었기 때문입니다.
581회 연속 실패는 어떤 정상 상황에서도 나올 수 없는 값입니다.
그런데 그 값을 보고 멈추는 코드가 없었습니다.
그리고 계속 두드리는 것이 차단을 연장시켰습니다.
화면도 도움이 안 됐습니다.
마지막 확인: 05:07 (방금) · ⚠️ 조회 실패 568회 연속
이 표시만으로는 서버가 막은 것인지 우리 쪽이 고장 난 것인지 알 수
없습니다. "마지막 확인: 방금"은 오히려 정상처럼 보입니다.
재발을 막으려고 넣은 것 셋
① Retry-After를 읽고 그때까지 멈춘다
# 헤더가 없으면 1시간. 분 단위로 두면 차단 중에 다시 두드린다.
# 상한 24시간 — 비정상적으로 큰 값에 폴링이 영영 안 돌아오는 것을 막는다.
until = parse_retry_after(resp) or (now + 3600)
until = min(until, now + 86400)여기에 함정이 두 개 있었습니다.
백오프는 전역이어야 합니다.
429는 엔드포인트 단위로 걸리는데 구독별로 백오프를 두면, 차단 중에도
다른 구독의 요청이 계속 나갑니다.
성공하면 풀어야 합니다.
안 풀면 남은 시간 내내 쉬면서 정작 필요한 순간을 놓칩니다.
② 연속 실패 20회면 자동으로 멈춘다
AUTO_PAUSE_FAILURE_THRESHOLD = 20 # 약 15분여기서도 세 가지를 밟았습니다.
첫째, 판정을 ==이 아니라 **>=**로 해야 합니다.
==이면 재기동 등으로 카운터가 임계를 건너뛸 때 영영 안 멈춥니다.
둘째, 재개할 때 실패 카운터를 0으로 되돌려야 합니다.
안 그러면 첫 실패에 곧바로 다시 멈춥니다.
셋째, 사용자가 끈 것과 시스템이 멈춘 것을 구별해야 합니다.
활성 플래그를 건드리지 않고 중지 사유만 따로 기록해서, 화면이 "왜
멈췄는지"를 설명할 수 있게 했습니다.
③ 스테이징은 주기 폴링을 하지 않는다
환경변수 하나로 스테이징의 폴링만 끕니다.
커맨드와 UI QA는 계속할 수 있게 모듈 자체는 그대로 로드합니다.
운영 환경에서 이 플래그가 켜지지 않도록 가드를 함께 넣었습니다.
"스테이징에서만 끈다"는 의도는 코드가 강제해야 합니다.
설정 파일에 적어두는 것으로는 언젠가 반대로 들어갑니다.
각자 자기 몫만 줄이는 것은 답이 아니다
여기서 한 가지가 더 드러났습니다.
같은 IP에서 같은 엔드포인트를 두드리는 프로세스가 하나가 아니었습니다.
우리 쪽 조회를 다 껐는데도 차단이 풀리지 않았습니다.
확인해보니 다른 저장소의 프로세스가 같은 곳을 두드리고 있었습니다.
7시간 동안 568회 연속 실패하면서요.
"다 껐다"고 말할 때 무엇을 세었는지가 중요합니다.
프로세스 이름으로 세면 다른 저장소, 다른 런타임의 것이 시야에 안 들어옵니다.
launchd·Docker·cron은 서로의 프로세스를 보여주지 않습니다.
그래서 자원 단위로 세는 것으로 바꿨습니다.
lsof -nP -iTCP | grep <대상 IP> # 이게 0이어야 "다 껐다"그리고 각 프로세스가 자기 몫을 조금씩 줄이는 방식은 근본 대응이 아니었습니다.
구독자나 인스턴스가 늘면 선형으로 증가해서 곧 다시 넘습니다.
하지 않기로 한 것
IP 로테이션이나 프록시는 검토했다가 접었습니다.
익명 스크래핑이라면 선택지가 됐을 수 있습니다.
그런데 이 서비스는 로그인한 계정으로 조회합니다.
IP를 바꿔도 계정은 그대로이고, 오히려 "같은 계정이 여러 IP에서 접속"이라는
더 나쁜 신호가 됩니다.
위험이 일시 차단에서 계정 제재로 올라가는 셈이라 하지 않았습니다.
남은 트레이드오프
회당 요청 수를 줄이면 한 주기를 도는 데 시간이 더 걸립니다.
실시간성이 그만큼 떨어집니다. 조회 대상이 늘수록 이 지연도 같이 늡니다.
자동 중지도 마찬가지입니다.
20회 연속 실패까지는 계속 두드리므로 약 15분은 여전히 요청이 나갑니다.
임계를 낮추면 일시적인 네트워크 오류에도 멈춰버려서, 정작 필요한 순간에
쉬고 있는 상태가 됩니다. 어느 쪽으로 틀려도 손해라 15분에서 타협했습니다.
백오프 상한 24시간도 임의로 정한 값입니다.
헤더가 그보다 긴 값을 주면 우리는 24시간 뒤에 다시 두드리게 되는데,
그때 아직 안 풀렸으면 또 한 번 차단을 연장시키는 셈입니다.
탐침을 아주 드물게 한 번만 보내는 것으로 위험을 줄였지만 없애지는
못했습니다.
그리고 이 글의 결론도 여전히 표본 81곳에서 나온 추정입니다.
서버의 실제 정책은 공개돼 있지 않습니다.
버스트 테스트로 한 번 틀린 적이 있으니, 이 축도 언제든 다시 의심해야
한다고 생각합니다. 다음에 또 차단당하면 그때는 이 글의 기준부터
반증해볼 참입니다.
정리
- 짐작을 축으로 삼으면 대응도 그만큼 빗나갑니다. 로그를 세어 축부터 찾습니다
- "얼마나 자주"보다 "한 번에 얼마나"를 먼저 봅니다
- 차단 응답의
Retry-After를 읽습니다. 답이 그 안에 있는 경우가 많습니다 - 공유 자원은 자원 단위로 셉니다. 프로세스 이름으로 세면 남의 것이 안 보입니다
- 각자 조금씩 줄이는 것은 선형으로 다시 늘어납니다. 총량을 상수로 만듭니다