
내 서버를 내가 DDOS 칠 뻔했다 (Rate Limiting 완벽 가이드)
Rate Limiter 없이 API를 공개하면 사용자 자신이 DDOS 공격자가 될 수 있다. Token Bucket, Leaky Bucket, Sliding Window 등 핵심 알고리즘을 비교하고, Redis와 Lua Script를 사용해 분산 환경에서도 완벽하게 동작하는 Rate Limiter를 구현하는 방법을 정리해본다.

Rate Limiter 없이 API를 공개하면 사용자 자신이 DDOS 공격자가 될 수 있다. Token Bucket, Leaky Bucket, Sliding Window 등 핵심 알고리즘을 비교하고, Redis와 Lua Script를 사용해 분산 환경에서도 완벽하게 동작하는 Rate Limiter를 구현하는 방법을 정리해본다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

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

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

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

Rate Limiting을 처음 공부하게 된 건 단순한 상상에서 시작했다.
"선착순 100명에게 치킨 쿠폰 지급!" 같은 이벤트를 하면 어떤 일이 벌어질까?
실제 사례는 흔하다. 대규모 마케팅 메시지가 발송되면, 사용자들은 동시에 새로고침(F5)을 누르기 시작한다. 특정 IP 몇 개가 1초에 API를 500번씩 호출한다. 북한 해커일까? 경쟁사의 공격일까?
아니다. 그냥 "쿠폰 받으려고 새로고침을 광클하는 순수한 사용자"들이다. 서버에 문지기(Rate Limiter)가 없으면, 누구든 요청을 보내는 족족 다 받아주다가 과로사한다. 모니터링 대시보드는 빨간불로 도배되고, CPU는 100%를 찍고, 데이터베이스 커넥션 풀은 고갈된다. 사용자가 곧 DDOS 공격자가 되는 셈이다.
API 서버는 공공재가 아닙니다. 한정된 리소스(CPU, 메모리, DB 커넥션)를 나눠 써야 합니다. Rate Limiter는 선택이 아니라 시스템의 생명보험입니다.
Rate Limiting을 구현하는 방법은 여러 가지가 있습니다. 상황에 맞는 알고리즘을 골라야 합니다.
가장 널리 쓰이고 이해하기 쉽습니다. EC2의 CPU 크레딧도 이 방식입니다.
1초마다 토큰이 10개씩 채워집니다.100개입니다 (넘치면 버려짐).429 Too Many Requests)합니다.12:00:59에 100개 요청.12:01:00에 카운터 초기화 후 다시 100개 요청.Fixed Window의 단점을 해결하기 위해 윈도우를 시간 흐름에 따라 이동시킵니다.
분산 환경(서버가 여러 대)에서는 메모리(In-Memory)에서 카운트하면 안 됩니다. 서버 A의 카운트와 서버 B의 카운트가 공유되지 않기 때문입니다. 이때 Redis가 정답입니다. 하지만 Redis 명령어를 여러 번 쓰면 Race Condition이 발생할 수 있습니다.
해결책은 Lua Script입니다. Redis 안에서 스크립트가 실행되는 동안은 다른 명령어가 끼어들지 못합니다(Atomic).
-- redis-rate-limit.lua
local key = KEYS[1] -- rate_limit:user:123
local limit = tonumber(ARGV[1]) -- 100회
local window = tonumber(ARGV[2]) -- 60초
local current = redis.call('get', key)
if current and tonumber(current) >= limit then
return 0 -- 차단
else
redis.call('incr', key)
if not current then
redis.call('expire', key, window) -- 처음일 때 만료 시간 설정
end
return 1 -- 허용
end
Node.js (NestJS/Express)에서 이렇게 씁니다:
const isAllowed = await redis.eval(
luaScript,
1,
`rate_limit:${userId}`,
100, // limit
60 // window seconds
);
if (!isAllowed) {
throw new HttpException('Too Many Requests', 429);
}
무작정 429 에러만 던지면 사용자는 "뭐야? 서버 고장 났나?" 하고 더 광클을 합니다.
친절하게 알려줘야 합니다.
응답 헤더에 "이만큼 기다렸다가 다시 오세요"라고 알려줍니다.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
(30초 뒤에 다시 시도해라)
프론트엔드나 모바일 앱에서는 429를 받으면 바로 재시도하면 안 됩니다.
1초 후, 2초 후, 4초 후, 8초 후... 이렇게 대기 시간을 2배씩 늘려가며 재시도해야 합니다.
이것이 네트워크 예절(Etiquette)입니다.
REST API는 "요청 수"로 제한하면 되지만, GraphQL은 다릅니다. 단 한 번의 요청으로 100만 개의 데이터를 가져올 수 있기 때문입니다. ("Nested Query 공격")
그래서 GraphQL에서는 "복잡도(Complexity) 기반" 제한을 걸어야 합니다.
요청이 들어오면 쿼리의 총점을 계산하고, 그 점수만큼 토큰을 차감하는 방식입니다. 이를 통해 "무거운 쿼리"를 날리는 사용자를 효과적으로 제어할 수 있습니다.
Rate Limiting을 적용하면, 이벤트 때 서버가 죽지 않는다. 광클하는 사용자에게는 "잠시 후 다시 시도해주세요" 라는 메시지를 보내고, 일반 사용자들은 쾌적하게 서비스를 이용한다.
API를 만들고 있다면 기억하자. 사용자를 믿지 마라. 그들은 (악의가 있든 없든) 서버를 부수러 온다. Rate Limiter는 선택이 아니라 필수다.
Rate Limiting clicked for me when I thought through a simple scenario.
Imagine sending a push notification: "First 100 people get a free Chicken Coupon!"
What happens next is predictable. Users immediately start hammering the Refresh button. A few IP addresses end up hitting the API 500 times per second. North Korean hackers? Corporate espionage?
No. Just "users spamming Refresh to get the coupon."
A server without a Bouncer (Rate Limiter) politely tries to process every single request until it collapses. Dashboards turn red, CPU hits 100%, DB connection pools exhaust. In effect, well-meaning users become a Distributed Denial of Service (DDOS) attack. This scenario is common enough that Rate Limiting isn't optional — it's infrastructure.
API servers are shared resources. If one person hogs the CPU/DB, other 99 people can't connect. Rate Limiting is System Life Insurance.
Most common and easy to understand. AWS EBS Burst Balance uses this.
12:00:59.12:01:00.12:01:01.Solves the boundary issue by calculating a weighted average of the previous window and current window. This is the industry standard for production.
In a distributed system (multiple servers), you cannot store the counter in local memory variables.
You need a shared store like Redis.
However, typical Redis operations (GET then INCR) suffer from Race Conditions.
The solution is Lua Script. Scripts executed inside Redis are Atomic (no other commands can interrupt them).
-- redis-rate-limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('get', key)
if current and tonumber(current) >= limit then
return 0 -- Block
else
redis.call('incr', key)
if not current then
redis.call('expire', key, window) -- Set TTL if first time
end
return 1 -- Allow
end
Using this in Node.js ensures perfectly accurate counting even with high concurrency.
Don't just throw an error. Tell the user when to come back.
Standard HTTP header that tells the client how many seconds to wait.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
If you are building the frontend or mobile app, NEVER retry immediately on a 429. Use Exponential Backoff:
After applying Rate Limiting, my server survived the next marketing event. Spammers received a polite "429 Too Many Requests", while normal users redeemed their coupons smoothly.
If you are building an API, remember: Never trust the client. Whether intentional or accidental, they have the power to crush your infrastructure. Rate Limiting is not a feature; it is a necessity.
While "Sliding Window Counter" is efficient, it's an approximation. For 100% accuracy, we use Sliding Window Log.
In a huge system, a single Redis instance might become the bottleneck. You can use a Redis Cluster or share the load using consistent hashing based on User ID. However, ensure your Lua scripts run on the correct shard where the User's key resides. Also, consider Local Caching (in-memory) for extremely hot keys (like a global DDOS attack IP), syncing with Redis asynchronously to save network calls.
GraphQL is tricky because one HTTP request can query the entire database. "100 requests per minute" doesn't work if one request has a complexity cost of 10,000. Solution: Cost Analysis Rate Limiting.
User = 1 point, Posts = 5 points).Rate Limiting isn't just for security; it's a business model.
Implement this by checking the User's Role/Plan in your middleware before checking the Redis counter.
styles.css or logo.png. Only limit API endpoints.