[Nest.js] Custom Provider 그리고 IoC

Hoplin·2023년 7월 2일
1
post-thumbnail

Nest.js Module

Nest.js의 대표적인 구성요소로는 Module, Provider, Service가 있다. Nest.js Module을 통해 외부 모듈을 import하여 의존성 주입을 할 수 있으며, 반대로 모듈에 소속된 Provider를 외부로 export할 수 도 있다. 그리고 모듈로서 선언을 하기 위해서는 모듈 클래스 위에 @Module이라는 데커레이터가 존재한다. 우선 이 데커레이터의 메타데이터를 살펴보자.

링크 : https://github.com/nestjs/nest/blob/master/packages/common/interfaces/modules/module-metadata.interface.ts

import { Abstract } from '../abstract.interface';
import { Type } from '../type.interface';
import { DynamicModule } from './dynamic-module.interface';
import { ForwardReference } from './forward-reference.interface';
import { Provider } from './provider.interface';

/**
 * Interface defining the property object that describes the module.
 *
 * @see [Modules](https://docs.nestjs.com/modules)
 *
 * @publicApi
 */
export interface ModuleMetadata {
  /**
   * Optional list of imported modules that export the providers which are
   * required in this module.
   */
  imports?: Array<
    Type<any> | DynamicModule | Promise<DynamicModule> | ForwardReference
  >;
  /**
   * Optional list of controllers defined in this module which have to be
   * instantiated.
   */
  controllers?: Type<any>[];
  /**
   * Optional list of providers that will be instantiated by the Nest injector
   * and that may be shared at least across this module.
   */
  providers?: Provider[];
  /**
   * Optional list of the subset of providers that are provided by this module
   * and should be available in other modules which import this module.
   */
  exports?: Array<
    | DynamicModule
    | Promise<DynamicModule>
    | string
    | symbol
    | Provider
    | ForwardReference
    | Abstract<any>
    | Function
  >;
}

Provider

이중 살펴볼 것은 proivder이다. Provider는 Module에서 배열 타입으로 선언되어있다. 즉 여러개의 Provider를 전달할 수 있다는 의미이다. provider는 앱이 제공하고자 하는 핵심 기능 즉, 비즈니스 로직을 수행하는 역할을 한다. provider에는 service,repository, factory, helper등 여러 형태로 구현이 가능ㅎ다.이를 통해 Single Point of Failure(단일 책임 원칙)을 방지할 수 있다.

기본적으로 nest g를 통해 생성하는 service 또한 Provider지만, 필요에 따라 Custom Provider를 직접 작성해야하는 경우도 있다. Custom Provider가 필요한 경우는 아래의 경우들이 있다.

  1. Nest.js가 만들어주는 인스턴스가 아닌, 인스턴스를 직접 생성해야하는 경우
  2. 여러 크래스가 의존관계에 있을때 이미 존재하는 클래스를 재사용하는 경우
  3. 테스트를 위해 모의 버전으로 프로바이더를 작성해야하는 경우.

이번에는 Proider의 인터페이스를 살펴보자.

링크 : https://github.com/nestjs/nest/blob/master/packages/common/interfaces/modules/provider.interface.ts

import { Scope } from '../scope-options.interface';
import { Type } from '../type.interface';
import { InjectionToken } from './injection-token.interface';
import { OptionalFactoryDependency } from './optional-factory-dependency.interface';

export type Provider<T = any> =
  | Type<any>
  | ClassProvider<T>
  | ValueProvider<T>
  | FactoryProvider<T>
  | ExistingProvider<T>;
export interface ClassProvider<T = any> {
  /**
   * Injection token
   */
  provide: InjectionToken;
  /**
   * Type (class name) of provider (instance to be injected).
   */
  useClass: Type<T>;
  /**
   * Optional enum defining lifetime of the provider that is injected.
   */
  scope?: Scope;
  /**
   * This option is only available on factory providers!
   *
   * @see [Use factory](https://docs.nestjs.com/fundamentals/custom-providers#factory-providers-usefactory)
   */
  inject?: never;
  /**
   * Flags provider as durable. This flag can be used in combination with custom context id
   * factory strategy to construct lazy DI subtrees.
   *
   * This flag can be used only in conjunction with scope = Scope.REQUEST.
   */
  durable?: boolean;
}

export interface ValueProvider<T = any> {
  /**
   * Injection token
   */
  provide: InjectionToken;
  /**
   * Instance of a provider to be injected.
   */
  useValue: T;
  /**
   * This option is only available on factory providers!
   *
   * @see [Use factory](https://docs.nestjs.com/fundamentals/custom-providers#factory-providers-usefactory)
   */
  inject?: never;
}

