Next.js API에 Rate Limiting 구현하기 (메모리 기반)

API 보안을 위해 Rate Limiting이 필요했습니다. Redis 없이 메모리 기반으로 구현하고 HTTP 429 표준을 준수한 방법을 공유합니다.

1. 왜 Rate Limiting이 필요한가?

1.1 실제 발생할 수 있는 공격

1. 브루트포스 공격
   - 로그인 API에 수천 번의 비밀번호 시도
   - 인증 코드 API에 000000~999999 모두 시도

2. 이메일 열거 공격
   - 이메일 중복 확인 API로 가입된 이메일 수집
   - 존재하는 계정 목록 확보

3. 리소스 고갈 공격
   - 이메일 발송 API 남용 → 비용 증가
   - DB 집중 요청 → 서비스 장애

4. 스크래핑/자동화
   - 데이터 무단 수집
   - 자동 계정 생성

1.2 Rate Limiting의 효과

✅ 공격 속도 제한 (초당 수천 → 분당 수십)
✅ 비용 보호 (이메일 발송 비용)
✅ 서비스 안정성 확보
✅ 정상 사용자 보호

1.3 구현 방식 비교

방식 장점 단점 적합한 상황
메모리 기반 구현 간단, 빠름 서버 재시작 시 초기화 단일 서버, MVP
Redis 기반 분산 환경, 영속성 인프라 필요 다중 서버, 프로덕션
외부 서비스 관리 불필요 비용, 의존성 대규모 서비스

2. 메모리 기반 Rate Limiter 구현

2.1 핵심 아이디어

Sliding Window 알고리즘:
1. IP별로 첫 요청 시간과 요청 수 저장
2. 윈도우(예: 1분) 내 요청 수 카운트
3. 제한 초과 시 429 응답
4. 윈도우 만료 시 초기화

2.2 타입 정의

// types/rate-limit.ts

/** Rate Limit 설정 */
export interface RateLimitConfig {
  maxRequests: number;  // 윈도우 내 최대 요청 수
  windowMs: number;     // 윈도우 크기 (밀리초)
}

/** Rate Limit 체크 결과 */
export interface RateLimitResult {
  allowed: boolean;      // 요청 허용 여부
  current: number;       // 현재 요청 수
  remaining: number;     // 남은 요청 수
  resetInSeconds: number; // 윈도우 초기화까지 남은 시간
}

/** 저장소 엔트리 */
export interface RateLimitEntry {
  count: number;        // 요청 수
  firstRequest: number; // 첫 요청 타임스탬프
}

2.3 전체 구현

// lib/core/rate-limit.ts
import { NextRequest, NextResponse } from "next/server";

// ============================================
// 상수
// ============================================

const DEFAULT_CONFIG: RateLimitConfig = {
  maxRequests: 10,
  windowMs: 60 * 1000, // 1분
};

const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;  // 5분
const ENTRY_TTL_MULTIPLIER = 2;

// ============================================
// API별 설정
// ============================================

export const rateLimitConfigs: Record<string, RateLimitConfig> = {
  "send-verification": {
    maxRequests: 10,
    windowMs: 60 * 1000, // 1분 - 이메일 발송 보호
  },
  "verify-code": {
    maxRequests: 20,
    windowMs: 60 * 1000, // 1분 - 브루트포스 방지
  },
  "check-email": {
    maxRequests: 30,
    windowMs: 60 * 1000, // 1분 - 이메일 열거 방지
  },
  "register": {
    maxRequests: 5,
    windowMs: 60 * 60 * 1000, // 1시간 - 대량 계정 생성 방지
  },
};

// ============================================
// 메모리 저장소
// ============================================

// 구조: Map<apiKey, Map<ip, RateLimitEntry>>
const store = new Map<string, Map<string, RateLimitEntry>>();
let lastCleanup = Date.now();

// ============================================
// 유틸리티 함수
// ============================================

/**
 * 클라이언트 IP 추출
 * 프록시/로드밸런서 환경 고려
 */
