[NestJS] Joi로 환경변수 validation
application bootstrap 전이 아니라 그 이후, 즉 런타임에서 환경변수를 읽어와야 할 때가 있다.
가령 TypeOrmModule
의 경우, DataSource
초기화를 위해 필요한 모든 커넥션 정보들이 환경변수로부터 다 읽어져와야 해서 데이터베이스 관련 환경변수들이 빠졌는지 아닌지는 쉽게 확인할 수 있다.
그러나 모듈 초기화에서 체크하지 않는, 메서드 내부에서 그 때 그 때 읽어서 활용하는 환경변수의 경우 env에 없어도 애플리케이션 실행에 문제가 되지 않는다. 그 변수를 호출하는 쪽에서 에러를 내면 그제서야 알아차리는 식이다.
Joi
라는 잘 만들어진 라이브러리를 사용하여 validation schema를 만들고, 이를 ConfigModule
에 등록해둔다면 런타임에서 환경변수가 없다거나 유효하지 않다는 에러를 맞닥뜨리지 않아도 될 것이다.
Joi 설치
Joi
라이브러리를 설치해준다.
npm i joi
혹시 @nestjs/config
가 없다면 이 역시 설치해준다
npm i @nestjs/config
Schema 작성
애플리케이션에서 사용해야 하는 모든 환경변수들을 schema에 다 정의해준다.
schema 이름은 대충 아무거나 짓고,
변수의 이름과 타입 (필요하다면 세부 타입까지)을 Joi.object
안에다 적어준다.
// env-validation.ts
import Joi from 'joi';
const JoiValidationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'local')
.default('local'),
PORT: Joi.number().port().default(3000),
DB_HOST: Joi.string(),
DB_PORT: Joi.number().port(),
DB_USER: Joi.string(),
DB_NAME: Joi.string(),
DB_PASS: Joi.string(),
ADMIN_MAIL: Joi.string().email(),
ADMIN_AUTH_URI: Joi.string().uri()
REDIS_HOST: Joi.string().ip(),
REDIS_PORT: Joi.number().port().default(6379),
REDIS_SESSION_TTL: Joi.number().default(3600),
REDIS_PASSWORD: Joi.string(),
});
Schema 적용
AppModule
내부에 ConfigModule
를 import해준 후, 아까 작성한 schema를 등록해준다.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JoiValidationSchema } from './config/env-validation';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: JoiValidationSchema
}),
...
],
controllers: [...],
providers: [...],
})
export class AppModule {}
환경변수는 앞으로도 계속 추가되거나 변경될 여지가 있다. 그럴 경우엔 일일히 개발자가 Joi Schema 역시 업데이트해줘야 한다.
하지만
본인이구현해놓고
Joi schema를 업데이트해야 한다는 사실을 까먹을 수도 있으니 환경변수 파일을 읽어서 schema와 싱크를 맞추도록 조치를 취해줘야 한다.
우선 위의 JoiValidationSchema
밑에 환경변수를 읽어오는 함수를 추가해줬다.
오로지 키(변수 이름)만 가져오도록 했고, 주석('#')은 제외했다.
function parseEnvContent(content) {
const lines = content.split('\n');
const envObject = {};
lines.forEach((line) => {
if (line && !line.startsWith('#')) {
const [key, value] = line.split('=');
envObject[key.trim()] = value.trim();
}
});
return envObject;
}
환경(NODE_ENV
)별로 변수를 각각 검사해서 세부 에러를 잡아내기 위해 아래의 함수도 추가해줬다.
function validateEnvSchema(nodeEnv: string, envFilePath: string, schema: Joi.ObjectSchema) {
const envFileContent = fs.readFileSync(envFilePath, 'utf8');
const envObject = parseEnvContent(envFileContent);
const envKeys = Object.keys(envObject);
const schemaKeys = Object.keys(schema.describe().keys);
const missingKeys = envKeys.filter(key => !schemaKeys.includes(key));
if (missingKeys.length > 0) {
console.error(`[${nodeEnv}] Missing keys in Joi schema:\n${missingKeys.join('\n')}`);
process.exit(1);
}
}
그리고 바로 위의 함수들을 호출하게끔 해주면 된다.
// env-validation.ts
import fs from 'fs';
import path from 'path'
...
validateEnvSchema('local', path.join('.env.local'), JoiValidationSchema);
validateEnvSchema('development', path.join('.env.development'), JoiValidationSchema);
validateEnvSchema('production', path.join('.env.production'), JoiValidationSchema);
+) 환경별로 Schema 분리
만약 로컬과 배포환경 간에 필수로 요구해야 하는 변수들이 조금씩 다르다면 공통 부분만 따로 빼내어 각각 별도의 schema를 만드는 것도 가능할 것 같다.
const JoiValidationSchema = {
/* 로컬/배포 환경에서 공통으로 쓰이는 변수들 */
};
export const DeployValidationSchema = Joi.object({
...JoiValidationSchema,
/* 배포(개발계/운영계) 환경에서만 쓰이는 변수들 */
})
export const LocalValidationSchema = Joi.object({
...JoiValidationSchema,
/* 로컬 환경에서만 쓰이는 변수들 */
})
...
validateEnvSchema('local', path.join('.env.local'), LocalValidationSchema);
validateEnvSchema('development', path.join('.env.development'), DeployValidationSchema);
validateEnvSchema('production', path.join('.env.production'), DeployValidationSchema);
이렇게 할 경우 AppModule
도 수정해주면 된다.
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: process.env.NODE_ENV === 'local' ? LocalValidationSchema : DeployValidationSchema,
}),
...
})
export class AppModule {}
NODE_ENV
에 따라 schema를 달리 적용하도록 수정해줬다.