[NestJS] REST API에 캐시

도도·2021년 8월 1일
2

기술Velog

목록 보기
5/28

REST API에 캐시를 적용시켜 보자.

  1. 캐시 모듈 설치
  2. Redis 설치 및 연결
  3. 캐시 모듈 설치 및 env 적용
  1. CASE1. 수동으로 캐시 관리하기
  2. CASE2. 컨트롤러 전체에 캐시 적용하기
  3. CASE3. 컨트롤러 전체에 커스텀 캐시 정책 적용하기
  4. 프로젝트 적용 예시

1. 캐시 모듈 설치

$ npm install cache-manager
$ npm install -D @types/cache-manager

2. Redis 설치 및 연결

  • docker를 이용해서 Redis를 사용한다.
  • 여기서는 EC2 환경에서 Redis Backend를 뛰었으며, redis-cli을 통해 접속을 확인하고 오자.
docker run -d \
  -e REDIS_PASSWORD=dosimpact\
  -p 6380:6379 \
  --name redis_api_cache \
  --restart always \
  redis:latest /bin/sh -c 'redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}'

3. 캐시 모듈 설치 및 env 적용

  • API 캐시를 적용시키고자 하는 컨트롤러가 있는 모듈에 캐시 모듈을 주입 시켜야 한다.
  • ConfigModule을 불러와서 .env 설정을 가져온다.
  • CacheModule.register 를 통해 env에 설정된 값으로 RedisStore를 등록한다.
  • 그럼 Controller에서 캐시매니저를 주입받을 준비가 끝났다.
@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: process.env.NODE_ENV === 'dev' ? '.env.dev' : '.env.test',
      ignoreEnvFile: process.env.NODE_ENV === 'prod',
    }),
    CacheModule.register({
      store: redisStore as CacheStoreFactory,
      url: process.env.REDIS_API_CACHE_URL,
      ttl: +process.env.REDIS_API_CACHE_TTL, // 10초 캐슁
      // max: 3, // 3개의 key값 유지
    }),
    TypeOrmModule.forFeature([
      Category,
      CategoryList,
      Corporation,
      DailyStock,
      FinancialStatement,
    ]),
  ],
  controllers: [FinanceController],
  providers: [FinanceService],
  exports: [FinanceService],
})

4. CASE1. 수동으로 캐시 관리하기

  • 캐시 매니저를 통해서 set/get/del 함수로 key-value 값을 관리할 수 있다.
cacheManager.set('name',dododo) // redis에 key-value 등록
cacheManager.get('name')        // redis에 key-value 일릭
cacheManager.del('name')        // redis에 key-value 삭제
  • GET 요청에 대해, 결과값을 캐시해두고 , 같은 요청이 들어오면 캐시값을 주도록 해보자.
  • example code
import { CacheKey,  CacheTTL,  CACHE_MANAGER} from '@nestjs/common';
import { Cache } from 'cache-manager';

@Controller()
export class AppController {
  constructor(
    // ✅ CACHE_MANAGER 라는 예약된 상수로 캐시매니저를 주입받는다.
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
  ) {
    // ✅ 캐시 매니저를 다루는 기본적인 함수들
    const test = async () => {
      // await cacheManager.set('name', 'dodo', { ttl: 1000 });
      console.log(await cacheManager.get('name'));
      console.log(await cacheManager.del('name'));
      console.log(await cacheManager.get('name'));
    };
  }
  // ✅ 더하기에 대해서 캐싱처리를 해보자.
  @Get('/adder2')
  async findAll2(
    @Query('a') a: number,
    @Query('b') b: number,
    @Req() request: Request,
  ): Promise<number> {
    // ✅ 더하기에 대해, 캐시가 있는 경우, 캐시값을 리턴한다.
    const memo: string | null = await this.cacheManager.get(request.url);
    if (memo) {
      return Number(memo);
    }
    // ✅ 그렇지 않다면 더하기 값을 구하고 캐시 처리후 리턴한다.
    const sleep = async (ms) => new Promise((res) => setTimeout(res, ms));
    console.log('wait...', a, '+', b);
    await sleep(3000);
    console.log('wait...', a, '+', b);
    const res = a + b;
    await this.cacheManager.set(request.url, res);
    return res;
  }
}

5. CASE2. 컨트롤러 전체에 캐시 적용하기

  • CASE1 은 캐시처리를 수동으로 해서 높은 자유도를 주지만 반복적인 코드가 있다.

  • 그러면 특정 컨트롤러 객체에 요청이 들어오면 알아서 CASE1과 같은 로직을 해주면 좋을 것 같다.

  • 컨트롤러에 캐시인터셉터를 적용시키면 가능하다.

  • 하지만 query,params,body에 대한 차이점은 무시한다.

  • 변수가 없는 GET 요청에 적합

import { UseInterceptors,CacheInterceptor,CacheKey,  CacheTTL,  CACHE_MANAGER} from '@nestjs/common';
import { Cache } from 'cache-manager';

//✅ 네스트가 제공해주는 인터셉터에 캐시 인터셉터(역시 네스트가 제공) 데코레이터를 달아주자. 
@UseInterceptors(CacheInterceptor)
@Controller()
export class AppController {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
  ) {  }

  // ✅ CacheKey 값을 설정해 주면 자동으로 캐시 처리가 된다. 
  // TTL 로 Time to Live 값 설정가능 
  // ❌, 하지만 문제점은 a,b 가 바뀌어도 3초동안 캐시된 값을 리턴한다. 
  @CacheKey('adder')
  @CacheTTL(3)
  @Get('/adder')
  async findAll(@Query('a') a: number, @Query('b') b: number): Promise<number> {
    const sleep = async (ms) => new Promise((res) => setTimeout(res, ms));
    console.log('wait...', a, '+', b);
    await sleep(3000);
    console.log('wait...', a, '+', b);
    return a + b;
  }
}

