금요일 오후 4시에 배포가 진행됨. 세 개의 대시보드 전체에서 모니터링이 빨간색으로 깜빡임. 사용자들이 checkout, login, 그리고 중요한 모든 API 호출에서 429 에러를 보고 있음. rate limiter가 요청을 거부하고 있거나, 더 나쁘게는 타사 API가 당신을 거부하고 있고, 재시도 로직이 상황을 악화시키고 있음. HTTP 429 Too Many Requests 상태 코드가 당신과 정상적인 주말 사이의 유일한 것이 됨. 대부분의 개발자는 429가 '속도를 늦춰야 함'을 의미한다는 것을 알고 있음. 그들이 놓치는 것은 어떤 요청을 재시도할지, 언제 지수 백오프를 할지, 그리고 죽음의 소용돌이를 실제로 방지하는 Retry-After 헤더를 어떻게 설정할지임. API가 트래픽을 거부하기 시작할 때 실제로 일어나는 일을 알아보자.

나는 이 문제의 양쪽을 모두 경험했음. 잘못 구성된 빌드 프로세스 때문에 실수로 CMS API를 DDoS하는 개발자였고, 런어웨이 클라이언트로부터 우리 자신의 서버를 보호하기 위해 rate limiting을 구현하는 사람이었음. 두 경험 모두 문서가 다루지 않는 것들을 배웠음. 모든 것을 함께 살펴보자.

HTTP 429가 실제로 의미하는 것은?

HTTP 429는 2012년에 발표된 RFC 6585에 정의되어 있음. 스펙은 놀랍도록 짧음. 요점은 다음과 같음: 사용자(또는 클라이언트)가 주어진 시간 내에 너무 많은 요청을 보냈음.

그게 다임. 이것은 rate limiting 응답임. 서버가 말하고 있는 것은 "당신의 요청을 이해했고, 아마도 유효하지만, 속도를 늦춰야 합니다."라는 뜻임.

이것은 403 Forbidden(당신은 허락받지 않았음) 또는 503 Service Unavailable(전체 서버가 어려움)과 다름. 429는 타겟팅됨. 이것은 당신의 요청 속도에 대한 것임.

응답에는 클라이언트에게 다시 시도하기 전에 얼마나 오래 기다려야 하는지 알려주는 Retry-After 헤더가 포함되어야 함. "should"라고 한 이유는 많은 API가 신경 쓰지 않기 때문임. 이는 모두의 삶을 더 어렵게 만듦.

실제로 429를 볼 곳

  • 타사 API: Stripe, OpenAI, GitHub, Contentful, Sanity — 모두 rate limit을 가지고 있음
  • CDN 및 호스팅 플랫폼: Vercel, Cloudflare, AWS는 edge rate limit을 초과하면 429를 반환함
  • 자신의 API: rate limiting을 구현했다면 (그리고 해야 함)
  • 빌드 프로세스: 모든 페이지에 대해 CMS API를 치는 정적 사이트 생성은 rate limit을 쉽게 트리거할 수 있음
  • 웹 스크래핑: 외부 소스에서 공격적으로 데이터를 가져오는 경우

429 에러의 일반적인 원인

프로덕션에서 실제로 마주한 시나리오를 분석해보자. 대략 발생 빈도 순서로 나열함.

1. 정적 사이트 빌드가 Headless CMS를 폭주시키기

headless 아키텍처로 작업하는 팀이 가장 많이 당하는 문제임. 2,000개의 페이지가 있고 각 페이지는 CMS에서 데이터가 필요함. 빌드 프로세스가 모든 요청을 병렬로 발사하면, CMS가 대규모 스파이크를 보고, 429를 반환하기 시작함. 빌드가 실패함.

headless CMS 프로젝트에서 일할 때 이것을 정기적으로 봄. 수정에는 요청 큐잉과 동시성 제한이 포함되는데, 이는 아래에서 다룸.

