일반적으로 nest 모듈을 다루는데 있어, 대부분의 응용 프로그램 코드 예제를 포함해 여러 공식 문서에서 정의하는 모듈 바인딩 방식은 “static” 모듈이다.
모듈은 알고 있듯이, 프로 바이더 및 컨트롤러와 같이 전체 응용 프로그램의 모듈 식 부분으로 구성되는 구성 요소 그룹을 정의한다. 그리고 이러한 구성 요소에 대한 실행 컨텍스트 또는 범위를 제공한다. 예를 들면, 모듈에 정의 된 공급자(provider)는 내보낼 필요없이 모듈의 다른 구성원에게 표시된다. 흔히 이러한 방식을 “전이적 방식”이라고도 하는데, 간단하게 말해서 x와 z가 관련되어 있고, y와 z가 관련되어 있다면 x와 y또한 관련이 되있다는 뜻이다.
위의 내용이 너무 추상적이 아닐까 싶은데, 그럼 nest의 기본 코드 진행을 통해 알아보자.
먼저 UsersService
를 제공하고 내보내는 UsersModule
을 정의한다. UsersModule
은 UsersService
의 호스트(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
에서 호스팅되는 AuthService
에 UsersService
를 삽입할 수 있다. 아래와 같이 말이다.
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
를 위와 같이 사용 가능케 한다.
위의 진행과정을 간략히 정리해보자.
UsersModule
자체가 소비하는 다른 모듈(AuthModule
)을 전이적으로 가져오고 종속성을 전이적으로 해결하는 것을 포함하여 UsersModule
을 인스턴스화 시킨다.
AuthModule
을 인스턴스화하고 UsersModule
에서 내보낸 공급자를 AuthModule
의 구성 요소에서 사용할 수 있도록 해준다.
AuthService
에 UsersService
의 인스턴스를 주입한다.
정적 모듈 바인딩을 사용하면 소비 모듈(위 예시에선 AuthModule
)이 호스트 모듈의 공급자(위 예시에선 UsersService
)가 구성되는 방식에 영향을 줄 수 있는 기회가 없다.
자, 위의 문장이 어떤 댕소리인가 생각해보자.
일단 UsersService
는 호스트 모듈의 공급자다. 그리고 해당 공급자는 위에서도 보았듯이 AuthModule
에 어떠한 영향을 끼치게 된다.
그런데 만약, AuthModule
뿐만아니라 해당 UsersModule
을 호스트 모듈로 가지는 즉, UsersService
를 호스트 계층 공급자로 필요로하는 다른 모듈들 또한 존재한다면 어떨까?
각각의 다른 사용 사례에서 다르게 동작해야하는 범용 모듈이 필요할 때가 있을 것이다. 쉽게 말하면, 같은 UsersService
지만 사용 방식을 다르게 해야할 필요성이 존재하는 경우가 생길 수도 있다는 것이다.
즉, 우리는 이러한 상황의 해결을 위해 다이나믹 모듈을 사용하게 된다.
동적 모듈 기능을 사용하여 소비 모듈이 API를 사용하여 구성 모듈을 가져올 때, 사용자 정의하는 방법을 제어할 수 있도록 구성 모듈을 “동적”으로 만들 수 있다.
다시 말해, 단순히 한 모듈을 다른 모듈로 가져오는 정적 모듈 바인딩과는 반대로, 동적 모듈은 모듈을 가져오는데 있어 해당 모듈의 속성과 동작을 사용자 정의하기위한 API를 제공한다.
아마 위의 설명으로만은 감이 잡히지 않을 것이다. Nest에서 두루 사용하게 되는 ConfigModule
을 통해 우린 알아볼 수 있다. ConfigModule
은 doteniv
와 같이 환경변수에 따라서 다르게 동작하는 모듈이다.
먼저 코드를 알아보기 전, 우리가 시행하고자하는 요구 사항을 알아보자.
우리의 요구 사항은 ConfigModule
이 options
객체를 받아 들여 커스터마이즈 하도록 하는 것이다. 지원하려는 기능은 다음과 같다. 기본 샘플은 .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];
}
}
위와 같이 ConfigModule
과 ConfigService
를 정의함으로써 ConfigService
는 .env에서 파일을 찾을 수 있게 되었다. 하지만 이건 options
를 아래와 같이
const options = { folder: './config' };
직접 정의해서 사용했기 때문에 의미가 없다. 우리는 런타임에 동적으로 구동하는 모듈을 구성하는 것이 목적이므로 위의 options
처리는 어울리지 않다. 실제로는 register()
에서 받은 options
를 ConfigService
로 주입해야한다.
이러한 처리는 어떻게 진행할까?
런타임에 먼저 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 Provider
는provide
,useValue
속성을 가진다.
이제, 이렇게 ConfigModule
을 커스텀 프로바이더 형식에 따라 수정해주었으면, CONFIG_OPTIONS
provider를 CongifService
에 삽입하여 프로세스를 완료 할 수 있다.
이때 해당 CONFIG_OPTIONS
는 기존에 우리가 접했던 클래스기반 토큰이 아닌, 비클래스기반 토큰이다. 간단히 말하자면, 지금까지는 일반적 프로바이더 지정에서 클래스 이름을 그대로 프로바이더 토큰으로 사용했다.
아래와 같이 말이다. ( 단순히 예시로 든 것이다.. 내용은 무시)
@Module({
providers: [CatsService], // CatsService 클래스를 그대로 프로바이더 토큰으로 사용
})
하지만 우리는 지금 다음과 같이 문자열로써 프로바이더 토큰을 사용하였다.
providers: [
{
provide: 'CONFIG_OPTIONS',
useValue: options, //options 객체를 provider로 정의
},
ConfigService,
],
즉, CONFIG_OPTIONS
을 options
객체와 연결하는 것이다.
이 프로바이더를 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';