Prologue: API를 공개하면 어떤 일이 벌어질까
API를 공개하면 어떤 일이 벌어질까? Rate Limiting 없이 API를 공개했다가 하루 만에 수만 건의 요청을 받았다는 사례는 흔하다. 한 IP에서 초당 수백 건씩 크롤링하거나, 잘못된 무한 루프가 서버를 괴롭히는 일은 생각보다 자주 일어난다. 클라우드 비용이 수십 배로 뛰었다는 이야기도 어렵지 않게 찾을 수 있다.
이런 사례들을 보면서 깨달았다. API를 공개한다는 건, 세상에 서버의 문을 여는 것이다. Rate Limiting이 필요하다.
Aha! 결국 이거였다: 수도꼭지와 물통 비유
Rate Limiting을 이해하는 데 도움이 된 비유가 있다.
내 API는 수도꼭지다. 사용자들은 물을 받아가려고 줄을 선다. 근데 누군가 대형 물통을 가져와서 계속 물을 받아간다면? 다른 사람들은 물을 못 받는다. 수도 요금(AWS 비용)도 터진다.
해결책은 세 가지였다:
- Fixed Window: "1분에 100번만 받아갈 수 있어요." 시계를 보고 1분마다 카운터를 리셋한다.
- Sliding Window: "지금부터 정확히 1분 전까지 100번만 받아갔는지 체크해요." 더 정밀하다.
- Token Bucket: "물통에 토큰이 100개 있어요. 요청할 때마다 토큰 1개씩 소비하고, 초당 10개씩 자동으로 채워져요." 버스트 트래픽을 허용하면서도 평균 속도를 제한한다.
처음엔 복잡해 보였지만, 결국 이 비유로 정리됐다. "누가, 얼마나, 언제까지 내 자원을 쓸 수 있는가?"
Deep Dive: 실제로 Rate Limiting 구현하기
1. Rate Limiting 알고리즘 비교
네 가지 주요 알고리즘의 특징을 비교해봤다.
Fixed Window Counter
가장 단순한 방법. 시간을 고정된 구간으로 나누고, 각 구간마다 카운터를 센다.
// Fixed Window with Redis
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
async function fixedWindowRateLimit(
identifier: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
const now = Date.now()
const window = Math.floor(now / (windowSeconds * 1000))
const key = `ratelimit:${identifier}:${window}`
const count = await redis.incr(key)
if (count === 1) {
// 첫 요청이면 TTL 설정
await redis.expire(key, windowSeconds)
}
const allowed = count <= limit
const remaining = Math.max(0, limit - count)
return { allowed, remaining }
}
// 사용 예시: 사용자당 분당 60회 제한
const { allowed, remaining } = await fixedWindowRateLimit(
`user:${userId}`,
60,
60
)
if (!allowed) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': '60',
'X-RateLimit-Remaining': '0',
'Retry-After': '60'
}
})
}
문제는 경계 케이스였다. 00:59에 60번, 01:01에 60번 요청하면 2분에 120번이 되는데, 시스템은 이걸 막지 못한다. 윈도우가 넘어가는 순간 카운터가 리셋되기 때문이다.
Sliding Window Counter
Fixed Window의 문제를 해결한 버전. 정확히 지난 N초간의 요청 수를 센다.
// Sliding Window with Redis Sorted Set
async function slidingWindowRateLimit(
identifier: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; reset: number }> {
const now = Date.now()
const windowStart = now - (windowSeconds * 1000)
const key = `ratelimit:sliding:${identifier}`
// 1. 오래된 요청 제거
await redis.zremrangebyscore(key, 0, windowStart)
// 2. 현재 윈도우 내 요청 수 확인
const count = await redis.zcard(key)
if (count < limit) {
// 3. 새 요청 추가
await redis.zadd(key, { score: now, member: `${now}-${Math.random()}` })
await redis.expire(key, windowSeconds)
return {
allowed: true,
remaining: limit - count - 1,
reset: now + (windowSeconds * 1000)
}
}
return {
allowed: false,
remaining: 0,
reset: now + (windowSeconds * 1000)
}
}
정확하지만, Redis에 Sorted Set을 써야 해서 메모리 사용량이 늘어난다. 트래픽이 많으면 비용이 문제가 될 수 있다.
Token Bucket (실무에서 가장 널리 쓰이는 방식)
버킷에 토큰을 채워두고, 요청마다 토큰을 소비한다. 토큰은 일정한 속도로 자동 충전된다.
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
// Upstash Ratelimit 라이브러리 사용
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// Token Bucket 방식으로 설정
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(10, '10s', 3), // 10초당 10개, 최대 버스트 3개
analytics: true,
prefix: '@upstash/ratelimit',
})
// API Route에서 사용
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
const { success, limit, remaining, reset, pending } = await ratelimit.limit(ip)
if (!success) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
})
}
// 정상 처리
return new Response('OK', {
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
})
}
Token Bucket이 선호되는 이유: 버스트를 허용하면서도 평균 속도를 제한할 수 있다. 사용자가 가끔 많은 요청을 보내도 괜찮지만, 지속적으로 많이 보내면 막힌다.
2. API Key 관리: 생성, 검증, 순환
Rate Limiting만으로는 부족할 수 있다. "누가" API를 쓰는지 알아야 하기 때문이다. 그래서 API Key 도입이 필요해진다.
API Key 생성과 저장
import crypto from 'crypto'
import { hash, verify } from '@node-rs/argon2'
// API Key 생성 (사용자에게 한 번만 보여줌)
export async function generateApiKey(userId: string) {
// 안전한 랜덤 키 생성 (32 bytes = 256 bits)
const apiKey = `sk_${crypto.randomBytes(32).toString('base64url')}`
// 해시해서 DB에 저장 (원본은 저장하지 않음)
const hashedKey = await hash(apiKey, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
})
// DB 저장
await db.apiKey.create({
data: {
userId,
keyHash: hashedKey,
keyPrefix: apiKey.substring(0, 10), // 식별용 prefix만 저장
scopes: ['read', 'write'],
createdAt: new Date(),
lastUsedAt: null,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1년
},
})
// 생성된 키는 한 번만 반환 (다시 볼 수 없음)
return apiKey
}
// API Key 검증 (미들웨어에서 사용)
export async function verifyApiKey(apiKey: string) {
if (!apiKey.startsWith('sk_')) {
return null
}
const prefix = apiKey.substring(0, 10)
// prefix로 DB 조회 (인덱싱 최적화)
const storedKey = await db.apiKey.findFirst({
where: { keyPrefix: prefix },
include: { user: true },
})
if (!storedKey) {
return null
}
// 해시 비교로 검증
const isValid = await verify(storedKey.keyHash, apiKey)
if (!isValid) {
return null
}
// 만료 체크
if (storedKey.expiresAt && storedKey.expiresAt < new Date()) {
return null
}
// 마지막 사용 시간 업데이트 (비동기로)
db.apiKey.update({
where: { id: storedKey.id },
data: { lastUsedAt: new Date() },
}).catch(() => {}) // fire-and-forget
return {
userId: storedKey.userId,
scopes: storedKey.scopes,
user: storedKey.user,
}
}
핵심은 원본 키를 절대 저장하지 않는 것. 비밀번호처럼 해시만 저장한다. DB가 털려도 키는 안전하다.
Scope 기반 권한 제어
// API Key에 권한 부여
const scopes = ['posts:read', 'posts:write', 'analytics:read']
// 미들웨어에서 권한 체크
export async function requireScope(apiKey: string, requiredScope: string) {
const auth = await verifyApiKey(apiKey)
if (!auth) {
throw new Error('Invalid API key')
}
if (!auth.scopes.includes(requiredScope)) {
throw new Error(`Missing required scope: ${requiredScope}`)
}
return auth
}
// 사용 예시
export async function DELETE(request: Request) {
const apiKey = request.headers.get('authorization')?.replace('Bearer ', '')
if (!apiKey) {
return new Response('Unauthorized', { status: 401 })
}
try {
await requireScope(apiKey, 'posts:delete')
} catch (error) {
return new Response('Forbidden', { status: 403 })
}
// 삭제 로직...
}
3. IP 제한: Allowlist와 Blocklist
API Key만으로도 충분할 것 같지만, 특정 IP에서만 접근을 허용하고 싶을 때가 있다.
// IP Allowlist 체크
export async function checkIpAllowlist(request: Request, userId: string) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ??
request.headers.get('x-real-ip') ??
'unknown'
// DB에서 사용자의 IP allowlist 조회
const allowlist = await db.ipAllowlist.findMany({
where: { userId },
})
// Allowlist가 설정되어 있으면 체크
if (allowlist.length > 0) {
const isAllowed = allowlist.some(entry => {
if (entry.cidr) {
return isIpInCidr(ip, entry.cidr)
}
return entry.ip === ip
})
if (!isAllowed) {
return { allowed: false, reason: 'IP not in allowlist' }
}
}
// Blocklist 체크 (글로벌)
const isBlocked = await redis.sismember('ip:blocklist', ip)
if (isBlocked) {
return { allowed: false, reason: 'IP blocked' }
}
return { allowed: true, ip }
}
// CIDR 체크 (예: 192.168.1.0/24)
function isIpInCidr(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split('/')
const ipNum = ipToNumber(ip)
const rangeNum = ipToNumber(range)
const mask = -1 << (32 - parseInt(bits))
return (ipNum & mask) === (rangeNum & mask)
}
function ipToNumber(ip: string): number {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0)
}
4. CORS 설정: API 보안의 기본
API를 브라우저에서 호출할 때 CORS 설정이 중요하다. 모든 도메인을 허용하면 보안 위험이 생긴다.
// Next.js API Route CORS 미들웨어
export function corsMiddleware(allowedOrigins: string[]) {
return (request: Request) => {
const origin = request.headers.get('origin')
// Origin이 허용 목록에 있는지 체크
const isAllowed = origin && (
allowedOrigins.includes('*') ||
allowedOrigins.includes(origin)
)
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400', // 24시간 캐시
}
if (isAllowed && origin) {
headers['Access-Control-Allow-Origin'] = origin
headers['Access-Control-Allow-Credentials'] = 'true'
}
return headers
}
}
// 사용 예시
export async function OPTIONS(request: Request) {
const corsHeaders = corsMiddleware(['https://myapp.com', 'https://app.myapp.com'])
return new Response(null, {
status: 204,
headers: corsHeaders(request),
})
}
export async function GET(request: Request) {
const corsHeaders = corsMiddleware(['https://myapp.com', 'https://app.myapp.com'])
// API 로직...
const data = { message: 'Hello' }
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
...corsHeaders(request),
},
})
}
5. Cloudflare WAF와 DDoS 보호
Rate Limiting을 애플리케이션 레벨에서 구현하더라도, 네트워크 레벨에서도 보호가 필요하다. Cloudflare를 앞단에 두는 것이 일반적이다.
Cloudflare의 장점:
- DDoS 보호: 대규모 공격은 Cloudflare가 막아준다
- Bot 필터링: 악의적인 봇을 자동으로 차단
- Rate Limiting: 애플리케이션 도달 전에 1차 필터링
- Analytics: 어떤 IP에서 얼마나 요청하는지 한눈에 볼 수 있다
설정은 간단하다:
- DNS를 Cloudflare로 변경
- WAF Rules에서 Rate Limiting 규칙 추가
- Bot Fight Mode 활성화
근데 함정이 있다. Cloudflare를 쓰면 x-forwarded-for 헤더로 실제 IP를 가져와야 한다. 그렇지 않으면 모든 요청이 Cloudflare IP에서 온 것처럼 보인다.
// Cloudflare를 통한 요청의 실제 IP 가져오기
export function getRealIp(request: Request): string {
// Cloudflare는 CF-Connecting-IP 헤더를 제공
const cfIp = request.headers.get('cf-connecting-ip')
if (cfIp) return cfIp
// 일반적인 프록시 헤더들
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) return forwarded.split(',')[0].trim()
const realIp = request.headers.get('x-real-ip')
if (realIp) return realIp
return 'unknown'
}
6. Request Validation과 Sanitization
Rate Limiting과 API Key만으로는 부족하다. 요청 자체를 검증해야 한다.
import { z } from 'zod'
// Request 스키마 정의
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(50000),
tags: z.array(z.string()).max(10).optional(),
published: z.boolean().default(false),
})
// Validation 미들웨어
export async function validateRequest<T>(
request: Request,
schema: z.ZodSchema<T>
): Promise<{ data: T | null; error: string | null }> {
try {
const body = await request.json()
const data = schema.parse(body)
return { data, error: null }
} catch (error) {
if (error instanceof z.ZodError) {
return {
data: null,
error: error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '),
}
}
return { data: null, error: 'Invalid request body' }
}
}
// 사용 예시
export async function POST(request: Request) {
// API Key 검증
const apiKey = request.headers.get('authorization')?.replace('Bearer ', '')
const auth = await verifyApiKey(apiKey!)
if (!auth) {
return new Response('Unauthorized', { status: 401 })
}
// Rate Limiting
const { success } = await ratelimit.limit(`user:${auth.userId}`)
if (!success) {
return new Response('Too Many Requests', { status: 429 })
}
// Request Validation
const { data, error } = await validateRequest(request, createPostSchema)
if (error) {
return new Response(JSON.stringify({ error }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
// 실제 로직...
const post = await createPost(auth.userId, data!)
return new Response(JSON.stringify(post), {
headers: { 'Content-Type': 'application/json' },
})
}
7. 모니터링과 알림
마지막 퍼즐은 모니터링이다. 이상한 패턴을 빨리 감지해야 한다.
// Suspicious Activity 감지
export async function detectSuspiciousActivity(userId: string, ip: string) {
const now = Date.now()
const key = `suspicious:${userId}:${ip}`
// 지난 1분간 실패한 요청 수
const failCount = await redis.incr(`${key}:fail`)
await redis.expire(`${key}:fail`, 60)
// 5번 이상 실패하면 경고
if (failCount >= 5) {
await sendAlert({
type: 'suspicious_activity',
userId,
ip,
message: `${failCount} failed attempts in 1 minute`,
})
// IP를 임시 블록 (1시간)
await redis.setex(`ip:blocked:${ip}`, 3600, '1')
}
// 지난 1시간간 총 요청 수
const totalCount = await redis.incr(`${key}:total`)
await redis.expire(`${key}:total`, 3600)
// 1시간에 1000번 이상이면 경고
if (totalCount >= 1000) {
await sendAlert({
type: 'high_traffic',
userId,
ip,
message: `${totalCount} requests in 1 hour`,
})
}
}
// Vercel Log Drain 또는 Webhook으로 알림
async function sendAlert(alert: {
type: string
userId: string
ip: string
message: string
}) {
// Slack Webhook
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🚨 Security Alert: ${alert.type}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${alert.type}*\nUser: ${alert.userId}\nIP: ${alert.ip}\n${alert.message}`,
},
},
],
}),
})
}
8. API Versioning으로 보안 업데이트 관리
보안 취약점이 발견되면 빠르게 패치해야 한다. 근데 기존 사용자들이 갑자기 API를 못 쓰게 되면 안 된다. 버전 관리가 필요하다.
// URL 기반 버저닝
// /api/v1/posts
// /api/v2/posts
// 또는 헤더 기반
// API-Version: 2024-02-15
export function getApiVersion(request: Request): string {
// 헤더에서 버전 확인
const headerVersion = request.headers.get('api-version')
if (headerVersion) return headerVersion
// URL에서 버전 확인
const url = new URL(request.url)
const pathMatch = url.pathname.match(/\/api\/v(\d+)\//)
if (pathMatch) return pathMatch[1]
// 기본값은 최신 버전
return 'latest'
}
// 버전별 핸들러
export async function GET(request: Request) {
const version = getApiVersion(request)
switch (version) {
case '1':
return handleV1(request)
case '2':
return handleV2(request)
default:
return handleLatest(request)
}
}
이렇게 하면 보안 업데이트를 새 버전에만 적용하고, 구버전은 deprecated 공지 후 점진적으로 제거할 수 있다.
Summary: API 보안은 레이어별로 쌓는 것
API 보안을 한 번에 해결하려고 하면 압도된다. 레이어별로 쌓아야 한다.
1. 네트워크 레벨
- Cloudflare WAF로 DDoS와 악의적인 봇 차단
- 지리적 제한 (필요시 특정 국가만 허용)
2. 애플리케이션 레벨
- Rate Limiting (Token Bucket 추천)
- API Key 인증 및 Scope 기반 권한 제어
- IP Allowlist/Blocklist
- CORS 설정
3. 데이터 레벨
- Request Validation (Zod)
- Input Sanitization
- Output Filtering (민감한 데이터 노출 방지)
4. 모니터링 레벨
- 이상 패턴 감지
- 실시간 알림
- 로그 분석
그리고 가장 중요한 건 HTTP 헤더를 제대로 쓰는 것이다:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1708041600
Retry-After: 60
사용자들이 언제 다시 요청할 수 있는지 알려주면, 불필요한 재시도가 줄어든다.
API를 공개한다는 건, 세상에 서버의 문을 여는 것이다. 문을 여는 순간부터 보안은 선택이 아니라 필수다. Rate Limiting은 그 첫걸음이다.