2. 누락되거나 손상된 캐싱

캐싱 레이어가 작동하지 않기 때문에 모든 페이지 로드가 새로운 API 호출을 트리거하면, 특히 트래픽 스파이크가 있을 때 rate limit을 빠르게 초과함. revalidate가 실수로 0으로 설정된 Next.js 앱을 한 번 디버깅했음. 이는 ISR이 사실상 비활성화되었음을 의미함. 모든 방문자가 Contentful에 대한 새로운 API 호출을 트리거함. 실제 트래픽으로 429를 받기 시작하는 데 약 45분이 걸렸음.

3. 백오프 없는 재시도 루프

코드가 에러를 받고, 즉시 재시도하고, 다른 에러를 받고, 즉시 재시도함... 축하함. rate limit을 트리거하는 기계를 만들었음. webhook 핸들러, 백그라운드 작업, 심지어 클라이언트 측 fetch 호출에서도 이 패턴을 봤음.

4. 여러 서비스가 API 키를 공유하기

스테이징 환경, 프로덕션 환경, 로컬 개발 설정, CI/CD 파이프라인이 모두 동일한 API 키를 사용함. 각각은 개별적으로 괜찮아 보이지만, 집합적으로 rate limit 예산을 빠르게 소모함.

5. 디바운싱 없는 클라이언트 측 Fetch

모든 키 입력에 API 호출을 발사하는 검색-입력 기능. 500ms마다 폴링하는 대시보드. 사용자가 스크롤할 수 있는 것보다 빠르게 fetch를 트리거하는 무한 스크롤. 이런 패턴은 절대적으로 429를 트리거할 수 있음. 특히 모든 사용자에게 곱해질 때.

6. 실제 학대 또는 공격

때때로 429는 정확히 해야 할 일을 함 — 합리적이지 않은 수의 요청으로부터 서버를 보호함. 봇, 자격증 채우기, 스크래핑 — rate limiting은 첫 번째 방어선임.

Retry-After 헤더 설명

Retry-After 헤더는 서버가 정확히 언제 다시 시도할지를 알려주는 방법임. 두 가지 형식으로 올 수 있음:

대기할 초:

HTTP/1.1 429 Too Many Requests
Retry-After: 60

특정 날짜/시간:

HTTP/1.1 429 Too Many Requests
Retry-After: Thu, 01 Jan 2026 00:00:00 GMT

초 형식이 훨씬 더 일반적임. 날짜 형식은 RFC 7231에서 정의한 HTTP-date를 사용함.

대부분의 튜토리얼이 말하지 않을 것은: 많은 API가 Retry-After를 전혀 보내지 않거나, 일관되지 않게 보냄. OpenAI API는 일반적으로 이를 포함함. GitHub API는 X-RateLimit-Reset과 함께 포함함. 많은 작은 API는 그냥 naked 429를 보내고 추측하게 함.

일부 API는 또한 추가 rate limit 헤더를 보냄:

헤더 목적 예시
X-RateLimit-Limit 윈도우당 허용되는 최대 요청 100
X-RateLimit-Remaining 현재 윈도우에서 남은 요청 0
X-RateLimit-Reset 윈도우가 초기화될 Unix 타임스탐프 1735689600
Retry-After 재시도 전 대기할 초 30

항상 이 헤더들을 확인함. 이들은 더 똑똑한 재시도 로직을 구현하고 limit에 도달하기 전에 주의깊게 속도를 늦출 수 있게 함.

클라이언트로서 429 에러 처리 방법

당신이 429 에러를 받는 쪽일 때, 이것이 올바르게 처리하는 방법임.

지터와 함께 지수 백오프

이것은 표준임. 고정된 시간을 기다리지 말고 — 각 재시도마다 지연을 지수적으로 증가시키고, 일부 무작위성(지터)을 추가하여 thundering herd 문제를 방지함.

