새소식

반응형
Back-end/NestJS

[NestJS | Docs] Provider 개념 정리 (+ Custom Provider)

2024.02.02
  • -
반응형

1. Provider란?

Providers는 Nest에서 기본을 구성하는 요소 중 하나입니다. Nest에서는 사용되는 대부분의 기본 클래스를 Provider로 취급합니다.

  • Nest에서 클래스는 services, repositories, factories, helpers, 등의 요소로 활용됩니다.

 

Provider의 핵심 아이디어는 그러한 클래스들이 의존성 주입이 가능하다는 점입니다. 이는 객체들이 서로서로 다양한 관계의 형성과 그러한 객체들 간의 연결을 맺어주는 기능을 Nest 런타임 시스템에게 온전히 맡겨지기 때문에 가능한 것입니다.

 

공식문서 Providers 설명 사진

 

위 사진에서 component가 객체 하나라고 보면 되고, 그들이 서로 의존하는 관계가 화살표로 표시되어 있는데 이러한 component의 생성과 각 연결을 맺어주는 것을 Nest가 대신 해주는 것이라고 보면 될 것 같습니다.

 

또한 위 사진에서 보면 알 수 있듯이 Controller는 HTTP 요청을 처리하는 역할로 그보다 더 복잡한 작업이 요구되는 경우에 Provider에게 그 작업을 위임하여 요소 간 기능 분리를 시킬 때 Provider가 사용되는 것입니다.(SOLID의 S원칙)

 

Providers는 module에서 providers라는 속성에 선언되는 plain JavaScript 클래스입니다.

 

 

1-1. Services

Providersservices의 형태로 만들어지기도 합니다. CatsService라는 service의 코드를 보도록 하겠습니다. 이 service는 데이터 저장과 검색의 역할을 할 것이고 CatsController에서 끌어다 쓸 수 있도록 하는 몇 가지 작업을 해줄 것입니다.

  • CatsController에서 끌어다 쓰기 때문에 의존성을 어떻게 Providers를 통해 처리하는지 설명하기 좋은 예시라고 볼 수 있습니다.

 

// cats.service.ts
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

 

이 클래스는 하나의 멤버 변수두 개의 메서드를 갖고 있습니다. 일반적인 클래스와 다른 점이라고는 클래스 바로 위에 @Injectable() 데코레이터 붙었다는 점인데요. 이 @Injectable 데코레이터는 그것이 붙여진 클래스가 'Nest IoC 컨테이너에 의해 관리될 수 있는 클래스다' 라고 선언하는 일종의 "메타데이터"(그 클래스를 구분하는 정보를 부가하는)를 부여해주는 역할입니다.

  • Nest IoC 컨테이너는 개발자가 제어해야 할 요소들을 Nest 런타임 시스템에서 대신 제어해줄 때, 그러한 제어를 위해 사용되는 컨테이너입니다.

그 다음, 위 코드에서 사용되는 Cat 인터페이스를 만들어주겠습니다.

// interfaces/cat.interface.ts
export interface Cat {
  name: string;
  age: number;
  breed: string;
}

 

 

이제 cats를 검색하는 service 클래스를 완성했으니 이 클래스를 CatsController에서 쓸 수 있도록 해봅시다.

import { Controller, Get, Post, Body } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

 

CatsService가 이 클래스 생성자(constructor)를 통해서 주입된 것을 확인할 수 있습니다.

  • 주입되었음을 알 수 있는 증거는 이 클래스에서 CatsService 객체를 직접 생성해주지 않았음을 볼 수 있기 때문입니다. 만약 직접 생성해 주었다면 new 키워드를 통해 새로운 인스턴스를 만드는 부분이 있었겠죠.

생성자에서 Service를 주입받을 때 해당하는 인스턴스의 접근지정자를 private으로 지정했는데 이렇게 해주면 따로 멤버 변수에서 선언하지 않고 즉시 선언 및 초기화가 가능합니다.

  • 일반적인 상황에서는 멤버 변수에 먼저 선언을 하고 생성자 내부에서 `this.catsService = catsService`와 같은 작업을 해주어야 변수의 초기화가 되기 때문에 다른 메서드들이 this 키워드를 통해 this.catsService와 같이 사용할 수 있습니다.

 

