Node.js/NestJS

[NestJS - Caching] 15분 안에 NestJS에서 캐싱 사용해보기

턴태 2022. 11. 11. 18:35

캐싱

캐싱은 어플리케이션의 수행 속도를 향상시키는 데에 큰 도움을 주는 간단하지만 강력한 기술입니다. 이는 일시적인 저장소 역할을 하며, 고성능 데이터 접근을 제공합니다.

 

프로젝트 생성

먼저 실습할 프로젝트 폴더를 하나 생성합니다. 저는 nestjs-cache로 명명했습니다.

해당 폴더에서 nest cli를 사용하여 새로운 프로젝트를 생성합니다.

사이드 프로젝트에서 패키지 매니저를 yarn으로 쓰고 있어서 yarn을 선택했습니다.

패키지 설치

캐싱에 필요한 패키지를 설치해줍니다. 먼저 cache-manager를 설치합니다.

yarn add cache-manager
# npm i cache-manager

그후에는 타입을 지정하기 위해 @types/cache-manager를 설치합니다.

yarn add -D @types/cache-manager
# npm i -D @types/cache-manager

 

캐시 모듈 임포트

어플리케이션을 구동하면서 캐시모듈을 사용하기 위해서 app.module.ts 파일에서 cache 모듈을 임포트합니다.

