• Home
  • About
    • lahuman photo

      lahuman

      열심히 사는 아저씨

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

nestjs에서 cache 처리

13 Sep 2022

Reading time ~6 minutes

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);
  }
}

전체 예제 바로 가기

참고자료

  • Nestjs REST 애플리케이션의 캐시 처리와 캐시 무효화
  • How to exclude a route from caching in nest.js
  • TPS 지표 이해하기


nestjscacheredis Share Tweet +1