개발/Node & Javascript

Nest.js 탐험기3 (부록) - cache를 커스텀 해보자

말고기 2021. 1. 22. 18:38
728x90
반응형

1. 개요

Nest.js에서는 유용한 cache module을 제공하고 있다.
하지만 몇 가지 기능상에 제약이 있다.

예를 들어 실제 운영상에서 쓰인다면 다음과 같은 불편함들이 있다.

  • cache manager를 inject해서 사용하는 경우, get, set 등의 기본적인 기능만 사용 가능하다. (물론 store에 접근하면 mGet이나 mSet을 사용할 수 있지만 불편하다..)
  • 미세 옵션 등등을 조정하고 싶지만 할수가 없다. (retry라던가, showFriendlyErrorStack 등등의 ioredis option들을 자세히 확인할 수 없다.)

그래서 추가적으로 해당 부분들을 사용할 수 있게 바꾸어 보려고 한다.

2. 사용할 수 있는 옵션들

우리가 사용할 수 있는 옵션들은 크게 다음과 같을 것이다.

  1. 기존의 Cache는 버리고, 새로운 캐시 모듈을 만들어 사용하자!!
  2. 기존의 Cache 기능을 활용할 수 있는 방향으로 해보자!!

둘 다 장단점이 존재한다.

1번의 경우에는 nest module 간섭없이 쉽게 구현할 수 있다는 것이 장점이지만, nest module의 CacheKey annotation의 기능들을 활용하지 못하는게 단점이 된다.
2번의 경우에는 기존의 nest.js에서 지원하는 기능들을 사용할 수 있지만 nest.js쪽에서 지원하는 형태로 맞추어 주거나 또한 버전이 변경되었을 경우 인터페이스가 변경되면 해당 포멧을 맞춰주어야 하는 번거로움이 존재한다.

하지만 해당 부분에서는 공부도 할겸 2번의 케이스를 통해서 살펴보도록 하자.

3. 그렇다면 소스를 보고 Gak을 보자

nest.js의 cache를 살펴 보고, 어떻게 구현할 수 있을지 한 번 각을 재보자.

cache.module.ts + ....

먼저 entry point인 cache module과 option을 살펴보자.
몇가지 상세한 부분들이 있지만, 흐름만 살펴보도록 하자.


// cache.module.ts
@Module({
  providers: [createCacheManager()],
  exports: [CACHE_MANAGER],
})
export class CacheModule {
  /**
   * Configure the cache manager statically.
   *
   * @param options options to configure the cache manager
   *
   * @see [Customize caching](https://docs.nestjs.com/techniques/caching#customize-caching)
   */
  static register(options: CacheModuleOptions = {}): DynamicModule {
    return {
      module: CacheModule,
      providers: [{ provide: CACHE_MODULE_OPTIONS, useValue: options }],
    };
  }

  /**
   * Configure the cache manager dynamically.
   *
   * @param options method for dynamically supplying cache manager configuration
   * options
   *
   * @see [Async configuration](https://docs.nestjs.com/techniques/caching#async-configuration)
   */
  static registerAsync(options: CacheModuleAsyncOptions): DynamicModule {
    return {
      module: CacheModule,
      imports: options.imports,
      providers: [
        ...this.createAsyncProviders(options),
        ...(options.extraProviders || []),
      ],
    };
  }
  ....
}

// cache.provider.ts
export function createCacheManager(): Provider {
  return {
    provide: CACHE_MANAGER,
    useFactory: (options: CacheManagerOptions) => {
      const cacheManager = loadPackage('cache-manager', 'CacheModule', () =>
        require('cache-manager'),
      );
      const memoryCache = cacheManager.caching({
        ...defaultCacheOptions,
        ...(options || {}),
      });
      return memoryCache;
    },
    inject: [CACHE_MODULE_OPTIONS],
  };
}

// cache-module.interface.ts
export interface CacheModuleOptions extends CacheManagerOptions {
  [key: string]: any;
}

export interface CacheManagerOptions {
  store?: string | CacheStoreFactory;
  ttl?: number;
  max?: number;
  isCacheableValue?: (value: any) => boolean;
}