export interface FactoryProvider<T = any> {
  /**
   * Injection token
   */
  provide: InjectionToken;
  /**
   * Factory function that returns an instance of the provider to be injected.
   */
  useFactory: (...args: any[]) => T | Promise<T>;
  /**
   * Optional list of providers to be injected into the context of the Factory function.
   */
  inject?: Array<InjectionToken | OptionalFactoryDependency>;
  /**
   * Optional enum defining lifetime of the provider that is returned by the Factory function.
   */
  scope?: Scope;
  /**
   * Flags provider as durable. This flag can be used in combination with custom context id
   * factory strategy to construct lazy DI subtrees.
   *
   * This flag can be used only in conjunction with scope = Scope.REQUEST.
   */
  durable?: boolean;
}

export interface ExistingProvider<T = any> {
  /**
   * Injection token
   */
  provide: InjectionToken;
  /**
   * Provider to be aliased by the Injection token.
   */
  useExisting: any;
}

Provider는 결국 ClassProvider, ValueProvider, FactoryProvider, ExistingProviderType 타입을 받을 수 있으며, 각각의 인터페이스가 정의되어있는것을 볼 수 있다. 그리고 각각이 무슨 역할 하는지를 살펴볼 것이다. Type은 단순히 클래스 이름을 그대로 쓰는것을 의미한다. TypeScript에서 Class는 type 네임스페이스와 value 네임스페이스 각각에 생성된다는것을 되새긴다.

Value Provider

Value Provide는 provideuseValue 속성을 가진다. useValue는 어떤 타입도 받을 수 있다.

provideinjection token이다. injection token은 클래스 이름, 문자열, Symbol, Abstract, Function등을 사용할 수 있다.
useValue 구문을 사용하여 외부 라이브러리에서 프로바이더를 삽입하거나, 실제 구현을 모의객체로 대체할 수 있다.

예를 들어 아래와 같이 value provider를 구현했다고 가정하자

  providers: [
    {
      provide: MailService,
      useValue: mockedMailService
    },
  ]

위와같이 작성하면, MailService를 프로바이더로 지정하지만, 실제 값은 mockedMailService로 사용(의존성 주입)하겠다는 의미가 된다.

예시로 문자열을 useValue로 넘겨보도록 한다. inject token은 문자열 CONNECTION으로 지정한다.


// app.module.ts
@Module({
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: 'CONNECTION',
      useValue: 'value provider test',
    }
  ],
})
export class AppModule {}

// app.service.ts
@Injectable()
export class AppService {
  constructor(
    @Inject('CONNECTION') private connection: string,
  ) {}


  valueProvider(): string {
    console.log(process.env.MODE);
    return this.animal.sound();
  }
}

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

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

  @Get('/value-provider')
  public valueProvider(): string {
    return this.appService.valueProvider();
  }
}

이제 localhost:3000/value-provider에 요청을 보내면 아래와 같이 value provider test가 잘 반환된것을 볼 수 있다.

중요하게 봐야할것은 Module의 Value Provider 부분이다.

{
  provide: 'CONNECTION',
  useValue: 'value provider test',
}

그리고 이 provider를 주입시키기 위해 @Inject parameter decorator를 사용하였다. @Inject 데코레이터는 injection token을 단일 매개변수로 받는다. 위 예시에서도 CONNECTION이라는 문자열을 토큰으로 전달해 주입하는것을 볼 수 있다.

Class Provider

Class Provider는 useClass 속성을 사용한다. provide속성은 동일하게 사용한다. Class Provider를 활용하여 프로바이더로 사용해야할 인스턴스를 동적으로 구성할 수 있다. 이 점을 활용하여 특정 조건에 따라 주입해야할 인스턴스를 달리 해줄 수 있다.

// animal.abstract.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export abstract class Animal {
  public abstract sound(): string;
}

// cat.ts
import { Injectable } from '@nestjs/common';
import { Animal } from './animal.abstract';

@Injectable()
export class Cat extends Animal {
  public sound(): string {
    return 'meow';
  }
}

// dog.ts
import { Injectable } from '@nestjs/common';
import { Animal } from './animal.abstract';

@Injectable()
export class Dog extends Animal {
  public sound(): string {
    return 'bark';
  }
}

위와 같이 Animal이라는 추상클래스와, 이를 구현한 Cat,Dog라는 클래스가 있다. 만약에 환경변수에 따라 production인 경우에는 Dog 클래스를, 이외의 경우에는 Cat 클래스를 주입하고 싶다고 가정해본다.

// app.module.ts
@Module({
  imports: [
    LoggerModule.forRoot(AppService.name),
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `${__dirname}/.env`,
    }),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: Animal,
      useClass: process.env.MODE === 'production' ? Dog : Cat,
    },
  ],
})
export class AppModule {}

// app.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import { MyLogger } from './logger/logger.service';
import { Animal } from './class-provider-test/animal.abstract';

@Injectable()
export class AppService {
  constructor(
    private readonly animal: Animal,
  ) {}

  classProvider(): string {
    console.log(process.env.MODE);
    return this.animal.sound();
  }
}

