nestjs에서 cache 처리
매번 데이터 조회 시
RDBMS
질의를 하게 되어 성능에 이슈가 확인 되었습니다. 이를 해결할 방안으로cache
를 적용해보았습니다. nestjs에서는 기본적으로 캐쉬기능을 제공합니다.
진행하는 프로젝트에서
TPS(Transaction Per Second)
가770
에서 캐쉬 적용 후930
까지 약20%
상향을 확인했습니다.
graph LR; request[request] -->|URI| get{GET}; get --> |Y| nocache{NoCache}; get --> |N| callP(next); nocache --> |Y| callng(next); nocache --> |N| hascache{HasCache}; hascache --> |N| callg(next); callg --> savecache(SaveCache); hascache --> |Y| getcache(GetCache); callP --> hasevict{hasEvict}; hasevict --> |Y| keys(getKeys); hasevict --> |N| uri(uri); keys --> removecache(removeCache); uri --> removecache; removecache --> return[RETURN]; callng --> return; getcache --> return; savecache --> return;
자 그럼, 본격적으로 cache 적용을 진행합니다.
필요한 라이브러리 설치
# cache-manager 설치
$ npm install cache-manager
$ npm install -D @types/cache-manager
# redist store 설치
$ npm install cache-manager-ioredis
$ npm install --dev @types/cache-manager-ioredis
app.module.ts
설정 파일에 cache 모듈과 전체 interceptor를 설정하여 사용 처리 합니다.
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 { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
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';
import { APP_INTERCEPTOR } from '@nestjs/core';
@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.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
isGlobal: true,
useFactory: (configService: ConfigService) =>
configService.get('cacheConfig')
}),
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('*');
}
}
cache.ts
cache에 store를 redis 로 처리 하는 설정입니다.
import * as redisCacheStore from 'cache-manager-ioredis';
export default () => {
const appendOptions: { password?: string; tls?} = {};
if (process.env.REDIS_PASSWORD !== '') {
appendOptions.password = process.env.REDIS_PASSWORD;
}
if (process.env.REDIS_TLS === 'true') {
appendOptions.tls = {};
}
const cacheConfig: any = {
store: redisCacheStore,
ttl: 10, // 초
max: 5000, // cache 갯수
readyLog: true,
host: process.env.REDIS_HOST,
port: +process.env.REDIS_PORT,
reconnectOnError(err) {
const targetError = "READONLY";
if (err.message.includes(targetError)) {
// Only reconnect when the error contains "READONLY"
return true; // or `return 1;`
}
},
...appendOptions,
};
return { cacheConfig };
};
httpcache.interceptor.ts
cache 처리를 위한 interceptor 입니다. redis에 저장시, key prefix를 설정하고, NoCache, CacheEvict를 처리 하였습니다.
import {
CacheInterceptor,
CallHandler,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { Redis } from 'ioredis';
import { Observable, tap } from 'rxjs';
import { CACHE_EVICT_METADATA, IGNORE_CACHE_METADATA } from './cache.decorator';
import { Store } from 'cache-manager';
interface RedisCache extends Cache {
store: RedisStore;
}
interface RedisStore extends Store {
name: 'redis';
getClient: () => Redis;
isCacheableValue: (value: any) => boolean;
}
const CACHE_MANAGER = 'CACHE_MANAGER';
const CACHE_MODULE_OPTIONS = 'CACHE_MODULE_OPTIONS';
const CACHE_KEY_METADATA = 'cache_module:cache_key';
const CACHE_TTL_METADATA = 'cache_module:cache_ttl';
const isFunction = (val: any): boolean => typeof val === 'function';
const isUndefined = (obj: any): obj is undefined => typeof obj === 'undefined';
const isNil = (val: any): val is null | undefined => isUndefined(val) || val === null;
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
// constructor(
// @Inject(CACHE_MANAGER)
// cacheManager: RedisCache,
// reflector: any
// ) {
// super(cacheManager, reflector);
// }
private readonly CACHE_EVICT_METHODS = [
'POST', 'PATCH', 'PUT', 'DELETE'
];
protected trackBy(context: ExecutionContext): string | undefined {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const isHttpApp = httpAdapter && !!httpAdapter.getRequestMethod;
const cacheMetadata = this.reflector.get(
CACHE_KEY_METADATA,
context.getHandler(),
);
if (!isHttpApp || cacheMetadata) {
return cacheMetadata;
}
const request = context.getArgByIndex(0);
if (!this.isRequestCacheable(context)) {
return undefined;
}
return `cache:${httpAdapter.getRequestUrl(request)}`;
}
protected isRequestCacheable(context: ExecutionContext): boolean {
const http = context.switchToHttp();
const request = http.getRequest();
const ignoreCaching = this.reflector.getAll(
IGNORE_CACHE_METADATA,
[
context.getHandler(),
context.getClass(),
]
);
return !ignoreCaching.find(f => f === true) && request.method === 'GET';
}
async intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest<Request>();
if (this.CACHE_EVICT_METHODS.includes(req.method)) {
const reflector: Reflector = this.reflector;
const evictKeys = reflector.getAllAndMerge(CACHE_EVICT_METADATA, [
context.getClass(),
context.getHandler(),
]);
// 캐시 무효화 처리
return next.handle().pipe(
tap(() => {
if (evictKeys.length > 0) return this._clearCaches(evictKeys);
return this._clearCaches([req.originalUrl]);
}),
);
}
// // 기존 캐싱 처리
// return super.intercept(context, next);
// ttl 처리를 위해서 직접 구현
const key = this.trackBy(context);
// method TTL 에 우선순위를 둔다.
const ttlValueOrFactory =
this.reflector.getAll(CACHE_TTL_METADATA, [
context.getHandler(),
context.getClass(),
]).find(f => f) ?? null;
if (!key) {
return next.handle();
}
try {
const value = await this.cacheManager.get(key);
if (!isNil(value)) {
return (value);
}
const ttl = isFunction(ttlValueOrFactory)
? await ttlValueOrFactory(context)
: ttlValueOrFactory;
return next.handle().pipe(
tap(response => {
const args = isNil(ttl) ? [key, response] : [key, response, { ttl }];
this.cacheManager.set(...args);
}),
);
} catch {
return next.handle();
}
}
/**
* @param cacheKeys 삭제할 캐시 키 목록
*/
private async _clearCaches(cacheKeys: string[]): Promise<boolean> {
const client: Redis = await this.cacheManager.store.getClient();
const _keys = await Promise.all(
cacheKeys.map((cacheKey) => client.keys(`cache:*${cacheKey}*`)),
);
const keys = _keys.flat();
const result2 = await Promise.all(keys.map((key) => !!this.cacheManager.del(key)));
return result2.flat().every((r) => !!r);
}
}
cache.decorator.ts
NoCache, CacheEvict 을 Anotation으로 사용하기 위한 데코레이터 입니다.
import { CustomDecorator, SetMetadata } from '@nestjs/common';
export const CACHE_EVICT_METADATA = 'cache:CACHE_EVICT';
export const IGNORE_CACHE_METADATA = 'cache:IGNORE_CACHE';
export const NoCache = () => SetMetadata(IGNORE_CACHE_METADATA, true);
export const CacheEvict = (
...cacheEvictKeys: string[]
): CustomDecorator<string> => SetMetadata(CACHE_EVICT_METADATA, cacheEvictKeys);
user.controller.ts
사용 예제 입니다.
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
Req,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { ApiBasicAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CacheEvict, NoCache } from 'src/common/core/cache.decorator';
import { AuthExceptionFilter } from '../common/filters/auth-exceptions.filter';
import { AuthenticatedGuard } from '../common/guards/authenticated.guard';
import { CreateUserDto, ModifyUserDto, SearchUserDto, UserRO } from './dto/user.dto';
import { UserService } from './user.service';
@Controller('user')
@ApiTags('사용자')
@UseFilters(AuthExceptionFilter)
export class UserController {
constructor(private readonly service: UserService) { }
@ApiOperation({ summary: 'TEST 사용자 추가 (권한 없이 추가 가능)' })
@ApiResponse({ status: 200, type: UserRO })
@ApiBody({ type: CreateUserDto })
@Post("addTestUser")
async addTestUser(@Body() userDto: CreateUserDto): Promise<UserRO> {
return this.service.save(userDto, 'test');
}
@ApiOperation({ summary: '사용자 조회' })
@ApiResponse({ status: 200, type: UserRO })
@Get()
async find(@Query() searchUser: SearchUserDto): Promise<UserRO> {
return this.service.find(searchUser);
}
@ApiOperation({ summary: '사용자 목록 조회' })
@ApiResponse({ status: 200, type: UserRO })
@UseGuards(AuthenticatedGuard)
@ApiBasicAuth()
@Get(':id')
@NoCache()
async findUser(@Param("id") id: string): Promise<UserRO> {
return this.service.findUser(id);
}
@ApiBody({ type: CreateUserDto })
@ApiOperation({ summary: '사용자 등록' })
@ApiResponse({ status: 201, type: UserRO })
@UseGuards(AuthenticatedGuard)
@ApiBasicAuth()
@Post()
async addUser(@Req() req, @Body() userDto: CreateUserDto): Promise<UserRO> {
return await this.service.save(userDto, req.user.id);
}
@ApiBody({ type: ModifyUserDto })
@ApiOperation({ summary: '사용자 수정' })
@ApiResponse({ status: 200, type: UserRO })
@UseGuards(AuthenticatedGuard)
@ApiBasicAuth()
@Put(':id')
@CacheEvict('user')
async modifyUser(@Param("id") id: string, @Body() userDto: ModifyUserDto): Promise<UserRO> {
return await this.service.update(id, userDto);
}
@ApiOperation({ summary: '사용자 삭제' })
@ApiResponse({ status: 200, })
@UseGuards(AuthenticatedGuard)
@ApiBasicAuth()
@Delete(':id')
async deleteUser(@Param("id") id: string,): Promise<void> {
await this.service.remove(id);
}
}