TechFeedTechFeed
Backend

429 Too Many Requests, Retry-After, 백오프 | API 한 번에 몰리면 어디를 줄일까?

429는 인증 실패가 아니라 짧은 시간에 호출이 많아 서버가 기다리라고 한 응답이다. Retry-After 초와 HTTP 날짜를 읽고, 헤더가 없으면 지수 백오프에 지터를 넣는다. 크론·웹훅·액션이 같은 키를 쓰면 동시 호출과 배치부터 줄인다. 개발자, API, 백엔드, Node.js, Next.js, 깃허브 기준. 2026년 9월 MDN·RFC 6585·깃허브 REST 문서.

by

429는 비밀번호가 틀린 게 아니라, 그 키나 아이피로 짧은 시간에 너무 많이 쳐서 서버가 잠깐 기다리라고 한 응답입니다. 먼저 Retry-After 초를 읽고, 헤더가 없으면 지수 백오프에 지터를 넣으세요.


로컬에서 한 건씩 치던 호출이 배포 뒤에만 막히는 아침이 있습니다. 크론이 정각에 몰리고, 카카오 알림 발송과 토스 웹훅 재시도가 같은 키를 쓰면 한도가 한꺼번에 깎이죠. 제가 페이지뷰 핑을 분에 몰아 넣었을 때도 같은 줄이 났습니다.


재시도 횟수만 늘리면 더 세게 맞습니다. 동시 호출 수와 배치 크기를 먼저 줄이세요. 근거는 MDN 429 상태 코드, Retry-After 헤더, RFC 6585 4절에 있습니다.


429는 401과 다른 칸이다

429는 권한 거절이 아니라 호출을 잠시 줄이라는 신호입니다. MDN은 클라이언트가 주어진 시간에 요청을 너무 많이 보냈을 때 나는 클라이언트 오류로 적습니다. 흔히 속도 제한이라고 부르는 그 칸이죠.


401은 토큰이 없거나 만료된 자리, 403은 토큰은 있는데 그 동작이 막힌 자리입니다. 429를 보고 키를 갈아끼우면 같은 분 안에 더 빨리 한도를 깎습니다. 본문 상태 숫자와 헤더를 같이 봐야 칸이 갈립니다.


상태먼저 볼 것
401인증이 없거나 만료액세스 토큰, 만료 시각
403인증은 됐는데 권한이 없음역할, 행 단위 정책
429짧은 시간에 호출이 많음Retry-After, 남은 한도 헤더
503서버가 잠시 받을 수 없음Retry-After, 업스트림 상태

제한 단위는 서버마다 다릅니다. MDN도 서버 전체일 수도 있고 자원별일 수도 있다고 적습니다. 아이피 기준인 곳도 있고, 로그인된 사용자나 앱 키 기준인 곳도 있어요. 카카오 비즈니스 메시지와 토스 결제 콜백은 키와 상점 아이디 단위로 깎이는 달이 많아서, 노트북 한 대에선 안 보이던 한도가 액션 러너 세 개에서 한꺼번에 드러납니다.


먼저 기억할 것 | 429 본문에 "한도 초과"만 있고 헤더가 비어 있어도, 그건 재시도를 금지한다는 뜻이 아닙니다. 헤더가 없으면 내가 대기 시간을 정해야 합니다. 같은 초를 모든 프로세스가 같이 쓰면 문이 열리는 순간에 또 몰립니다.


API 호출이 몰려 서버가 429로 잠시 기다리라고 응답하는 개념 이미지
짧은 시간에 같은 키로 호출이 몰리면 서버는 429로 속도를 늦추라고 한다

Retry-After는 어디서 읽나

응답 헤더의 Retry-After부터 읽으면 됩니다. MDN은 429에서 이 헤더가 다시 쳐도 되는 때까지의 대기라고 적습니다. RFC 9110도 초 단위 숫자와 HTTP 날짜 두 가지 문법을 허용합니다.


