NestJS 모듈단위 환경변수 검증
문제 인식
팀의 NestJS 프로젝트가 비대해지면서 다음과 같은 문제들이 발생함
ConfigModule.forRoot 만 스키마 검증이 가능해서 서버 당 하나의 Validation Schema 가 존재
여러 서버에서 하나의 모듈을 재활용하는 구조에서 환경변수의 변화가 있는 경우 각 Server 의 Validation Schema 를 전부 수정해야 함.
모듈별 환경변수 검증
핵심 아이디어
각 모듈이 자신이 필요한 환경변수만 독립적으로 검증하고 관리하는 방법을 사용하도록 변경
// ✅ 새로운 방식: 각 모듈이 필요한 환경변수만 검증
@Module({
imports: [
ConfigModule.forFeature([databaseConfig]), // DB 관련만
],
})
export class DatabaseModule {}
@Module({
imports: [
ConfigModule.forFeature([paymentConfig]), // 결제 관련만
],
})
export class PaymentModule {}
기대 효과
관심사 분리: 각 모듈이 자신의 설정만 책임
독립적 개발: 모듈별로 필요한 환경변수만 설정하면 됨
명확한 에러: 어떤 모듈의 어떤 환경변수가 문제인지 즉시 파악
유연한 배포: 필요한 모듈만 선택적으로 활성화 가능
라이브러리 설계
설계 원칙
기존 코드 호환성: 최소한의 변경으로 마이그레이션 가능
NestJS 표준 준수:
ConfigModule.forFeature활용타입 안전성: TypeScript 타입 추론 지원
간단한 API: 복잡하지 않은 사용법
핵심 API
// 기존 방식
export default registerAs('database-config', (): IDatabaseConfig => {
const configs: JoiConfig<IDatabaseConfig> = { /* ... */ };
return JoiUtil.validate(configs); // 매번 수동 호출
});
// ✅ 새로운 방식: validation 자동 처리
export const databaseConfig = configFactory<IDatabaseConfig>('database', {
// 기존과 동일한 설정 구조 유지
});
사용법
1. 모듈별 설정 정의
Database Module
// modules/database/config/database.config.ts
import { configFactory } from 'my-config';
import * as Joi from 'joi';
export interface IDatabaseConfig {
host: string;
port: number;
username: string;
password: string;
database: string;
ssl: boolean;
poolSize: number;
connectionTimeout: number;
}
export const databaseConfig = configFactory<IDatabaseConfig>('database', {
host: {
value: process.env.DB_HOST,
joi: Joi.string().hostname().required(),
},
port: {
value: parseInt(process.env.DB_PORT || '5432'),
joi: Joi.number().port().default(5432),
},
username: {
value: process.env.DB_USERNAME,
joi: Joi.string().required(),
},
password: {
value: process.env.DB_PASSWORD,
joi: Joi.string().required(),
},
database: {
value: process.env.DB_NAME,
joi: Joi.string().required(),
},
ssl: {
value: process.env.DB_SSL === 'true',
joi: Joi.boolean().default(false),
},
poolSize: {
value: parseInt(process.env.DB_POOL_SIZE || '10'),
joi: Joi.number().min(1).max(100).default(10),
},
connectionTimeout: {
value: parseInt(process.env.DB_CONNECTION_TIMEOUT || '30000'),
joi: Joi.number().min(1000).default(30000),
},
});
Payment Module
// modules/payment/config/payment.config.ts
import { configFactory } from 'my-config';
import * as Joi from 'joi';
export interface IPaymentConfig {
stripeSecretKey: string;
stripePublishableKey: string;
webhookSecret: string;
currency: string;
successUrl: string;
cancelUrl: string;
}
export const paymentConfig = configFactory<IPaymentConfig>('payment', {
stripeSecretKey: {
value: process.env.STRIPE_SECRET_KEY,
joi: Joi.string().pattern(/^sk_(test_|live_)[a-zA-Z0-9]{99}$/).required(),
},
stripePublishableKey: {
value: process.env.STRIPE_PUBLISHABLE_KEY,
joi: Joi.string().pattern(/^pk_(test_|live_)[a-zA-Z0-9]{99}$/).required(),
},
webhookSecret: {
value: process.env.STRIPE_WEBHOOK_SECRET,
joi: Joi.string().pattern(/^whsec_[a-zA-Z0-9]{32}$/).required(),
},
currency: {
value: process.env.PAYMENT_CURRENCY,
joi: Joi.string().valid('usd', 'eur', 'krw', 'jpy').default('usd'),
},
successUrl: {
value: process.env.PAYMENT_SUCCESS_URL,
joi: Joi.string().uri().required(),
},
cancelUrl: {
value: process.env.PAYMENT_CANCEL_URL,
joi: Joi.string().uri().required(),
},
});
2. 각 모듈에서 forFeature 사용
Database Module
// modules/database/database.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { databaseConfig } from './config/database.config';
import { DatabaseService } from './database.service';
@Module({
imports: [
// ✅ 이 모듈만의 환경변수 검증
ConfigModule.forFeature([databaseConfig]),
// 검증된 설정으로 TypeORM 설정
TypeOrmModule.forRootAsync({
useFactory: (configService) => {
const dbConfig = configService.get('database');
return {
type: 'postgres',
host: dbConfig.host,
port: dbConfig.port,
username: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
ssl: dbConfig.ssl,
extra: {
max: dbConfig.poolSize,
connectionTimeoutMillis: dbConfig.connectionTimeout,
},
autoLoadEntities: true,
synchronize: false,
};
},
inject: [ConfigService],
}),
],
providers: [DatabaseService],
exports: [DatabaseService, TypeOrmModule],
})
export class DatabaseModule {}
Payment Module
// modules/payment/payment.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { paymentConfig } from './config/payment.config';
import { PaymentService } from './payment.service';
import { PaymentController } from './payment.controller';
@Module({
imports: [
// ✅ Payment 모듈만의 환경변수 검증
ConfigModule.forFeature([paymentConfig]),
],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
})
export class PaymentModule {}
3. 서비스에서 검증된 설정 사용
Database Service
// modules/database/database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IDatabaseConfig } from './config/database.config';
@Injectable()
export class DatabaseService {
private readonly dbConfig: IDatabaseConfig;
constructor(private configService: ConfigService) {
// 타입 안전하게 검증된 설정 접근
this.dbConfig = this.configService.get<IDatabaseConfig>('database')!;
}
getConnectionString(): string {
const { username, password, host, port, database, ssl } = this.dbConfig;
const protocol = ssl ? 'postgresql+ssl' : 'postgresql';
return `${protocol}://${username}:${password}@${host}:${port}/${database}`;
}
getPoolConfiguration() {
return {
max: this.dbConfig.poolSize,
connectionTimeoutMillis: this.dbConfig.connectionTimeout,
ssl: this.dbConfig.ssl,
};
}
getDatabaseInfo() {
return {
host: this.dbConfig.host,
port: this.dbConfig.port,
database: this.dbConfig.database,
ssl: this.dbConfig.ssl,
};
}
}
4. App Module (최소화된 전역 설정)
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './modules/database/database.module';
import { PaymentModule } from './modules/payment/payment.module';
import { EmailModule } from './modules/email/email.module';
import { AuthModule } from './modules/auth/auth.module';
import { UserModule } from './modules/user/user.module';
@Module({
imports: [
// 전역 ConfigModule (기본 설정만)
ConfigModule.forRoot({
isGlobal: true,
// 개별 모듈 설정은 각 모듈의 forFeature에서 처리
// 여기서는 공통 환경변수만 처리 (NODE_ENV, PORT 등)
}),
// 각 모듈이 독립적으로 환경변수 검증 수행
DatabaseModule, // DB_* 환경변수 검증
AuthModule, // JWT_*, BCRYPT_* 환경변수 검증
UserModule, // 사용자 관련 환경변수 검증
],
})
export class AppModule {}
구현 가이드
라이브러리 구현
1. 핵심 파일 구조
src/
├── index.ts # 메인 export
├── config-factory.ts # configFactory 함수
├── joi-util.ts # 기존 JoiUtil (수정 없음)
└── types.ts # 타입 정의
2. configFactory 구현
// config-factory.ts
import { registerAs } from '@nestjs/config';
import JoiUtil from './joi-util';
import { JoiConfig } from './types';
export function configFactory<T>(
token: string,
config: JoiConfig<T>
) {
return registerAs(token, (): T => {
return JoiUtil.validate(config);
});
}
3. 타입 정의
// types.ts
import { Schema } from 'joi';
export interface ConfigProps {
value: unknown;
joi: Schema;
}
export type JoiConfig<T> = Record<keyof T, ConfigProps>;
4. 메인 export
// index.ts
export { configFactory } from './config-factory';
export { default as JoiUtil } from './joi-util';
export type { JoiConfig, ConfigProps } from './types';
실제 동작 과정
# 1. 앱 시작
npm run start
# 2. AppModule 로딩
✓ ConfigModule.forRoot() 실행 (전역 기본 설정)
# 3. DatabaseModule 로딩
✓ ConfigModule.forFeature([databaseConfig]) 실행
✓ databaseConfig() 호출 → JoiUtil.validate() 실행
✓ DB_* 환경변수 검증
✓ 성공 시 'database' 네임스페이스에 설정 등록
# 4. PaymentModule 로딩 (ENABLE_PAYMENT=true인 경우)
✓ ConfigModule.forFeature([paymentConfig]) 실행
✓ STRIPE_* 환경변수 검증
✓ 성공 시 'payment' 네임스페이스에 설정 등록
# 5. EmailModule 로딩 (ENABLE_EMAIL=true인 경우)
✓ ConfigModule.forFeature([emailConfig]) 실행
✓ SMTP_* 환경변수 검증
# 만약 어떤 단계에서 검증 실패 시
❌ Error: [database] Validation failed - DB_HOST is required
❌ 앱 시작 중단, 명확한 에러 메시지로 빠른 문제 파악
에러 처리 개선
// 개선된 에러 메시지 예시
// Before (기존 방식)
Error: Validation failed - Is there an environment variable missing?
"DB_HOST" is required
"STRIPE_SECRET_KEY" is required
"SMTP_PASSWORD" is required
// 어떤 모듈의 문제인지 알 수 없음
// After (새로운 방식)
Error: [database] Validation failed - Is there an environment variable missing?
"DB_HOST" is required
Error: [payment] Validation failed - Is there an environment variable missing?
"STRIPE_SECRET_KEY" must be a valid Stripe secret key format
Error: [email] Validation failed - Is there an environment variable missing?
"SMTP_PASSWORD" is required