1-2. Dependency Injection: DI (의존성 주입)

Nest는 의존성 주입(Dependency Injection)으로 흔히 알려진 강력한 디자인 패턴을 기반으로 만들어졌습니다. 

 

Nest는 TypeScript가 제공하는 타입 기능으로 인해, 의존성을 관리하기가 매우 쉽습니다. 아래 예시를 보면, Nest는 CatsService의 인스턴스를 생성하고 그것을 반환함으로써 catsService를 내놓습니다.

  • 혹은 singleton의 일반적인 경우, 어딘가에서 이미 요청되었다면 새로 생성하지 않고 그 존재하는 인스턴스를 반환합니다.

 

이러한 의존성이 생성되고 controller의 생성자로 전달됩니다.

constructor(private catsService: CatsService) {}

 

1-3. Scope 개념

Providers는 기본적인 상태인 경우 애플리케이션 생명주기와 동기화된 수명을 가집니다. 애플리케이션이 시작될 때, 모든 의존성들이 해결되기 때문에 이때 모든 provider들은 인스턴스화 되어야 합니다. 이와 똑같이 애플리케이션이 종료될 때, 각 Provider들은 파기될 것입니다.

 

하지만 이 Provider 수명을 request-scoped로 만드는 방식 또한 존재합니다.

 

scope의 개념은 크게 3가지가 있습니다.

  • request-scoped(REQUEST): 각 수신 요청마다 Provider의 새 인스턴스가 독점적으로 생성됩니다. 그 인스턴스는 요청 처리가 완료된 후 garbage-collected 됩니다.
  • default-scoped(DEFAULT): 해당 Provider에 대한 하나의 인스턴스가 애플리케이션 전역에 걸쳐 공유됩니다. 그렇기 때문에 이 범위의 인스턴스 수명은 애플리케이션 생명주기에 달려있는 것입니다. 애플리케이션이 부팅되면 모든 Singleton Provider들은 인스턴스화 됩니다.
  • transient-scoped(TRANSIENT): transient(일시적) Provider는 그것을 사용하는 consumer들 사이에 공유되지 않습니다. transient Provider를 주입한 각 consumer는 새로운, 그리고 dedicated(본인에게만 할당된) 인스턴스를 받게됩니다.

 

2-4. Optional providers

아주 가끔, 반드시 해결될 필요가 없는 의존성을 가지는 경우가 있을 수 있습니다.

 

예를 들어, 만든 클래스가 설정 객체(configuration object)에 의존하는데, 어떠한 설정 객체도 넘겨받지 못했다면, 기본 값(default value)이 사용되도록 하면 됩니다. 이런 경우에, 해당 설정 provider가 없어도 에러가 발생하지 않을 것이기 때문에 이 의존성은 선택 사항이 됩니다.

 

provider가 선택 사항임을 알리기 위해 @Optional() 데코레이터를 생성자의 시그니처에서 해당 객체에 대해 사용합니다.

import { Injectable, Optional, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  constructor(@Optional() @Inject('HTTP_OPTIONS') private httpClient: T) {}
}

 

위 예시는 custom provider를 사용한 예시이기에, custom token에 HTTP_OPTIONS를 포함시킨 것입니다.

 

이러한 예시는 생성자에서 클래스를 통해 의존성을 명시하는 생성자 기반 주입 방식(constructor-based)을 보여줍니다.

 

2-5. Property-based injection(속성 기반 주입)

이제껏 사용했던 방식은 모두 생성자 기반의 주입이었습니다. 몇 가지 구체적인 경우에, property-based(속성 기반) 주입이 유용하게 사용될 수 있습니다.

 

