NestJS - 동적 모듈로 환경변수 구성


이 포스트는 NestJS 의 동적 모듈에 대해 알아본다.

소스는 example, user-service 에 있습니다.


1. 동적 모듈

동적 모듈 구성은 호스트 모듈 (=Provider 나 Controller 와 같은 컴포넌트를 제공하는 모듈) 을 가져다 쓰는 소비 모듈에서 호스트 모듈 생성 시 동적으로 값을 설정하는 방식이다.

동적 모듈의 예로 ConfigModule 이 있는데 ConfigModule 은 실행 환경에 따라 서버에 설정되는 환경 변수를 관리하는 모듈이다.

동적 모듈을 구성하는 방법은 @nestjs/config 로 ConfigModule 을 동적으로 생성하는 방법dotenv 를 직접 이용하여 동적 모듈을 생성하는 방법이 있다.


2. dotenv 를 이용한 Config 설정 (dotenv 를 직접 이용하여 동적 모듈 생성)

Node.js 에서는 dotenv 라이브러리를 이용해 각 환경 변수를 .env 파일에 저장 후 서버 구동 시 해당 파일을 읽어 환경 변수로 설정할 수 있다.

$ nest new ch06

$ npm i dotenv 
$ npm i -D @types/dotenv 

dotenv 는 기본적으로 루트 디렉터리에 있는 .env 파일을 읽는다.
환경 변수 파일은 민감한 정보가 저장되는 경우가 많기 때문에 repository 에 배포되지 않도록 한다. git 을 사용한다면 .gitignore 파일에 추가해야 한다.

하지만 repository 에 배포하지 않으면 배포 시 마다 직접 .env 파일을 생성해주어야 하므로 민감한 정보를 서버가 구동될 때 환경 변수로 설정하는 것이 좋다. 예를 들면 AWS 의 Secret Manager 에서 값을 읽어서 프로비저닝 과정에서 환경 변수에 넣어줄 수 있다.
NestJS 가 구동되기 전에 서버가 프로비저닝 되는 과정에서 AWS Secret Manager 에서 읽어와서 소스 코드 내의 .env 파일을 수정하도록 하는 방법이 있다.

Node.js 는 NODE_ENV 환경 변수를 통해 서버의 환경을 구성하는데 NODE_ENV 는 아래 명령어로 설정하거나 OS 구동 시 변수를 설정해야 한다.

  • Windows: set NODE_ENV=dev
  • Linux: export NODE_ENV=dev

서버 구동 시마다 새로 설정하기 번거로우니 package.json 의 npm run start:dev 를 아래와 같이 수정한다.

package.json

    "prebuild": "rimraf dist",  // 파일  폴더 삭제
    "start:dev": "npm run prebuild && NODE_ENV=local nest start --watch",
// .dev.env
DATABASE_HOST=devhost


// .local.env
DATABASE_HOST=localhost

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as dotenv from 'dotenv';
import * as path from 'path';

// dotenv 패키지 직접 사용
dotenv.config({
  path: path.resolve(
          process.env.NODE_ENV === 'dev'
                  ? '.dev.env'
                  : process.env.NODE_ENV === 'stage'
                          ? '.stage.env'
                          : '.local.env',
  ),
});
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

app.controller.ts

  @Get('env')
  getEnv(): string {
    return process.env.DATABASE_HOST;
  }
$ curl --location 'http://localhost:3000/env'
localhost%

3. NestJS 의 Config Package (@nestjs/config 로 ConfigModule 을 동적으로 생성)

앞에선 dotenv 패키지를 직접 사용했는데 NestJS 는 dotenv 를 내부적으로 활용하는 @nestjs/config 패키지를 제공한다. 해당 패키지를 이용하여 ConfigModule 을 동적으로 생성할 수 있다.

$ npm i @nestjs/config

@nestjs/config 에는 ConfigModule 모듈이 이미 존재하므로 AppModule 에서 이 모듈을 동적으로 가져온다.

app.module.ts

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

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

정적 모듈을 가져올 때와 다르게 ConfigModule.forRoot() 메서드를 호출하는데 forRoot() 는 DynamicModule 을 리턴하는 정적 메서드이다.

forRoot() 시그니처

/**
 * Loads process environment variables depending on the "ignoreEnvFile" flag and "envFilePath" value.
 * Also, registers custom configurations globally.
 * @param options
 */
static forRoot(options?: ConfigModuleOptions): DynamicModule;
/**
 * Registers configuration object (partial registration).
 * @param config
 */
