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