import { CacheModule, Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [CacheModule.register()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

이제 CacheModule.register() 안에 원하는 옵션을 넣어서 사용할 수 있습니다. 여기에 사용할 수 있는 옵션들은 다음과 같습니다.

더보기
  • ttl: 캐시에 대해 전역적으로 ttl을 제공합니다. ttl은 캐시의 항목이 제거되기 전에 콘텐츠가 캐시 안에 지속되는 시간으로 기본값은 5초입니다.
ttl: 60
  • max: 캐시를 비우기 전에, 한 번에 캐시에 지속시킬 수 있는 항목의 수를 지정합니다.
max: 1000
  • isGlobal: 해당 모듈을 전역적으로 사용할 수 있게 해줍니다. 따라서 다른 모듈에서 임포트하지 않아도 됩니다.
isGlobal: true

따로 옵션은 두지 않고 빈 상태로 등록만 해놓겠습니다. 기본적으로 캐시 저장소를 지정하지 않으면 인메모리로 메모리 내 저장소를 사용하는데, redis와 같은 다른 저장소를 지정할 수도 있습니다. 일단은 나중에 구현할 예정입니다.

 

애플리케이션에서 캐싱 구현

1. 캐시 매니저(Cache Manager)

기본적으로 생성되는 App Controller의 로직은 App Service에서 실행되므로, App Service 파일에서 작업해보겠습니다. 먼저 해야할 것은 생성자 함수에 캐시 매니저를 주입하는 것입니다.

 

// app.service.ts

import { Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Injectable()
export class AppService {
  constructor(private readonly cacheManager: Cache) {}

  getHello(): string {
    return 'Hello World!';
  }
}
// app.controller.ts

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  async getHello() {
    return this.appService.getHello();
  }
}

생성자 함수에 cacheManager를 입력했지만, 아직 주입하지 않은 상태입니다. 그렇기 때문에 @Inject 데코레이터를 사용하여 토큰으로 CACHE_MANAGER를 사용하여 캐시 매니저를 주입해줍니다. 이제 정상적으로 캐시 매니저에 접근할 수 있게 됐습니다.

constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}

 

일단 따로 저장소를 지정하지 않았으므로 기본적으로 메모리 저장소를 사용하게 되며, 메모리 저장소에 직접 항목을 설정하거나 값을 가져올 수 있습니다.

 

비동기적으로 메서드를 구성하며, this.cacheManager를 통해 사용합니다. 테스트해보기 위해 기본적으로 작성된 getHello 함수를 아래와 같이 수정합니다.

 

  async getHello() {
    await this.cacheManager.set('cached_item', { key: 32 });
    const cachedItem = await this.cacheManager.get('cache_item');
    console.log(cachedItem);
    return 'Hello World!';
  }

캐시에 항목 설정하기

await this.cacheManager.set('cached_item', { key: 32 });

캐시 항목을 설정하기 위해서는 set 메서드를 사용해야 합니다. 캐시 매니저의 set을 호출한 다음 키와 값을 지정해줍니다.

set('키': string, number | string | object | any(unknown) )
// set: (key: string, value: unknown, ttl?: number) => Promise<void>

값으로 지정할 수 있는 것은 원시적인 정수형 혹은 문자열이 될 수도 있으며 객체를 지정할 수도 있습니다. 위 예제에서는{ key: 32 }와 같이 실제로 키와 값을 가지는 객체를 값으로 지정했습니다.

캐시 값 가져오기

 

캐시에 저장된 값을 가져오기 위해서는 get 메서드를 통해 가져올 수 있습니다.

get('키')
// get: <unknown>(key: string) => Promise<unknown>

위의 예제에서는 방금 저장한 따끈따끈한 값을 키를 통해서 불러왔습니다.

const cachedItem = await this.cacheManager.get('cache_item');

이제 직접 요청을 보내 캐시에 항목을 설정하고, 값을 불러와보겠습니다. 여기서는 Postman을 사용했습니다. Insomnia 등을 사용해도 무방합니다. GET http://localhost:3000 요청을 보냅니다.

 

포스트맨에서의 응답 바디는 Hello World를 보여줍니다. 하지만, 애플리케이션 로그로 돌아가면 우리가 가져온 캐시 값이 로그에 찍혀있는 것을 볼 수 있습니다.

 

다시 서비스 파일로 돌아가서 캐시에 항목을 설정할 때, 정수형 값을 입력하여 옵션을 제공할 수 있습니다. 여기서의 옵션은 ttl에 관한 옵션입니다. 전역으로 캐시 모듈을 등록할 때 ttl을 기본값인 5초로 설정했던 것과 다르게, 직접 ttl 옵션을 전달하여 ttl을 더 이르게, 혹은 더 늦게 설정할 수 있습니다.

 

await this.cacheManager.set('cached_item', { key: 32 }, 10);

 

캐시된 항목 삭제

캐시된 항목을 직접 삭제할 수도 있습니다. 여기서는 del 메서드를 호출하여 캐시된 항목을 삭제합니다.

await this.cacheManager.del('cached_item');
// del: (key: string) => Promise<void>
  async getHello() {
    await this.cacheManager.set('cached_item', { key: 32 }, 10);
    await this.cacheManager.del('cached_item');
    const cachedItem = await this.cacheManager.get('cached_item');
    console.log(cachedItem);
    return 'Hello World!';
  }

일단 캐싱한 후에 바로 삭제를 하며, 삭제후에 바로 값을 불러와서 삭제된 것을 확인하도록 코드를 작성했습니다.

 

그러면 실제로 요청을 보내서 캐싱된 항목이 삭제되었는지 확인해보겠습니다.

 

요청을 보냈을 때 응답은 기존과 동일하게 나오지만, 앞선 사례와 달리 키를 불러오지 못하고 이미 삭제된 항목이기 때문에 undefined를 반환하는 것을 볼 수 있습니다.

 

캐싱 리셋

캐싱한 항목들을 직접 전부 삭제할 수도 있습니다. del 메서드로 일일이 삭제하는 것이 아니라 reset 메서드를 통해서 캐시 저장소 내부의 항목을 모두 삭제합니다.

await this.cacheManager.reset();

 

2. 캐시 인터셉터(Cache Interceptor)

서비스 레이어에서 캐싱을 구현할 수도 있지만, 경로 단계에서도 캐싱을 구현할 수 있습니다. 컨트롤러를 통해 경로 단계에서 캐싱을 적용하기 위해서 nestjs의 인터셉터를 사용해보도록 하겠습니다. 인터셉터를 사용하기 위해서는 @UseInterceptor 데코레이터를 붙여주어야 합니다.

 

import {
  CacheInterceptor,
  Controller,
  Get,
  UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';

@UseInterceptors(CacheInterceptor)
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  async getHello() {
    return this.appService.getHello();
  }
}

이처럼 컨트롤러 스코프에서 인터셉터를 적용하면, 모든 라우트에 대하여 캐싱하며 기본적으로, 앱 모듈에 등록한 캐시 옵션을 기반으로 캐싱을 진행합니다. 신기하게도 기존에 캐싱된 값을 반환하는 라우트가 있어서 이 경로로 빠르게 요청을 여러 번 보내면, 로그에서 undefined라는 로그가 하나 찍히는 것을 볼 수 있는데, 이는 라우트 핸들러를 한 번만 작동시키기 때문입니다. 라우트 핸들러를 한 번 작동하면 응답을 캐시에 등록하기 때문에 다시 핸들러롤 호출할 필요가 없기 때문입니다.

 

실제로 포스트맨에서 요청을 연속으로 다섯 번 보내보겠습니다.

 

 

하지만 여기서 ttl은 전역 스코프에서 기본값인 5초로 설정되어 있기 때문에 5초가 충분히 지난 다음 요청을 다시 보내면 undefined라는 로그를 또 출력합니다.

이처럼 동일한 요청이 여러 번 왔을 때, 다시 핸들러로 돌아가서 클라이언트에게 응답하는 데에 자원을 사용할 필요가 없다는 점에서 매우 강력한 이점을 지니게 됩니다.


여기서 일일이 컨트롤러마다 지정하기 번거롭고 모든 컨트롤러에 한 번에 캐싱을 적용하고 싶다면, 앱 모듈에서 프로바이더로 적용하는 방법을 사용하여 적용할 수 있습니다.

 

import { CacheInterceptor, CacheModule, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [CacheModule.register()],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

프로바이더에 등록할 때, 위처럼 코드를 작성합니다. APP_INTERCEPTOR로 프로바이더를 사용하며, 이때 클래스는 CacheInterceptor를 사용한다는 의미입니다.

 

처음 보면 낯설게 느껴질 수도 있는데, 모든 프로바이더는 사실 이런 식으로 등록된 것입니다. 예를 들어서, AppService 프로바이더도 이런 식으로 작성할 수 있습니다.

 

import { CacheInterceptor, CacheModule, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [CacheModule.register()],
  controllers: [AppController],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
    {
      provide: AppService,
      useClass: AppService,
    },
  ],
})
export class AppModule {}