예를 들어, 최상위 클래스가 하나 혹은 여러 개의 provider에 의존하는 경우, 그것을 상속하는 모든 하위 클래스들은 내부 생성자에서 super()를 호출하여 그러한 provider를 전부 전달해야 하는 경우 이는 다소 번거로운 일 일것입니다. 이러한 귀찮은 일을 하지 않기 위해서, 속성 수준(멤버 변수 필드)에서 @Inject() 데코레이터를 사용할 수 있습니다.

 

import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  @Inject('HTTP_OPTIONS')
  private readonly httpClient: T;
}

 

공식 문서에는, 만약 만든 클래스가 또 다른 클래스를 상속하지 않는다면, 웬만해서는 생성자 기반 주입을 사용하도록 권장하고 있습니다.

 

더 자세한 예시를 보겠습니다. 예를 들어, 아래와 같이 부모 클래스와 자식 클래스가 있고, 부모 클래스에서 TestServiceA를 주입받아서 사용하는 상황을 가정하겠습니다.

 

// propertyBase.testServiceA.ts
@Injectable()
export class TestServiceA {
	testHello() {
		return 'hello Test A';
	}
}
// propertyBase.parentService.ts
// parentService를 직접 참조하는 클래스가 없기 때문에 Injectable이 없어도 됩니다.
export class ParentService {
	constructor(private readonly testServiceA: TestServiceA) {}

	testHello(): string {
		return 'hello world';
	}

	parentTest(): string {
		return this.testServiceA.testHello();
	}
}
// // propertyBase.childService.ts
@Injectable()
export class ChildService extends ParentService {
	testHello(): string {
		return this.parentTest();
	}
}

 

아래 StudyController에서는 ParentService를 상속한 ChildService의 메서드만 사용할 것이기 때문에, ParentService의 메서드는 어떠한 것도 사용되지 않아 굳이 @Injectable() 데코레이터가 필요하지 않습니다.

 

@Controller('study')
export class StudyController {
	constructor(
		private readonly studyService: StudyService,
		private readonly childService: ChildService
	) {}
    
	...
    
	// Property - based injection 예제
	@Get('propertyBase')
	propertyBaseTest(): string {
		return this.childService.testHello();
	}
    
}

StudyController에서 @Get() 데코레이터를 사용해서 /propertyBase 엔드포인트를 만들었을 때, 그 결과는 'hello Test A'가 출력될 것 같지만 실제 testHello()를 찾을 수 없다는 오류가 발생합니다.

 

그 이유는 StudyController에서 생성자를 통해 ChildService를 주입받았지만, ChildService의 부모 클래스인 ParentService는 주입할 수 있는 클래스로 설정이 되어있지 않았기 때문에 ParentService에서 사용하는 TestServiceA 가 주입되지 않았기 때문입니다.

 

이를 해결하기 위해선 ChildService에서 super() 키워드를 사용하여 TestServiceA의 인스턴스를 부모 클래스에게 전달해주어야 합니다.

@Injectable()
export class ChildService extends ParentService {
	constructor(private readonly tsA: TestServiceA) {
		super(tsA);
	}

	testHello(): string {
		return this.parentTest();
	}
}

 

어떠한 클래스가 다른 provider를 extend 하지 않거나 상속관계 있지 않은 경우에는 생성자 기반의 주입방식을 사용하는 것이 좋습니다.

 

2-6. Provider registration (Provider 등록)

우리는 앞서 하나의 provider(CatsService)를 정의했고 그 provider를 사용하는 consumer(CatsController)도 만들었으니, Nest가 의존성 주입을 할 수 있도록 그 service를 등록해야 합니다.

 

module(app.module.ts) 파일을 수정하고 @Module 데코레이터의 providers 배열에 추가하면 등록이 됩니다.

 

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class AppModule {}

 

이제 위 코드로 인해 Nest가 CatsController 클래스의 의존성을 해결할 수 있을 것입니다.

 

앞서 만든 파일들의 구조는 아래와 같습니다.