static forFeature(config: ConfigFactory): DynamicModule;

관례상 동적 모듈 작성 시 forRoot 나 register 를 붙임 (비동기는 forRootAsync, registerAsync)

ConfigModuleOptions 을 인수로 받기 때문에 ConfigModule 은 소비 모듈이 원하는 옵션값을 전달하여 원하는 대로 동적으로 ConfigModule 을 생성한다.

ConfigModuleOptions 시그니처

export interface ConfigModuleOptions {
    /**
     * If "true", values from the process.env object will be cached in the memory.
     * This improves the overall application performance.
     * See: https://github.com/nodejs/node/issues/3104
     */
    cache?: boolean;
    /**
     * If "true", registers `ConfigModule` as a global module.
     * See: https://docs.nestjs.com/modules#global-modules
     */
    isGlobal?: boolean;
    /**
     * If "true", environment files (`.env`) will be ignored.
     */
    ignoreEnvFile?: boolean;
    /**
     * If "true", predefined environment variables will not be validated.
     */
    ignoreEnvVars?: boolean;
    /**
     * Path to the environment file(s) to be loaded.
     */
    envFilePath?: string | string[];
    /**
     * Environment file encoding.
     */
    encoding?: string;
    /**
     * Custom function to validate environment variables. It takes an object containing environment
     * variables as input and outputs validated environment variables.
     * If exception is thrown in the function it would prevent the application from bootstrapping.
     * Also, environment variables can be edited through this function, changes
     * will be reflected in the process.env object.
     */
    validate?: (config: Record<string, any>) => Record<string, any>;
    /**
     * Environment variables validation schema (Joi).
     */
    validationSchema?: any;
    /**
     * Schema validation options.
     * See: https://joi.dev/api/?v=17.3.0#anyvalidatevalue-options
     */
    validationOptions?: Record<string, any>;
    /**
     * Array of custom configuration files to be loaded.
     * See: https://docs.nestjs.com/techniques/configuration
     */
    load?: Array<ConfigFactory>;
    /**
     * A boolean value indicating the use of expanded variables, or object
     * containing options to pass to dotenv-expand.
     * If .env contains expanded variables, they'll only be parsed if
     * this property is set to true.
     */
    expandVariables?: boolean | DotenvExpandOptions;
}

이제 루트 디렉터리에 있는 .env 파일을 환경 변수로 등록한다.

app.module.ts

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

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath:
        process.env.NODE_ENV === 'dev'
          ? '.dev.env'
          : process.env.NODE_ENV === 'stage'
          ? '.stage.env'
          : '.local.env',
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

다시 curl 을 날려보면 환경 변수가 잘 출력되는 것을 확인할 수 있다.

$ curl --location 'http://localhost:3000/env'
localhost%

NestJS 가 제공하는 ConfigModule 은 .env 파일에서 읽어온 환경 변수 값을 가져오는 Provider 인 ConfigService 가 있는데 이를 컴포넌트에 주입하여 사용하면 된다.

app.controller.ts

import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller()
export class AppController {
  constructor(
          private readonly configService: ConfigService,
  ) {}

  @Get('config')
  getConfig(): string {
    return this.configService.get('DATABASE_HOST');
  }
}

$  curl --location 'http://localhost:3000/config'
localhost%

NestJS - Custom Provider2. ClassProvider 와 비교해서 보세요.


4. 유저 서비스 환경 변수 구성

@nestjs/config 패키지와 joi 라이브러리(validation 수행) 를 설치한다.

$  npm i @nestjs/config joi

루트 디렉터리 아래 .local.env, .dev.env 파일 생성 후 아래와 같이 구성한다.

EMAIL_SERVICE=Gmail
EMAIL_AUTH_USER=YOUR-GAMIL
EMAIL_AUTH_PASSWORD=YOUR-GMAIL-PASSWORD
EMAIL_BASE_URL=http://localhost:3000

4.1. 커스텀 Config 파일 생성

@nestjs/config 패키지에서 제공하는 ConfigModule 을 이용하여 모든 환경 변수가 들어있는 .env 파일의 내용을 가져다 쓸 때엔 DatabaseConfig 등으로 의미 있는 단위로 묶어서 처리해본다.

src/config/emailConfig.ts

import { registerAs } from '@nestjs/config';

