ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • NestJs, Redis 캐싱 기록
    공부하기/node.js 2023. 3. 12. 13:46

    과거 프로젝트를 진행할 때 조인이 많이 걸린 테이블 행의 상세 정보(findOne)를 데이터베이스에서 select할 경우 쿼리 속도가 굉장히 느렸던 경험이 있습니다. 테이블간의 관계가 많고 테이블 행의 갯수가 많을 수록 select 하는 시간은 오래걸리기 때문에 요청한 클라이언트에 대한 응답은 느릴 수 밖에 없었던 것입니다.

    이 포스팅은 ' 이러한 문제를 어떻게 해결해야할까? '에서 출발하였습니다.

    물론 데이터베이스 쿼리 실행계획을 고려한 쿼리 최적화 및 테이블 인덱스 설정이 우선이지만 이 포스팅에서는 다루지 않겠습니다.

    테스트는 실제 데이터베이스에 연결하지는 않지만 sleep 함수를 통해 쿼리에 1초 딜레이를 발생시켜 쿼리를 수행한다고 가정합니다.

    - 레디스 전략 중 Look Aside (Lazy Loading)를 사용합니다.

     

    그림으로 이해하기

    1. 공통 : 컨트롤러 엔드포인트에 도달하기 전 인터셉터에서 레디스에 캐시 키 값이 존재하는지 검사합니다.
      1. 캐시 키 값이 존재하는 경우 : 곧바로 사용자에게 데이터를 전달합니다.
      2. 캐시 키 값이 존재하지 않는 경우 : 데이터베이스에서 데이터를 검색한 후 응답합니다. 

     

    사전 준비

    npm i ioredis --save
    npm i @liaoliaots/nestjs-redis -- save

     

    레디스 옵션 설정

    // cache.config.ts
    import {
      RedisModuleAsyncOptions,
      RedisModuleOptions,
    } from '@liaoliaots/nestjs-redis';
    import { ConfigService } from '@nestjs/config';
    
    class RedisConfig {
      static getConfig(configService: ConfigService): RedisModuleOptions {
        return {
          config: {
            host: configService.get<string>('REDIS_HOSTNAME'),
            port: configService.get<number>('REDIS_PORT'),
            password: configService.get<string>('REDIS_PASSWORD'),
          },
        };
      }
    }

     

    redis-service 생성

    import { Injectable } from '@nestjs/common';
    import { InjectRedis, DEFAULT_REDIS_NAMESPACE } from '@liaoliaots/nestjs-redis';
    import Redis from 'ioredis';
    
    @Injectable()
    export class CustomRedis {
      constructor(@InjectRedis() private readonly _redis: Redis) {}
    
      async get(key: string) {
        return this._redis.get(key);
      }
    
      async set(key: string, value: any, expire: number = 60) {
        return this._redis.set(key, value, 'EX', expire);
      }
    
      async del(key: string) {
        return this._redis.del(key);
      }
    }

     

    레디스 모듈 import

    import { RedisModule } from '@liaoliaots/nestjs-redis';
    import { Module } from '@nestjs/common';
    
    import { redisConfigAsync } from '../config-module/cache.config';
    import { CustomRedis } from './custom-redis.service';
    
    @Module({
      imports: [RedisModule.forRootAsync(redisConfigAsync, false)],
      providers: [CustomRedis],
      exports: [CustomRedis],
    })
    export class CustomCacheModule {}

     

    NestJs interceptor 생성

    import {
      Injectable,
      NestInterceptor,
      ExecutionContext,
      CallHandler,
    } from '@nestjs/common';
    import { PATH_METADATA } from '@nestjs/common/constants';
    import { Reflector } from '@nestjs/core';
    import { Observable, of } from 'rxjs';
    import { CustomRedis } from 'src/cache-module/custom-redis.service';
    
    @Injectable()
    export class RedisCacheInterceptor implements NestInterceptor {
      constructor(
        private readonly reflector: Reflector,
        private readonly _redis: CustomRedis,
      ) {}
      async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
        const startTime = new Date().getTime();
    
        const ctx = context.switchToHttp();
        const request = ctx.getRequest();
        const { id } = request.params;
        // 컨트롤러 prefix를 알 수 있다.
        const controllerPath = this.reflector.get<string>(
          PATH_METADATA,
          context.getClass(),
        );
    
        const key = `${controllerPath}:${id}`;
        const findedKey = await this._redis.get(key);
        const endTime = new Date().getTime();
        console.log('in-memory - 걸린시간 : ', (endTime - startTime) / 1000 + 'S');
    
        if (findedKey) return JSON.parse(findedKey);
        return next.handle();
      }
    }

     

    테스트를 진행할 모듈에 CacheModule import

    import { CacheModule, Module } from '@nestjs/common';
    import { CustomCacheModule } from '../cache-module/cache-module';
    import { RedisCacheInterceptor } from '../interceptors/http-interceptor';
    import { UserController } from './user.controller';
    import { UserService } from './user.service';
    
    @Module({
      imports: [CustomCacheModule],
      controllers: [UserController],
      providers: [UserService, RedisCacheInterceptor],
      exports: [UserService],
    })
    export class UserModule {}

     

    테스트를 진행할 컨트롤러 엔드포인트에 인터셉터 연결

      @Get('/:id')
      @UseInterceptors(RedisCacheInterceptor)
      // @Header('Cache-Control', 'max-age=60')
      async findOne(@Param('id', ParseIntPipe) id: number) {
        const startTime = new Date().getTime();
        console.log('진입');
    
        const findedUser = await this.userService.findOne(id);
        const endTime = new Date().getTime();
        console.log('db - 걸린시간 : ', (endTime - startTime) / 1000 + 'S');
        return findedUser;
      }

     

    테스트 진행 결과

     

    Cache Hit인 경우 : 평균적으로 0.001초

    Cache Miss인 경우 : 평균적으로 sleep 시간 + 0.01초

     

    속도 차이는 쿼리의 수행시간이 오래걸릴수록 엄청나게 커진다.

    쿼리 속도가 1초라고 가정하면 천배정도의 차이를 보인다.

Designed by Tistory.