/src
|-- cats
|     |-- dto
|          |-- create-cat.dto.ts
|     |-- interfaces
|          |-- cat.interface.ts
|     |-- cats.controller.ts
|     |-- cats.service.ts
|-- app.module.ts
|-- main.ts

 

2. Custom Provider

Nest는 Provider들 간의 의존 관계를 해결하기 위해 사용하는 내장 IoC 컨테이너를 갖고 있습니다. 이러한 특성은 위에서 설명했던 의존성 주입에 기반하며, 이것의 위력은 사실 위에서 설명했던 것보다 훨씬 더 강력한 힘을 갖고 있습니다.

 

provider는 몇 가지 방식으로 정의될 수 있습니다:

  • plain values, classes, 비동기/동기 factories

 

앞선 1장에서 의존성 주입의 다양한 측면과 Nest에서 그것을 어떻게 사용하는지를 알아보았는데요. 클래스에 인스턴스를 주입할 때 사용되는 그 예시 중 하나는 생성자 기반 의존성 주입이었습니다.

 

의존성 주입이 기본적인 Nest 코어로 구축되었다는 사실을 알아도 그렇게 놀랍지 않을 것입니다. 지금까지는 하나의 주요 패턴만을 살펴보았는데, 애플리케이션은 더 복잡해질 수록, 의존성 주입 시스템의 모든 기능을 이용하게 될 것입니다. 그렇기 때문에 우리는 Custom Provider에 대해서도 잘 알고 있어야 합니다.

 

2-1. DI 기초

DI(의존성 주입); 의존성을 가진 객체의 인스턴스화를 우리가 만든 클래스 자체에서 직접 명령적으로(new 키워드) 수행하는 대신, IoC 컨테이너에 위임하는 IoC(제어 역전) 기법입니다. 앞선 예시들에서 무슨 일들이 벌어졌는지 다시 한 번 살펴보도록 하겠습니다. 

 

먼저 우리는 provider를 정의합니다. @Injectable() 데코레이터가 CatsService 클래스를 provider로 지정해줍니다. 

 

import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  findAll(): Cat[] {
    return this.cats;
  }
}

 

그리고 나서 Nest가 controller 클래스에게 이 provider를 주입하도록 합니다.(생성자 기반)

import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

 

마지막으로 Nest IoC 컨테이너에게 provider를 등록합니다.

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class AppModule {}

 

이 작업이 수행되기 위해 물밑에서는 정확히 어떤 일이 일어나고 있는 것일까요? 여기엔 세 가지 핵심 단계가 존재합니다.

  1. cats.service.ts에서 @Injectable() 데코레이터가 CatsService를 Nest IoC 컨테이터에 의해 관리될 수 있는 클래스로 선언합니다.
  2. cats.controller.ts에서 CatsController는 생성자 주입으로 CatsService 토큰에 대한 의존성을 선언합니다.
  constructor(private catsService: CatsService)

 

3. app.module.ts에서 CatsService 토큰을 cats.service.ts 파일의 CatsService 클래스와 연관짓습니다. 이후에 이러한 연관이 어떻게 일어나는지 알아보겠습니다.

 

 

Nest IoC 컨테이너가 CatsController를 인스턴스화 할 때, 우선 모든 의존성들을 찾습니다. CatsService 의존성을 발견하면, 등록 단계(3번)에 따라CatsService 토큰에 대한 조회를 수행하여 CatsService 클래스를 반환합니다. SINGLETON 범위(default)로 가정했을 때, Nest는 CatsService의 인스턴스를 만들고, 캐싱한 다음, 반환하는 단계를 거치거나 만약 인스턴스가 이미 캐싱되었다면 존재하던 인스턴스를 반환합니다.

 

위 설명은 핵심을 보이기 위해 약간의 단순화된 설명입니다. 우리가 간과한 한 가지 중요한 부분은 의존성을 위해 코드를 분석하는 프로세스는 사실 매우 정교하고 애플리케이션 시작 중에 발생한다는 점입니다.

 

