본문 바로가기
NestJS

[NestJS] Redis 적용하기 - Rate Limiting

by Programmer.Junny 2025. 4. 21.

1. Rate Limiting 이란?

모든 사용자들이 API에 제한없이 계속해서 호출할 수 있다면 CPU, 메모리, DB 등의 리소스가 부족해진다.

결국 과도한 서버비용이라는 최악의 결말을 보게 된다.

이러한 DDOS 등을 막기 위해 Rate Limiting을 구현해야 한다.

API Gateway나 NGINX와 같은 웹 서버에서 처리하면 되는 것 아닌지?

물론 API Gateway나 NGINX 같은 웹 서버에서 처리를 제한하면 서버에 부담가지 않게 할 수 있다.

다만 두 가지의 단점이 있는데,

1. 유연성 부족

- 사용자별(user ID) 한도, 경로별 다른 정책, 클레임 기반 차등 제한 등 세밀한 설정이 어렵다.

2. 정책 변경 시 배포 필요

- NGINX 설정 파일 수정 후 리로드(reload)해야 반영

이러한 단점으로 인해 애플리케이션 Rate Limiting 과는 상호보완적인 부분이 있다.

2. IORedisToken 설정하기

import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import type { Redis as RedisType } from 'ioredis';
import Keyv from 'keyv';
import KeyvRedis from 'keyv-redis';
import { RedisService } from './redis.service';
import { KEYV_TOKEN } from './redis.constants';
import { IORedisToken } from './redis.constants';

@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    // 1) ioredis 클라이언트 한 번만 생성
    {
      provide: IORedisToken,
      useFactory: (cs: ConfigService): RedisType => {
        return new Redis({
          host: cs.get<string>('app.redis.host'),
          port: cs.get<number>('app.redis.port'),
          // password, db 등 필요시 추가
        });
      },
      inject: [ConfigService],
    },
    // 2) Keyv 인스턴스는 위 클라이언트를 재활용
    {
      provide: KEYV_TOKEN,
      useFactory: (client: Redis) => {
        return new Keyv({
          store: new KeyvRedis({ client }),
          ttl: 60_000,
        });
      },
      inject: [IORedisToken],
    },
    // 3) 기존 RedisService (KEYV_TOKEN 주입)
    RedisService,
  ],
  exports: [RedisService, KEYV_TOKEN, IORedisToken],
})
export class RedisModule {}

redis.module.ts 에서 IORedisToken으로 Redis 세팅을 설정하고, Keyv 인스턴스는 해당 Redis 설정을 재활용하도록 변경한다.

export const KEYV_TOKEN = 'KEYV_CLIENT';
export const IORedisToken = 'IORedisClient';

redis.constants.ts 는 위와 같이 IORedisToken을 추가한다.

3. RateLimiterGuard 구현하기

// src/guards/rate-limiter.guard.ts
import {
    Injectable,
    CanActivate,
    ExecutionContext,
    HttpException,
    HttpStatus,
    Inject,
  } from '@nestjs/common';
  import { Reflector } from '@nestjs/core';
  import type { Redis } from 'ioredis';
  import {
    RATE_LIMITER_KEY,
    RateLimitOptions,
  } from '../decorator/rate-limiter.decorator';
  import { IORedisToken } from 'src/redis/redis.constants';
  
  @Injectable()
  export class RateLimiterGuard implements CanActivate {
    private readonly keyPrefix = 'token_bucket:';
  
    private readonly luaScript = `
      local key = KEYS[1]
      local capacity = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local now = tonumber(ARGV[3])
      local requested = tonumber(ARGV[4])
  
      -- 기존 상태 조회
      local bucket = redis.call("HMGET", key, "tokens", "last_refill")
      local tokens = bucket[1]
      local last_refill = bucket[2]
  
      if not tokens or tokens == false then
        tokens = capacity
        last_refill = now
      else
        tokens = tonumber(tokens)
        last_refill = tonumber(last_refill)
      end
  
      -- 토큰 보충
      local delta = math.max(0, now - last_refill)
      local tokensToAdd = delta * refillRate
      tokens = math.min(capacity, tokens + tokensToAdd)
  
      -- TTL 계산: capacity가 모두 리필되는 데 걸리는 초
      local ttl = math.ceil(capacity / refillRate)
  
      if tokens < requested then
        -- 토큰 부족: 상태만 업데이트하고 거부
        redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
        redis.call("EXPIRE", key, ttl)
        return 0
      else
        -- 토큰 소모: 상태 업데이트 후 허용
        tokens = tokens - requested
        redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
        redis.call("EXPIRE", key, ttl)
        return 1
      end
    `;
  
    constructor(
      private readonly reflector: Reflector,
      @Inject(IORedisToken) 
      private readonly redis: Redis,
    ) {}
  
    async canActivate(context: ExecutionContext): Promise<boolean> {
      const req = context.switchToHttp().getRequest();
  
      // 1) 키 전략: 인증된 사용자면 user:{id}, 아니면 ip:{ip}
      const identifier = req.user?.id
        ? `user:${req.user.id}`
        : `ip:${req.ip}`;
  
      // 2) 동적 경로 처리: route.path 우선, 없으면 쿼리 제거한 URL
      const routePath = req.route?.path ?? req.url.split('?')[0];
  
      // 최종 Redis 키
      const key = `${this.keyPrefix}${identifier}:${routePath}`;
  
      // 3) 데코레이터 옵션 가져오기
      const options: RateLimitOptions =
        this.reflector.get(RATE_LIMITER_KEY, context.getHandler()) || {};
  
      // 4) 기본 한도 (예: 인증/비인증 구분)
      // Guard에 옵션값이 없고, 로그인이 된 경우 요청 제한 횟수 100번, 미로그인 시 10번
      const baseCapacity = req.user ? 100 : 10;
      // Guard 옵션이 없고, 로그인이 된 경우 초당 20개 토큰이 채워짐, 미로그인 시 초당 1개
      const baseRefill = req.user ? 20 : 1;
  
      // 5) 데코레이터로 오버라이드 가능
      const capacity = options.capacity ?? baseCapacity;
      const refillRate = options.refillRate ?? baseRefill;
  
      const now = Math.floor(Date.now() / 1000);
      const requested = 1;
  
      try {
        const result = await this.redis.eval(
          this.luaScript,
          1,
          key,
          capacity,
          refillRate,
          now,
          requested,
        );
  
        if (result === 1) return true;
  
        throw new HttpException(
          `Too many requests to ${routePath}`,
          HttpStatus.TOO_MANY_REQUESTS,
        );
      } catch (err) {
        if (err instanceof HttpException) throw err;
        throw new HttpException(
          'Rate Limiter Internal Error',
          HttpStatus.INTERNAL_SERVER_ERROR,
        );
      }
    }
  }

