NestJS 帶你飛! 시리즈 번역 17# Injection Scopes

2023. 6. 12. 14:09개발 문서 번역/NestJS

728x90
이 포스팅은 「NestJS 기초실무 가이드 : 강력하고 쉬운 Node.js 웹 프레임워크로 웹사이트 만들기」
(서명: NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式)
책이 출간되었습니다.

https://docs.nestjs.com/fundamentals/injection-scopes

 

Nest에서는 대부분의 상황에서 싱글톤 패턴(Singleton Pattern, 單例模式)을 채택하여 각각 인스턴스를 관리하고 있습니다.

각기 들어오는 요청들은 모두 같은 인스턴스를 공유하며, 이 인스턴스는 Nest App이 종료될 때까지 유지됩니다.

하지만 어떤 상황에서는 각 요청에 대한 처리를 개별적으로 수행해야 할 수도 있는데

이 경우 주입 범위(Injection Scope, 注入作用域) 를 지정하여 인스턴스의 생성 시점을 결정할 수 있습니다.

 

주의: 인스턴스 생성 시점을 정할수 있긴 하지만 정말 필요한것이 아니라면 싱글톤 패턴을 유지할것을 권장합니다.
시스템 효율 면에서도 그렇고 매 요청마다 인스턴스를 생성해버린다면 리소스 처리에 비용이 많이 들어갈것이고
해당 리소스를 GC(가비지 컬렉션) 하는데도 비용이 소모될것이기 때문입니다.

 

범위

Nest는 총 3가지의 범위를 지정할 수 있습니다.

 

1. 기본 범위(Default Scope, 預設作用域): 기본값이며 싱글톤 패턴의 적용 방식입니다. (권장)

2. 요청 범위(Request Scope, 請求作用域): 매 요청마다 새로운 인스턴스를 생성합니다.
해당 요청의 Provider는 공용 인스턴스이며 요청이 끝남과 동시에 가비지 컬렉션이 진행됩니다.

3. 독립 범위(Transient Scope, 獨立作用域): 매 Provider는 독립된 인스턴스로써 각각의 Provider와는 공유되지 않습니다.

 

Provider의 범위 설정

Provider에 범위를 설정하고자 한다면 @Injectable 데코레이터 안에 설정하면 됩니다.

이 데코레이터는 선택값 파라미터 scope를 지원하여 파라미터에 Nest가 기본적으로 제공하는 enum - Scope를 통해

설정할 수 있습니다. 아래는 app.service.ts의 예제입니다.

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

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

  getHello(): string {
    return 'Hello World!';
  }

}

커스텀 Provider라면 scope 속성을 추가해주면 됩니다. 아래는 app.module.ts의 예제입니다.

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

@Module({
  imports: [
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService,
    {
      provide: 'USERNAME',
      useValue: 'WOO',
      scope: Scope.REQUEST // scope 속성 추가
    }
  ]
})
export class AppModule {
}

 

Controller단에서 범위 설정

Controller 에서 적용하고자 한다면 @Controller 데코레이터 파라미터에 설정해주면 됩니다. 

위와 같이 선택값 파라미터를 통해 설정할 수 있습니다.

라우팅이 설정되어있다면 path 속성에 추가 해주면 되고 scopescope 속성을 추가해줍니다.

아래는 app.controller.ts 예제입니다.

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

@Controller({ scope: Scope.REQUEST })
export class AppController {
  constructor(
    private readonly appService: AppService
  ) {
  }

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

}

 

 

버블링 스코프(作用域冒泡)

스코프의 구성은 전체 의존성 주입 체이닝의 스코프 범위에 영향을 미칩니다.

이게 무슨 말일까요? 아래에서 예를 들어보겠습니다.

 

StorageServiceAppModuleBookModule에서 사용되고 있고

BookService는 또 AppModule에서 사용되고 있습니다.

StrageService의 스코프를 요청 범위로 제한하고 싶다면
StorageServiceBookServiceAppService또한 요청 범위 스코프로 변경됩니다.

이 원리로 생각해보면 AppControllerAppService를 의존하고 있기 때문에 요청 범위 스코프로 변경될 것입니다.

BookService를 요청 범위 스코프로 설정한다면 AppServiceAppController만이 요청 범위 스코프입니다.

StorageServiceBookService를 의존하고 있지 않기 때문입니다.

 

요청 범위 스코프와 요청 객체

 

요청 객체 스코프는 매 요청마다 인스턴스를 생성함을 의미하기 때문에
REQUEST를 통해 요청 객체를 얻어올 수 있습니다. 아래는 app.service.ts의 예제입니다.

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