이때 둘이 같기 때문에 별도로 두 번 적지 않고 AppService 하나로 정리하여 등록하는 것입니다. 위 코드로 직접 요청을 보내도 앞선 예제에서의 결과를 동일하게 반환합니다.

 

이제 인터셉터를 프로바이더로 적용하여 모든 컨트롤러에 캐싱을 적용하였습니다.


또는, 스코프를 좁혀 경로 단계에서 캐싱을 적용할 수도 있습니다. 컨트롤러에서 요청 메서드를 데코레이터로 지정하는 방식과 같이 데코레이터를 사용하여 해당 경로에 캐싱을 적용합니다.

import { CacheKey, Controller, Get, UseInterceptors } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @CacheKey('some_routes')
  async getHello() {
    return this.appService.getHello();
  }
}

먼저 해당 경로에 캐시 키를 적용하기 위해서 @CacheKey() 데코레이터를 사용하여 우리가 원하는 키를 적용할 수 있습니다.

 

앞서 서비스에서 ttl을 지정했던 것과 같이 데코레이터를 사용하여 ttl을 적용할 수도 있습니다. 여기서는 60초로 ttl을 적용해보겠습니다.

import { CacheKey, CacheTTL, Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @CacheKey('some_routes')
  @CacheTTL(60)
  async getHello() {
    return this.appService.getHello();
  }
}

이상태로 포스트맨을 통해 해당 요청을 다섯 번 정도 보내보면, 한 개의 undefined 로그가 보일 것으로 예상할 수 있습니다.

여기까지 캐시 매니저와 캐시 인터셉터를 통해서 핸들러, 경로, 컨트롤러 스코프에서 캐싱을 사용하는 방법을 알아봤습니다. 일단 redis를 캐시 저장소로 사용하기 전에 캐시 매니저를 모킹하는 방법을 알아보겠습니다.

 

캐시 매니저를 모킹하여 유닛 테스트 실행

먼저 테스트를 위하여 app.service.spec.ts 파일을 생성해줍니다. 먼저 describe에서 app service에 대한 것을 적습니다. 테스트 파일에서도 app service를 사용하므로, app service를 실제 AppService와 동일하게 설정했습니다.

 

// app.service.spec.ts

import { AppService } from './app.service';