Lua script 로 처리한 부분은 redis.eval에 의해 명령들이 원자적으로 처리할 수 있기 때문이다.

기본 값으로는 로그인 사용자는 초당 20개씩 토큰이 채워지고, 요청 제한 횟수가 100번으로 세팅했으며,

미로그인 사용자는 초당 1개씩 토큰이 채워지고, 요청 제한 횟수가 10번이다.

참고로 redis는 추가적으로 세팅하지 않고 IORedisToken으로 설정한 Redis를 재사용한다.

4. RateLimiter 데코레이터 구현

import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { RateLimiterGuard } from '../guards/rate-limiter.guard';

export interface RateLimitOptions {
  capacity?: number;   // 최대 토큰 개수
  refillRate?: number; // 초당 채워지는 토큰 개수
}

export const RATE_LIMITER_KEY = 'rate_limiter_options';

export const RateLimiter = (options: RateLimitOptions = {}) =>
  applyDecorators(
    SetMetadata(RATE_LIMITER_KEY, options),
    UseGuards(RateLimiterGuard),
  );

추가적으로 데코레이터를 구현하여, capacity와 refillRate 값을 오버라이드하여 세팅할 수 있도록 구현하였다.

5. 라우터에 적용하기

//  모든 posts를 가져온다
  @Get()
  @ApiOperation({ 
    summary: '모든 게시글 가져오기', 
    description: '모든 게시글을 Paginate 하게 가져옵니다.' 
  })
  @IsPublic(IsPublicEnum.IS_PUBLIC)
  @RateLimiter() // 비로그인 사용자 1초당 1토큰 회복, 최대 제한 횟수 10번
  // @UseInterceptors(LogInterceptor)
  // @UseFilters(HttpExceptionFilter)
  getPosts(
    @Query() query: PaginatePostDto,
  ) { 
    return this.postsService.paginatePosts(query);
  }

모든 Post들을 읽어오는 API에 위와 같이 데코레이터를 적용할 수 있다.

@RateLimiter( {capacity: 100, refillRate: 10})

추가적으로 사용자가 원하는대로 값을 오버라이드 할 수 있어 유연하다.

6. 테스트 결과

현재는 미로그인 사용자는 초당 1개씩 토큰이 채워지고, 10번 제한이므로 대략 3초에 걸쳐서 14번정도 클릭하면 위와 같은 에러를 확인할 수 있다.

참고한 블로그 링크

https://velog.io/@saewoohan/API-%EC%9A%94%EC%B2%AD-%EA%B3%BC%EB%B6%80%ED%95%98-%EB%B0%A9%EC%A7%80-%EC%B2%98%EB%A6%AC%EC%9C%A8-%EC%A0%9C%ED%95%9C%EA%B8%B0-%EC%86%8C%EA%B0%9C%EC%99%80-%EC%A0%81%EC%9A%A9%EA%B8%B0

 

서버야, 너무 숨 막히지 않니? (처리율 제한기)

API 요청 과부하 방지! 처리율 제한기 소개와 적용기

velog.io

 

최근댓글

최근글

skin by © 2024 ttuttak