import { Request } from 'express';

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

  constructor(
    @Inject(REQUEST) private readonly request: Request
  ) {}

  getHello(): string {
    return 'Hello World!';
  }
}

 

인스턴스화 실험

여기서는 간단한 실험 하나를 해보려고 합니다. 이 실험에서는 AppModule, BookModuleStorageModule의 구조로 생성하여 각각의 스코프에서 인스턴스화 시간과 인스턴스의 공유를 확인해보겠습니다.

$ nest generate module common/storage
$ nest generate service common/storage
$ nest generate module common/book
$ nest generate service common/book

 

이어서 storage.service.ts의 내용을 수정해봅시다.
constructor에는 난수를 포함한 문자열을 작성해줍니다.
해당 난수를 통해 해당 인스턴스가 동일한 인스턴스인지 확인할 수 있을것이고
이 방법을 통해 인스턴스의 시기 또한 관찰이 가능해집니다.

Create와 Read를 담당할 메서드를 작성합니다.

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

@Injectable()
export class StorageService {

  constructor() {
    console.log(`Storage: ${Math.random()}`);
  }

  private list: any[] = [];

  public getItems(): any[] {
    return this.list;
  }

  public addItem(item: any): void {
    this.list.push(item);
  }

}

storage.module.ts의 내용을 수정하여 StorageService를 내보내줍니다.

import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';

@Module({
  providers: [
    StorageService
  ],
  exports: [
    StorageService
  ]
})
export class StorageModule {}

이번에는 book.service.ts의 내용을 수정해봅시다.
StorageService를 주입하여 해당 데이터에 접근 할 수 있는 메서드를 만들어보겠습니다.

이번에도 constructor에 난수의 문자열을 출력하게 해줍니다.

import { Injectable } from '@nestjs/common';
import { StorageService } from '../storage/storage.service';

@Injectable()
export class BookService {

  constructor(
    private readonly storage: StorageService
  ) {
    console.log(`Book: ${Math.random()}`);
  }

  public getBooks(): any[] {
    return this.storage.getItems();
  }

  public addBook(book: any): void {
    this.storage.addItem(book);
  }

}

StorageService를 사용하니 StorageModule도 가져와야 합니다.

book.module.ts를 수정하여 BookService 또한 내보내봅시다.

import { Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { BookService } from './book.service';

@Module({
  imports: [
    StorageModule
  ],
  providers: [
    BookService
  ],
  exports: [
    BookService
  ]
})
export class BookModule {}

 

마지막으로 app.service.tsapp.controller.ts를 수정하면 됩니다.

먼저 app.service.ts를 수정합니다. BookServiceStorageService를 주입해주고
각각의 데이터 엑세스를 담당할 메서드를 만들어줍니다.

import { Injectable } from '@nestjs/common';
import { BookService } from './common/book/book.service';
import { StorageService } from './common/storage/storage.service';

@Injectable()
export class AppService {

  constructor(
    private readonly bookService: BookService,
    private readonly storage: StorageService
  ) {
    console.log(`AppService: ${Math.random()}`);
  }

  public addBookToStorage(book: any): void {
    this.storage.addItem(book);
  }

  public addBookToBookStorage(book: any): void {
    this.bookService.addBook(book);
  }

  public getStorageList(): any[] {
    return this.storage.getItems();
  }

  public getBookList(): any[] {
    return this.bookService.getBooks();
  }

}

 

app.controller.ts를 수정해봅시다.
constructorAppService를 통해 BookServiceStorageService의 추가 메서드를 동작시키고
/compare의 경로로 같은 StorageService 인스턴스를 사용하고 있는지 확인해봅시다.

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

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService
  ) {
    this.appService.addBookToStorage({ name: 'Nest Tutorial' });
    this.appService.addBookToBookStorage({ name: 'Angular Tutorial' });
    console.log(`AppController: ${Math.random()}`);
  }

  @Get('/compare')
  getCompare() {
    return {
      storage: this.appService.getStorageList(),
      books: this.appService.getBookList()
    };
  }

}

 

스코프 영역의 기본값

기본값은 특별한 설정이 필요하지 않기때문에 Nest App을 시작해주면 됩니다.

시작되면 터미널에는 아래와 같은 메세지가 출력될겁니다.

Storage: 0.9157634645201473
Book: 0.19173047603829163
AppService: 0.7427502150509011
AppController: 0.2906479562343478

 

이건 바로 싱글톤 패턴이기 때문에 Nest가 Bootstrap될 때 의존성을 가지고있는 항목들이 모두 생성되며