describe('AppService', () => {
  let appService: AppService;
});

실제로 테스트 모듈을 생성하기 때문에 아래와 같이 테스트 모듈을 다룰 수 있게, moduleRef라는 변수에 모듈을 할당합니다. 또한, 서비스 파일을 테스트할 것이므로, 빈 임포트 배열과 빈 컨트롤러 배열을 넣어 초기화한 후에, 앱 서비스를 프로바이더 배열에 넣었습니다.

import { Test } from '@nestjs/testing';
import { AppService } from './app.service';

describe('AppService', () => {
  let appService: AppService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [AppService],
    });
  });
});

여기서 캐시 매니저를 앱 서비스에 주입시켰기 때문에, 앱 서비스가 캐시 매니저를 의존하는 관계가 형성됐었습니다. 그렇기 때문에 캐시 매니저를 모킹해야 합니다. 따라서, 객체를 사용하여 제공할 항목으로 CACHE_MANAGER 토큰을 지정합니다. 이는 앱서비스에 주입된 것과 같은 토큰입니다. 그 다음 mockCacheManager를 값으로서 지정합니다.

 

여기서 mockCacheManager는 우리가 직접 모킹해야 하는 값이므로, 바깥에서 전역변수로 만들어주도록 합니다. 우리가 앱 서비스의 캐시 매니저에서 메서드를 사용했던 것 기억하시나요? 이처럼 메서드를 사용하기 때문에 객체로 변수를 만들어야 합니다. 각각의 기능은 jest의 fn 메서드를 통해 모킹합니다.

 

const mockCacheManager = {
  set: jest.fn(),
  get: jest.fn(),
  del: jest.fn(),
  reset: jest.fn(),
};

...
        AppService,
        {
          provide: CACHE_MANAGER,
          useValue: mockCacheManager,
        },
...

이제 캐시 매니저를 성공적으로 모킹했으므로 모듈에서 컴파일을 사용할 수 있습니다. 이제 모킹할 앱 서비스에 테스트 모듈의 앱 서비스를 전달합니다.

...
        AppService,
        {
          provide: CACHE_MANAGER,
          useValue: mockCacheManager,
        },
      ],
    }).compile();

    appService = moduleRef.get<AppService>(AppService);

테스트할 앱서비스도 모두 준비됐으므로 이제 본격적으로 실제 테스트를 위해서 코드 하단에 it 함수를 작성하여 테스트를 시작해봅시다.

 

일단 우리가 앱 서비스를 정의하였기 때문에, 이 앱 서비스가 정상적으로 정의되었는지 확인하기 위한 테스트 코드를 먼저 작성합니다.

  it('should be defined', () => {
    expect(AppService).toBeDefined();
  });

이제 터미널에서 테스트 명령을 내려보겠습니다.

yarn test app.service

모든 종속성을 확실히 모킹해주었으므로 정상적으로 앱 서비스가 정의된 것을 볼 수 있습니다. 이처럼 캐시 매니저를 모킹할 때, 캐시 매니저를 객체를 통해 해당 메서드들을 직접 모킹해줌으로써 앱 서비스의 종속 관계가 올바르게 생성되었습니다.

 

Redis 적용

분산된 환경에서 애플리케이션을 구동하는 경우, 한 곳에서 모든 캐시를 관리하게 됩니다. 그러면 redis를 직접 시스템에서 실행시키기 위해서 redis를 설치해줍시다. 맥을 사용하는 경우 homebrew를 사용하여 redis를 설치할 수 있습니다.

 

brew install redis

레디스를 설치가 완료된 다음, 레디스를 실행시켜줍니다.

brew services start redis

레디스를 애플리케이션에 통합하기 위해서, cache-manager-redis-store라는 패키지를 추가로 설치해줍시다.

yarn add cache-manager-redis-store
# npm i cache-manager-redis-store

cache-manager-redis-store와 호환성의 문제가 있으므로 redis 패키지의 버전을 3.x.x로 다운시켜주어야 합니다.

다시 app.module로 돌아가서 캐시 모듈의 옵션으로 적용해주어야 합니다. 일단 cache-manager-redis-store를 redisStore로 별칭을 만들어줍니다. 그 이후 store를 직접 지정할 수 있는데 그 값으로 redisStore를 지정해주고, 저장소의 호스트와 포트를 입력합니다. 간단히 실습할 것이므로 따로 환경변수를 사용하지 않고 바로 값을 입력하였습니다.

