본문으로 건너뛰기
분산 Rate Limiting 구현 가이드 - Redis와 Token Bucket 알고리즘

# 분산 시스템 Rate Limiting 완벽 구현 가이드: Redis + Token Bucket으로 API 남용 막기

Table of Contents

월요일 오전 9시, API 서버의 CPU가 100%를 찍습니다. 로그를 확인하니 한 사용자가 초당 10,000개의 요청을 보내고 있습니다. 정상 사용자들은 503 에러를 받고, 고객 지원팀에 전화가 폭주합니다. 클라우드 비용은 평소의 10배로 치솟았고, 경영진이 긴급 회의를 소집합니다.

이런 상황을 어떻게 막을 수 있을까요?

답은 Rate Limiting(속도 제한)입니다. 이 글에서는 프로덕션 환경에서 검증된 분산 Rate Limiting 시스템을 Redis와 Token Bucket 알고리즘으로 구축하는 방법을 실전 코드와 함께 상세히 알려드립니다.

Rate Limiting이 왜 필수인가?

실제 발생하는 문제들

1. API 남용 (API Abuse)

// 악의적 사용자의 스크립트
for (let i = 0; i < 1000000; i++) {
 fetch('https://api.example.com/expensive-operation')
.then(response => response.json())
.then(data => console.log(data))
}

// 결과:
// - 100만 건의 요청이 동시에 발생
// - 데이터베이스 과부하
// - 정상 사용자 서비스 불가
// - 클라우드 비용 폭증

비용 영향:

정상 트래픽: 100 req/sec
악의적 트래픽: 10,000 req/sec (100배)

AWS Lambda 비용:
정상: $100/월
공격 시: $10,000/월

데이터베이스 연결:
정상: 50개
공격 시: 5,000개 → 연결 풀 고갈!

2. DDoS 공격

# 분산 서비스 거부 공격
# 100개 IP에서 동시에 공격
for ip in $(cat bot-ips.txt); do
 curl -X POST https://api.example.com/signup \
 -H "X-Forwarded-For: $ip" \
 -d '{"email":"fake@email.com"}' &
done

# 1분에 100,000개 회원가입 요청!
# 이메일 발송 비용만 $5,000

3. 크롤러/봇 트래픽

# 무례한 크롤러
import requests

for page in range(1, 100000):
 response = requests.get(
 f'https://api.example.com/products?page={page}'
 )
 # 0.001초 간격으로 요청 (초당 1000회!)
 time.sleep(0.001)

# 결과:
# - SEO 봇이 아닌 악의적 스크래핑
# - 서버 리소스 낭비
# - 정상 사용자 응답 지연

4. 실수로 인한 루프

// 개발자의 실수
function updateDashboard() {
 fetch('/api/stats')
.then(data => {
 renderChart(data)
 updateDashboard() // ️ 재귀 호출 종료 조건 없음!
 })
}

updateDashboard() // 무한 루프 시작!

// 프로덕션 배포 후...
// 수천 명의 사용자가 동시에 무한 요청
// → 서버 다운!

Rate Limiting의 경제적 가치

비용 절감:

시나리오: e-커머스 API

Without Rate Limiting:
- API 호출: 1억 건/월
- AWS 비용: $50,000
- DB 비용: $30,000
- 총: $80,000/월

With Rate Limiting:
- API 호출: 2천만 건/월 (정상 트래픽만)
- AWS 비용: $10,000
- DB 비용: $6,000
- 총: $16,000/월

절감액: $64,000/월 = $768,000/년!

Rate Limiting 알고리즘 완벽 비교

1. Token Bucket (토큰 버킷) ⭐ 추천!

작동 원리:

버킷 용량: 100 토큰
재충전 속도: 10 토큰/초

[토큰 버킷]
┌─────────────┐
│ ○ ○ ○ ○ ○ │ 토큰: 100개
│ ○ ○ ○ ○ ○ │
│ ○ ○ ○ ○ ○ │
└─────────────┘

요청 1개 = 토큰 1개 소비
1초마다 10개 토큰 충전

구현 예시:

