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 에러 추적 시스템도 함께 적용해보세요.
시리즈 목차:
- Next.js API에 Rate Limiting 구현하기 (메모리 기반) ← 현재 글
- 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 조합으로 구분할 수 있습니다.