한 가지 주요 특징은 의존성 분석(혹은 의존성 그래프를 만드는 일)이 일시적라는 것입니다. 위의 예시에서 만약 CatsService 자체에 의존성이 있는 경우, 이 의존성도 해결될 것입니다. 의존성 그래프는 의존성들이 올바른 순서로 해결되기를 보장해 줍니다.

  • 그렇기에 필연적으로 bottom up 방식입니다.

 

이 메커니즘은 개발자가 그러한 복잡한 의존성 그래프를 관리할 필요가 없도록 해줍니다.

 

2-2. Standard provider (일반 provider)

@Module() 데코레이터를 더 자세히 봅시다. app.module에서 우리는 다음을 정의합니다:

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})

providers 속성은 providers의 배열을 가집니다. 앞서 우리는 그러한 providers를 클래스 이름을 갖는 리스트를 통해 공급했습니다. 사실 `providers: [CatsSercvice]` 문법은 더 복잡한 문법을 줄여서 표현했던 방식입니다.

 

원래는 아래와 같은 표현으로 사용됩니다.

providers: [
  {
    provide: CatsService,
    useClass: CatsService,
  },
];

 

이렇게 명시적으로 등록하는 방식을 보고나니, 이제야 등록 단계가 이해가 됩니다. 여기서 우리가 분명하게 CatsService 토큰을 CatsService 클래스로 연관지어주고 있습니다. 줄임표현은 토큰을 사용해서 그 토큰에 해당하는 이름을 가진 클래스의 인스턴스를 요청하는 가장 일반적인 사용 사례를 단순화하기 위한 편의상의 표기일 뿐인 것입니다.

 

2-3. Custom Providers란?

만약 일반 providers에 제공되는 범위를 넘어선 요구사항이 요구되는 경우 무슨 일이 벌어질까요?

 

범위를 넘어선 요구사항이란 다음과 같은 것들을 말합니다.

  • Nest가 클래스를 인스턴스화 하도록 하지 않고 custom 인스턴스를 생성하고 싶을 때(혹은 캐싱된 인스턴스를 제공)
  • 두 번째 의존성에서 기존 클래스를 재사용하고자 하는 경우
  • 테스트를 위한 mock(모의) 버전으로 클래스를 오버라이드하고 싶은 경우

Nest는 이러한 경우들을 다루기 위해 Custom provider를 정의할 수 있게 해줍니다. custom provider를 정의하는 데에는 몇 가지 방식이 존재합니다.

 

2-4. Value Providers: useValue

useValue 구문은 상수값을 주입하는 경우나 외부 라이브러리를 Nest 컨테이너로 주입하는 경우, mock(모의) 객체의 실제 구현을 대체하는 경우에 유용합니다. Nest가 모의의 CatsService를 테스트를 위해 강제로 사용하고 싶다고 해봅시다.

 

import { CatsService } from './cats.service';

const mockCatsService = {
  /* mock implementation
  ...
  */
};

@Module({
  imports: [CatsModule],
  providers: [
    {
      provide: CatsService,
      useValue: mockCatsService,
    },
  ],
})
export class AppModule {}

 

이 예시에서, CatsService 토큰이 mockCatsService 모의 객체로 대신 resolve 됩니다. useValue는 이름에서 알 수 있듯이 어떠한 'value'를 필요로 합니다. 위 경우에서는 대체하는 CatsService 클래스와 동일한 인터페이스를 가진 리터럴 객체를 의미합니다.

 

타입스크립트의 structural typing(구조적 타이핑)으로 인해, 호환 가능한 인터페이스를 갖는 어떠한 객체든 사용할 수 있습니다. (리터럴 객체나 new 키워드로 인스턴스화 된 클래스의 인스턴스 등)

  • 구조적 타이핑은 같은 구조를 갖는 클래스끼리 타입 호환이 가능한 것을 말합니다.

 

