NestJS Rate Limiting이 필요한 이유
API에 속도 제한이 없으면 악의적 요청, 크롤러, 버그가 있는 클라이언트가 서버를 압도할 수 있다. @nestjs/throttler는 NestJS 공식 Rate Limiting 모듈로, 데코레이터 기반의 선언적 속도 제한을 제공한다. 메모리, Redis, 커스텀 스토리지를 지원하며, Guard 기반이므로 기존 인증·인가 파이프라인과 자연스럽게 통합된다.
이 글에서는 Throttler 기본 설정, 엔드포인트별 커스텀 제한, Redis 분산 저장소, 사용자별 동적 제한, 그리고 실무 운영 패턴까지 다룬다.
기본 설정: 글로벌 Rate Limit
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [
ThrottlerModule.forRoot([
{
name: 'short',
ttl: 1000, // 1초
limit: 3, // 초당 3회
},
{
name: 'medium',
ttl: 10000, // 10초
limit: 20, // 10초당 20회
},
{
name: 'long',
ttl: 60000, // 1분
limit: 100, // 분당 100회
},
]),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard, // 전역 Guard로 등록
},
],
})
export class AppModule {}
다중 제한(Multiple Throttlers)을 설정하면 모든 조건을 동시에 충족해야 한다. 초당 3회 AND 분당 100회를 모두 만족해야 요청이 통과한다. 이렇게 하면 순간 폭발(burst)과 지속적 남용을 동시에 방어할 수 있다.
| 제한 이름 | TTL | Limit | 방어 대상 |
|---|---|---|---|
| short | 1초 | 3회 | 순간 폭발 요청 |
| medium | 10초 | 20회 | 빠른 반복 요청 |
| long | 60초 | 100회 | 지속적 남용 |
엔드포인트별 커스텀 제한
로그인, 결제 등 민감한 엔드포인트는 더 엄격한 제한이 필요하고, 공개 API는 느슨한 제한이 적합하다.
import { Throttle, SkipThrottle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
// 로그인: 1분에 5회로 제한 (브루트포스 방어)
@Post('login')
@Throttle({ short: { ttl: 60000, limit: 5 } })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
// 비밀번호 재설정: 1시간에 3회
@Post('reset-password')
@Throttle({ short: { ttl: 3600000, limit: 3 } })
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto);
}
}
@Controller('health')
export class HealthController {
// 헬스체크는 Rate Limiting 제외
@Get()
@SkipThrottle()
check() {
return { status: 'ok' };
}
}
// 컨트롤러 전체에 SkipThrottle 적용
@SkipThrottle()
@Controller('internal')
export class InternalController {
// 내부 API는 모두 제외
@Get('metrics')
metrics() { ... }
}
추적 키 커스터마이징: IP vs 사용자
기본적으로 Throttler는 IP 주소를 기준으로 요청을 추적한다. 하지만 프록시 뒤에서는 모든 요청이 같은 IP로 보이므로, 인증된 사용자 ID나 API 키를 기준으로 변경해야 한다.
import { ThrottlerGuard, ThrottlerRequest } from '@nestjs/throttler';
import { Injectable, ExecutionContext } from '@nestjs/common';
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
// 추적 키 커스터마이징
protected async getTracker(req: ThrottlerRequest): Promise<string> {
// 인증된 사용자가 있으면 userId, 없으면 IP
const user = req['user'];
if (user?.id) {
return `user-${user.id}`;
}
// X-Forwarded-For에서 실제 클라이언트 IP 추출
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0].trim();
}
return req.ip;
}
// 특정 요청 제외 (내부 서비스 간 통신)
protected async shouldSkip(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const internalKey = req.headers['x-internal-key'];
if (internalKey === process.env.INTERNAL_API_KEY) {
return true;
}
return false;
}
}
// app.module.ts에서 교체
@Module({
providers: [
{
provide: APP_GUARD,
useClass: CustomThrottlerGuard,
},
],
})
Redis 분산 저장소: 다중 인스턴스 지원
기본 메모리 저장소는 단일 인스턴스에서만 동작한다. 서버가 여러 대로 스케일아웃되면 인스턴스별로 별도 카운터가 동작하여 제한이 N배로 느슨해진다. Redis 저장소로 카운터를 공유해야 한다.
// 설치
// npm install @nestjs/throttler-storage-redis ioredis
import { ThrottlerStorageRedisService } from '@nestjs/throttler-storage-redis';
import Redis from 'ioredis';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 1000, limit: 3 },
{ name: 'long', ttl: 60000, limit: 100 },
],
storage: new ThrottlerStorageRedisService(
new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
password: process.env.REDIS_PASSWORD,
db: 1, // 메인 캐시와 DB 분리
keyPrefix: 'throttle:',
enableReadyCheck: true,
maxRetriesPerRequest: 3,
}),
),
}),
],
})
Redis에 저장되는 키 구조:
# 키 패턴
throttle:{tracker}-{throttlerName}-{ttl}
# 예시
throttle:user-42-short-1000 → "2" (TTL: 1초)
throttle:user-42-long-60000 → "47" (TTL: 60초)
throttle:192.168.1.100-short-1000 → "1" (TTL: 1초)
사용자 등급별 동적 제한
프리미엄 사용자에게 더 높은 Rate Limit을 제공하는 것은 SaaS에서 흔한 패턴이다.
@Injectable()
export class TieredThrottlerGuard extends ThrottlerGuard {
constructor(
private readonly userService: UserService,
@Inject(THROTTLER_OPTIONS) options: ThrottlerModuleOptions,
storageService: ThrottlerStorageService,
reflector: Reflector,
) {
super(options, storageService, reflector);
}
protected async getLimit(
context: ExecutionContext,
throttlerName: string,
): Promise<number> {
const req = context.switchToHttp().getRequest();
const user = req.user;
if (!user) return this.getDefaultLimit(throttlerName);
// 사용자 등급별 제한
const tierLimits: Record<string, Record<string, number>> = {
FREE: { short: 3, long: 60 },
PRO: { short: 10, long: 300 },
ENTERPRISE: { short: 50, long: 1000 },
};
const tier = user.tier || 'FREE';
return tierLimits[tier]?.[throttlerName]
?? this.getDefaultLimit(throttlerName);
}
private getDefaultLimit(name: string): number {
return name === 'short' ? 3 : 100;
}
}
// 응답 헤더에 제한 정보 포함 (클라이언트 친화적)
@Injectable()
export class RateLimitHeaderInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
tap(() => {
const res = context.switchToHttp().getResponse();
// Throttler가 자동으로 설정하는 헤더:
// X-RateLimit-Limit: 100
// X-RateLimit-Remaining: 97
// X-RateLimit-Reset: 1709000060
// Retry-After: 30 (429 응답 시)
}),
);
}
}
429 응답 커스터마이징
// Exception Filter로 429 응답 포맷 통일
import { ThrottlerException } from '@nestjs/throttler';
@Catch(ThrottlerException)
export class ThrottlerExceptionFilter implements ExceptionFilter {
catch(exception: ThrottlerException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
response.status(429).json({
statusCode: 429,
error: 'Too Many Requests',
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
retryAfter: response.getHeader('Retry-After'),
path: request.url,
timestamp: new Date().toISOString(),
});
}
}
WebSocket과 GraphQL Rate Limiting
// WebSocket Gateway에서 Throttler 사용
@Injectable()
export class WsThrottlerGuard extends ThrottlerGuard {
async handleRequest(
requestProps: ThrottlerRequest,
): Promise<boolean> {
// WebSocket context에서 클라이언트 식별
const client = requestProps.context.switchToWs().getClient();
const tracker = client.handshake?.auth?.userId
|| client.handshake?.address;
// ... 나머지 로직
return super.handleRequest({ ...requestProps, tracker });
}
}
// GraphQL에서 사용 (HTTP 컨텍스트 추출)
@Injectable()
export class GqlThrottlerGuard extends ThrottlerGuard {
getRequestResponse(context: ExecutionContext) {
const gqlCtx = GqlExecutionContext.create(context);
const ctx = gqlCtx.getContext();
return { req: ctx.req, res: ctx.res };
}
}
Sliding Window vs Fixed Window
| 알고리즘 | 동작 | 장점 | 단점 |
|---|---|---|---|
| Fixed Window | 고정 시간 구간별 카운트 | 구현 단순, 메모리 효율 | 경계 시점에 2배 burst 가능 |
| Sliding Window | 현재 시점 기준 과거 N초 카운트 | 균일한 제한 | 메모리/연산 비용 높음 |
| Token Bucket | 일정 속도로 토큰 충전, 요청 시 소비 | burst 허용 + 평균 제한 | 구현 복잡 |
@nestjs/throttler v5는 기본적으로 Fixed Window 방식을 사용한다. 더 정밀한 제어가 필요하면 커스텀 StorageService를 구현하여 Sliding Window나 Token Bucket을 적용할 수 있다.
운영 체크리스트
- 프록시 뒤 IP: Nginx/ALB 뒤에서는
X-Forwarded-For헤더에서 실제 IP를 추출해야 한다. 그렇지 않으면 모든 요청이 프록시 IP로 추적된다 - Redis 장애: Redis 연결 실패 시 Rate Limiting이 동작하지 않을 수 있다. 서킷브레이커 패턴으로 Redis 장애 시 메모리 폴백을 구현한다
- 응답 헤더:
X-RateLimit-*헤더를 반환하여 클라이언트가 남은 한도를 확인할 수 있게 한다 - 모니터링: Micrometer나 Prometheus 커스텀 메트릭으로 429 응답 비율을 추적한다. 급증하면 DDoS 또는 설정 오류를 의심한다
- 화이트리스트: 내부 서비스, 헬스체크, 모니터링 엔드포인트는
@SkipThrottle()로 제외한다 - 문서화: API 문서에 각 엔드포인트의 Rate Limit을 명시한다. 클라이언트 개발자가 재시도 로직을 올바르게 구현할 수 있다
NestJS Throttler는 Guard 기반의 선언적 Rate Limiting으로, 최소한의 코드로 API를 보호한다. 핵심은 다중 제한으로 burst와 지속적 남용을 동시에 방어하고, Redis 저장소로 다중 인스턴스에서 일관된 제한을 적용하는 것이다. 사용자 등급별 동적 제한까지 추가하면 SaaS 수준의 API Rate Limiting을 완성할 수 있다.