node:http와 redis를 사용한 Rate limiter

발행: (2025년 12월 18일 오전 08:24 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

“node:http와 Redis를 사용한 Rate limiter” 커버 이미지

Daniel Madrid

소개

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(또는 버킷)들로 나눕니다.

각 요청은 다음을 수행합니다:

  1. 현재 sub‑window의 카운터를 증가시킵니다.
  2. 메인 윈도우 안에 포함되는 전체 sub‑window의 카운터를 합산합니다.
  3. 합산된 총합이 설정된 제한을 초과하면 요청을 거부합니다.
  4. 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

AspectProsCons
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

ScenarioRecommended strategy
Simple internal APIsFixed‑window counter – 코드가 최소이고 이해하기 쉬움.
Public‑facing endpoints with bursty trafficSliding‑window counter – 부드러운 스로틀링, 사용자 경험 향상.
Very high QPS where Redis memory is a concernFixed‑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.

Back to Blog

관련 글

더 보기 »

celery-plus 🥬 — Node.js용 현대적인 Celery

왜 확인해 보세요? - 🚀 기존 Python Celery 워커와 함께 작동합니다 - 📘 TypeScript로 작성되었으며 전체 타입을 제공합니다 - 🔄 RabbitMQ AMQP와 Redis를 지원합니다 - ⚡ Async/a...