6. CASE3. 컨트롤러 전체에 커스텀 캐시 정책 적용하기

  • CASE2의 문제점은 GET 요청의 파라미터가 변경되어도 즉각 캐시가 invalid 되지 않는다.
  • Request요청의 key값으로 querystring까지 넣어서 캐시 처리를 해보자.
  • (목적 : 내가 원하는 캐시정책을 만들어 보자.)
  • (body까지 key값으로 캐시처리를 하는 예가 있지만, 예시로만 참고)

HttpCacheInterceptor.ts

import { CacheInterceptor, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';

// ✅( 0) CacheInterceptor : path 만 동일하면 캐슁
// -문제점 : querystring이 다르면 다른요청이 와야하는데, 그렇지 못함

// ✅ (1) 캐시정책 : originalUrl (Params/query) 이 동일하면 캐슁
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
  trackBy(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest<Request>();
    return request.originalUrl;
  }
}

// ✅ (2) 캐시정책 : protocol://hostname+originalUrl 이 동일하면 캐슁
@Injectable()
export class HttpHostCacheInterceptor extends CacheInterceptor {
  trackBy(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest<Request>();
    return request.protocol + '://' + request.hostname + request.originalUrl;
  }
}

// ✅ (3) 캐시정책 : originalUrl (Params/query) + Body 동일하면 캐슁
@Injectable()
export class HttpBodyCacheInterceptor extends CacheInterceptor {
  trackBy(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest<Request>();
    return request.originalUrl + JSON.stringify(request.body);
  }
}

AppController.ts

import { HttpBodyCacheInterceptor } from './common/service/HttpCacheInterceptor';

// ✅ 인터셉터 하나로 모든 컨트롤러에 캐시 적용
@UseInterceptors(HttpBodyCacheInterceptor)
@Controller()
export class AppController {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
  ) {}

  @Post('/adder3')
  async findAll3(@Body() { a, b }: { a: number; b: number }): Promise<number> {
    const sleep = async (ms) => new Promise((res) => setTimeout(res, ms));
    console.log('wait...', a, '+', b);
    await sleep(3000);
    console.log('wait...', a, '+', b);
    const res = a + b;
    return res;
  }
}

7. 프로젝트 적용 예시 - financeModule/controller 에 적용

1.env 설정 및 CacheModule 임포트

  • ConfigModule을 import 해야, process.env 로 접근이 가능.
  • App.module에 사실 global ConfigModule이 있긴하다.
  • global ConfigModule의 ConfigService을 사용하려면
  • Factory패턴을 사용해서, ConfigService을 inject 하도록 하자.
    ref
import * as redisStore from 'cache-manager-redis-store';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: process.env.NODE_ENV === 'dev' ? '.env.dev' : '.env.test',
      ignoreEnvFile: process.env.NODE_ENV === 'prod',
    }),
    CacheModule.register({
      store: redisStore as CacheStoreFactory,
      url: process.env.REDIS_API_CACHE_URL,
      ttl: +process.env.REDIS_API_CACHE_TTL, // 10초 캐슁
      // max: 3, // 3개의 key값 유지
    }),
    TypeOrmModule.forFeature([
      Category,
      CategoryList,
      Corporation,
      DailyStock,
      FinancialStatement,
    ]),
  ],
  controllers: [FinanceController],
  providers: [FinanceService],
  exports: [FinanceService],
})
export class FinanceModule {}

2. 인터셉터 적용

  • "/api/finance/corporations/00{}" 라고 캐시 키가 잡히는 모습
  • "/api/finance/dailystock/006400?take=10{}" 라고 캐시 키가 잡히는 모습
  • HttpCacheInterceptor.ts
// ✅ (3) 캐시정책 : originalUrl (Params/query) + Body 동일하면 캐슁
@Injectable()
export class HttpBodyCacheInterceptor extends CacheInterceptor {
  trackBy(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest<Request>();
    return request.originalUrl + JSON.stringify(request.body);
  }
}

FinanceController.ts

import { HttpBodyCacheInterceptor } from 'src/common/service/HttpCacheInterceptor';

@UseInterceptors(HttpBodyCacheInterceptor)
@Controller('/api/finance/')
export class FinanceController {
  constructor(private readonly financeService: FinanceService) {}

  // stock --- api

  // (1) 기업 리스트 출력
  @Get('corporations')
  async getCorporations() {
    return this.financeService.getCorporations();
  }
  // (2) 기업 리스트 출력 (검색어 기능 )
  @Get('corporations/:term')
  async getCorporationsWithTerm(@Param('term') term: string) {
    return this.financeService.getCorporationsWithTerm({ term });
  }

  // (3) 기업 리스트 출력 (검색어 기능 )
  @Get('corporation/:term')
  async getCorporation(@Param('term') term: string) {
    return this.financeService.getCorporation({ term });
  }

  // daily-stock --- api
  @Get('dailystock/:term')
  async getDailyStocks(
    @Param('term') term: string,
    @Query('skip') skip: number,
    @Query('take') take: number,
  ) {
    return this.financeService.getDailyStocks({ term, take, skip });
  }
}
profile
프론트 주력의 JS 개발자 입니다.! Node.js, React, NestJS 에 관심이 많습니다.

1개의 댓글

comment-user-thumbnail
2022년 10월 31일

감사합니다

답글 달기