class TokenBucket:
 def __init__(self, capacity, refill_rate):
 self.capacity = capacity # 최대 100개
 self.tokens = capacity # 현재 100개
 self.refill_rate = refill_rate # 10개/초
 self.last_refill = time.time()

 def consume(self, tokens=1):
 # 시간 경과에 따라 토큰 재충전
 now = time.time()
 elapsed = now - self.last_refill
 refill = int(elapsed * self.refill_rate)

 self.tokens = min(
 self.capacity,
 self.tokens + refill
 )
 self.last_refill = now

 # 토큰이 충분한지 확인
 if self.tokens >= tokens:
 self.tokens -= tokens
 return True # 요청 허용
 return False # 요청 거부

장점:

  • 버스트 트래픽 처리: 순간적으로 100개 요청 가능
  • 유연성: 평소엔 10 req/sec, 필요시 100 req/sec
  • 사용자 친화적: 일시적 과부하 허용

단점:

  • 구현 복잡도 높음
  • 분산 환경에서 동기화 필요

2. Leaky Bucket (새는 양동이)

작동 원리:

[큐에 요청 쌓임]
 ↓ ↓ ↓
┌─────────────┐
│ REQ REQ REQ │
│ REQ REQ │ 큐 크기: 100
│ │
└──────┬──────┘
 ↓ 일정한 속도로 처리 (10 req/sec)
 [처리됨]

구현:

class LeakyBucket:
 def __init__(self, capacity, leak_rate):
 self.capacity = capacity
 self.queue = []
 self.leak_rate = leak_rate

 def add_request(self, request):
 if len(self.queue) < self.capacity:
 self.queue.append(request)
 return True
 return False # 큐 가득 참

 def process(self):
 # 일정한 속도로 처리
 while self.queue:
 request = self.queue.pop(0)
 handle_request(request)
 time.sleep(1 / self.leak_rate)

장점:

  • 일정한 처리 속도: 후단 시스템 보호
  • 구현 간단

단점:

  • 버스트 트래픽 처리 불가
  • 큐 관리 필요 (메모리 사용)

3. Fixed Window (고정 윈도우)

작동 원리:

분 단위로 카운트

00:00 - 00:59: 100 requests
01:00 - 01:59: 100 requests
02:00 - 02:59: 100 requests

각 윈도우마다 리셋

구현:

import time

class FixedWindow:
 def __init__(self, limit, window_size):
 self.limit = limit # 100 requests
 self.window_size = window_size # 60 seconds
 self.counter = {}

 def allow_request(self, user_id):
 current_window = int(time.time() / self.window_size)
 key = f"{user_id}:{current_window}"

 count = self.counter.get(key, 0)

 if count < self.limit:
 self.counter[key] = count + 1
 return True
 return False

장점:

  • 매우 간단한 구현
  • 메모리 효율적

단점:

  • 경계 문제: 윈도우 경계에서 2배 요청 가능
00:59 - 01:00 사이:
00:59:50 ~ 00:59:59: 100 requests
01:00:00 ~ 01:00:09: 100 requests
→ 20초간 200 requests! (Rate limit 우회)

4. Sliding Window Log

작동 원리:

최근 60초간의 모든 요청 타임스탬프 기록

현재: 10:05:30

로그:
10:04:31 (59초 전)
10:04:45 (45초 전)
10:05:10 (20초 전)
10:05:29 (1초 전)

10:03:00 (90초 전 - 제외)

구현:

from collections import deque
import time

class SlidingWindowLog:
 def __init__(self, limit, window_size):
 self.limit = limit
 self.window_size = window_size
 self.requests = {} # {user_id: deque([timestamps])}

 def allow_request(self, user_id):
 now = time.time()
 window_start = now - self.window_size

 if user_id not in self.requests:
 self.requests[user_id] = deque()

 user_log = self.requests[user_id]

 # 오래된 로그 제거
 while user_log and user_log[0] < window_start:
 user_log.popleft()

 if len(user_log) < self.limit:
 user_log.append(now)
 return True
 return False