async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  maxRetries: number = 5
): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxRetries) {
      throw new Error(`Still getting 429 after ${maxRetries} retries`);
    }

    // 먼저 Retry-After 헤더 확인
    const retryAfter = response.headers.get('Retry-After');
    let delay: number;

    if (retryAfter) {
      // 초 또는 날짜일 수 있음
      const parsed = parseInt(retryAfter, 10);
      if (!isNaN(parsed)) {
        delay = parsed * 1000;
      } else {
        delay = new Date(retryAfter).getTime() - Date.now();
      }
    } else {
      // 지터와 함께 지수 백오프
      const baseDelay = Math.pow(2, attempt) * 1000;
      const jitter = Math.random() * 1000;
      delay = baseDelay + jitter;
    }

    console.log(`Rate limited. Waiting ${Math.round(delay / 1000)}s before retry ${attempt + 1}`);
    await new Promise(resolve => setTimeout(resolve, delay));
  }

  // TypeScript가 원하는 것. 절대 도달하지 않지만
  throw new Error('Unexpected end of retry loop');
}

빌드 프로세스를 위한 요청 큐잉

수백 또는 수천 개의 API 호출을 해야 하는 정적 사이트 생성의 경우, 동시성 제어가 있는 큐를 사용함:

import pLimit from 'p-limit';

// 5개의 동시 요청으로 제한
const limit = pLimit(5);

const pages = await getAllPageSlugs(); // ['/', '/about', '/blog/post-1', ...] 반환

const results = await Promise.all(
  pages.map(slug =>
    limit(() => fetchWithRetry(`https://api.cms.com/pages/${slug}`))
  )
);

p-limit 라이브러리(2026년 주당 250만 npm 다운로드)는 내 go-to임. 요청 사이에 지연을 추가할 수도 있음:

const limit = pLimit(3);

const delay = (ms: number) => new Promise(r => setTimeout(r, ms));

const results = await Promise.all(
  pages.map((slug, i) =>
    limit(async () => {
      if (i > 0) await delay(200); // 요청 사이에 200ms
      return fetchWithRetry(`https://api.cms.com/pages/${slug}`);
    })
  )
);

Next.js API Routes에서 Rate Limiting 구현

이제 다른 쪽으로 넘어감 — 당신은 API를 구축하고 있고 보호해야 함. Next.js로 구축하고 있다면, API routes에 rate limiting을 추가하는 방법을 봄.

간단한 인메모리 Rate Limiter

단일 서버 배포 또는 개발 중에는 이것이 작동함:

// lib/rate-limit.ts
type RateLimitEntry = {
  count: number;
  resetTime: number;
};

const rateLimitMap = new Map<string, RateLimitEntry>();

export function rateLimit({
  windowMs = 60 * 1000,
  maxRequests = 100,
}: {
  windowMs?: number;
  maxRequests?: number;
} = {}) {
  return function check(identifier: string): {
    allowed: boolean;
    remaining: number;
    resetIn: number;
  } {
    const now = Date.now();
    const entry = rateLimitMap.get(identifier);

    if (!entry || now > entry.resetTime) {
      rateLimitMap.set(identifier, {
        count: 1,
        resetTime: now + windowMs,
      });
      return { allowed: true, remaining: maxRequests - 1, resetIn: windowMs };
    }

    if (entry.count >= maxRequests) {
      return {
        allowed: false,
        remaining: 0,
        resetIn: entry.resetTime - now,
      };
    }

    entry.count++;
    return {
      allowed: true,
      remaining: maxRequests - entry.count,
      resetIn: entry.resetTime - now,
    };
  };
}

Next.js App Router API route에서 사용하기:

// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';