import { CacheInterceptor, CacheModule, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { redisStore } from 'cache-manager-redis-store';
import { RedisClientOptions } from 'redis';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    CacheModule.registerAsync<Promise<RedisClientOptions>>({
      isGlobal: true,
      useFactory: async () => {
        const store = await redisStore({
          socket: {
            host: 'localhost',
            port: 6379,
          },
          ttl: 100, // 여기서 ttl을 설정하면 됩니다.
        });
        return {
          store: () => store,
        };
      },
    }),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

이렇게 캐시 모듈에 새롭게 레디스 스토어를 캐시 저장소로 지정을 했으므로, 이전에 앱 서비스에서 작성한 console.log에서 null을 반환하게 될 것입니다.

 

또한, 이전에는 메모리 저장소에 캐시를 저장했기 때문에 애플리케이션을 재시작하면 모든 캐시가 초기화되었지만, 따로 레디스를 사용하여 그곳에 캐시를 저장하기 때문에 애플리케이션이 재시작해도 캐싱된 항목을 유지하게 됩니다.

 

포스트맨을 통해서 요청을 보내봅시다. 일단 바로 요청을 보내면 null을 반환하게 됩니다.

이제 그 상태에서 애플리케이션을 바로 재시작한 다음 요청을 보내보면

응답이 여전히 레디스 저장소에 저장되어 있으므로 null 로그를 찍지 않는 것을 볼 수 있습니다.

 

후기

https://www.youtube.com/watch?v=KXnkhWRCj40

본 내용은 유튜브를 참고하였으며, 정상 작동하지 않는 코드들은 직접 구글링하여 고쳤습니다. 2022년 11월 11일을 기준으로 정상적으로 작동하는 것임을 확인하였습니다. 패키지 버전들은 아래와 같습니다.

"cache-manager": "^5.1.3",
"cache-manager-redis-store": "^3.0.1",
"redis": "^4.5.0",

 

캐싱에 대해서 감이 잡히지 않았었는데 직접 실습해보면서 캐싱을 쓰는 이유, 적용하는 방법들, 레디스와의 연결을 확실히 알 수 있었습니다. 특히 컨트롤러에 캐싱을 적용해서 굳이 다시 요청을 보내더라도 클라이언트에 응답하기 위해 시간을 할애하지 않아도 되는 부분이 가장 인상깊었습니다. 앞으로 프로젝트나 과제에 직접 접목시켜서 응용하면, 대용량 트래픽에 있어서 조금은 더 잘 대응할 수 있을 것 같습니다.

 

수정 히스토리

2022.11.15

세팅에서 ttl이 적용이 안되는 오류가 있습니다. 그래서 해당 내용 수정하였는데, 아직 공식적인 세팅이 없어서 서비스 스코프에서 cache-manager의 set 메서드의 ttl이 개별적으로 적용되지 않습니다. 컨트롤러 스코프의 @CacheTTL() 데코레이터를 통한 ttl 옵션은 정상적으로 작동합니다.

 

수정 이전 코드

import { CacheInterceptor, CacheModule, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { redisStore } from 'cache-manager-redis-store';
import { RedisClientOptions } from 'redis';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    CacheModule.registerAsync<Promise<RedisClientOptions>>({
      useFactory: async () => {
        const store = await redisStore({
          socket: {
            host: 'localhost',
            port: 6379,
          },
        });
        return {
          store: {
            create: () => store,
          },
        };
      },
    }),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

수정 이후 코드

import { CacheInterceptor, CacheModule, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { redisStore } from 'cache-manager-redis-store';
import { RedisClientOptions } from 'redis';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    CacheModule.registerAsync<Promise<RedisClientOptions>>({
      isGlobal: true,
      useFactory: async () => {
        const store = await redisStore({
          socket: {
            host: 'localhost',
            port: 6379,
          },
          ttl: 100, // 여기서 ttl을 설정하면 됩니다.
        });
        return {
          store: () => store,
        };
      },
    }),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

ttl 옵션 적용 및 전역 설정을 추가했습니다.