숫자는 받은 뒤부터의 초입니다. Retry-After: 30이면 30초를 쉽니다. 날짜면 그 시각까지 쉽니다. Date.parse로 밀리초를 구한 뒤 지금과 빼면 대기 길이가 나와요. 파싱이 실패하면 헤더가 없다고 보고 내 백오프로 넘어갑니다.


대기
초 숫자3600받은 뒤 3600초
HTTP 날짜Wed, 21 Oct 2015 07:28:00 GMT그 시각까지
없음(헤더 누락)지수 백오프 + 지터

깃허브 REST는 1차 한도와 2차 한도를 나눕니다. 공식 문서는 남은 횟수와 초기화 시각 헤더를 읽고, 2차 한도에서는 Retry-After를 따르라고 적습니다. 429만 보고 무조건 1초 뒤에 다시 치면, 문이 아직 닫힌 채로 또 맞습니다.


버셀 함수 안에서 외부 API를 치면, 함수 최대 시간과 대기 시간이 겹칩니다. 30초를 기다리라고 했는데 함수가 10초에 끊기면 재시도 로그가 함수 타임아웃으로 바뀌죠. 대기가 길면 그 요청은 실패로 두고, 큐에 넣어 다음 실행에서 이어 가는 편이 안전합니다. 함수 시계 칸은 배포 함수 타임아웃 글과 자리를 나눕니다.