따라서 useValue는 클래스의 인스턴스가 아닌 문자열이나 객체 리터럴과 같은 값을 의존성 주입하기 위한 설정 등록입니다. 위의 예제에서 테스트를 위해 모듈을 설정할 때, 실제 CatsService 대신 mockCatsService를 사용하도록 설정합니다. `provide: CatsService`는 NestJS에게 어떤 서비스를 대체할 것인지 알려주는 것이며, `useValue: mockCatsService`는 실제로 대체될 객체를 지정하는 것입니다.

 

그렇게 함으로써 어떤 클래스에서 CatsService를 주입받을 때, 그 CatsService의 로직을 통해 반환되는 값이 아니라 우리가 모의로 설정한 리터럴 객체인 mockCatsService가 주입되게 됩니다.

 

그렇기 때문에 해당 객체는 CatsService가 반환하는 값의 형태를 띠고 있을 것입니다.

 

예를 들어, 실제 CatsService가 다음과 같이 생겼다고 가정해 보겠습니다.

export class CatsService {
  findAll(): Cat[] {
    // 실제 데이터베이스에서 고양이 데이터를 조회
  }

  findOne(id: string): Cat {
    // 특정 ID의 고양이 데이터를 조회
  }

  // 기타 메소드들...
}

 

그러면 mockCatsService는 다음과 같이 만들어질 것입니다.

const mockCatsService = {
  findAll: () => [{ name: 'MockCat1' }, { name: 'MockCat2' }],
  findOne: (id: string) => ({ name: `MockCat${id}` }),

  // 기타 메소드들의 모의 구현...
};

 

이는 CatsController에 대한 테스트를 진행할 때, CatsController가 CatsService에 의존하면서 생기는 데이터베이스에 대한 접근을 해야만 하는 경우에 그러한 데이터베이스 연결에 대한 의존성 없이 기능을 테스트 할 수 있음을 보장해 줍니다.

 

2-5. Non-class-based provider tokens

지금까지 우리는 provider 토큰으로 클래스의 이름을 사용했습니다.

  • providers 배열에 나열된 provide 속성의 값

non-class-based provider token 방식은 생성자 기반 주입에 사용되는 표준 패턴에 해당하며, 여기서 토큰은 클래스 이름이기도 합니다.

 

그리고 토큰은 때때로, 문자열이나 symbolDI 토큰으로 사용할 수 있는 유연성이 필요할 때가 있습니다. 예를 들어:

import { connection } from './connection';

@Module({
  providers: [
    {
      provide: 'CONNECTION',
      useValue: connection,
    },
  ],
})
export class AppModule {}

이 예제에서, 우리는 string-valued 토큰('CONNECTION')을 외부 파일로부터 import 했던 이미 존재하는 connection 객체에 연관시킵니다.

 

문자열 말고도 JavaScript symbol이나 TypeScipt enum도 사용가능합니다.

 

앞서 우리는 생성자 기반 주입 패턴으로 provider를 주입하는 방법에 대해 알아보았었습니다. 이 패턴은 의존성이 클래스 이름으로 선언되도록 요구합니다. custom provider인 'CONNECTION'는 문자열 값 토큰을 사용합니다. 이런 provider는 어떻게 주입해 주어야 하는지 알아봅시다.

 

문자열 값 토큰 provider를 주입하기 위해선 @Inject() 데코레이터를 사용합니다. 이 데코레이터는 하나의 인수를 취합니다. - "token"

@Injectable()
export class CatsRepository {
  constructor(@Inject('CONNECTION') connection: Connection) {}
}

위 예시에서는 설명을 위해 'CONNECTION' 문자열을 직접 사용했지만, 깔끔한 코드 정리를 위해서는 constants.ts와 같은 별도의 파일에서 토큰을 정의하여 관리하는 것이 권장됩니다. 자체 파일에 정의되어 필요한 경우 그러한 토큰은 import 된 symbol이나 enum과 동일하게 취급합니다.

  • ex) CONNECTION = "CONNECTION"

 

2-6. Class providers: useClass

