ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • nestjs에서 특정 시간동안 게시글 / 댓글 / 대댓글 등록 제한 처리
    NodeJS 2022. 12. 12. 17:40

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

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

    원하는 동작

    시퀀스 다이어그램

     

    필요한 라이브러리 설치

    # 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를 사용한다고 많이 알려지고 있습니다.

    참고자료

Designed by Tistory.