node:http와 redis를 사용한 Rate limiter
Source: Dev.to

소개
node:http 서버를 그대로 실행할 때, 속도 제한(rate limiting)을 추가하는 것이 필요 이상으로 무겁게 느껴질 때가 많습니다. 대부분의 솔루션은 Express‑style 미들웨어나 애플리케이션 앞에 배치되는 API gateway를 전제로 합니다.
이 접근 방식은 프레임워크 가정이나 마법 같은 라이프사이클 훅 없이, 요청이 계속 진행될 수 있는지를 판단하는 함수만으로 금속에 더 가깝게 유지합니다.
디자인 목표
- Framework‑agnostic:
http.createServer와 직접 작동합니다. - Redis‑backed: 여러 프로세스나 서버에서도 안전합니다.
결과는 특정 생태계를 알 필요 없이 처음부터 끝까지 읽을 수 있는 것입니다.
기본 요청 핸들러
Rate limiters는 서버의 다른 부분에서 사용되는 동일한 RequestHandler 타입을 따릅니다. 핸들러는 요청을 검사하고, 필요에 따라 응답을 작성하며, 요청을 종료하거나 계속 진행하도록 합니다.
// request-handler.ts
import http from "node:http";
export type RequestHandler = (
incomingMessage: http.IncomingMessage,
serverResponse: http.ServerResponse,
) => ReturnType;
Source: …
Rate limiter strategies
Fixed‑window counter
Fixed‑window 전략은 직관적입니다:
- 각 클라이언트마다 Redis 키가 하나씩 할당됩니다.
- 요청이 들어올 때마다 카운터를 증가시킵니다.
- 키는
windowSize초가 지나면 자동으로 만료됩니다.
제한에 도달하면, 윈도우가 리셋될 때까지 추가 요청을 거부합니다.
// redis-rate-limiter.ts
import http from "node:http";
import { createClient, createClientPool } from "redis";
import type { RequestHandler } from "./request-handler";
export function fixedWindowCounter(
client: ReturnType<typeof createClient> | ReturnType<typeof createClientPool>,
config: {
limit: number;
windowSize: number; // seconds
baseKey: string;
getClientIdFromIncomingRequest: (
incomingMessage: http.IncomingMessage,
) => string;
},
): RequestHandler {
return async (incomingMessage, serverResponse) => {
const key = `${config.baseKey}:${config.getClientIdFromIncomingRequest(
incomingMessage,
)}`;
// Current count (0 if the key does not exist or is not a number)
const raw = await client.get(key);
const currentValue = raw ? parseInt(raw, 10) : 0;
const count = Number.isNaN(currentValue) ? 0 : currentValue;
// If the limit has been reached, reject the request
if (count >= config.limit) {
const ttl = await client.ttl(key);
const retryAfter =
ttl > 0
? new Date(Date.now() + ttl * 1000).toUTCString()
: new Date(Date.now() + config.windowSize * 1000).toUTCString();
serverResponse.statusCode = 429;
serverResponse.setHeader("Retry-After", retryAfter);
serverResponse.setHeader("Content-Type", "text/plain");
serverResponse.end("Too Many Requests");
return;
}
// Increment the counter and set the expiration atomically
const transaction = client.multi();
transaction.incr(key);
// NX ensures we only set the expiry the first time the key is created
transaction.expire(key, config.windowSize, "NX");
await transaction.exec();
};
}
When this works well
- 트래픽이 낮거나 중간 정도일 때.
- 간단한 남용 방지 요구사항이 있을 때.
- 내부 혹은 신뢰할 수 있는 클라이언트에 적용할 때.
Known limitations
- 윈도우 경계에서 트래픽이 급증할 수 있습니다(“버스트” 문제).
- 적용이 거친 입맛을 가집니다 – 제한이 윈도우당 한 번만 검사됩니다.
이러한 제약이 허용된다면, Fixed‑window는 단순성 면에서 뛰어납니다.
Sliding‑window counter
Sliding‑window는 약간의 복잡성을 감수하고 보다 부드러운 적용을 제공합니다. 단일 카운터 대신 요청을 더 작은 sub‑window(또는 버킷)들로 나눕니다.
각 요청은 다음을 수행합니다:
- 현재 sub‑window의 카운터를 증가시킵니다.
- 메인 윈도우 안에 포함되는 전체 sub‑window의 카운터를 합산합니다.
- 합산된 총합이 설정된 제한을 초과하면 요청을 거부합니다.
- Redis가 sub‑window 버킷을 자동으로 만료하도록 합니다.
// redis-rate-limiter.ts
import http from "node:http";
import { createClient, createClientPool } from "redis";
import * as Utils from "../utils";
import type { RequestHandler } from "./request-handler";
export function slidingWindowCounter(
client: ReturnType<typeof createClient> | ReturnType<typeof createClientPool>,
config: {
limit: number;
windowSize: number; // seconds (overall window)
subWindowSize: number; // seconds (size of each bucket)
baseKey: string;
getClientIdFromIncomingRequest: (
incomingMessage: http.IncomingMessage,
) => string;
},
): RequestHandler {
return async (incomingMessage, serverResponse) => {
const key = `${config.baseKey}:${config.getClientIdFromIncomingRequest(
incomingMessage,
)}`;
// Retrieve all bucket counters for this client
const rawBuckets = await client.hGetAll(key);
const bucketValues = Object.values(rawBuckets)
.map((v) => parseInt(v, 10))
.map((v) => (Number.isNaN(v) ? 0 : v));
// Total requests in the sliding window
Source: …
const total = Utils.getNumberArraySum(bucketValues);
// If the limit is exceeded, reject the request
if (total >= config.limit) {
const retryAfter = new Date(
Date.now() + config.subWindowSize * 1000,
).toUTCString();
serverResponse.statusCode = 429;
serverResponse.setHeader("Retry-After", retryAfter);
serverResponse.setHeader("Content-Type", "text/plain");
serverResponse.end("Too Many Requests");
return;
}
// Determine the bucket that corresponds to the current time
const now = Date.now();
const bucketTimestamp =
Math.floor(now / (config.subWindowSize * 1000)) *
(config.subWindowSize * 1000);
// Increment the bucket and set its TTL (only the first time it is created)
const transaction = client.multi();
transaction.hIncrBy(key, bucketTimestamp.toString(), 1);
// The bucket should live for the remainder of the main window
transaction.hExpire(
key,
bucketTimestamp.toString(),
config.windowSize,
"NX",
);
await transaction.exec();
};
}
Trade‑offs
| Aspect | Pros | Cons |
|---|---|---|
| Request distribution | 더 부드럽고, 윈도우 경계에서 급격한 스파이크가 없음. | 서브‑윈도우당 하나의 필드가 필요해 Redis 메모리가 약간 늘어남. |
| Accuracy | 실제 슬라이딩 윈도우를 근사함; granularity = subWindowSize. | subWindowSize가 크면 근사가 거칠어짐. |
| Complexity | 여전히 비교적 단순; 기본 Redis 명령만 사용. | 추가적인 관리 작업 필요(해시 필드, TTL 처리). |
| Performance | 단일 해시(HGETALL)를 읽고 두 개의 해시 명령을 원자적으로 기록. | HGETALL 비용은 활성 서브‑윈도우 수에 비례(최대 windowSize / subWindowSize). |
| Burst handling | 전체 레이트를 유지하면서 제한된 버스트 허용. | 매우 공격적인 버스트는 하나의 서브‑윈도우 안에 들어가면 통과될 수 있음. |
Tip: 대부분의 워크로드에서
windowSize / subWindowSize가 약 20–30개의 버킷을 넘지 않도록subWindowSize를 선택하세요. 이렇게 하면HGETALL연산이 저렴하면서도 세밀한 슬라이딩 윈도우를 제공할 수 있습니다.
Choosing the right strategy
| Scenario | Recommended strategy |
|---|---|
| Simple internal APIs | Fixed‑window counter – 코드가 최소이고 이해하기 쉬움. |
| Public‑facing endpoints with bursty traffic | Sliding‑window counter – 부드러운 스로틀링, 사용자 경험 향상. |
| Very high QPS where Redis memory is a concern | Fixed‑window or token‑bucket 구현(예시 없음) – 클라이언트당 하나의 숫자 값만 저장. |
| Need for precise rate limiting (e.g., per‑second limits) | 작은 subWindowSize(예: 1 s)를 사용한 Sliding‑window 또는 실제 토큰‑버킷 알고리즘. |
위 두 구현은 기존 Node.js 서비스에 바로 적용할 수 있습니다; Redis 클라이언트 인스턴스와 시그니처에 맞는 설정 객체만 전달하면 됩니다. throttling을 즐기세요!
Cons
- 더 많은 Redis 작업.
- 약간 더 복잡함.
- 여전히 의도적으로 실용적이며, 이론적이지 않음.
서버에서 Rate Limiter 사용하기
아래는 Node HTTP 서버에 rate limiter를 연결하는 최소 예시입니다.
// server.ts
import http from "node:http";
import { createClient } from "redis";
import { fixedWindowCounter } from "./rate-limiters";
const redis = createClient();
await redis.connect();
const rateLimiter = fixedWindowCounter(redis, {
limit: 100,
windowSize: 60,
baseKey: "rate-limit",
// Extract a client identifier from the incoming request
getClientIdFromIncomingRequest: (req) =>
req.socket.remoteAddress ?? "unknown",
});
const server = http.createServer();
server.on("request", async (req, res) => {
await rateLimiter(req, res);
// If the limiter has already ended the response, stop processing
if (res.writableEnded) {
return;
}
res.statusCode = 200;
res.end("OK");
});
server.listen(3000);
제한기가 요청을 진행하지 않아야 한다고 판단하면 응답을 종료하고, 핸들러의 나머지 부분은 건너뛰게 됩니다.
마무리 생각
This setup isn’t meant to replace dedicated gateways or edge‑rate limiting. It’s a pragmatic, educative solution.
- Everything happens in plain Node.
- Redis does the counting.
- The behavior is easy to reason about by just reading the code.
If that’s the kind of setup you prefer, this approach fits nicely without dragging in a framework.
