
API 보안 실제: Rate Limiting, API Key 관리, IP 제한
공개 API를 운영하다 보면 예상치 못한 대량 요청에 시달릴 수 있다. Rate Limiting과 API Key 관리로 API를 보호하는 방법을 정리했다.

공개 API를 운영하다 보면 예상치 못한 대량 요청에 시달릴 수 있다. Rate Limiting과 API Key 관리로 API를 보호하는 방법을 정리했다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

API를 공개하면 어떤 일이 벌어질까? Rate Limiting 없이 API를 공개했다가 하루 만에 수만 건의 요청을 받았다는 사례는 흔하다. 한 IP에서 초당 수백 건씩 크롤링하거나, 잘못된 무한 루프가 서버를 괴롭히는 일은 생각보다 자주 일어난다. 클라우드 비용이 수십 배로 뛰었다는 이야기도 어렵지 않게 찾을 수 있다.
이런 사례들을 보면서 깨달았다. API를 공개한다는 건, 세상에 서버의 문을 여는 것이다. Rate Limiting이 필요하다.
Rate Limiting을 이해하는 데 도움이 된 비유가 있다.
내 API는 수도꼭지다. 사용자들은 물을 받아가려고 줄을 선다. 근데 누군가 대형 물통을 가져와서 계속 물을 받아간다면? 다른 사람들은 물을 못 받는다. 수도 요금(AWS 비용)도 터진다.
해결책은 세 가지였다:
처음엔 복잡해 보였지만, 결국 이 비유로 정리됐다. "누가, 얼마나, 언제까지 내 자원을 쓸 수 있는가?"
네 가지 주요 알고리즘의 특징을 비교해봤다.
가장 단순한 방법. 시간을 고정된 구간으로 나누고, 각 구간마다 카운터를 센다.
// 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번이 되는데, 시스템은 이걸 막지 못한다. 윈도우가 넘어가는 순간 카운터가 리셋되기 때문이다.
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을 써야 해서 메모리 사용량이 늘어난다. 트래픽이 많으면 비용이 문제가 될 수 있다.
버킷에 토큰을 채워두고, 요청마다 토큰을 소비한다. 토큰은 일정한 속도로 자동 충전된다.
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이 선호되는 이유: 버스트를 허용하면서도 평균 속도를 제한할 수 있다. 사용자가 가끔 많은 요청을 보내도 괜찮지만, 지속적으로 많이 보내면 막힌다.
Rate Limiting만으로는 부족할 수 있다. "누가" API를 쓰는지 알아야 하기 때문이다. 그래서 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가 털려도 키는 안전하다.
// 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 })
}
// 삭제 로직...
}
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)
}
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),
},
})
}
Rate Limiting을 애플리케이션 레벨에서 구현하더라도, 네트워크 레벨에서도 보호가 필요하다. Cloudflare를 앞단에 두는 것이 일반적이다.
Cloudflare의 장점:
설정은 간단하다:
근데 함정이 있다. 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'
}
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' },
})
}
마지막 퍼즐은 모니터링이다. 이상한 패턴을 빨리 감지해야 한다.
// 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}`,
},
},
],
}),
})
}
보안 취약점이 발견되면 빠르게 패치해야 한다. 근데 기존 사용자들이 갑자기 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 공지 후 점진적으로 제거할 수 있다.
API 보안을 한 번에 해결하려고 하면 압도된다. 레이어별로 쌓아야 한다.
1. 네트워크 레벨그리고 가장 중요한 건 HTTP 헤더를 제대로 쓰는 것이다:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1708041600
Retry-After: 60
사용자들이 언제 다시 요청할 수 있는지 알려주면, 불필요한 재시도가 줄어든다.
API를 공개한다는 건, 세상에 서버의 문을 여는 것이다. 문을 여는 순간부터 보안은 선택이 아니라 필수다. Rate Limiting은 그 첫걸음이다.