NestJS - 대충 서비스 만들어보기 (8)
지난 포스팅에서 미처 마무리하지 못했던 env 를 정리해보도록 하겠습니다.
- Custom Config
위 구조는 현재까지 작성한 env 관련 디렉토리와 파일입니다.
@nestjs/config 를 사용해 ConfigModule 을 Node 실행 환경에 따라 동적으로 구성하고, ConfigService 를 provider 로 등록해
config 가 필요한 컴퍼넌트가 ConfigService 와 협력하는 구조로 되어 있습니다.
.development.env 또는 .production.env 와 같은 .env 파일은 환경에 따라 .staging.env 같은 파일이 추가될 순 있겠지만
이 파일을 괜히 사용하는 모듈에 따라 분리하는 건 오히려 관리가 더 어려울 것 같고, 하나의 통째로 들어가있는 ConfigService 를
모듈별로 의미있게 나눈다면 좀 더 깔끔할 것 같습니다.
이럴 경우 @nestjs/config 에서 제공하는 registerAs 함수를 활용할 수 있습니다.
먼저 config 하위에 slackConfig.ts 파일을 생성합니다.
아마도 저는 앞으로 의미있게 분리할 config 가 추가될때마다 이 위치에 추가를 할 것 같습니다.
예를들면 databaseConfig.ts 와 같이 말이죠.
import { registerAs } from '@nestjs/config';
export const slackConfig = registerAs('slack', () => ({
token: process.env.SLACK_TOKEN,
channel: process.env.SLACK_CHANNEL,
}));
slackConfig.ts 에 들어갈 내용은 위와 같습니다. registerAs 함수에 대한 내용은 조금 이따 보겠습니다.
...
import { slackConfig } from './config/slackConfig';
@Module({
imports: [
...
ConfigModule.forRoot({
envFilePath: path.resolve(
__dirname,
process.env.NODE_ENV === 'production'
? 'config/env/.production.env'
: 'config/env/.development.env',
),
load: [slackConfig],
isGlobal: true,
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
변경된 app.module.ts 파일입니다. ConfigModule 을 동적으로 구성할 때 slackConfig 를 load 하도록 하고,
루트인 앱 모듈에서 설정한 ConfigModule 을 다른 모듈들에서 별도의 import 없이 사용할 수 있도록 isGlobal 을 세팅했습니다.
import { Injectable, Inject } from '@nestjs/common';
import axios from 'axios';
import { ConfigType } from '@nestjs/config';
import { slackConfig } from '../config/slackConfig';
@Injectable()
export class SlackService {
private token: string;
private channel: string;
constructor(
@Inject(slackConfig.KEY) private config: ConfigType<typeof slackConfig>,
) {
this.token = config.token;
this.channel = config.channel;
}
...
}
이제 기존 ConfigService 가 위치해있던 코드를 slackConfig 를 주입받아서 사용하는 코드로 변경하면 됩니다.
@nestjs/config 의 ConfigType 과 typescript 의 typeof 키워드를 통해 주입하는 config 의 타입을 명시할 수 있습니다.
혹시나 any 를 써야하나 싶었는데, 좀 더 type-safe 해진 것 같습니다.
slack.module.ts 에 provider 로 등록했었던 ConfigService 는 이제 불필요하니 제거하고
앱을 돌려보면 잘 동작하는걸 확인할 수 있습니다.
- registerAs
import { ConfigModule } from '..';
import { ConfigFactory } from '../interfaces';
import { ConfigObject } from '../types';
export interface ConfigFactoryKeyHost<T = unknown> {
KEY: string;
asProvider(): {
imports: [ReturnType<typeof ConfigModule.forFeature>];
useFactory: (config: T) => T;
inject: [string];
};
}
/**
* Registers the configuration object behind a specified token.
*/
export declare function registerAs<TConfig extends ConfigObject, TFactory extends ConfigFactory = ConfigFactory<TConfig>>
(token: string, configFactory: TFactory): TFactory & ConfigFactoryKeyHost<ReturnType<TFactory>>;
아까 registerAs 함수를 사용할 때 첫번째 인자로는 'slack' 문자열을 넣었고, 두번째 인자로는 config 를 담은 객체를 반환하는 함수를
넣었었습니다. registerAs 함수 선언을 보면 첫번째 인자는 token 이고, 두번째 인자는 configFactory 입니다.
registerAs 함수의 반환 타입은 configFactory 의 타입과 ConfigFactoryKeyHost 타입의 교차 타입으로 되어 있습니다.
ConfigFactoryKeyHost 를 보면 KEY 가 있는데, 여기에 우리가 넘겼던 token 이 들어가게 됩니다.
그래서 별도로 생성한 slackConfig 를 inject 할 때 slackConfig.KEY 를 Inject 의 토큰값으로 넣을 수 있었던 것입니다.
요약해서 registerAs 함수는 곧 원하는 token 으로 ConfigFactory 를 등록할 수 있도록 만들어주는 함수라고 이해하면 될 것 같습니다.
ConfigModule 에서 동적 모듈을 구성할 때 load 는 ConfigFactory 의 배열을 받을 수 있도록 되어 있기 때문에 registerAs 로 생성한
SlackConfigFactory 를 load 로 넘길 수 있었다고 보면 됩니다.
- Joi (환경 변수 validation)
보통 환경 변수를 담은 파일에는 누락된 내용이 없을거라고 기대하긴 하지만, 언제든 문제는 발생할 수 있습니다.
따라서 파일에 우리가 원하는 환경 변수가 제대로 들어있는지를 확인하기 위한 검증 단계도 필요합니다.
이를 검사하기 위해선 joi 라는 라이브러리를 사용합니다.
joi 는 스키마를 설명하기에 가장 강력한 언어이자 JS 용 데이터 유효성 검사 도구입니다.
JS 용 데이터 유효성 검사 도구이므로 joi 는 NestJS 에서만 사용가능한건 아니지만, NestJS config 공식 문서에서도 joi 를 사용하는
예시가 들어있을만큼 확실한 것 같습니다.
먼저 config 하위에 validation 파일을 생성합니다.
import * as Joi from 'joi';
export const validationSchema = Joi.object({
SLACK_TOKEN: Joi.string().required(),
SLACK_CHANNEL: Joi.string().required(),
});
Joi 를 사용해 위와 같이 환경 변수 스키마를 정의해줍니다.
함수 네이밍이 너무 명료해서 따로 코멘트가 필요할까 싶긴하지만, TOKEN 과 CHANNEL 둘 다 string 타입이면서 optional 이 아닌
필수로 있어야 한다는 걸 나타냅니다.
...
import { validationSchema } from './config/validation';
@Module({
imports: [
AdsModule,
SlackModule,
ConfigModule.forRoot({
envFilePath: path.resolve(
__dirname,
process.env.NODE_ENV === 'production'
? 'config/env/.production.env'
: 'config/env/.development.env',
),
load: [slackConfig],
isGlobal: true,
validationSchema,
}),
],
...
})
export class AppModule {}
app.module.ts 에서 ConfigModule 을 동적으로 구성할 때 validationSchema 를 넣어줍니다.
/**
* 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>;
ConfigModule 의 forRoot 메서드가 받는 ConfigModuleOptions 중 validate 와 관련된 타입을 보면 위와 같은데,
validationSchema 로 Joi 를 통한 validation schema 라는 주석이 달려있는걸 볼 수 있습니다. 타입은 비록 any 긴 하지만요.
그 외에 validate 나 validationOptions 도 있는걸보면 Joi 의 다른 api 를 사용하거나, Joi 가 아닌 다른 방식을 통해서도
환경 변수에 대한 유효성 검사는 가능해보입니다.
validate 에 대한 예시는 NestJs 공식 문서에도 나와있으니 필요시 참고하시면 되겠습니다 (링크)
이전 포스팅들에서 DTO 에 대한 유효성 검사를 할 때 class-validator 를 사용했었고 환경 변수 유효성 검사 부분을 보면서
동일하게 class-validator 를 사용할 수 있지 않을까 하는 생각이 들긴했는데 공식 문서에서 그런 예시에 대해 얘기하네요.
나중에 시간이 되면 별도로 올려보겠습니다.
테스트 겸 .development.env 파일에서 TOKEN 이나 CHANNEL 둘 중 하나를 제거하고 NestJS 앱을 구동해보면 위와 같은 에러가
나면서 앱이 동작하지 않는 것을 확인할 수 있습니다.
동일한 상황에서 Joi 를 통한 스키마 정의에서 required 를 제거하면, 앱이 정상 동작하는 것도 확인할 수가 있구요.
현회사에선 dotenv 로 전환을 하지 못해서 아직 공용으로 config.json 과 같은 파일에 필요한 변수들을 등록하고 load 해서 사용하는
형태로 운영하고 있는데, dotenv 가 아니더라도 Joi 와 같은 라이브러리를 사용해 앱 부팅시 config.json 을 검증하는 형태로
작업이 가능할 것 같습니다.