useClass 구문을 사용하면 토큰이 resolve 해야 하는 클래스를 동적으로 결정할 수 있습니다. 예를 들어, 추상 클래스 ConfigService가 있다고 가정해 보겠습니다. 현재 환경(개발, 배포,...)에 따라 Nest에서 다른 구성(configuration) service 구현체를 제공하고자 합니다. 다음 코드는 이러한 전략에 대한 모습입니다.

const configServiceProvider = {
  provide: ConfigService,
  useClass:
    process.env.NODE_ENV === 'development'
      ? DevelopmentConfigService
      : ProductionConfigService,
};

@Module({
  providers: [configServiceProvider],
})
export class AppModule {}

 

먼저 리터럴 객체로 configServiceProvider를 정의한 다음 모듈 데코레이터의 providers 속성에 전달한 것을 볼 수 있습니다. 이는 약간의 코드 정리에 불과하지만 기능적으로는 이 장에서 동일한 기능을 수행 합니다.

 

또한, ConfigService 클래스 이름을 해당 provider의 토큰으로 사용했습니다. ConfigService에 의존하는 어떠한 클래스든지, Nest는 해당 클래스의 인스턴스를 다른 곳에서 선언되었을 모든 기본 구현(default implementation)을 오버라이드하여 인스턴스를 주입합니다.

  • 제공되는 클래스는 DevelopmentConfigService 또는 ProductionConfigService일 것이며, 기본 구현은 @Injectable() 데코레이터로 선언된 ConfigService를 의미합니다.

 

2-7. Factory providers: userFactory

useFactory 구문을 사용하면 providers를 동적으로 생성할 수 있습니다. 이 provider는 factory 함수로부터 반환된 값으로 제공됩니다. factory 함수는 필요에 따라 단순할 수도 복잡할 수도 있습니다. 

 

단순한 factory는 다른 어떤 provider에도 의존하지 않을 것입니다.

그러나 더 복잡한 factory는 결과를 계산함에 있어서 필요한 다른 provider를 또 자체적으로 주입할 수도 있습니다.

 

후자의 복잡한 factory인 경우, factory provider 구문은 한 쌍의 연관된 메커니즘을 갖습니다. 

  • factory 함수는 (선택적으로) 인수를 받을 수 있습니다.
  • (선택적) inject 속성은 해당 provider의 인스턴스화 과정 동안에 Nest가 확인하여 factory 함수에 인수로 전달할 provider 배열을 받습니다.
const connectionProvider = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider, optionalProvider?: string) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
  //       \_____________/            \__________________/
  //        This provider              The provider with this
  //        is mandatory.              token can resolve to `undefined`.
};

@Module({
  providers: [
    connectionProvider,
    OptionsProvider,
    // { provide: 'SomeOptionalProvider', useValue: 'anything' },
  ],
})
export class AppModule {}

위 예시에서 useFacory의 factory 함수는 익명 함수로 구현되어 있는데 그 안의 내용을 보면 또 다른 provider인 optionsProvider와 optionalProvider를 필요로 하는 것을 볼 수 있습니다. 이러한 provider들을 factory 함수에 주입해주기 위해 inject 속성을 추가하여 해당 속성에 provider를 클래스 이름 혹은 문자열 값으로 토큰을 넣어줍니다.

 

2-8. Alias providers: useExisting

useExisiting 구문을 사용하면 이미 존재하는 provider에게 alias(별칭)를 만들어줄 수 있습니다. 이는 똑같은 provider에 접근하는 두 가지 방식을 만들게 됩니다. 아래 예시에서 (string-based) AliasedLoggerService 토큰은 LoggerServie 토큰에 대한 alias 입니다.

 

AliasedLoggerService에 대한 의존성과 LoggerService에 대한 종속성이 각각 하나씩 있다고 가정해보겠습니다. 만약 두 의존성 모두 SINGLETON scope으로 명시되었다면, 둘은 모두 동일한 인스턴스로 의존성이 resolve 되게 됩니다.

 

@Injectable()
class LoggerService {
  /* implementation details */
}

const loggerAliasProvider = {
  provide: 'AliasedLoggerService',
  useExisting: LoggerService,
};