export function getClientIp(request: NextRequest): string {
  const forwardedFor = request.headers.get("x-forwarded-for");
  if (forwardedFor) {
    return forwardedFor.split(",")[0].trim();
  }

  return (
    request.headers.get("cf-connecting-ip") ||
    request.headers.get("x-real-ip") ||
    "unknown"
  );
}

/**
 * 만료된 엔트리 정리
 */
function cleanupExpiredEntries(): void {
  const now = Date.now();

  if (now - lastCleanup < CLEANUP_INTERVAL_MS) {
    return;
  }

  lastCleanup = now;

  for (const [apiKey, ipMap] of store.entries()) {
    const config = rateLimitConfigs[apiKey] || DEFAULT_CONFIG;
    const maxAge = config.windowMs * ENTRY_TTL_MULTIPLIER;

    for (const [ip, entry] of ipMap.entries()) {
      if (now - entry.firstRequest > maxAge) {
        ipMap.delete(ip);
      }
    }

    if (ipMap.size === 0) {
      store.delete(apiKey);
    }
  }
}

// ============================================
// 핵심 함수
// ============================================

/**
 * Rate Limit 체크
 */
export function checkRateLimit(apiKey: string, ip: string): RateLimitResult {
  cleanupExpiredEntries();

  const config = rateLimitConfigs[apiKey] || DEFAULT_CONFIG;
  const now = Date.now();

  // API별 저장소
  if (!store.has(apiKey)) {
    store.set(apiKey, new Map());
  }
  const ipMap = store.get(apiKey)!;

  // IP별 엔트리
  let entry = ipMap.get(ip);

  // 윈도우 만료 체크
  if (entry && now - entry.firstRequest >= config.windowMs) {
    entry = undefined;
  }

  if (!entry) {
    entry = { count: 1, firstRequest: now };
    ipMap.set(ip, entry);

    return {
      allowed: true,
      current: 1,
      remaining: config.maxRequests - 1,
      resetInSeconds: Math.ceil(config.windowMs / 1000),
    };
  }

  entry.count += 1;

  const resetInSeconds = Math.ceil(
    (entry.firstRequest + config.windowMs - now) / 1000
  );
  const remaining = Math.max(0, config.maxRequests - entry.count);

  return {
    allowed: entry.count <= config.maxRequests,
    current: entry.count,
    remaining,
    resetInSeconds,
  };
}

/**
 * Rate Limit 초과 시 에러 응답
 */
export function createRateLimitResponse(result: RateLimitResult): NextResponse {
  return NextResponse.json(
    {
      error: `요청이 너무 많습니다. ${result.resetInSeconds}초 후에 다시 시도해주세요.`,
      retryAfter: result.resetInSeconds,
    },
    {
      status: 429,
      headers: {
        "Retry-After": result.resetInSeconds.toString(),
        "X-RateLimit-Remaining": result.remaining.toString(),
        "X-RateLimit-Reset": result.resetInSeconds.toString(),
      },
    }
  );
}

/**
 * Rate Limit 미들웨어 헬퍼
 */
export function applyRateLimit(
  request: NextRequest,
  apiKey: string
): NextResponse | null {
  const ip = getClientIp(request);

  if (ip === "unknown") {
    console.warn("[RateLimit] Request without IP headers", { apiKey });
    return NextResponse.json(
      { error: "요청을 처리할 수 없습니다." },
      { status: 400 }
    );
  }

  const result = checkRateLimit(apiKey, ip);

  if (!result.allowed) {
    return createRateLimitResponse(result);
  }

  return null;
}

3. API Route에서 사용

3.1 기본 사용법

// app/api/auth/send-verification/route.ts
import { NextRequest, NextResponse } from "next/server";
import { applyRateLimit } from "@/lib/core/rate-limit";

export async function POST(request: NextRequest) {
  // Rate Limit 체크 (맨 앞에!)
  const rateLimitResult = applyRateLimit(request, "send-verification");
  if (rateLimitResult) return rateLimitResult;

  // 비즈니스 로직
  const { email } = await request.json();
  // 이메일 발송 로직...

  return NextResponse.json({ success: true });
}

3.2 여러 API에 적용

