문제: 왜 지금도 Rate Limiting이 자주 실패할까

대부분의 서비스는 Rate Limiting을 “붙이긴” 합니다. 그런데 실제 장애 상황에서는 다음 이유로 금방 무력화됩니다.

  • 인메모리 카운터만 사용해서 멀티 인스턴스에서 제한이 일관되지 않음
  • 프록시/CDN 뒤에서 클라이언트 IP 추출이 잘못되어 정상 사용자가 차단됨
  • 429 응답은 보내지만 Retry-After 같은 복구 힌트를 주지 않음

핵심은 단순 차단이 아니라 공정하게 제한하고, 정상 사용자가 복구 가능한 UX를 주는 것입니다.

설계 원칙: “정책 분리 + 공유 저장소 + 관측성”

정책은 엔드포인트별로 분리한다

로그인/회원가입/비밀번호 재설정은 공격 표면이 다릅니다. 같은 제한값을 전 구간에 적용하면 오탐이 늘고 방어력은 떨어집니다.

예시 정책:

  • /auth/login: IP 기준 분당 10회
  • /auth/password-reset: 계정 + IP 기준 시간당 5회
  • /api/search: 사용자 토큰 기준 초당 20회(버스트 허용)

카운터는 Redis 같은 공유 저장소를 쓴다

오토스케일 환경에서는 인메모리 제한이 사실상 무용지물입니다. 인스턴스가 늘어날수록 공격자가 더 쉽게 우회합니다.

관측 지표를 반드시 남긴다

아래 지표를 대시보드에 분리해 두면 운영 판단이 빨라집니다.

  • 429 응답률(엔드포인트별)
  • 상위 차단 키(IP/user/apiKey)
  • 차단 후 5분 내 정상 재시도 성공률

Express + Redis 구현 예시

1) 프록시 신뢰 설정 먼저 고정

import express from 'express';

const app = express();
app.set('trust proxy', 1); // Nginx/ALB 뒤라면 환경에 맞게 조정

프록시 홉 수를 잘못 설정하면 실제 IP 대신 내부 IP를 읽어 차단 정확도가 무너집니다.

2) 로그인 엔드포인트 고정 윈도우 제한

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    code: 'RATE_LIMITED',
    message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.'
  },
  store: new RedisStore({
    sendCommand: (...args) => redis.sendCommand(args)
  })
});

app.post('/auth/login', loginLimiter, async (req, res) => {
  // 로그인 로직
  res.status(200).json({ ok: true });
});

3) 429 응답에 복구 힌트 넣기

app.use((err, req, res, next) => {
  if (err?.status !== 429) return next(err);

  const retryAfter = Number(res.getHeader('Retry-After') || 60);
  return res.status(429).json({
    code: 'TOO_MANY_REQUESTS',
    retry_after_seconds: retryAfter,
    message: '요청 한도를 초과했습니다. 잠시 후 재시도해 주세요.'
  });
});

사용자는 기다려야 할 시간을 알 수 있고, 클라이언트는 백오프 로직을 안정적으로 구현할 수 있습니다.

알고리즘 선택: 고정 윈도우 vs 슬라이딩 윈도우

고정 윈도우(Fixed Window)

  • 장점: 구현 단순, 비용 낮음
  • 단점: 경계 시점에서 버스트 허용량이 커질 수 있음

관리자 API처럼 예측 가능한 트래픽에는 충분히 실용적입니다.

슬라이딩 윈도우(Sliding Window)

  • 장점: 공정한 제한, 경계 버스트 완화
  • 단점: 구현 복잡도와 저장 비용 증가

로그인/인증처럼 공격 표면이 큰 구간에는 슬라이딩 윈도우가 더 안전합니다.

운영 체크리스트

릴리스 전

  • trust proxy가 실제 네트워크 토폴로지와 일치하는가
  • Redis 장애 시 fail-open/fail-closed 정책을 정했는가
  • 429 응답 본문과 Retry-After를 API 문서에 명시했는가

릴리스 후

  • 정상 사용자 차단률이 급증하지 않는가
  • 특정 ASN/국가/UA에서 차단이 집중되는가
  • 차단 정책 변경 후 인증 성공률이 회복되는가

요약

Rate Limiting은 보안 기능이면서 동시에 사용자 경험 기능입니다. 정확한 키 추출, 공유 저장소, 복구 가능한 429 응답을 함께 설계해야 실제 운영에서 효과가 납니다.

처음에는 단순한 고정 윈도우로 시작하되, 인증·결제 같은 민감 구간은 슬라이딩 윈도우와 더 강한 정책으로 분리해 운영하세요.

내부 링크