@Module({
  providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}

 

정리하면 useClass는 해당 provider에 의존하는 모든 클래스에게 각기 다른 새 인스턴스를 생성하여 주입하는 방식이었다면, useExisting은 해당 provider에 의존하는 모든 클래스에게 기존 인스턴스에 대한 참조를 재사용하여 주입하는 방식입니다. 즉, 기존 인스턴스에 대한 새로운 참조를 추가해주는 것입니다.

 

  •  useClass를 사용하여 custom provider를 resolve하는 것은 injectable 데코레이터를 통해 동일하게 작동하지만 이 접근 방식에 따르면 클래스를 명시적으로 오버라이드 할 수 있습니다.
  • useValue를 사용한 resolve는 상수값 또는 custom API를 주입하는데 자주 사용됩니다. 예를 들어, HTTP 호출을 그룹화하되 Nest 의존성 주입 트리에서 사용할 수 있게 하려는 경우입니다.
  • 이전에 언급했듯이 Nest는 동기식 및 비동기식으로 값을 resolve 하는 것을 모두 지원하므로 useFactory를 통한 resolve는 다른 방법과 가장 큰 차이를 보여줍니다. 하지만 그 안에서의 의존성 주입은 (반)수동으로 수행해야 하므로, inject 속성을 제공하여 명시적으로 주입해야 합니다.

 

2-9. service가 아닌 providers (Non-service based providers)

providers는 보통 service를 제공하는 데 사용되비만, 그 사용에만 국한되어 사용되어야 하는 것은 아닙니다. provider는 어떠한 value든지 제공할 수 있습니다. 예를 들어, provider는 아래와 같이 현재 환경에 맞게 구성 객체의 배열을 제공하는 데에 사용될 수도 있습니다.

const configFactory = {
  provide: 'CONFIG',
  useFactory: () => {
    return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
  },
};

@Module({
  providers: [configFactory],
})
export class AppModule {}

 

2-10. custom provider 내보내기(export)

다른 providers와 마찬가지로 custom provider는 선언하는 모듈로 범위가 지정됩니다. 다른 모듈에서도 해당 provider가 보이게 하려면 우선 해당 모듈에서 내보내 주어야 합니다. custom provider를 내보내려면 해당 provider 토큰이나 full provider object를 사용할 수 있습니다.

 

다음은 그 예시입니다.

const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

@Module({
  providers: [connectionFactory],
  exports: ['CONNECTION'], // token 사용
})
export class AppModule {}

 

또는 아래와 같이 full provider object를 사용하여 내보낼 수도 있습니다.

const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

@Module({
  providers: [connectionFactory],
  exports: [connectionFactory], // full provider object 사용
})
export class AppModule {}

 

3. 비동기 providers(Asynchronous providers)

때로는 하나 이상의 비동기 작업이 완료될 때까지 애플리케이션 시작을 지연시켜야 하는 경우가 있습니다. 예를 들어, 데이터베이스와의 연결이 설정될 때까지는 어떠한 request도 허락하지 않고 싶은 경우가 있을 수 있습니다.

 

비동기 providers를 사용하면 이를 가능하게 해줍니다.

 

useFactory 구문과 함께 async/await을 사용하면 됩니다. 그러면 factory는 Promise를 반환하는데, factory 함수는 비동기 작업을 await 할 수 있습니다. 이렇게 await을 사용하여 Nest는 이러한 provider에 의존하는 클래스를 인스턴스화하기 전에 promise의 해결(resovle)을 기다립니다.

{
  provide: 'ASYNC_CONNECTION',
  useFactory: async () => {
    const connection = await createConnection(options);
    return connection;
  },
}

 

이 provider도 다른 공급자와 마찬가지로 토큰을 통해 다른 컴포넌트에 주입해야 하므로 위의 예시의 경우에는 @Inject('ASYNC_CONNECTION') 과 같이 주입할 수 있습니다.

 

 

이상으로 NestJS에서 Provider를 다루는 방법이었습니다. 감사합니다.

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.