// 'email' 이라는 토큰으로 ConfigFactory 를 등록할 수 있는 함수
export default registerAs('email', () => ({
  service: process.env.EMAIL_SERVICE,
  auth: {
    user: process.env.EMAIL_AUTH_USER,
    pass: process.env.EMAIL_AUTH_PASSWORD,
  },
  baseUrl: process.env.EMAIL_BASE_URL,
}));

src/config/validationSchema.ts

import * as Joi from 'joi';

export const validationSchema = Joi.object({
  EMAIL_SERVICE: Joi.string().required(),
  EMAIL_AUIH_USER: Joi.string().required(),
  EMAIL_AUTH_PASSWORD: Joi.string().required(),
  EMAIL_BASE_URL: Joi.string().required().uri(),
});

4.2. 동적 ConfigModule 등록

위에서 만든 .env 파일을 루트 경로가 아닌 src/config/env 디렉터리로 옮겨서 관리하도록 설정한다.

Nestjs 의 기본 옵션은 .ts 파일 외 asset 은 제외하도록 되어있으므로 .env 파일을 out 디렉터리(dist) 에 복사할 수 있도록 nest-cli.json 옵션을 수정한다.

nest-cli.json

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [{
      "include": "./config/env/*.env",
      "outDir": "./dist"
    }]
  }
}

이제 AppModule 에 ConfigModule 을 동적 모듈로 등록한다.

app.module.ts

import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ConfigModule } from '@nestjs/config';
import emailConfig from './config/emailConfig';
import { validationSchema } from './config/validationSchema';

@Module({
  imports: [
    UsersModule,
    ConfigModule.forRoot({
      envFilePath: [`${__dirname}/config/env/.${process.env.NODE_ENV}.env`],
      load: [emailConfig], // ConfigFactory 지정
      isGlobal: true, // 전역으로 등록해서 어느 모듈에서나 사용 가능
      validationSchema, // 환경 변수 값에 대해 유효성 검사 수행
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

이제 emailConfig 를 주입받아서 사용해본다.

/src/email/email.service.ts

import { Inject, Injectable } from '@nestjs/common';
import Mail from 'nodemailer/lib/mailer';
import * as nodemailer from 'nodemailer';
import emailConfig from '../config/emailConfig';
import { ConfigType } from '@nestjs/config';

interface EmailOptions {
  to: string;
  subject: string;
  html: string;
}
@Injectable()
export class EmailService {
  private transporter: Mail;

  // constructor() {
  //   this.transporter = nodemailer.createTransport({
  //     service: 'Gmail',
  //     auth: {
  //       user: 'test@gmail.com',
  //       pass: 'aaaaa',
  //     },
  //   });
  // }

  constructor(
    // 주입받을 때 @Inject 데커레이터의 토큰을 앞에서 만든 ConfigFactory 의 KEY 인 `email` 문자열로 넣어준다.
    @Inject(emailConfig.KEY) private config: ConfigType<typeof emailConfig>,
  ) {
    this.transporter = nodemailer.createTransport({
      service: config.service, // env 파일에 있는 값들
      auth: {
        user: config.auth.user,
        pass: config.auth.pass,
      },
    });
  }

  // 가입 인증 메일 발송
  async sendMemberJoinVerification(email: string, signupVerifyToken: string) {
    const baseUrl = this.config.baseUrl;
    const url = `${baseUrl}/users/email-verify?signupVerifyToken=${signupVerifyToken}`;
    const mailOptions: EmailOptions = {
      to: email,
      subject: '가입 인증 메일',
      html: `가입확인 버튼를 누르시면 가입 인증이 완료됩니다.<br />
        <form action="${url}" method="POST">
          <button>가입확인</button>
        </form>`,
    };

    return await this.transporter.sendMail(mailOptions);
  }
}
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "assu",
    "email": "test@test.com",
    "password": "test"
}'

정상적으로 메일이 오는 것을 확인할 수 있다.

$  tree -L 4 -N -I "node_modules"
.
├── app.module.ts
├── config
│   ├── emailConfig.ts
│   ├── env
│   │   ├── .dev.env
│   │   ├── .local.env
│   └── validationSchema.ts
├── email
│   ├── email.module.ts
│   └── email.service.ts
├── main.ts
└── users
    ├── UserInfo.ts
    ├── dto
    │   ├── create-user.dto.ts
    │   ├── user-login.dto.ts
    │   └── verify-email.dto.ts
    ├── users.controller.ts
    ├── users.module.ts
    └── users.service.ts

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10