// app/api/auth/verify-code/route.ts
export async function POST(request: NextRequest) {
  const rateLimitResult = applyRateLimit(request, "verify-code");
  if (rateLimitResult) return rateLimitResult;

  // 인증 코드 검증 로직...
}

// app/api/auth/check-email/route.ts
export async function POST(request: NextRequest) {
  const rateLimitResult = applyRateLimit(request, "check-email");
  if (rateLimitResult) return rateLimitResult;

  // 이메일 중복 확인 로직...
}

// app/api/auth/register/route.ts
export async function POST(request: NextRequest) {
  const rateLimitResult = applyRateLimit(request, "register");
  if (rateLimitResult) return rateLimitResult;

  // 회원가입 로직...
}

4. 클라이언트 에러 처리

4.1 429 응답 처리

async function sendVerificationCode(email: string) {
  const response = await fetch("/api/auth/send-verification", {
    method: "POST",
    body: JSON.stringify({ email }),
  });

  if (response.status === 429) {
    const data = await response.json();
    throw new Error(`${data.error}`);
  }

  if (!response.ok) {
    throw new Error("인증 코드 발송에 실패했습니다.");
  }

  return response.json();
}

// React에서 사용
function VerificationForm() {
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (email: string) => {
    try {
      await sendVerificationCode(email);
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="text-red-500">{error}</p>}
      {/* ... */}
    </form>
  );
}

4.2 Retry-After 헤더 활용

async function fetchWithRetry(url: string, options: RequestInit) {
  const response = await fetch(url, options);

  if (response.status === 429) {
    const retryAfter = parseInt(
      response.headers.get("Retry-After") || "60"
    );

    // 사용자에게 남은 시간 표시
    throw new RateLimitError(retryAfter);
  }

  return response;
}

class RateLimitError extends Error {
  constructor(public retryAfter: number) {
    super(`${retryAfter}초 후에 다시 시도해주세요.`);
  }
}

5. 테스트

5.1 유닛 테스트

// lib/core/rate-limit.test.ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { checkRateLimit, rateLimitConfigs } from "./rate-limit";

describe("checkRateLimit", () => {
  beforeEach(() => {
    // 테스트 간 상태 초기화를 위해 store를 리셋
    // (실제 구현에서는 테스트용 리셋 함수 필요)
  });

  it("첫 요청은 항상 허용", () => {
    const result = checkRateLimit("send-verification", "192.168.1.1");

    expect(result.allowed).toBe(true);
    expect(result.current).toBe(1);
    expect(result.remaining).toBe(9);
  });

  it("제한 초과 시 차단", () => {
    const ip = "192.168.1.2";
    const apiKey = "send-verification";

    // 10번 요청 (허용)
    for (let i = 0; i < 10; i++) {
      const result = checkRateLimit(apiKey, ip);
      expect(result.allowed).toBe(true);
    }

    // 11번째 요청 (차단)
    const result = checkRateLimit(apiKey, ip);
    expect(result.allowed).toBe(false);
    expect(result.remaining).toBe(0);
  });

  it("다른 IP는 독립적으로 카운트", () => {
    const apiKey = "send-verification";

    // IP1에서 10번 요청
    for (let i = 0; i < 10; i++) {
      checkRateLimit(apiKey, "192.168.1.1");
    }

    // IP2는 여전히 허용
    const result = checkRateLimit(apiKey, "192.168.1.2");
    expect(result.allowed).toBe(true);
    expect(result.current).toBe(1);
  });

  it("윈도우 만료 후 초기화", async () => {
    vi.useFakeTimers();

    const apiKey = "send-verification";
    const ip = "192.168.1.3";
    const config = rateLimitConfigs[apiKey];

    // 10번 요청 (제한 도달)
    for (let i = 0; i < 10; i++) {
      checkRateLimit(apiKey, ip);
    }

    expect(checkRateLimit(apiKey, ip).allowed).toBe(false);

    // 윈도우 시간 경과
    vi.advanceTimersByTime(config.windowMs + 1);

    // 다시 허용
    const result = checkRateLimit(apiKey, ip);
    expect(result.allowed).toBe(true);
    expect(result.current).toBe(1);

    vi.useRealTimers();
  });
});

