Nest <정적 모듈 바인딩 vs 동적 모듈 바인딩>

DatQueue·2022년 10월 15일
4

NestJS _TIL

목록 보기
1/12
post-thumbnail

정적 모듈 바인딩 (Static module use case)


일반적으로 nest 모듈을 다루는데 있어, 대부분의 응용 프로그램 코드 예제를 포함해 여러 공식 문서에서 정의하는 모듈 바인딩 방식은 “static” 모듈이다.

모듈은 알고 있듯이, 프로 바이더 및 컨트롤러와 같이 전체 응용 프로그램의 모듈 식 부분으로 구성되는 구성 요소 그룹을 정의한다. 그리고 이러한 구성 요소에 대한 실행 컨텍스트 또는 범위를 제공한다. 예를 들면, 모듈에 정의 된 공급자(provider)는 내보낼 필요없이 모듈의 다른 구성원에게 표시된다. 흔히 이러한 방식을 “전이적 방식”이라고도 하는데, 간단하게 말해서 x와 z가 관련되어 있고, y와 z가 관련되어 있다면 x와 y또한 관련이 되있다는 뜻이다.


위의 내용이 너무 추상적이 아닐까 싶은데, 그럼 nest의 기본 코드 진행을 통해 알아보자.


먼저 UsersService를 제공하고 내보내는 UsersModule을 정의한다. UsersModuleUsersService호스트(host)모듈이다.

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

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

다음으로 UsersModule을 가져와서 UsersModule에서 내보낸 공급자를 AuthModule내에서 사용할 수 있도록 하는 AuthModule을 정의한다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

이러한 구문을 통해 예를 들어 AuthModule에서 호스팅되는 AuthServiceUsersService를 삽입할 수 있다. 아래와 같이 말이다.

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}
  /*
    Implementation that makes use of this.usersService
  */
}

이러한 구조가 바로 정적(static)모듈 바인딩이다. Nest가 모듈을 서로 연결하는 데 필요한 모든 정보는 이미 호스트 및 소비 모듈에서 선언되었다. Nest는 AuthModule내에서 UsersService를 위와 같이 사용 가능케 한다.


위의 진행과정을 간략히 정리해보자.

  1. UsersModule자체가 소비하는 다른 모듈(AuthModule)을 전이적으로 가져오고 종속성을 전이적으로 해결하는 것을 포함하여 UsersModule을 인스턴스화 시킨다.

  2. AuthModule을 인스턴스화하고 UsersModule에서 내보낸 공급자를 AuthModule의 구성 요소에서 사용할 수 있도록 해준다.

  3. AuthServiceUsersService의 인스턴스를 주입한다.


동적 모듈 바인딩 (Dynamic module use case)


동적 모듈 바인딩은 왜 필요할까?


정적 모듈 바인딩을 사용하면 소비 모듈(위 예시에선 AuthModule)이 호스트 모듈의 공급자(위 예시에선 UsersService)가 구성되는 방식에 영향을 줄 수 있는 기회가 없다.

자, 위의 문장이 어떤 소리인가 생각해보자.

일단 UsersService는 호스트 모듈의 공급자다. 그리고 해당 공급자는 위에서도 보았듯이 AuthModule에 어떠한 영향을 끼치게 된다.

그런데 만약, AuthModule뿐만아니라 해당 UsersModule을 호스트 모듈로 가지는 즉, UsersService를 호스트 계층 공급자로 필요로하는 다른 모듈들 또한 존재한다면 어떨까?

각각의 다른 사용 사례에서 다르게 동작해야하는 범용 모듈이 필요할 때가 있을 것이다. 쉽게 말하면, 같은 UsersService지만 사용 방식을 다르게 해야할 필요성이 존재하는 경우가 생길 수도 있다는 것이다.


즉, 우리는 이러한 상황의 해결을 위해 다이나믹 모듈을 사용하게 된다.


동적 모듈 기능을 사용하여 소비 모듈이 API를 사용하여 구성 모듈을 가져올 때, 사용자 정의하는 방법을 제어할 수 있도록 구성 모듈을 “동적”으로 만들 수 있다.

