본문으로 바로가기

프로바이더(Provider)

컨트롤러는 요청과 응답을 가공하고 처리하는 역할을 맡습니다. 하지만 서버가 제공하는 핵심기능은 전달받은 데이터를 어떻게 비즈니스 로직으로 해결하는가 입니다.

만약 음식 배달 앱에서 메뉴 목록 조회를 요청했다고 했을 때, 사용자 주변에서 위치한 가게를 DB에서 검색하는 작업을 수행해야 합니다.

또 사용자가 좋아할만한 메뉴가 학습되어 있다면 이를 기반으로 추천 메뉴 구성을 바꿀 수도 있을 것입니다.

앱이 제공하고자 하는 핵심 기능, 즉 비즈니스 로직을 수행하는 역할을 하는 것이 프로바이더입니다.

컨트롤러가 이 역할을 수행할 수도 있겠지만 소프트웨어 구조상 분리해두는 것이 단일 핵심 원칙(SRP, Single Responsibility Principle)에 더 부합하겠죠? 그렇지 않으면 코드가 뒤죽박죽 스파게티처럼 될겁니다.

 

프로바이더는 서비스(Service), 레포지터리(Repository), 팩토리(Factory), 헬퍼(Helper) 등 여러가지 형태로 구현이 가능합니다.

각각의 개념은 소프트웨어 아키텍처를 다루는 다른 자료를 참고하시길 바랍니다.

https://wikidocs.net/158499

Nest에서 제공하는 프로바이더의 핵심은 의존성을 주입할 수 있다는 점입니다.

의존성을 주입하기 위한 라이브러리가 많이 있지만 Nest가 이를 제공해주기 때문에 손쉽게 사용할 수 있습니다.

의존성 주입(DI, Dependency Injection)은 OOP에서 많이 활요하고 있는 기법입니다.
의존성 주입을 이용하면 객체를 생성하고 사용할 때 관심사를 분리할 수 있습니다.

UsersController 코드를 다시 살펴봅시다.

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
  ...
  
  @Delete(':id')
  remove(@Param('id') id: string) {
  	return this.usersService.remove(+id);
  }
}

컨트롤러는 비즈니스 로직을 직접 수행하지 않습니다. 컨트롤레에 연결된 UsersService에서 수행합니다.

UsersService는 UsersController의 생성자에서 주입받아 usersService라는 객체 멤버 변수에 할당되어 사용되고 있습니다.

아직 데이터베이스를 연결하지 않았기 때문에 UsersService 내부의 코드는 문자열을 리턴하는 임시코드만 작성되어 있지만 UsersService에게 어떻게 작업을 위임하는지 보여줍니다.

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

@Injectable()
export class UsersService {
  ...
  
  remove(id: number) {
    return `This action removes a #${id} user`;
  }
}

@Injectable 데코레이터를 주목하세요. UsersService 클래스에 이 데코레이터를 선언함으로써 다른 어떤 Nest 컴포넌트에서도 주입할 수 있는 프로바이더가 됩니다. 별도의 스코프(Scope)를 지정해 주지 않으면 일반적으로 싱글톤 인스턴스가 생성됩니다.

 

 

프로바이더 등록

프로바이더 인스턴스 역시 모듈에서 사용할 수 있도록 등록을 해주어야 합니다.

자동 생성된 코드에서 UsersModule 모듈에 등록해 둔 것을 볼 수 있습니다.

@Module({
  ...
  providers: [UsersService]
})
export class UsersModule {}

 

 

속성(Property) 기반 주입

지금까지는 생성자를 통해 프로바이더를 주입받았습니다.

하지만 프로바이더를 직접 주입받아 사용하지 않고 상속관계에 있는 자식 클래스를 주입 받아 사용하고 싶은 경우가 있습니다.

레거시 클래스를 확장한 새로운 클래스를 만드는 경우 새로 만든 클래스를 프로바이더로 제공하고 싶은 경우입니다.