6. 핵심 개념 정리

6.1 Rate Limit 알고리즘 비교

알고리즘 설명 장점 단점
Fixed Window 고정 시간 윈도우 구현 간단 경계에서 2배 요청 가능
Sliding Window 이동 윈도우 균일한 제한 구현 복잡
Token Bucket 토큰 충전 방식 버스트 허용 메모리 사용
Leaky Bucket 일정 속도 처리 균일한 처리 버스트 불가

6.2 HTTP 응답 표준

429 Too Many Requests

헤더:
- Retry-After: 재시도까지 대기 시간 (초)
- X-RateLimit-Remaining: 남은 요청 수
- X-RateLimit-Reset: 윈도우 초기화까지 시간

6.3 IP 추출 우선순위

1. x-forwarded-for - 프록시/로드밸런서에서 설정
2. cf-connecting-ip - Cloudflare
3. x-real-ip - Nginx
4. 직접 연결 IP

7. 베스트 프랙티스

7.1 Rate Limit 설정 가이드

// API별 권장 설정
{
  // 인증 관련 (엄격)
  "login": { maxRequests: 5, windowMs: 15 * 60 * 1000 },       // 5회/15분
  "send-verification": { maxRequests: 10, windowMs: 60 * 1000 }, // 10회/분
  "verify-code": { maxRequests: 20, windowMs: 60 * 1000 },      // 20회/분
  "register": { maxRequests: 5, windowMs: 60 * 60 * 1000 },     // 5회/시간

  // 일반 API (유연)
  "api-default": { maxRequests: 100, windowMs: 60 * 1000 },     // 100회/분

  // 고빈도 작업 (더 유연)
  "quick-input": { maxRequests: 60, windowMs: 60 * 1000 },      // 60회/분
}

7.2 보안 고려사항

1. IP 헤더 신뢰
   - 프록시 환경에서만 x-forwarded-for 신뢰
   - 직접 연결에서는 헤더 조작 가능

2. Unknown IP 처리
   - 거부하거나 매우 엄격한 제한 적용
   - 로깅으로 모니터링

3. 메모리 관리
   - 주기적인 정리 로직 필수
   - 메모리 누수 모니터링

7.3 확장 고려사항

단일 서버 → 다중 서버 전환 시:

1. Redis 기반으로 마이그레이션
   - 동일한 인터페이스 유지
   - store를 Redis로 교체

2. 주의사항
   - 네트워크 지연 고려
   - Redis 장애 시 fallback 처리

8. 참고 자료


9. 다음 단계

Rate Limiting을 구현했다면, API 에러 추적 시스템도 함께 적용해보세요.

시리즈 목차:

  1. Next.js API에 Rate Limiting 구현하기 (메모리 기반) ← 현재 글
  2. API 에러 추적 개선하기: errorId 패턴으로 디버깅 시간 단축

10. FAQ (자주 묻는 질문)

Q: 메모리 기반 Rate Limiter의 한계는?

A: 서버 재시작 시 초기화되고, 다중 서버 환경에서 공유되지 않습니다. 단일 서버 환경이나 MVP에서는 충분하지만, 프로덕션에서는 Redis 기반을 권장합니다.

Q: 로드밸런서 뒤에서 IP를 어떻게 얻나요?

A: x-forwarded-for 헤더를 사용합니다. 첫 번째 IP가 원본 클라이언트 IP입니다. Cloudflare를 사용하면 cf-connecting-ip 헤더도 확인하세요.

Q: 정상 사용자가 차단되면 어떻게 하나요?

A: Retry-After 헤더로 대기 시간을 알려주고, UI에서 친절한 메시지를 표시합니다. 제한을 너무 엄격하게 설정하지 않도록 주의하세요.

Q: 공유 IP(회사, 학교)에서 문제가 없나요?

A: 문제가 될 수 있습니다. 이런 환경을 고려해 제한을 여유있게 설정하거나, IP + User-Agent 조합으로 구분할 수 있습니다.