export interface CacheModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
    /**
     * Injection token resolving to an existing provider. The provider must implement
     * the `CacheOptionsFactory` interface.
     */
    useExisting?: Type<CacheOptionsFactory>;
    /**
     * Injection token resolving to a class that will be instantiated as a provider.
     * The class must implement the `CacheOptionsFactory` interface.
     */
    useClass?: Type<CacheOptionsFactory>;
    /**
     * Function returning options (or a Promise resolving to options) to configure the
     * cache module.
     */
    useFactory?: (...args: any[]) => Promise<CacheModuleOptions> | CacheModuleOptions;
    /**
     * Dependencies that a Factory may inject.
     */
    inject?: any[];
    extraProviders?: Provider[];
}

위에서 보면
CACHE_MANAGER를 export하는 것을 볼 수 있고,
추가적으로 import option에서는 크게 CacheModuleOptions와 CacheModuleAsyncOptions를 볼 수 있다.
결국 해당 설정들을 통해서 'cache-manager'를 통해서 cacheManager.caching(options)를 호출한다.
그리고 이를 내부적으로 CacheManager로 사용하는 것을 알 수 있다.

그렇다면, 저기서 사용하는 CacheManager란 무엇일까?

// cache-manager.interface.ts
export interface CacheStoreFactory {
  /**
   * Return a configured cache store.
   *
   * @param args Cache manager options received from `CacheModule.register()`
   * or `CacheModule.registerAcync()`
   */
  create(args: LiteralObject): CacheStore;
}

// cache-manager module의 Cache interface

export interface Cache {
    set<T>(key: string, value: T, options: CachingConfig): Promise<any>;
    set<T>(key: string, value: T, ttl: number): Promise<any>;
    set<T>(key: string, value: T, options: CachingConfig, callback: (error: any) => void): void;
    set<T>(key: string, value: T, ttl: number, callback: (error: any) => void): void;

    // Because the library accepts multiple keys as arguments but not as an array and rather as individual parameters
    // of the function, the type definition had to be changed to this rather than specific ones
    // actual definitions would looks like this (impossible in typescript):
    // wrap<T>(...keys: string[], work: (callback: (error: any, result: T) => void) => void, options: CachingConfig, callback: (error: any, result: T) => void): void
    // wrap<T>(...keys: string[], work: (callback: (error: any, result: T) => void) => void, callback: (error: any, result: T) => void): void
    // wrap<T>(...keys: string[], work: (callback: (error: any, result: T) => void) => void, options: CachingConfig): void
    // wrap<T>(...keys: string[], work: (callback: (error: any, result: T) => void) => void): Promise<any>;
    wrap<T>(...args: WrapArgsType<T>[]): Promise<any>;

    get<T>(key: string, callback: (error: any, result: T) => void): void;
    get<T>(key: string): Promise<any>;

    del(key: string, callback: (error: any) => void): void;
    del(key: string): Promise<any>;

    reset(): Promise<void>;
    reset(cb: () => void): void;

    store: Store;
}

다음과 같이 CacheOption을 받아서 store를 생성하고 있다.
즉 cache-manager쪽에서는 해당 store를 내부적으로 가지고 있고, 해당 store를 통해서 method를 delegate하는 것을 볼 수 있다.

3. 정리

우선 개략적인 flow는 아래와 같다.

  1. 앱에서 CacheOptionsFactory를 통해서 cache option을 생성한다.
  2. 위에서 만들어진 CacheOption을 통해서 CacheModule.registerAsync() 시에 cacheOption들을 설정하여 받는다.
  3. 내부적으로 '''cache-manager''' module을 호출해서 해당 cache option들을 inject해서 Cache를 만든다.
    1. option에서 store를 받을 때, CacheStoreFactory를 받는데, 해당 부분을 통해서 'cache-manger'에서는 내부 store를 생성하게 된다.
  4. 해당 CacheModule을 활용해서 interceptor등 에서 활용한다.

4. 그렇다면 어떻게 custom하게 바꿀 수 있을까?

4.1. CacheStoreFactory, CacheStore를 구현하기

가장 권장하는 방법인 것 같다. 외부에 option으로 CacheStoreFactory를 통해서 store를 생성할 수 있음을 알 수 있다.
외부로 노출되어 있고, 또한 자유롭게 option 등을 설정해서 store에 접근할 수 있을 것 같다.