장점:

  • 정확한 제한: 경계 문제 없음
  • 공정한 분배

단점:

  • 메모리 많이 사용: 모든 타임스탬프 저장
  • 성능 이슈: 대규모 트래픽에서 느림

알고리즘 선택 가이드

| 알고리즘 | 구현 난이도 | 정확도 | 메모리 | 버스트 처리 | 추천 용도 |
|----------------|------------|-------|--------|-----------|----------------------------|
| Token Bucket | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | | **API Gateway (최고추천)** |
| Leaky Bucket | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | | 백그라운드 작업 처리 |
| Fixed Window | ⭐ | ⭐ | ⭐ | | 간단한 프로토타입 |
| Sliding Log | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | | 정확한 제한이 필요한 경우 |

프로덕션 추천: Token Bucket + Redis

Redis로 분산 Rate Limiting 구현하기

왜 Redis인가?

1. 빠른 성능

메모리 기반 저장소
응답 시간: < 1ms
처리량: 100,000 ops/sec

2. 원자성 (Atomicity)

Lua 스크립트로 Race Condition 방지
여러 서버에서 동시 접근해도 안전

3. 만료 기능 (TTL)

자동으로 오래된 데이터 삭제
메모리 관리 불필요

기본 구현 (Node.js + Redis)

설치:

npm install redis ioredis

간단한 Fixed Window 구현:

const Redis = require('ioredis')
const redis = new Redis({
 host: 'localhost',
 port: 6379
})

async function rateLimit(userId, limit = 100, window = 60) {
 const key = `rate_limit:${userId}:${Math.floor(Date.now() / 1000 / window)}`

 // 현재 카운트 증가
 const current = await redis.incr(key)

 // 첫 요청이면 TTL 설정
 if (current === 1) {
 await redis.expire(key, window)
 }

 // 제한 확인
 if (current > limit) {
 const ttl = await redis.ttl(key)
 throw new Error(`Rate limit exceeded. Retry after ${ttl} seconds`)
 }

 return {
 allowed: true,
 remaining: limit - current
 }
}

// 사용 예시
app.get('/api/data', async (req, res) => {
 try {
 const result = await rateLimit(req.user.id, 100, 60)

 res.set('X-RateLimit-Limit', '100')
 res.set('X-RateLimit-Remaining', result.remaining)

 // 실제 API 로직
 res.json({ data: 'success' })
 } catch (error) {
 res.status(429).json({ error: error.message })
 }
})

문제점:

// ️ Race Condition 발생!
// 두 서버에서 동시에 요청

Server 1: current = redis.incr(key) // 99
Server 2: current = redis.incr(key) // 100

Server 1: if (current === 1) redis.expire(key, 60) // 실행 안됨
Server 2: if (current === 1) redis.expire(key, 60) // 실행 안됨

// 결과: TTL이 설정되지 않음!
// → 키가 영원히 Redis에 남음 (메모리 누수)

프로덕션급 구현: Lua 스크립트

Lua로 원자성 보장:

-- rate_limit.lua
-- Token Bucket 알고리즘

local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 최대 100
local rate = tonumber(ARGV[2]) -- 10 tokens/sec
local requested = tonumber(ARGV[3]) -- 요청 토큰 수
local now = tonumber(ARGV[4])

-- Redis에서 현재 상태 가져오기
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])

-- 초기화
if tokens == nil then
 tokens = capacity
 last_refill = now
end

-- 경과 시간 계산
local elapsed = now - last_refill

-- 토큰 재충전
local refill = math.floor(elapsed * rate)
tokens = math.min(capacity, tokens + refill)

-- 마지막 재충전 시간 업데이트
if refill > 0 then
 last_refill = now
end

-- 요청 처리
local allowed = 0
local remaining = tokens

if tokens >= requested then
 tokens = tokens - requested
 allowed = 1
 remaining = tokens
end

-- Redis 업데이트
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', key, 3600) -- 1시간 TTL

-- 결과 반환
return {allowed, remaining, capacity}

Node.js에서 사용:

const fs = require('fs')
const Redis = require('ioredis')

