Javascript/NestJS

NestJS에서 환경변수 다루기

kyoulho 2023. 3. 27. 09:07

웹 애플리케이션을 개발할 때, 로컬 환경에서 개발하다가, 서버에 배포할 때, 서버 환경에서 운영하게 됩니다. 이때, 로컬 환경과 서버 환경이 달라서 발생하는 문제를 해결하기 위해서는 환경 변수를 설정할 필요가 있습니다.

저는 사이드 프로젝트를 준비하면서 로컬에서 사용할 환경 변수를 지정하기 위해 @nestjs/config 라이브러리를 사용하였습니다.

@nestjs/config

NodeJS에는 대표적으로 환경 변수를 설정하는 라이브러리 dotenv가 존재하며 NestJS에서는 dotenv를 내부적으로 사용하는 @nestjs/config 라이브러리가 존재합니다.

npm i --save @nestjs/config

 

. env 파일

먼저 Root 디렉터리에 .dev.env 파일을 생성합니다. Root 디렉토리란 src 디렉토리의 상위 디렉토리입니다.

꼭 Root 디렉토리일 필요는 없지만 다른 곳에 존재한다면 나중에 설정을 해주어야 합니다.

또한 env 파일에는 데이터베이스에 대한 정보 혹은 API Key, JWT secret 정보 등이 담기기 때문에. gitignore에 추가하여 줍니다.

SERVER_PORT=8080

DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=root
DB_SCHEMA=nestjs_app

JWT_SECRET=secret

 

package.json script

package.json 파일의 sciprts에서 start:dev 명령 시 NODE_ENV에 dev라는 값을 정의하도록 수정합니다.

...
"scripts": {
...
    "start:dev": "NODE_ENV=dev nest start --watch",
}

 

AppModule

AppModule에서 ConfigModule.forRoot() 함수를 통해 ConfigModule을 import 합니다.

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `.${process.env.NODE_ENV}.env`,
    }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        type: 'mariadb',
        host: configService.get('DB_HOST'),
        port: configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_SCHEMA'),
        entities: [__dirname + '/../!**!/!*.entity.{js,ts}'],
        synchronize: true,
      }),
    }),
    AuthModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

AppModule 내에서 TypeORM 설정과 ConfigModule을 함께 쓰기 위해서는 위에 코드처럼 TypeOrmModule.forRootAsync() 함수를 사용하여 비동기로 TypeOrmModule 생성해야 합니다. ConfigService 객체가 생성된 이후에 useFactory 함수가 실행되어 env 파일의 정보를 전달합니다.

 

ConfigModuleOptions

ConfigModule.forRoot() 메서드의 인자는 ConfigModuleOptions에 대해 알아보겠습니다.

export interface ConfigModuleOptions {
    cache?: boolean;
    isGlobal?: boolean;
    ignoreEnvFile?: boolean;
    ignoreEnvVars?: boolean;
    envFilePath?: string | string[];
    encoding?: string;
    validate?: (config: Record<string, any>) => Record<string, any>;
    validationSchema?: any;
    validationOptions?: Record<string, any>;
    load?: Array<ConfigFactory>;
    expandVariables?: boolean | DotenvExpandOptions;
}
  • isGlobal?:boolean
    • ConfigModule을 전역으로 사용할지 여부입니다. Global로 등록하지 않으면 해당 모듈을 사용하는 곳에서 import를 받아야 하지만 Global로 등록 시 import를 하지 않고도 DI 받아 사용할 수 있습니다.
  • ignoreEnvFile?: boolean
    • env파일 사용 여부입니다. 해당 flag값이 true가 되면 env의 값들을 읽어오지 않습니다. 굳이 왜 있지 싶지만 또 나름 이해도 되는 옵션입니다.
  • envFilePath?: string | string []
    • env파일의 경로입니다. 단독으로 지정할 수도 있고 배열로도 지정할 수 있습니다. 배열로 지정할 경우 순서대로 탐색하여 가장 먼저 발견되는. env 파일을 로드합니다. 서버를 실행할 때 NODE_ENV를 정의하고 실행시켜 NODE_ENV와 동일한 파일명의 env 파일을 읽어오도록 하였습니다.
  • validationSchema?: any;
    • env 파일의 값들을 유효성 검사 할 수 있는 option입니다. Joi 라이브러리를 사용하도록 되어있으며 아래에서 자세히 다뤄보겠습니다.

 