다시 말해, 단순히 한 모듈을 다른 모듈로 가져오는 정적 모듈 바인딩과는 반대로, 동적 모듈은 모듈을 가져오는데 있어 해당 모듈의 속성과 동작을 사용자 정의하기위한 API를 제공한다.


Config module example


아마 위의 설명으로만은 감이 잡히지 않을 것이다. Nest에서 두루 사용하게 되는 ConfigModule을 통해 우린 알아볼 수 있다. ConfigModuledoteniv와 같이 환경변수에 따라서 다르게 동작하는 모듈이다.


먼저 코드를 알아보기 전, 우리가 시행하고자하는 요구 사항을 알아보자.


우리의 요구 사항은 ConfigModuleoptions객체를 받아 들여 커스터마이즈 하도록 하는 것이다. 지원하려는 기능은 다음과 같다. 기본 샘플은 .env파일의 위치를 프로젝트 루트 폴더에 하드 코딩한다. 선택한 폴더에서 .env파일을 config라는 프로젝트 루트 아래의 폴더(예: src의 형제 폴더)에 저장한다고 가정해보자.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [ConfigModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

구성 객체를 전달하는 _dynamic module_ import가 어떻게 보일지 생각해보자. 이 두 예제의 imports 배열의 차이점을 비교해볼 필요가 있다.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [ConfigModule.register({ folder: './config' })], // Dynamic module
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

첫 번째 예시 코드는 “정적 모듈 바인딩”이고 두 번째 예시 코드가 “동적 모듈 바인딩”이다.

어떤 차이가 있을까?

그 차이는 imports안의 배열을 보면 알 수 있다.


동적 모듈 바인딩에서 @Module안의 imports는 배열의 요소로써 ConfigModule.register()을 가지고 있다. 정적 모듈 바인딩에서처럼 그냥 ConfigModule을 불러오는 경우는 “인스턴스화”시킨 케이스인데 이와 다르게 ConfigModule.register()은 클래스 자체를 불러옴과 동시에 register()이라는 정적(static) 메서드를 호출한 것이다.

이와 같이 인스턴스 자체가 아닌 클래스 호출 방식을 사용하게 됨으로써 “사용자 정의”를 가능케 해줄 수 있다. register()은 따로 이름이 정해진 것은 아니다. 그렇지만 통상적으로 register혹은 forRoot로써 많이 사용한다.

register() 메서드는 우리에 의해 정의되므로 원하는 입력 인수를 받아들일 수 있다. 이 경우 적절한 속성을 가진 간단한 options 객체를 사용한다. 이것이 일반적인 경우이다.

register() 메서드는 리턴 값이 익숙한 imports리스트에 나타나기 때문에 module과 같은 것을 반환해야한다고 유추할 수 있다. 여기에는 모듈 리스트가 포함되어 있다.

사실, register() 메서드가 리턴 할 것은 DynamicModule이다. 동적 모듈은 거창한 것이 아니라 정적 모듈과 동일한 정확한 속성과 module이라는 추가 속성을 가진 런타임에 생성된 모듈에 지나지 않는다.


이러한 이해를 바탕으로 동적 ConfigModule의 선언을 알아보자.

import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({})
export class ConfigModule {
  static register(): DynamicModule {
    return {
      module: ConfigModule,
      providers: [ConfigService],
      exports: [ConfigService],
    };
  }
}

(참고로 다이나믹 모듈은 정확히 같은 인터페이스(DynamicModule)를 가진 객체와 module이라는 추가 속성을 반환해야 한다. module속성은 모듈 이름으로 사용되며 아래 예제와 같이 모듈의 클래스 이름과 동일해야 한다.)


ConfigModule은 기본적으로 다른 공급자가 사용할 수 있도록 주입 가능한 서비스 (ConfigService)를 제공하고 내보내기 위한 호스트이다. 동작을 사용자 정의하기 위해 옵션 개체를 읽어야 하는 것은 실제로 우리의 ConfigService이다.


register()메서드의 옵션을 ConfigService로 전달했다고 가정하고 코드를 살펴보자.

import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import { EnvConfig } from './interfaces';

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;

  constructor() {
    const options = { folder: './config' };

    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

위와 같이 ConfigModuleConfigService를 정의함으로써 ConfigService는 .env에서 파일을 찾을 수 있게 되었다. 하지만 이건 options를 아래와 같이

const options = { folder: './config' };

직접 정의해서 사용했기 때문에 의미가 없다. 우리는 런타임에 동적으로 구동하는 모듈을 구성하는 것이 목적이므로 위의 options처리는 어울리지 않다. 실제로는 register()에서 받은 optionsConfigService로 주입해야한다.

이러한 처리는 어떻게 진행할까?


런타임에 먼저 options객체를 Nest IoC 컨테이너에 바인딩 한 다음, Nest가 ConfigService에 삽입해주어야한다. 의존성 주입(_DI)을 이용하여 간단하게 options객체를 처리하는 것이 좋다.

옵션 객체를 IoC 컨테이너에 바인딩하는 방법을 먼저 살펴보자. 정적 register()메서드에서 이를 수행한다. 우리는 동적으로 모듈을 구성하고 있으며 모듈의 속성 중 하나는 공급자 리스트(providers list)이다. 따라서 우리는 옵션 객체를 provider로 정의해야한다. 이를 통해 ConfigService에 주입할 수 있게 되며 아래 단계에서 그것을 확인할 수 있다.

import { DynamicModule, Module } from '@nestjs/common';

import { ConfigService } from './config.service';

@Module({})
export class ConfigModule {
  static register(options): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        {
          provide: 'CONFIG_OPTIONS',
          useValue: options, //options 객체를 provider로 정의
        },
        ConfigService,
      ],
      exports: [ConfigService],
    };
  }
}