const redis = new Redis()

// Lua 스크립트 로드
const rateLimitScript = fs.readFileSync('./rate_limit.lua', 'utf8')

async function tokenBucketRateLimit(
 userId,
 capacity = 100, // 최대 100 토큰
 rate = 10, // 초당 10 토큰 재충전
 requested = 1 // 요청 토큰 수
) {
 const key = `rate_limit:${userId}`
 const now = Date.now() / 1000 // 초 단위

 const result = await redis.eval(
 rateLimitScript,
 1, // 키 개수
 key,
 capacity,
 rate,
 requested,
 now
 )

 const [allowed, remaining, limit] = result

 return {
 allowed: allowed === 1,
 remaining: remaining,
 limit: limit,
 retryAfter: allowed === 0 ? Math.ceil((requested - remaining) / rate) : null
 }
}

// Express 미들웨어
function rateLimitMiddleware(options = {}) {
 const {
 capacity = 100,
 rate = 10,
 getUserId = (req) => req.user?.id || req.ip
 } = options

 return async (req, res, next) => {
 const userId = getUserId(req)

 try {
 const result = await tokenBucketRateLimit(userId, capacity, rate, 1)

 // 헤더 설정
 res.set('X-RateLimit-Limit', result.limit)
 res.set('X-RateLimit-Remaining', result.remaining)

 if (!result.allowed) {
 res.set('Retry-After', result.retryAfter)
 return res.status(429).json({
 error: 'Too Many Requests',
 retryAfter: result.retryAfter
 })
 }

 next()
 } catch (error) {
 console.error('Rate limit error:', error)
 // 에러 시 요청 허용 (fail-open)
 next()
 }
 }
}

// 사용
app.use('/api', rateLimitMiddleware({
 capacity: 1000, // 버스트: 1000 요청
 rate: 100 // 평균: 100 req/sec
}))

Java + Spring Boot 구현

build.gradle:

dependencies {
 implementation 'org.springframework.boot:spring-boot-starter-data-redis'
 implementation 'io.lettuce:lettuce-core'
 implementation 'com.github.vladimir-bukhtoyarov:bucket4j-core:7.6.0'
 implementation 'com.github.vladimir-bukhtoyarov:bucket4j-redis:7.6.0'
}

RateLimitService.java:

@Service
public class RateLimitService {

 @Autowired
 private StringRedisTemplate redisTemplate;

 private static final String RATE_LIMIT_SCRIPT =
 "local key = KEYS[1]\n" +
 "local capacity = tonumber(ARGV[1])\n" +
 "local rate = tonumber(ARGV[2])\n" +
 "local requested = tonumber(ARGV[3])\n" +
 "local now = tonumber(ARGV[4])\n" +
 "\n" +
 "local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')\n" +
 "local tokens = tonumber(bucket[1]) or capacity\n" +
 "local last_refill = tonumber(bucket[2]) or now\n" +
 "\n" +
 "local elapsed = now - last_refill\n" +
 "local refill = math.floor(elapsed * rate)\n" +
 "tokens = math.min(capacity, tokens + refill)\n" +
 "\n" +
 "if refill > 0 then\n" +
 " last_refill = now\n" +
 "end\n" +
 "\n" +
 "local allowed = 0\n" +
 "if tokens >= requested then\n" +
 " tokens = tokens - requested\n" +
 " allowed = 1\n" +
 "end\n" +
 "\n" +
 "redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)\n" +
 "redis.call('EXPIRE', key, 3600)\n" +
 "\n" +
 "return {allowed, tokens, capacity}\n";

 public RateLimitResult checkRateLimit(
 String userId,
 int capacity,
 double rate,
 int requested
 ) {
 String key = "rate_limit:" + userId;
 double now = System.currentTimeMillis() / 1000.0;

 DefaultRedisScript<List> script = new DefaultRedisScript<>();
 script.setScriptText(RATE_LIMIT_SCRIPT);
 script.setResultType(List.class);

 List<Long> result = redisTemplate.execute(
 script,
 Collections.singletonList(key),
 String.valueOf(capacity),
 String.valueOf(rate),
 String.valueOf(requested),
 String.valueOf(now)
 );

 boolean allowed = result.get(0) == 1;
 int remaining = result.get(1).intValue();
 int limit = result.get(2).intValue();

 return new RateLimitResult(allowed, remaining, limit);
 }
}