ConfigService DI

다른 class에서 사용하기 위해서 생성자를 통해 DI 받으면 됩니다. 만일 isGlobal속성을 false로 했다면 사용하고나 하는 모듈에 먼저 import 해주셔야 합니다.

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

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

    @Get('/db-host')
    getDatabaseHost(): string {
        return this.configService.get('DATABASE_HOST');
    }
}

main.ts 파일의 bootstrap 함수에서 사용하고 싶다면 이런 식으로 사용할 수 있습니다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService);
  const port = configService.get<number>('SERVER_PORT');
  await app.listen(port);
  Logger.log(`application listening on port ${port}`);
}

bootstrap();

 

env 파일 유효성 검사

환경변수에 유효성을 검사하기 위해서 Joi 라이브러리를 사용합니다.

Joi는 Node.js에서 사용할 수 있는 데이터 유효성 검증 라이브러리입니다. 데이터 유효성 검증을 위해 스키마(Schema)를 정의하고, 이에 따라 데이터를 검증합니다. Joi는 다양한 데이터 유형(문자열, 숫자, 배열, 객체 등)과 이에 따른 검증 규칙을 제공합니다.

npm i --save joi

Joi를 사용하여 이메일의 형식을 검증하는 간단한 코드입니다.

const schema = Joi.object({
  email: Joi.string().email().required(),
});

const result = schema.validate({ email: 'example@example.com' });
console.log(result);
// { value: { email: 'example@example.com' }, error: undefined }

다시 AppModule로 돌아와서 Joi를 사용하여 env 파일의 값들을 검증합니다.

import * as from 'joi';
// TS에서는 import Joi from ‘joi’; 를 하면 error 발생하기에 이런식으로 사용합니다

@Module({
  imports: [
    ConfigModule.forRoot({
      //...
      validationSchema: Joi.object({
        NODE_ENV: Joi.string().valid('dev').required(),
        SERVER_PORT: Joi.string().required().default(8080),
        DB_HOST: Joi.string().required(),
        DB_PORT: Joi.number().required(),
        DB_USER: Joi.string().required(),
        DB_PASSWORD: Joi.string().required(),
        DB_SCHEMA: Joi.string().required(),
        JWT_SECRET: Joi.string().required(),
      }),
      //...
    }),
  ]
})

위에 코드를 보면 기본적인 사용법은 대충 알지 않을까 생각합니다. 더 자세한 사용법은 joi.dev

 

env 파일 함께 빌드하기

NestJS는 빌드할 때 ts파일을 제외한 assets 파일들은 제외가 되어 dist 디렉토리에 포함되지 않습니다.

그렇기 때문에 빌드하여 배포할 때 env 파일을 포함하고 싶다면 nest-cli.json 파일에 assets 속성에 option을 추가해줘야 합니다.

{
	...
  "compilerOptions": {
    "assets": [
      {
        "input": "./.env",
        "output": "./"
      }
    ]
  }
}

여기서 input 속성은 복사할 파일의 경로를 지정하고, output 속성은 해당 파일을 복사할 위치를 지정합니다. 위 예시에서는 루트에 .env 파일을 루트 폴더에 복사하도록 설정했습니다.

위에서도 설명했지만 env 파일에는 애플리케이션에서 사용되는 중요한 보안 정보가 포함될 수 있기 때문에 이를 함께 빌드하여 배포하는 것은 보안에 매우 취약하다고 할 수 있습니다. 혹여나 빌드된 파일이 어떠한 경로로 유출된다면 매우 위험하겠죠. 따라서, 서버나 호스팅 플랫폼과 같은 배포 환경에 별도로 저장하거나 AWS의 Secret Manger 혹은 Github Actions 같은 서비스를 사용하는 것을 추천합니다.