이럴 때는 자식 클래스에서 부모 클래스가 제공하는 함수를 호출하기 위해서는 부모 클래스에서 필요한 프로바이더를 super()를 통해 전달해주어야 합니다.

예를 들어 보겠습니다.

// @Injectable()이 선언되어 있지 않습니다. BaseService 클래스를 직접 참조하지 않기 때문입니다.
export class BaseService {
  constructor(private readonly serviceA: ServiceA) {}
  
  getHello(): string {
    return 'Hello World BASE!';
  }
  
  doSomeFuncFromA(): string {
    return this.serviceA.getHello();
  }
}
import { Injectable } from '@nestjs/common'

@Injectable()
export class ServiceA {
  getHello(): string {
    return 'Hello World A!';
  }
}
@Injectable()
export class ServiceB extends BaseService {
  getHello(): string {
    return this.doSomeFuncFromA();
  }
}

만약 컨트롤러에서 ServiceB를 주입받고, getHello()를 호출한다면 이는 BaseService의 doSomeFuncFromA 함수를 호출하게 됩니다. 하지만 BaseService는 주입을 받을 수 있는 클래스로 선언되어 있지 않기 때문에 Nest의 IoC 컨테이너는 생성자에 선언된 ServiceA를 주입하지 않습니다.

이 상태에서 컨트롤러에 서비스를 호출하는 엔드포인트를 만들고 동작을 해보면 에러가 발생합니다.

@Controller()
export class AppController {
  constructor (
    private readonly serviceB: ServiceB,
  ) {}
  
  @Get('/serviceB')
  getHelloC(): string {
    return this.serviceB.getHello();
  }
}
$ curl http://localhost:3000/serviceB
{
  "statusCode": 500,
  "message": "Internal server error"
}

콘솔에 찍혀있는 콜스택을 보니 this.serviceB 객체가 undefined라는 것을 알 수 있습니다.

이 문제를 해결하기 위해서는 ServiceB에서 super를 통해 ServiceA의 인스턴스를 전달해주어야 합니다.

@Injectable()
export class ServiceB extends BaseService {
  constructor(private readonly _serviceA: ServiceA) {
    super(_serviceA);
  }
  
  getHello(): string {
    return this.doSomeFuncFromA();
  }
}
$ curl http://localhost:3000/serviceB
Hello World A!

이번에 매번 super로 필요한 프로바이더를 전달하는 방식은 매우 귀찮습니다.

이럴 때는 속성 기반 프로바이더를 이용할 수 있습니다.

export class BaseService {
  @Inject(ServiceA) private readonly serviceA: ServiceA;
  ...
  
  doSomeFuncFromA(): string {
    return this.serviceA.getHello();
  }
}

BaseService 클래스의 serviceA 속성에 @Inject 데코레이터를 달아줍니다.

데코레이터의 인자는 타입(클래스 이름), 문자열, 심볼을 사용할 수 있습니다.

어떤 걸 쓸지는 프로바이더가 어떻게 정의되었느냐에 따라 달라집니다.

@Injectable() 이 선언된 클래스는 클래스 이름 타입을 쓰면 됩니다.

문자열과 심볼은 커스텀 프로바이더일 경우 사용합니다.

상속관계에 있지 않는 경우 속성 기반 주입을 사용하지 말고 생성자 기반 주입을 사용하는 것을 권장합니다.

 

 

스코프(Scope)

Node.js는 다른 웹 프레임워크와는 다르게 멀티 쓰레드 상태 비저장(Mulit-Threaded Stateless) 모델을 따르지 않습니다.

따라서 싱글톤 인스턴스를 사용하는 것은 안전한 방법입니다.

이는 요청으로 들어오는 모든 정보(DB 커넥션 풀, 전역 싱글톤 서비스 등)들을 공유할 수 있다는 것을 의미합니다.

 

하지만 GraphQL 애플리케이션의 요청별 캐싱을 한다거나 요청을 추적하거나 또는 멀티테넌시를 지원하기 위해서는 요청 기반으로 생명 주기를 제한해야 합니다.