@Data
@AllArgsConstructor
public class RateLimitResult {
 private boolean allowed;
 private int remaining;
 private int limit;
}

RateLimitInterceptor.java:

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

 @Autowired
 private RateLimitService rateLimitService;

 @Override
 public boolean preHandle(
 HttpServletRequest request,
 HttpServletResponse response,
 Object handler
 ) throws Exception {
 String userId = getUserId(request);

 RateLimitResult result = rateLimitService.checkRateLimit(
 userId,
 1000, // capacity
 100, // rate (tokens/sec)
 1 // requested tokens
 );

 response.setHeader("X-RateLimit-Limit", String.valueOf(result.getLimit()));
 response.setHeader("X-RateLimit-Remaining", String.valueOf(result.getRemaining()));

 if (!result.isAllowed()) {
 response.setStatus(429);
 response.setHeader("Retry-After", "10");
 response.getWriter().write("{\"error\":\"Too Many Requests\"}");
 return false;
 }

 return true;
 }

 private String getUserId(HttpServletRequest request) {
 // JWT 토큰에서 userId 추출 또는 IP 주소 사용
 String token = request.getHeader("Authorization");
 if (token != null) {
 // JWT 파싱 로직
 return extractUserIdFromToken(token);
 }
 return request.getRemoteAddr();
 }
}

Python + FastAPI 구현

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import redis.asyncio as redis
import time
import math

app = FastAPI()

# Redis 연결
redis_client = redis.from_url("redis://localhost:6379")

# Lua 스크립트
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = now - last_refill
local refill = math.floor(elapsed * rate)
tokens = math.min(capacity, tokens + refill)

if refill > 0 then
 last_refill = now
end

local allowed = 0
if tokens >= requested then
 tokens = tokens - requested
 allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', key, 3600)