// app.controller.ts

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

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

  @Get('/class-provider')
  public classProvider(): string {
    return this.appService.classProvider();
  }
}

injection token(provide)를 Dog, Cat클래스의 상위 타입인 Animal로 지정할 수 있다. 그리고 삼항연산자를 통해 클래스를 주입한다. 그리고 Animal 타입을 제공받은 Service는 Animal 추상 클래스에 선언된 추상메소드를 사용할 수 있다. .env파일을 변경해보며 요청의 변화를 살펴보자.

그리고 위 예시에서 IoC(Inversion Of Control, 제어반전)기술이 사용되는것을 볼 수 있다. Animal객체의 관리는 IoC 컨테이너가 관리한다. 그리고 이를 구현한 클래스들을 useClass속성에 필요에 따라 변경 및 분기문을 작성해 주면 되는것이다.

MODE=production

MODE="production"

MODE=dev

MODE="dev"

Factory Provider

팩토리 프로바이더 또한 인스턴스를 동적으로 구성하고자 할때 사용한다. 팩토리 프로바이더는 useFactory를 사용한다. 앞서 봤던 useValue, useClass와 다른점은 함수로 되어있다는 것이다.

useFactory은 원하는 인수와 리턴타입으로 작성하면 된다. 필요에 따라 useFactory에서 다른 프로바이더를 주입받아야 한다면 inject 속성을 추가하여 전달해주면 된다.

{
      provide: 'FACTORYTEST',
      useFactory: (sf: SomeProvider) => {
        // Factory Logic
      }
      inject: [SomeProvider]
},

Factory Provider를 활용해 Logger Dynamic Module을 만들어본다.이 글에서는 Dynamic Module에 대한 별도의 설명은 하지 않는다.(Dynamic Module에 대해)

기본적으로 NestJS에는 @nestjs/common 패키지에 Logger 클래스가 존재한다. 하지만, 내장 Logger는 콘솔에 기록만 할 수 있을뿐 파일로 저장하는 등 추가적인 작업을 하지 못한다. ConsoleLogger 클래스를 상속받아서 Logger 클래스를 확장시켜본다.

// logger.service.ts
import { ConsoleLogger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { measureMemory } from 'vm';

@Injectable()
export class MyLogger extends ConsoleLogger {
  constructor(classname: string) {
    super(classname);
  }

  log(message: any, context?: string): void;
  log(message: any, ...optionalParams: any[]): void;
  log(message: unknown, context?: unknown, ...rest: unknown[]): void {
    super.log.apply(this, [message]);
    this.doSomething();
  }

  error(message: any, stackOrContext?: string): void;
  error(message: any, stack?: string, context?: string): void;
  error(message: any, ...optionalParams: any[]): void;
  error(
    message: unknown,
    stack?: unknown,
    context?: unknown,
    ...rest: unknown[]
  ): void {
    super.error.apply(this, [message]);
    this.doSomething();
  }

  private doSomething() {
    console.log('Do something');
  }
}

log 레벨과 error레벨의 로그들을 출력하고, doSomething()메소드를 추가적으로 실행한다고 가정한다.

다만 MyLogger를 보면 생성자에 클래스 이름을 받는것을 볼 수 있다. 이는 Nest.js 로그에서 어떤 컨텍스트에서 발생한 로그인지를 판별해주는 부분을 출력값 역할을 한다

사용자가 LoggerModule을 다른 모듈에 import할때 forRoot를 호출하여 컨텍스트 이름을 전달할 수 있도록 한다.

forRoot는 Factory Provider를 통해 사용자가 전달한 이름을 MyLogger에 전달하여 생성된 인스턴스를 반환하도록 한다. 여기서는 별도의 외부 프로바이더를 사용하지 않기 때문에 inject 속성을 사용하지 않는다.

.// logger.module.ts

@Module({})
export class LoggerModule {
  static forRoot(serviceName: string): DynamicModule {
    return {
      module: LoggerModule,
      providers: [
        {
          useFactory: () => {
            return new MyLogger(serviceName);
          },
          provide: MyLogger,
        },
      ],
      exports: [MyLogger],
    };
  }
}

// app.module.ts
@Module({
  imports: [
    LoggerModule.forRoot(AppService.name),
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `${__dirname}/.env`,
    }),
  ],
  controllers: [AppController],
})
export class AppModule {}

// app.service.ts

@Injectable()
export class AppService {
  constructor(
    private myLogger: MyLogger,
  ) {}
  getHello(): string {
    this.myLogger.log('Hello');
    this.myLogger.error('world');
    return 'Hello World!';
  }
}

이제 요청을 보내면, log,error레벨 로그가 실행된 후 Do something이 잘 출력되는것을 볼 수 있다.

profile
더 나은 내일을 위해 오늘의 중괄호를 엽니다

0개의 댓글