const limiter = rateLimit({ windowMs: 60_000, maxRequests: 30 });

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
  const { allowed, remaining, resetIn } = limiter(ip);

  if (!allowed) {
    return NextResponse.json(
      { error: 'Too many requests. Please slow down.' },
      {
        status: 429,
        headers: {
          'Retry-After': String(Math.ceil(resetIn / 1000)),
          'X-RateLimit-Limit': '30',
          'X-RateLimit-Remaining': '0',
        },
      }
    );
  }

  // 실제 route 로직
  return NextResponse.json(
    { data: 'Here you go' },
    {
      headers: {
        'X-RateLimit-Limit': '30',
        'X-RateLimit-Remaining': String(remaining),
      },
    }
  );
}

Upstash Redis를 사용한 프로덕션 Rate Limiting

inMemory 접근법은 Vercel과 같은 serverless 플랫폼에서 실행할 때 깨짐. 각 함수 호출이 다른 인스턴스에 도달할 수 있기 때문임. 공유 저장소가 필요함. Upstash Redis는 2026년에 이를 위해 가장 인기 있는 선택임.

npm install @upstash/ratelimit @upstash/redis
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(30, '60 s'),
  analytics: true,
  prefix: 'api-ratelimit',
});
// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    const retryAfter = Math.ceil((reset - Date.now()) / 1000);
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      {
        status: 429,
        headers: {
          'Retry-After': String(retryAfter),
          'X-RateLimit-Limit': String(limit),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': String(reset),
        },
      }
    );
  }

  return NextResponse.json({ data: 'Success' }, {
    headers: {
      'X-RateLimit-Limit': String(limit),
      'X-RateLimit-Remaining': String(remaining),
    },
  });
}

Upstash의 무료 계층은 하루 10,000개의 요청을 제공함. 이는 소규모 프로젝트에 충분함. 그들의 Pro 플랜은 2026년 초 기준으로 하루 500K 명령어에 대해 $10/월부터 시작함.

Middleware 레벨 Rate Limiting

모든 API routes에 rate limiting을 원하면, Next.js middleware가 그 장소임:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
    const { success, reset } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: {
            'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
          },
        }
      );
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

Rate Limiting 전략 비교

모든 rate limiting 알고리즘이 동등하지는 않음. 주요 알고리즘들이 어떻게 비교되는지 봄:

알고리즘 작동 방식 장점 단점 최고의 사용
Fixed Window 고정된 시간 윈도우에서 요청을 셈 (예: 분당) 구현이 간단 윈도우 경계에서 폭증으로 limit의 2배까지 허용 가능 간단한 API, 내부 도구
Sliding Window 롤링 시간 기간 동안 요청을 셈 더 부드러운 분포 약간 더 복잡, 더 많은 메모리 대부분의 프로덕션 API
Token Bucket 토큰이 일정한 속도로 리필되고, 각 요청이 토큰을 소비 제어된 폭증 허용 더 복잡한 상태 관리 폭증 허용이 필요한 API
Leaky Bucket 요청이 큐에 들어가고 고정 속도로 처리됨 매우 부드러운 출력 속도 레이턴시 추가 가능, 요청이 드롭될 수 있음 webhook 배달, 작업 처리
Sliding Window Log 각 요청의 타임스탐프를 저장 가장 정확 규모에서 높은 메모리 사용 낮은 볼륨, 높은 정확도 필요

대부분의 웹 애플리케이션의 경우, sliding window가 달콤한 지점임. 이것이 Upstash가 기본적으로 사용하는 것이며, 다른 이유가 없는 한 추천하는 것임.

Astro 및 기타 프레임워크에서의 Rate Limiting

Astro로 구축하고 있다면, rate limiting은 다르게 작동함. Astro는 주로 정적-우선 프레임워크이기 때문임. 하지만 Astro의 서버 엔드포인트(SSR 모드에서 사용 가능)의 경우, 개념은 동일함:

// src/pages/api/data.ts
import type { APIRoute } from 'astro';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(30, '60 s'),
});