return {allowed, tokens, capacity}
"""

async def check_rate_limit(
 user_id: str,
 capacity: int = 100,
 rate: float = 10,
 requested: int = 1
):
 key = f"rate_limit:{user_id}"
 now = time.time()

 result = await redis_client.eval(
 RATE_LIMIT_SCRIPT,
 1,
 key,
 capacity,
 rate,
 requested,
 now
 )

 allowed = result[0] == 1
 remaining = int(result[1])
 limit = int(result[2])

 return {
 "allowed": allowed,
 "remaining": remaining,
 "limit": limit,
 "retry_after": math.ceil((requested - remaining) / rate) if not allowed else None
 }

# 미들웨어
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
 # IP 주소 또는 사용자 ID
 user_id = request.client.host

 result = await check_rate_limit(user_id, capacity=1000, rate=100)

 response = await call_next(request)

 response.headers["X-RateLimit-Limit"] = str(result["limit"])
 response.headers["X-RateLimit-Remaining"] = str(result["remaining"])

 if not result["allowed"]:
 return JSONResponse(
 status_code=429,
 content={"error": "Too Many Requests"},
 headers={"Retry-After": str(result["retry_after"])}
 )

 return response

@app.get("/api/data")
async def get_data():
 return {"message": "Success"}

엔터프라이즈급 고급 기능

1. 계층별 Rate Limit

사용자 등급에 따라 다른 제한 적용:

const RATE_LIMITS = {
 free: { capacity: 100, rate: 10 }, // 100 burst, 10/sec
 pro: { capacity: 1000, rate: 100 }, // 1000 burst, 100/sec
 enterprise: { capacity: 10000, rate: 1000 } // 10000 burst, 1000/sec
}

async function getUserRateLimit(userId) {
 const user = await db.users.findOne({ id: userId })
 const tier = user.subscription || 'free'
 return RATE_LIMITS[tier]
}

app.use('/api', async (req, res, next) => {
 const userId = req.user.id
 const limits = await getUserRateLimit(userId)

 const result = await tokenBucketRateLimit(
 userId,
 limits.capacity,
 limits.rate,
 1
 )

 if (!result.allowed) {
 // 업그레이드 제안
 return res.status(429).json({
 error: 'Rate limit exceeded',
 message: 'Upgrade to Pro for higher limits',
 upgradeUrl: '/pricing'
 })
 }

 next()
})

2. 동적 Rate Limit (서버 부하 기반)

async function getDynamicRateLimit() {
 const cpuUsage = await os.cpus().reduce((acc, cpu) => {
 const total = Object.values(cpu.times).reduce((a, b) => a + b)
 const idle = cpu.times.idle
 return acc + (1 - idle / total)
 }, 0) / os.cpus().length

 const memUsage = 1 - os.freemem() / os.totalmem()

 // 서버 부하가 높으면 제한 강화
 if (cpuUsage > 0.8 || memUsage > 0.8) {
 return { capacity: 50, rate: 5 } // 엄격
 } else if (cpuUsage > 0.5 || memUsage > 0.5) {
 return { capacity: 100, rate: 10 } // 보통
 } else {
 return { capacity: 200, rate: 20 } // 관대
 }
}

3. 엔드포인트별 다른 제한

const ENDPOINT_LIMITS = {
 '/api/search': { capacity: 10, rate: 1 }, // 비용이 높은 검색
 '/api/user': { capacity: 100, rate: 10 }, // 일반 조회
 '/api/upload': { capacity: 5, rate: 0.5 }, // 파일 업로드
 '/api/stats': { capacity: 1000, rate: 100 } // 가벼운 통계
}

app.use('/api', async (req, res, next) => {
 const endpoint = req.path
 const limits = ENDPOINT_LIMITS[endpoint] || { capacity: 100, rate: 10 }

 const result = await tokenBucketRateLimit(
 `${req.user.id}:${endpoint}`,
 limits.capacity,
 limits.rate,
 1
 )

 if (!result.allowed) {
 return res.status(429).json({
 error: 'Rate limit exceeded for ' + endpoint
 })
 }

 next()
})

4. IP 기반 + 사용자 기반 복합 제한

async function dualRateLimit(req) {
 const ip = req.ip
 const userId = req.user?.id

 // 1. IP 제한 (DDoS 방어)
 const ipLimit = await tokenBucketRateLimit(
 `ip:${ip}`,
 500, // IP당 500 burst
 50 // 50/sec
 )

 if (!ipLimit.allowed) {
 throw new Error('IP rate limit exceeded')
 }

 // 2. 사용자 제한 (API 남용 방어)
 if (userId) {
 const userLimit = await tokenBucketRateLimit(
 `user:${userId}`,
 1000, // 사용자당 1000 burst
 100 // 100/sec
 )

 if (!userLimit.allowed) {
 throw new Error('User rate limit exceeded')
 }
 }

 return { allowed: true }
}

프로덕션 배포 전략

1. Redis Cluster 구성

고가용성을 위한 클러스터:

# docker-compose.yml
version: '3'
services:
 redis-master:
 image: redis:7-alpine
 command: redis-server --appendonly yes
 ports:
 - "6379:6379"
 volumes:
 - redis-master-data:/data

 redis-replica-1:
 image: redis:7-alpine
 command: redis-server --slaveof redis-master 6379 --appendonly yes
 volumes:
 - redis-replica-1-data:/data
 depends_on:
 - redis-master

 redis-replica-2:
 image: redis:7-alpine
 command: redis-server --slaveof redis-master 6379 --appendonly yes
 volumes:
 - redis-replica-2-data:/data
 depends_on:
 - redis-master

 redis-sentinel-1:
 image: redis:7-alpine
 command: >
 redis-sentinel /etc/redis/sentinel.conf
 --sentinel monitor mymaster redis-master 6379 2
 --sentinel down-after-milliseconds mymaster 5000
 --sentinel parallel-syncs mymaster 1
 --sentinel failover-timeout mymaster 10000
 depends_on:
 - redis-master

volumes:
 redis-master-data:
 redis-replica-1-data:
 redis-replica-2-data:

2. 모니터링 대시보드

Prometheus + Grafana:

const prometheus = require('prom-client')

// 메트릭 정의
const rateLimitCounter = new prometheus.Counter({
 name: 'rate_limit_requests_total',
 help: 'Total rate limit checks',
 labelNames: ['user', 'result'] // result: allowed, blocked
})

const rateLimitHistogram = new prometheus.Histogram({
 name: 'rate_limit_duration_seconds',
 help: 'Rate limit check duration',
 buckets: [0.001, 0.005, 0.01, 0.05, 0.1]
})

// 사용
async function tokenBucketRateLimitWithMetrics(userId, capacity, rate, requested) {
 const start = Date.now()

 const result = await tokenBucketRateLimit(userId, capacity, rate, requested)

 const duration = (Date.now() - start) / 1000
 rateLimitHistogram.observe(duration)

 rateLimitCounter.inc({
 user: userId,
 result: result.allowed ? 'allowed' : 'blocked'
 })

 return result
}

// Prometheus 엔드포인트
app.get('/metrics', async (req, res) => {
 res.set('Content-Type', prometheus.register.contentType)
 res.end(await prometheus.register.metrics())
})

Grafana 쿼리:

# Rate limit 차단율
rate(rate_limit_requests_total{result="blocked"}[5m])
/ rate(rate_limit_requests_total[5m]) * 100

# 평균 응답 시간
rate(rate_limit_duration_seconds_sum[5m])
/ rate(rate_limit_duration_seconds_count[5m])

# 사용자별 차단 횟수
topk(10, sum by (user) (rate_limit_requests_total{result="blocked"}))

3. 알림 설정

# prometheus-alerts.yml
groups:
- name: rate_limiting
 interval: 30s
 rules:
 # Rate limit 차단율이 높음
 - alert: HighRateLimitBlockRate
 expr: |
 rate(rate_limit_requests_total{result="blocked"}[5m])
 / rate(rate_limit_requests_total[5m]) > 0.5
 for: 5m
 labels:
 severity: warning
 annotations:
 summary: "High rate limit block rate"
 description: "{{ $value | humanizePercentage }} of requests are being blocked"

 # Redis 연결 실패
 - alert: RedisConnectionFailure
 expr: redis_up == 0
 for: 1m
 labels:
 severity: critical
 annotations:
 summary: "Redis is down"
 description: "Rate limiting may not work properly"

 # Rate limit 응답 시간 증가
 - alert: SlowRateLimitCheck
 expr: |
 rate(rate_limit_duration_seconds_sum[5m])
 / rate(rate_limit_duration_seconds_count[5m]) > 0.1
 for: 5m
 labels:
 severity: warning
 annotations:
 summary: "Rate limit checks are slow"
 description: "Average duration: {{ $value }}s"

트러블슈팅 가이드

문제 1: Redis 메모리 부족

증상:

Error: OOM command not allowed when used memory > 'maxmemory'

해결책:

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru # LRU로 오래된 키 제거

# 또는 휘발성 키만 제거
maxmemory-policy volatile-lru

애플리케이션 레벨 해결:

// TTL을 더 짧게 설정
redis.call('EXPIRE', key, 300) // 5분 (기존 1시간에서 단축)

// 또는 사용자별 다른 TTL
const ttl = isPremiumUser(userId) ? 3600 : 300
redis.call('EXPIRE', key, ttl)

문제 2: Race Condition (Lua 스크립트 미사용 시)

증상:

TTL이 설정되지 않아 키가 계속 쌓임

해결책:

// 잘못된 방법
const current = await redis.incr(key)
if (current === 1) {
 await redis.expire(key, 60) // Race condition!
}

// 올바른 방법 1: Lua 스크립트
const result = await redis.eval(luaScript,...)

// 올바른 방법 2: SETEX 사용
await redis.setex(key, 60, 1)
await redis.incr(key)

문제 3: 분산 환경에서 부정확한 카운팅

증상:

여러 서버에서 다른 Redis 인스턴스를 사용
카운트가 분산되어 제한이 제대로 작동 안함

해결책:

// 잘못된 샤딩
const serverId = process.env.SERVER_ID
const redis = new Redis({ host: `redis-${serverId}` })

// 올바른 샤딩: 일관된 해싱
const Consistent = require('consistent-hashing')
const ring = new Consistent()

ring.add('redis-1')
ring.add('redis-2')
ring.add('redis-3')

function getRedisForUser(userId) {
 const server = ring.get(userId) // 같은 userId는 항상 같은 서버
 return redisClients[server]
}

실전 체크리스트

### 구현 전 체크리스트

□ 알고리즘 선택 (Token Bucket 추천)
□ Redis 설치 및 구성
□ Lua 스크립트 작성
□ Rate limit 수치 결정
 - Capacity (버스트 허용량)
 - Rate (평균 처리율)
 - TTL (키 만료 시간)
□ 사용자 식별 방법 (IP, JWT, API Key)

### 배포 전 체크리스트

□ Redis Cluster 구성 (고가용성)
□ 모니터링 설정 (Prometheus + Grafana)
□ 알림 설정 (AlertManager)
□ 부하 테스트 완료
□ Fail-open 정책 확인 (Redis 장애 시)
□ 에러 메시지 사용자 친화적으로 작성
□ HTTP 헤더 추가
 - X-RateLimit-Limit
 - X-RateLimit-Remaining
 - Retry-After

### 운영 체크리스트

□ 일일 차단율 모니터링
□ Redis 메모리 사용률 체크
□ 응답 시간 추적
□ 사용자 피드백 수집
□ Rate limit 수치 조정
□ 로그 분석 (악의적 사용자 탐지)

결론: 완벽한 Rate Limiting 시스템

프로덕션 환경에서 Rate Limiting은 선택이 아닌 필수입니다. 이 가이드에서 다룬 내용을 요약하면:

핵심 원칙

  1. Token Bucket + Redis - 가장 검증된 조합
  2. Lua 스크립트 - Race Condition 완벽 방지
  3. 계층별 제한 - 비즈니스 모델에 맞춘 유연성
  4. 모니터링 - 실시간 추적 및 알림

비즈니스 가치

Rate Limiting 구현 비용: $1,000 (개발 + 인프라)
연간 절감 비용: $768,000 (API 남용 방지)

ROI: 76,700%

추천 아키텍처

[사용자 요청]

[API Gateway]

[Rate Limit Middleware]

[Redis (Lua Script)]

[Token Bucket 확인]

[허용/거부 결정]

[응답 + 헤더]

마지막 조언

“처음부터 완벽할 필요는 없습니다.”

1주차: 간단한 Fixed Window로 시작 2주차: Token Bucket으로 업그레이드 3주차: Redis Cluster 구성 4주차: 모니터링 및 알림 추가

점진적으로 개선하면서 프로덕션에 안착시키세요.

Rate Limiting은 한 번 구현하면 영원히 당신의 서비스를 지켜줍니다. 이제 악의적 사용자와 DDoS 공격으로부터 당신의 API를 보호하세요!


다음 글 예고: “GraphQL N+1 쿼리 문제 완벽 해결 가이드: DataLoader로 성능 100배 향상”

질문이나 경험 공유는 댓글로! Rate Limiting 구현 과정에서 겪은 어려움이 있으신가요?

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트

# 데이터베이스 Deadlock 완벽 해결 가이드: PostgreSQL & MySQL 프로덕션 디버깅 실전 전략

게시:

블랙 프라이데이 3배 트래픽 상황에서 발생한 데이터베이스 데드락을 실시간으로 해결한 실전 경험을 바탕으로, PostgreSQL과 MySQL의 데드락 탐지, 디버깅, 예방 전략을 완벽하게 정리합니다. log_lock_waits, pg_stat_activity, SHOW ENGINE INNODB STATUS 활용법과 함께 연간 $3.1 trillion 손실을 방지하는 프로덕션 모니터링 설정까지 상세히 다룹니다.

읽기