• Home
  • About
    • lahuman photo

      lahuman

      열심히 사는 아저씨

    • Learn More
    • Facebook
    • LinkedIn
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

nestjs에서 특정 시간동안 게시글 / 댓글 / 대댓글 등록 제한 처리

16 Sep 2022

Reading time ~3 minutes

nestjs에서 특정 시간동안 게시글 / 댓글 / 대댓글 등록 제한 처리

24시간을 기준으로 게시글, 댓글, 대댓글에 대한 등록 제한처리 요청을 어떻게 처리해야 할지 고민을 했습니다. nestjs의 모듈 중에 Rate Limiting를 보게 되었습니다. 해당 모듈의 기능은 brunte-force 공격에서 보호하기 위한 기능이지만, 이를 수정하여 제가 원하는 동작을 하게 처리 하였습니다.

원하는 동작

sequenceDiagram; autonumber; actor User; loop 24시간 20개 제한; User->>+ community: 게시글 등록; community -->>- User: 성공; end; loop 24시간 50개 제한; User->>+ comment: 댓글 등록; comment -->>- User: 성공; end; loop 24시간 500개 제한; User->>+ reply: 대댓글 등록; reply -->>- User: 성공; end;

필요한 라이브러리 설치

# throttler 설치 
$ npm i --save @nestjs/throttler ioredis

scalout으로 서버가 여러대일 경우에도 제한이 동일하게 하기 위해서 redis에 throttler정보를 적재하도록 하였습니다.

throttler-storage-redis.service.ts

throttler의 storage를 구현해서 redis에 값을 저장하도록 합니다. 이 소스는 nestjs-throttler-storage-redis를 참조해서 생성하였습니다.

import { InjectRedis } from '@liaoliaots/nestjs-redis';
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class ThrottlerStorageRedisService {
    scanCount: number;

    constructor(@InjectRedis() private readonly redis: Redis,) {
        this.scanCount = 1000;
    }

    // key를 기반으로 
    async getRecord(key: string): Promise<number[]> {
        const ttls = (
            await this.redis.scan(
                0,
                'MATCH',
                `${this.redis?.options?.keyPrefix}${key}:*`,
                'COUNT',
                this.scanCount,
            )
        ).pop();
        return (ttls as string[]).map((k) => parseInt(k.split(':').pop())).sort();
    }

    async addRecord(key: string, ttl: number): Promise<void> {
        await this.redis.set(`${key}:${Date.now() + ttl * 1000}`, ttl, 'EX', ttl);
    }

}

throttler.guard.ts

ThrottlerGuard를 그냥 사용할 경우 ip와 class-method를 기반으로 한 md5 해쉬값을 키로 사용하게 됩니다. 키를 기반으로 redis에 적재하기 때문에 사용자 id로 변경할 경우 사용자별로 중복 처리확인이 가능하게 됩니다.

// throttler.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';

@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
  generateKey(context, prefix) {
    const suffix = `${context.getClass().name}-${context.getHandler().name}`;
    return (`throttle:${suffix}:${prefix}`);
  }
 
  protected getTracker(req: Record<string, any>): string {
    const token = req.headers.authorization
      ? req.headers.authorization.split('Bearer ')[1]
      : '';
    if (token) {
      const decode: any = jwt.verify(
        token,
        process.env.JWT_SECRET,
      )
      return decode.petUserId;
    }

    return req.ips.length ? req.ips[0] : req.ip;
  }
}

app.module.ts

ThrottlerModule 사용설정을 합니다. 이때는 위에서 생성한 service를 storage로 사용하도록 설정합니다.

import {
  CacheModule,
  MiddlewareConsumer,
  Module,
  NestModule,
} from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { WinstonModule } from 'nest-winston';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import Redis from 'ioredis';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
import { AppLoggerMiddleware } from './common/middleware/AppLoggerMiddleware';
import logging from './common/config/logging';
import databaseConfig from './common/config/database';
import { HttpCacheInterceptor } from './common/core/httpcache.interceptor';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['.env.local', '.env'],
      isGlobal: true,
      load: [logging, databaseConfig],
    }),
    HttpModule.register({
      timeout: 5000,
      maxRedirects: 5,
    }),
    WinstonModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) =>
        configService.get('logginConfig'),
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) =>
        configService.get('databaseConfig'),
    }),
    CacheModule.register({
      isGlobal: true,
      ttl: 60 * 60
    }),
    // throttlerModue 설정 storage를 reids 기반으로 생성한 service를 사용
    ThrottlerModule.forRoot({
            ttl: 60,
            limit: 30,
            storage: new ThrottlerStorageRedisService(new Redis({
                host: process.env.REDIS_HOST,
                port: +process.env.REDIS_PORT,
            })),
        }),
    UserModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService,   {
    // cacheFilter 등록
    provide: APP_INTERCEPTOR,
    useClass: HttpCacheInterceptor,
  },],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(AppLoggerMiddleware).forRoutes('*');
  }
}

사용 예제

valid 함수에 대하여 60초 동안 3회만 실행할 수 있도록 선언하였습니다.

// app.controller.ts
  @Post('valid')
  @UseGuards(ThrottlerBehindProxyGuard)
  @Throttle(3, 60)
  testValid(@Body() userDto: UserDto) {
    this.logger.log(userDto);
    // get an environment variable
    const dbUser = this.configService.get<string>('DATABASE_USER');

    // get a custom configuration value
    const dbHost = this.configService.get<string>('DATABASE_PASSWORD');
    return "pass valid! : " + dbUser + "/" + dbHost;
  }

요청이 많을시 오류

제한된 요청보다 많을경우 아래와 같이 492 오류가 아래와 같이 발생합니다.

{
  "statusCode": 429,
  "error": "ThrottlerException: Too Many Requests",
  "path": "/valid",
  "method": "POST",
  "timeStamp": "2022-09-16T12:32:23.131Z"
}

마치며

nestjs를 사용하면서, 정말 잘 만들어진 프레임워크이다라고 생각하고 있습니다.

이번 기능도 interceptor를 기반으로 충분히 만들 수 있을꺼라 생각했는데, 비슷한 모듈이 제공되어서 쉽게 만들수 있었습니다.

특히 요즘 대부분의 타입스크립트 기반의 API는 nestjs를 사용한다고 많이 알려지고 있습니다.

참고자료

  • Rate Limiting
  • nestjs-throttler-storage-redis


nestjsthrottlerredis Share Tweet +1