Retry-After를 초와 날짜 둘 다 읽기
function parseRetryAfter(value) { if (!value) return null; const trimmed = String(value).trim(); const asNumber = Number(trimmed); if (Number.isFinite(asNumber) && asNumber >= 0) { return asNumber * 1000; // 초 -> 밀리초 } const when = Date.parse(trimmed); if (Number.isNaN(when)) return null; return Math.max(0, when - Date.now()); } async function sleep(ms) { await new Promise(function (resolve) { setTimeout(resolve, ms); }); }

고정 간격으로 다시 치면 왜 더 막히나

같은 초를 여러 프로세스가 같이 기다리면, 문이 열리는 순간에 또 몰립니다. 1초, 1초, 1초로 고정하면 한도 창이 리셋되는 타이밍에 호출이 겹칩니다.


지수 백오프는 실패할 때마다 대기를 두 배로 늘립니다. 1초, 2초, 4초, 8초처럼요. 아마존 권고 문서도 재시도에 지수 백오프와 지터를 쓰라고 적습니다. 지터는 그 대기에 약간의 무작위 흔들림을 더하는 겁니다. 크론 세 개가 같은 분에 실패해도, 깨어나는 시각이 몇백 밀리초씩 갈라집니다.


상한도 필요합니다. 대기가 5분까지 커지면 함수가 먼저 죽습니다. 저는 한 요청의 최대 대기를 30초나 60초로 자르고, 그 안에 안 되면 큐에 되돌립니다. 무한 재시도는 한도만 더 깎습니다.


401이나 400을 429와 같은 루프에 넣으면 안 됩니다. 잘못된 본문은 기다려도 통과하지 않아요. 상태 숫자가 429일 때만 백오프를 타고, 그 외 4xx는 로그를 남기고 멈춥니다. 네트워크가 끊긴 ETIMEDOUT은 한도가 아니라 경로 칸이라, 같은 함수에 넣더라도 대기 공식은 따로 둡니다.


지수 백오프에 지터를 넣고 429만 재시도
function withJitter(ms) { const spread = ms * 0.2; return Math.floor(ms - spread + Math.random() * spread * 2); } async function fetchWithBackoff(url, options, maxTries) { const tries = maxTries || 5; let wait = 1000; for (let i = 0; i < tries; i++) { const res = await fetch(url, options); if (res.status !== 429) return res; const fromHeader = parseRetryAfter(res.headers.get('retry-after')); const delay = fromHeader != null ? fromHeader : withJitter(wait); const capped = Math.min(delay, 30000); await sleep(capped); wait = Math.min(wait * 2, 30000); } throw new Error('429 재시도 한도를 넘었습니다'); }
여러 프로세스가 같은 초에 재시도하다 다시 한도에 걸리는 개념 이미지
고정 간격 재시도는 문이 열리는 순간에 호출을 다시 겹치게 만든다

크론과 웹훅이 같은 키를 쓸 때

한 프로세스는 괜찮은데 배포 뒤에만 막히면, 같은 키를 쓰는 실행이 둘 이상인 경우가 많습니다. 로컬 한 터미널은 분당 한 자릿수지만, 깃허브 액션과 서버 크론과 웹훅 재시도가 겹치면 분당이 세 배가 됩니다.


카카오 알림 발송을 문의 저장 직후에 바로 치고, 실패하면 웹훅처럼 다시 넣는 구조가 흔합니다. 토스 결제 콜백도 상대가 실패 응답을 받으면 다시 보냅니다. 내가 그 콜백 처리 안에서 또 외부 API를 치면, 한 결제에 호출이 세 줄로 늘어납니다. 웹훅을 한 번만 반영하는 칸은 웹훅 재시도 폭주 글이 따로 다룹니다. 여기는 그 재시도가 바깥 API 한도를 깎는 자리만 봅니다.


겹치는 실행왜 한도가 빨리 닳나먼저 줄일 것
정각 크론 여러 대같은 분에 같은 키로 출발시작을 분 단위로 흩뿌리기
액션 러너 병렬잡마다 토큰이 같아 합산동시성 그룹, 대기 줄
웹훅 재시도실패 응답이 상대의 재전송을 부름빨리 200을 주고 큐에서 처리
프론트 재클릭버튼이 풀리기 전에 또 제출제출 중 잠금, 멱등 키

깃허브 액션이 같은 브랜치에서 분을 두 번 깎는 칸은 액션 동시성에서 다룹니다. 한도 글에서 그 설정까지 가져오면 초점이 흐려져요. 여기서는 "그 잡들이 같은 API 키를 들고 있는가"만 확인하면 됩니다.


키를 환경별로 나누는 것도 한 방법입니다. 프리뷰 배포와 운영이 같은 카카오 REST 키를 쓰면, 프리뷰 스모크 테스트가 운영 한도를 먹습니다. 로컬 콜백 URL이 막히면 터널로 주소를 여는 칸은 클라우드플레어 터널을 보면 됩니다. 키를 나누는 일과 주소를 여는 일은 다른 점검입니다.


큐와 배치로 호출을 줄이는 순서

재시도보다 먼저, 한 번에 나가는 개수를 줄입니다. Promise.all로 스무 건을 동시에 치면 한도가 한 번에 달고, 실패한 스무 건이 같이 백오프에 들어갑니다.


순서는 이렇게 잡으면 덜 헤맵니다. 먼저 화면에 필요한 건만 즉시 치고, 알림 메일이나 검색 색인처럼 늦어도 되는 일은 응답 뒤에 둡니다. Next.js의 after는 그 칸입니다. 자세한 타이밍은 응답 후 작업 글을 보세요. 그다음 남은 호출을 큐에 넣고, 워커가 한 번에 N건만 빼 가게 합니다.


배치 API가 있으면 한 건씩 대신 묶음으로 보냅니다. 목록 20개를 한 요청으로 조회할 수 있는데 20번 때리면, 한도만 20배입니다. 상대 문서에 묶음 끝이 있으면 그걸 쓰고, 없으면 내가 큐에서 간격을 둡니다.


동시 실행 숫자는 1부터 올려 보세요. 1인 사이트 크론이면 동시 1이 기본입니다. 제가 페이지뷰를 글마다 바로 핑 넣다가 막혔을 때는, 메모리 큐에 모았다가 200밀리초 간격으로 빼니까 429가 사라졌습니다. 화려한 라이브러리보다 간격과 동시 숫자가 먼저입니다.


  • [ ] 429 응답에서 상태 숫자와 Retry-After를 로그에 남겼다
  • [ ] 401·400을 재시도 루프에서 뺐다
  • [ ] 동시 호출을 1로 낮춘 뒤 간격을 넣었다
  • [ ] 정각 크론과 액션과 웹훅이 같은 키인지 확인했다
  • [ ] 대기가 함수 최대 시간을 넘으면 큐로 되돌렸다

동시 1, 간격 있는 단순 큐
const queue = []; let running = false; function enqueue(task) { queue.push(task); if (!running) drain(); } async function drain() { running = true; while (queue.length) { const task = queue.shift(); try { await task(); } catch (err) { console.error('큐 작업 실패', err); } await sleep(200); // 다음 호출까지 200ms } running = false; }
API 호출을 큐에 모아 간격을 두고 빼 가는 작업 흐름 이미지
한 번에 몰지 말고 큐에서 한 건씩 간격을 두고 내보내면 한도가 덜 닳는다

출처와 관련 문서

상태 숫자와 헤더 문법은 아래 문서가 기준입니다. 제품 콘솔의 한도 숫자는 바뀌니, 그 제품 페이지를 한 번 더 여세요.



브라우저 CORS 막힘과 429는 콘솔 문구가 비슷해 보여도 칸이 다릅니다. 프리플라이트에서 막히면 CORS 프리플라이트를 보세요. 모듈을 못 찾는 줄은 Cannot find module 쪽입니다.


자주 묻는 질문

429면 토큰이 만료된 건가요?

아닙니다. 만료는 보통 401입니다. 429는 그 키로 짧은 시간에 호출이 많다는 뜻이라, 키를 갈아끼우면 한도만 더 깎입니다. Retry-After를 읽고 호출 개수를 줄이세요.


Retry-After가 없으면 몇 초를 기다려야 하나요?

표준이 정한 기본 초는 없습니다. 1초부터 지수로 늘리고 지터를 넣으세요. 상한은 함수 최대 시간보다 짧게 자르고, 넘으면 큐에 되돌립니다. 상대 문서에 전용 헤더가 있으면 그걸 우선합니다.


Promise.all로 동시에 치면 왜 막히나요?

한도가 보통 분당·초당 횟수라, 동시에 스무 건이 나가면 한 번에 창을 채웁니다. 동시를 1로 낮추고 간격을 둔 뒤, 그래도 남으면 배치 API가 있는지 보세요.


카카오 알림과 토스 웹훅도 같은가요?

상태 숫자 429의 뜻은 같습니다. 다만 한도 단위가 키·상점·템플릿으로 갈립니다. 콘솔에 적힌 단위를 보고, 프리뷰와 운영 키를 나누세요. 웹훅은 빨리 200을 주고 처리는 큐에서 하면 상대의 재전송이 한도를 덜 먹습니다.


재시도 중에 사용자가 버튼을 또 눌러도 되나요?

안 됩니다. 제출 중에는 버튼을 잠그고, 같은 본문이면 멱등 키로 한 번만 반영하세요. 앞단 재클릭이 서버 재시도와 겹치면 호출이 두 줄이 됩니다.


429와 503은 어떻게 나누나요?

429는 내 호출이 많다는 뜻이고, 503은 서버가 잠시 받을 수 없다는 뜻입니다. 둘 다 Retry-After를 줄 수 있어요. 내 키가 원인인지, 상대 장애인지 본문과 상태 페이지를 같이 보면 갈립니다.


429는 키를 바꾸라는 신호가 아니라 속도를 낮추라는 신호입니다. Retry-After를 읽고, 헤더가 없으면 지수 백오프에 지터를 넣고, 동시 호출과 배치부터 줄이세요. 관련 글: fetch 타임아웃, 웹훅 재시도, 액션 동시성.


429Too Many RequestsRetry-After백오프지터속도 제한API백엔드Node.jsNext.js깃허브개발자

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기