4.2. CacheManager를 재정의 한다.

이 부분은 CacheManager를 자체적으로 implementation을 해서 사용하는 방법으로 store에 접근하지 않고도 자체적인 method를 구현할 수 있다.
하지만 기본적으로 CacheManager에 해당하는 method들을 만들어 주어야하고, 현재는 CacheInterceptor에서만 해당 instance를 사용하기 때문에 위험이 없지만 추후에 구현이 늘어날 때, 호환성을 고려해주어야 한다.

5. 다 해보자!

우선 실험의 목적이 강하니, 하나부터 열까지 다 해보도록 하자.
흐름은 아래와 같다.

  1. Store 구현
  2. StoreFactory 및 OptionsFactory 구현
  3. module 설정
  4. (optional) CACHE_MANGER를 구현해서 강제적으로 inject시키기

5.1. CacheStore 구현

아래는 cache-manager에서 내부적으로 가지고 있는 store를 구현한다.

import { CacheStore, CacheStoreSetOptions } from '@nestjs/common';

export class TodoCacheStore implements CacheStore {
  del(key: string): void | Promise<void> {
    console.log('custom store!!!');
    return undefined;
  }

  get<T>(key: string): Promise<T | undefined> | T | undefined {
    console.log('custom store!!!');
    return undefined;
  }

  set<T>(key: string, value: T, options?: CacheStoreSetOptions<T>): Promise<void> | void {
    console.log('custom store!!!');
    return undefined;
  }
}

5.2. CacheStoreFactory 구현

아래는 cache-manager가 initialize될 때, store를 생성할 때 사용된다.

export class TodoCacheStoreFactory implements CacheStoreFactory {
  create(args: LiteralObject): CacheStore {
    return new TodoCacheStore();
  }
}

5.3. CacheOptionsFactory 구현

아래는 nest에서 cache-manager initialize시점에 값을 설정해줄 때 사용한다. (store라는 명칭이 어색하긴 하다... 이름을 바꿔줘도 좋을 것 같은데)

@Injectable()
export class TodoCacheOptionsFactory implements CacheOptionsFactory {
  public createCacheOptions(): CacheModuleOptions {
    return {
      store: new TodoCacheStoreFactory(),
      host: 'localhost',
      port: 6379,
      ttl: 30,
      no_ready_check: true,
    };
  }
}

5.4. CacheModule 등록

아래와 같이 cache module을 생성해서 등록해주도록 하자.

@Global()
@Module({
  imports: [
    NestCacheModule.registerAsync({
      useClass: TodoCacheOptionsFactory,
      inject: [TodoCacheOptionsFactory],
    }),
  ],
  exports: [CacheModule],
})
export class TodoCacheModule {}

// app.module.ts
@Module({
  imports: [
    TodoCacheModule
    ....
  ],
  ....
})

5.5. 테스트

위와 같이 구현을 하게 되면 Cache 내부적으로 내가 커스텀하게 생성한 store를 가지게 된다.
또한 로그를 확인해보면, custom store쪽으로 정상적으로 호출된 것을 확인할 수 있다.

5.6. (Optional) CacheManager도 바꾸자

CacheManager는 따로 설정을 통해서 변경이 가능하다.
우선 아래와 같이 manager class를 생성해준다.

5.6.1. 덮어쓰는 형태

@Injectable()
export class TodoCacheManager implements Cache {
  store: Store;

  constructor() {
    console.log('options');
  }

  del(key: string, callback: (error: any) => void);
  del(key: string): Promise<any>;
  del(key: string, callback?: (error: any) => void): void | Promise<any> {
    console.log(key, callback);
    console.log('zzz');
    return undefined;
  }

  get<T>(key: string, callback: (error: any, result: T) => void): void;
  get<T>(key: string): Promise<any>;
  get(key: string, callback?): void | Promise<any> {
    console.log(key, callback);
    console.log('zzz');
    return undefined;
  }

  reset(): Promise<void>;
  reset(cb: () => void): void;
  reset(cb?: () => void): Promise<void> | void {
    console.log('zzz');
    return undefined;
  }