멀티 테넌시
하나의 애플리케이션 인스턴스가 여러 사용자에게 각각 다르게 동작하도록 하는 SW 아키텍처를 말합니다.
반대로 각 사용자마다 인스턴스가 새로 만들어지도록 하는 멀티 인스턴스 방식이 있습니다.
요즘 대부분의 서비스는 멀티 테넌시를 채택하고 있습니다.

컨트롤러와 프로바이더에 생명 주기를 스코프 옵션을 주어 지정할 수 있는 방법이 있습니다.

먼저 각각에 지정하는 방법을 알아보기 전에 스코프에 어떤 것들이 있는지 살펴봅시다.

  • DEFAULT: 싱글톤 인스턴스가 전체 애플리케이션에서 공유됩니다. 인스턴스 수명은 애플리케이션 수명주기와 같습니다. 애플리케이션이 부트스트랩(각주. 애플리케이션 또는 시스템이 구동되는 것) 과정을 마치면 모든 싱글톤 프로바이더의 인스턴스가 만들어집니다. 따로 선언하지 않으면 DEFAULT가 적용됩니다.
  • REQUEST: 들어오는 요청마다 별도의 인스턴스가 생성됩니다. 요청을 처리하고 나면 인스턴스는 쓰레기 수집(garbage-collected)가 됩니다.
  • TRANSIENT: 이 스코프를 지정한 인스턴스는 공유되지 않습니다. 임시(TRANSIENT) 프로바이더를 주입하는 각 컴포넌트는 새로 생성된 전용 인스턴스를 주입받게 됩니다.
가능하면 DEFAULT 스코프를 사용하는 것을 권장합니다. 싱글톤 인스턴스를 공유한다는 것은 인스턴스를 캐시할 수 있고, 초기화가 애플리케이션 시작 중에 한 번만 발생하므로 메모리와 동작 성능을 향상시킬 수 있습니다.

 

 

프로바이더에 스코프 적용하기

@Injectable() 데코레이터에 scope 속성을 주는 방법입니다.

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

@Injectable({ scope: Scope.REQUEST })
export class CatsService {}

커스텀 프로바이더를 사용할 때 역시 마찬가지입니다.

{
  provide: 'CACHE_MANAGER',
  useClass: CacheManager,
  scope: Scope.TRANSIENT,
}

 

 

컨트롤러에 스코프 적용하기

@Controller() 데코레이터는 ControllerOptions을 인자로 받을 수 있습니다. ControllerOptions는 ScopeOptions를 상속합니다.

export declare function Controller(options: ControllerOptions): ClassDecorator;

export interface ControllerOptions extends ScopeOptions, VersionOptions {
    path?: string | string[];
    host?: string | RegExp | Array<string | RegExp>;
}

export interface ScopeOptions {
    scope?: Scope;
}

따라서 다음 코드와 같이 scope 속성을 전달할 수 있습니다.

@Controller({
  path: 'cats',
  scope: Scope.REQUEST,
})
export class CatsController {}

 

 

스코프 계층 (Scope hierarchy)

스코프는 컨트롤러와 프로바이더에 선언할 수 있는데 만약 연관된 컴포넌트들이 서로 다른 스코프를 가지게 된다면 어떻게 될까요?

예를 들어 CatsController → CatsService → CatsRepository 와 같은 종속성 그래프를 가지고 있는 상태에서 CatsService는 REQUEST 스코프를 가지고, 나머지는 모두 DEFAULT 스코프를 가질 경우를 가정해 봅시다.

이때 CatsController는 CatsService에 의존적이기 때문에 REQUEST로 변경됩니다.

하지만 CatsRepository는 CatsService에 의존하고 있지 않으므로 그대로 DEFAULT로 남게 됩니다.

종속성을 가진 컴포넌트의 스코프를 따라가게 됩니다.

 

 

 

 

참고: NestJS로 배우는 백엔드 프로그래밍

 

반응형