참고로 위의 구문에 대해서 익숙치 않다면 “커스텀 프로바이더”에 관해 찾아보면 좋을 것이다. 커스텀 프로바이더에 관해 자세히 설명하진 않을 거지만 우린 위의 작성법을 커스텀 프로바이더 중에서도 “벨류 프로바이더(Value Provider)”를 통해서 구현할 수 있다.

Value Providerprovide, useValue 속성을 가진다.


이제, 이렇게 ConfigModule을 커스텀 프로바이더 형식에 따라 수정해주었으면, CONFIG_OPTIONS provider를 CongifService에 삽입하여 프로세스를 완료 할 수 있다.

이때 해당 CONFIG_OPTIONS는 기존에 우리가 접했던 클래스기반 토큰이 아닌, 비클래스기반 토큰이다. 간단히 말하자면, 지금까지는 일반적 프로바이더 지정에서 클래스 이름을 그대로 프로바이더 토큰으로 사용했다.

아래와 같이 말이다. ( 단순히 예시로 든 것이다.. 내용은 무시)

@Module({
  providers: [CatsService], // CatsService 클래스를 그대로 프로바이더 토큰으로 사용
})

하지만 우리는 지금 다음과 같이 문자열로써 프로바이더 토큰을 사용하였다.

providers: [
    {
      provide: 'CONFIG_OPTIONS',
      useValue: options, //options 객체를 provider로 정의
    },
    ConfigService,
],

즉, CONFIG_OPTIONSoptions객체와 연결하는 것이다.

이 프로바이더를 ConfigService에 주입하는 방법은 다음과 같다. 이전처럼 주입받으면 토큰의 이름을 적지 않고 아래처럼 바로 주입을 받았을 것이다.

@Injectable()
export class ConfigService{
constructor(private options: ConfigOptions) {}

하지만 이제 클래스 이름이 아닌 문자열을 토큰으로 등록했기 때문에 아래처럼 주입받으면 된다.

@Injectable()
export class ConfigService{
constructor(@Inject('CONFIG_OPTIONS') private options) {}

전체 ConfigService클래스의 코드는 다음과 같다.

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

import * as dotenv from 'dotenv';
import * as fs from 'fs';

import { EnvConfig } from './interfaces';

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;

  constructor(@Inject('CONFIG_OPTIONS') private options) {
    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

⁕참고사항

코드를 조금 더 직관적으로 알기 위해 문자열 기반 주입 토큰을 사용했지만 모범사례는 이를 별도의 파일에서 상수(or Symbol)로 정의하고 해당 파일을 가져오는 것이다. 아래를 참고.

export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글