export const GET: APIRoute = async ({ request }) => {
  const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
  const { success, reset } = await ratelimit.limit(ip);

  if (!success) {
    return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
      status: 429,
      headers: {
        'Content-Type': 'application/json',
        'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
      },
    });
  }

  return new Response(JSON.stringify({ data: 'Hello' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};

Cloudflare Workers에 edge 배포된 애플리케이션의 경우, Cloudflare의 내장 Rate Limiting 규칙도 고려할 수 있음. 이는 인프라 레벨에서 작동하고 애플리케이션 레벨 솔루션보다 훨씬 더 많은 트래픽을 처리할 수 있음. 그들의 Advanced Rate Limiting은 Business 플랜에서 좋은 요청 10,000개당 $0.05부터 시작함.

프로덕션에서 429 에러 모니터링 및 디버깅

볼 수 없으면 고칠 수 없음. 프로덕션에서 429 에러를 처리할 때 내 체크리스트는 다음과 같음:

429를 받고 있을 때

  1. 어떤 API가 429를 반환하는지 확인 — 상태 코드뿐만 아니라 응답 URL을 봄
  2. Retry-After 헤더를 로깅 — 지속적으로 매우 길면, 더 높은 계층 플랜이 필요할 수 있음
  3. 요청 패턴 감사 — 중복 호출을 하고 있나? 요청을 배치할 수 있나?
  4. 캐싱 구현stale-while-revalidate, Redis 캐싱, 또는 Next.js ISR을 사용하여 API 호출 감소
  5. 여러 환경이 API 키를 공유하는지 확인 — 이것이 가장 일반적인 "미스터리" 429 원인임

429를 보내고 있을 때

  1. 대시보드 설정 — 시간 경과에 따른 429 응답 비율 추적
  2. 상위 위반자 식별 — 가장 많이 limit에 도달하는 IP 주소 또는 API 키는 어떤 것인가?
  3. limit 검토 — limit이 너무 제한적인가? 너무 느슨한가? 서버 용량을 확인하고 조정함
  4. 항상 Retry-After 보내기 — 좋은 API 시민이 되자
  5. 도움이 되는 에러 메시지 포함 — 클라이언트에게 어떤 limit을 초과했는지, 언제 재시도할지 알려줌

잘 만들어진 429 응답 본문은 다음과 같음:

{
  "error": {
    "type": "rate_limit_exceeded",
    "message": "You've exceeded 30 requests per minute. Please wait before retrying.",
    "retryAfter": 42,
    "documentation": "https://docs.yourapi.com/rate-limits"
  }
}

이것은 단지 { "error": "Too many requests" }보다 무한히 더 도움이 됨.

headless 아키텍처에서 지속적인 rate limiting 문제를 다루고 있다면 — 빌드 중, 런타임에, 또는 둘 다 — 연락하기를 고려할 가치가 있을 수 있음. 다양한 CMS 및 프레임워크 조합에 걸쳐 많은 이런 문제를 봤으며, 일반적으로 증상을 밴드에이드하는 것이 아니라 패턴 레벨의 수정이 있음.

FAQ

HTTP 429 Too Many Requests는 무엇을 의미하나?

HTTP 429는 주어진 시간 기간 내에 서버에 너무 많은 요청을 보냈음을 의미하는 상태 코드임. 서버가 rate limiting하고 있음 — 속도를 늦추라고 요청하고 있음. 이것은 인증 에러나 서버 에러가 아님. 요청은 아마도 유효하지만, 단지 너무 많을 뿐임. 서버는 언제 다시 시도할지를 알려주는 Retry-After 헤더를 포함해야 함.

429 에러를 어떻게 고치나?

API에서 429 에러를 받고 있다면, 재시도 로직에 지터와 함께 지수 백오프를 구현하고, 요청 빈도를 줄이고, 중복 호출을 피하기 위해 캐싱을 추가하고, Retry-After 헤더를 존중함. 빌드 중에 limit에 도달한다면, 동시성 제어가 있는 요청 큐잉을 사용함. 지속적으로 발생한다면, 더 관대한 rate limit이 있는 더 높은 API 플랜으로 업그레이드해야 할 수 있음.

Retry-After 헤더는 무엇인가?

Retry-After 헤더는 429 (또는 503) 응답과 함께 보내져 클라이언트에게 다른 요청을 만들기 전에 얼마나 오래 기다려야 하는지 알려줌. 초 개수(예: Retry-After: 60) 또는 HTTP 날짜(예: Retry-After: Thu, 01 Jan 2026 00:00:00 GMT)로 지정할 수 있음. 모든 API가 이 헤더를 포함하지는 않지만, 잘 설계된 것들은 포함함.

Next.js에서 rate limiting을 어떻게 구현하나?

개발 또는 단일 서버 배포의 경우, IP 주소당 요청 개수를 추적하기 위해 인메모리 Map을 사용할 수 있음. Vercel과 같은 플랫폼에서의 프로덕션 serverless 배포의 경우, @upstash/ratelimit 패키지를 사용하여 Upstash Redis를 사용함. 개별 route 레벨에서 또는 Next.js middleware를 사용하여 모든 API routes 전체에 rate limiting을 적용할 수 있음.

429와 503 에러의 차이점은?

429 Too Many Requests는 구체적으로 rate limiting에 대한 것 — 당신의 클라이언트가 너무 많은 요청을 보내고 있음. 503 Service Unavailable은 서버가 과부하되거나 유지 관리 중이며 누구의 요청도 처리할 수 없음을 의미함. 둘 다 Retry-After 헤더를 포함할 수 있지만, 매우 다른 문제를 나타냄. 429는 당신을 타겟팅함. 503은 모두에게 영향을 줌.

Rate limiting이 DDoS 공격을 방지할 수 있나?

Rate limiting은 DDoS 공격 방어의 한 계층이지만, 그 자체로는 충분하지 않음. 애플리케이션 레벨 rate limiting (Next.js에서 구현하는 것과 같은)은 적당한 학대를 처리할 수 있지만, 심각한 DDoS 공격은 인프라 레벨에서 완화되어야 함 — Cloudflare, AWS Shield, 또는 호스팅 제공자의 내장 보호와 같은 서비스를 사용하여. 앱 레벨 rate limiting을 보안 관원으로, 인프라 레벨 보호를 요새 벽으로 생각함.

API에 어떤 rate limit을 설정해야 하나?

사용 사례에 따라 완전히 다름. 공개 API에 대한 일반적인 출발점은 IP당 분당 60개 요청, 또는 API 키당 시간당 1,000개 요청임. 인증된 사용자의 경우, 더 많이 허용할 수 있음. 핵심은 실제 사용 패턴을 모니터링하고, 합법적인 사용을 수용하되 약간의 여유를 두는 limit을 설정하고, 실제 데이터를 기반으로 조정하는 것임. 더 제한적으로 시작하고 느슨하게 함 — 사용자가 더 높은 비율에 의존한 후에 limit을 조였을 수 있음.

정적 사이트 빌드 중에 왜 429 에러를 받고 있나?

Next.js와 Astro와 같은 정적 사이트 생성기는 빌드 시 모든 페이지에 대한 데이터를 가져옴. 수백 또는 수천 개의 페이지가 있으면, 수백 또는 수천 개의 API 호출임. 초당 5-20개 요청 사이의 rate limit을 가진 대부분의 CMS API. 동시성을 3-5개 동시 요청으로 제한하기 위해 p-limit 또는 유사한 라이브러리를 사용하고, 배치 사이에 작은 지연을 추가하고, 증분 빌드(Next.js의 ISR, 또는 Astro의 증분 콘텐츠 컬렉션)를 사용하여 한 번에 모든 것을 다시 빌드하는 것을 피함.