Nest가 종료될 때 까지 유지되기 때문에 서버를 시작할 때 이 문자열들을 확인할 수 있고 서버를 다시 키지않는 한 다시는 볼 수 없다는것을 의미합니다.

 

브라우저를 열고 http://127.0.0.1:3000/compare 에 접속해보면

우리가 설정했던 값과 일치함을 확인할 수 있습니다.

BookModuleAppModule은 같은 StorageService를 사용하기 때문에 해당 결과에서 같은 자료를 반환하였습니다.

요청 범위 스코프

요청 범위 스코프를 BookService에 적용 시켜보겠습니다.

이론상 StorageService는 싱글톤으로 구성될 것이며
BookService, AppService, AppController는 요청 범위 스코프일 것입니다.

book.service.ts를 수정해봅시다.

import { Injectable, Scope } from '@nestjs/common';
import { StorageService } from '../storage/storage.service';

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

  constructor(
    private readonly storage: StorageService
  ) {
    console.log(`Book: ${Math.random()}`);
  }

  public getBooks(): any[] {
    return this.storage.getItems();
  }

  public addBook(book: any): void {
    this.storage.addItem(book);
  }

}

다시 Nest App을 가동해보면 터미널에는 아래와 같은 메세지가 출력됩니다.

Storage: 0.33657811591892006

어째서 StorageService만 메세지가 출력되는걸까요?

바로 StorageService만이 싱글톤 패턴을 유지하기 때문에 서버 가동과 동시에 생성되며

BookService는 요청 범위 스코프를 적용하여 매 요청이 들어올 때마다 인스턴스화 되어 서버 가동시에

메세지가 출력되지 않은것입니다.

 

그렇다면 아까와 같이 http://127.0.0.1:3000/compare에 접속하여 요청을 보내봅시다.

보내는 순간 터미널에는 아래와 비슷한 문자열이 출력됩니다.

Book: 0.7042566657876479
AppService: 0.844936918296987
AppController: 0.6670745344648639

브라우저에서 출력되는 화면은 아까와 일치합니다. 하지만 새로고침을 한번 눌러보면

누를 때마다 자료가 계속 늘어나는것을 확인할 수 있습니다. 왜일까요?
바로 AppController가 인스턴스화 될 때 해당 데이터들을 추가하기 때문에 StorageService에도 자료가 추가되기 때문입니다.

독립 범위 스코프

이 부분은 먼저 StorageService를 수정해 독립 범위 스코프로 만들어

BookServicescope를 삭제하고 storage.service.ts도 수정해보겠습니다.

 

서버를 다시 가동시켜보면 아래와 같은 메세지를 확인할 수 있습니다.

Storage: 0.34076490487437194
Storage: 0.44321780473447236
Book: 0.36169999596799207
AppService: 0.2503634011247873
AppController: 0.671389545982827

StorageService에 2개의 인스턴스가 생성된것을 확인할 수 있습니다.
이는 각각의 Provider 사이에 인스턴스를 공유하지 않는 독립 범위 스코프로 생성되었기 때문입니다.

StorageServiceBookService에서 한번, AppService에서 한번 생성되었기에 여기서는 2개의 인스턴스가 존재합니다.

 

다시 http://127.0.0.1:3000/compare에 접속해보면 우리가 예상 했던것과 똑같이
두개의 내용이 일치하지 않는걸 확인할 수 있습니다.

왜일까요? 바로 다른 인스턴스이기 때문입니다.

 

마치며

일반적인 상황에서는 이렇게 주입 범위를 변경할 일이 많지 않을것입니다.

하지만 특정 상황에서는 필수적으로 해야하는 작업이기 때문에 잘 쓰이지 않음에도 불구하고 우리가
이 포스팅을 통해 Nest의 의존성 주입 규칙에 대해 조금 더 알아야 하는 이유입니다.

아래는 오늘 학습의 요약본입니다.

 

1. Nest는 싱글톤 패턴을 통해 인스턴스를 관리합니다.

2. 주입 범위를 변경하여 인스턴스의 관리 규칙을 수정할 수 있습니다.

3. 주입 범위는 3개로 나뉩니다. 기본 범위, 요청 범위, 독립 범위

4. 기본 값은 기본 범위로, 싱글톤 패턴입니다.

5. 요청 범위는 매 요청마다 인스턴스가 생성됩니다.

6. 독립 범위는 각 Provider마다 인스턴스를 공유하지 않습니다.

7. 요청 범위는 REQUEST를 주입하여 요청 객체를 받아올 수 있습니다.