  set<T>(key: string, value: T, options: CachingConfig): Promise<any>;
  set<T>(key: string, value: T, ttl: number): Promise<any>;
  set<T>(key: string, value: T, options: CachingConfig, callback: (error: any) => void): void;
  set<T>(key: string, value: T, ttl: number, callback: (error: any) => void): void;
  set(key: string, value, options: CachingConfig | number, callback?: (error: any) => void): Promise<any> | void {
    console.log('zzz');
    return undefined;
  }

  wrap<T>(...args: WrapArgsType<T>[]): Promise<any> {
    console.log('zzz');
    return Promise.resolve(undefined);
  }

}

그리고 다음과 같이 provider를 등록해서 이를 export한다.

@Global()
@Module({
  providers: [
    {
      provide: CACHE_MANAGER,
      useFactory: () => {
        return new TodoCacheManager();
      },
    }
  ],
  exports: [CACHE_MANAGER],
})
export class TodoCacheModule {}

이렇게 할 경우에는 custom하게 CACHE_MANAGER를 등록해서 사용 가능하다.

5.6.2. 내부 로직을 태우면서 덮어씌우는 형태

이번에는 꼼수인데, externalProviders를 통해서 내부의 lifeCycle을 이용할 수 있는 방법이 있다.
우선 아래와 같이 cacheManager를 조금 바꿔준다.
constructor를 보면 Factory를 통해서 'cache-manager'의 초기화 부분을 따라하는 것을 볼 수 있다. 또한 store도 factory를 통해 생성된 것을 볼 수 있다.

@Injectable()
export class TodoCacheManager implements Cache {
  store: Store;

  constructor(options: CacheManagerOptions) {
    console.log('cache manager', options);
    if(!(options.store instanceof TodoCacheStoreFactory)) {
      throw new Error(`Store factory can't be injected`);
    }

    this.store = options.store.create(options) as Store;
  }

  del(key: string, callback: (error: any) => void);
  del(key: string): Promise<any>;
  del(key: string, callback?: (error: any) => void): void | Promise<any> {
    console.log(key, callback);
    console.log('zzz');
    return undefined;
  }

  get<T>(key: string, callback: (error: any, result: T) => void): void;
  get<T>(key: string): Promise<any>;
  get(key: string, callback?): void | Promise<any> {
    console.log(key, callback);
    console.log('zzz');
    return this.store.get(key);
  }

  reset(): Promise<void>;
  reset(cb: () => void): void;
  reset(cb?: () => void): Promise<void> | void {
    console.log('zzz');
    return undefined;
  }

  set<T>(key: string, value: T, options: CachingConfig): Promise<any>;
  set<T>(key: string, value: T, ttl: number): Promise<any>;
  set<T>(key: string, value: T, options: CachingConfig, callback: (error: any) => void): void;
  set<T>(key: string, value: T, ttl: number, callback: (error: any) => void): void;
  set(key: string, value, options: CachingConfig | number, callback?: (error: any) => void): Promise<any> | void {
    console.log('zzz');
    return this.store.set(key, value);
  }

  wrap<T>(...args: WrapArgsType<T>[]): Promise<any> {
    console.log('zzz');
    return Promise.resolve(undefined);
  }

}

그리고 아래와 같이 모듈을 등록해주게 되면, externalProvider가 나중에 등록되게 되면서 위의 CacheManager로 등록된 것을 볼 수 있다.

@Global()
@Module({
  imports: [
    CacheModule.registerAsync({
      useClass: TodoCacheOptionsFactory,
      inject: [TodoCacheOptionsFactory],
      extraProviders: [
        {
          provide: CACHE_MANAGER,
          useFactory: (options: CacheManagerOptions) => {
            return new TodoCacheManager(options);
          },
          inject: [CACHE_MODULE_OPTIONS],
        }
      ],
    }),
  ],
  exports: [CacheModule],
})
export class TodoCacheModule {}

6. 결론

  • 다음을 구현하면서, Nest.js의 module시스템과 내부 로직을 좀 더 잘 이해한 것 같다.
  • 현재 Cache에 대해서는 어떻게 구현을 할지 좀 더 고민 중이지만, 장단점을 이해하고, 고민해보아야겠다.

7. 출처

728x90
반응형