NestJS ConfigModule이란? — 환경 설정의 타입 안전한 관리
데이터베이스 URL, API 키, 포트 번호 — 모든 애플리케이션은 환경별로 다른 설정값을 관리해야 한다. process.env.DB_HOST를 직접 읽으면 타입이 항상 string | undefined이고, 오타를 런타임에야 발견하며, 검증도 없다. NestJS의 @nestjs/config는 타입 안전한 설정 객체, Joi/class-validator 검증, .env 파일 로딩, 네임스페이스 분리를 제공한다.
이 글에서는 기본 설정부터 registerAs() 네임스페이스, validate()로 부팅 시 검증, 커스텀 설정 팩토리, 동적 모듈 설정, 그리고 커스텀 데코레이터와 조합한 타입 안전 주입 패턴까지 정리한다.
1. 기본 설정 — .env 파일과 ConfigService
# 의존성 설치
npm install @nestjs/config
// app.module.ts
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 모든 모듈에서 주입 가능
envFilePath: [
`.env.${process.env.NODE_ENV}`, // .env.development, .env.production
'.env', // 폴백
],
expandVariables: true, // ${VAR} 참조 지원
cache: true, // 성능 최적화
}),
],
})
export class AppModule {}
# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=myapp
DATABASE_URL=postgresql://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}
JWT_SECRET=my-secret-key
JWT_EXPIRES_IN=3600
// 서비스에서 사용
@Injectable()
export class DatabaseService {
constructor(private readonly configService: ConfigService) {}
getConnectionUrl(): string {
// 기본 사용 — string | undefined 반환
const host = this.configService.get<string>('DATABASE_HOST');
// 기본값 지정
const port = this.configService.get<number>('DATABASE_PORT', 5432);
// 필수값 — 없으면 예외 발생
const url = this.configService.getOrThrow<string>('DATABASE_URL');
return url;
}
}
문제점: configService.get('DATABASE_HSOT') — 오타를 해도 컴파일 에러가 나지 않고, 런타임에 undefined가 반환된다. 이를 해결하는 것이 타입 안전 설정이다.
2. registerAs — 네임스페이스 분리와 타입 안전 설정
registerAs()는 관련 설정을 네임스페이스로 그룹화하고, TypeScript 타입을 부여한다.
// config/database.config.ts
import { registerAs } from '@nestjs/config';
export const databaseConfig = registerAs('database', () => ({
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
name: process.env.DATABASE_NAME || 'myapp',
username: process.env.DATABASE_USERNAME || 'postgres',
password: process.env.DATABASE_PASSWORD || '',
ssl: process.env.DATABASE_SSL === 'true',
pool: {
min: parseInt(process.env.DATABASE_POOL_MIN, 10) || 2,
max: parseInt(process.env.DATABASE_POOL_MAX, 10) || 10,
},
}));
// config/jwt.config.ts
export const jwtConfig = registerAs('jwt', () => ({
secret: process.env.JWT_SECRET,
expiresIn: parseInt(process.env.JWT_EXPIRES_IN, 10) || 3600,
refreshExpiresIn: parseInt(process.env.JWT_REFRESH_EXPIRES_IN, 10) || 604800,
}));
// config/redis.config.ts
export const redisConfig = registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB, 10) || 0,
keyPrefix: process.env.REDIS_KEY_PREFIX || 'app:',
}));
// app.module.ts — 설정 등록
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, jwtConfig, redisConfig], // 설정 팩토리 등록
}),
],
})
export class AppModule {}
사용 방법 3가지
// 방법 1: ConfigService.get() — 네임스페이스 경로로 접근
@Injectable()
export class SomeService {
constructor(private readonly config: ConfigService) {}
getDbHost() {
return this.config.get<string>('database.host'); // 네임스페이스.키
return this.config.get<number>('database.pool.max'); // 중첩 접근
}
}
// 방법 2: @Inject() + ConfigType — 타입 안전 (권장)
import { ConfigType } from '@nestjs/config';
@Injectable()
export class DatabaseConnectionService {
constructor(
@Inject(databaseConfig.KEY) // 토큰으로 주입
private readonly dbConfig: ConfigType<typeof databaseConfig>,
) {}
connect() {
// 완전한 타입 추론 — 오타 시 컴파일 에러
console.log(this.dbConfig.host); // string ✅
console.log(this.dbConfig.port); // number ✅
console.log(this.dbConfig.pool.max); // number ✅
console.log(this.dbConfig.hsot); // ❌ 컴파일 에러!
}
}
// 방법 3: 모듈의 forRootAsync에서 설정 주입
@Module({
imports: [
JwtModule.registerAsync({
inject: [jwtConfig.KEY],
useFactory: (config: ConfigType<typeof jwtConfig>) => ({
secret: config.secret,
signOptions: { expiresIn: config.expiresIn },
}),
}),
],
})
export class AuthModule {}
3. 설정 검증 — 부팅 시점에 잘못된 설정 차단
설정값이 누락되거나 잘못된 형식이면 앱이 시작되자마자 실패하는 것이 바람직하다. 프로덕션에서 첫 번째 요청이 올 때 실패하면 이미 늦었다.
3-1. Joi를 사용한 검증
npm install joi
import * as Joi from 'joi';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'staging', 'production')
.default('development'),
PORT: Joi.number().default(3000),
// 데이터베이스 — 필수
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432),
DATABASE_NAME: Joi.string().required(),
DATABASE_USERNAME: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(),
// JWT — 프로덕션에서만 필수
JWT_SECRET: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(), // prod: 32자 이상 필수
otherwise: Joi.string().default('dev-secret'),
}),
JWT_EXPIRES_IN: Joi.number().default(3600),
// Redis — 선택
REDIS_HOST: Joi.string().default('localhost'),
REDIS_PORT: Joi.number().default(6379),
}),
validationOptions: {
allowUnknown: true, // 정의하지 않은 env 변수 허용
abortEarly: false, // 모든 에러를 한 번에 표시
},
}),
],
})
export class AppModule {}
3-2. class-validator를 사용한 검증
import { IsString, IsNumber, IsIn, Min, validateSync } from 'class-validator';
import { plainToInstance } from 'class-transformer';
class EnvironmentVariables {
@IsIn(['development', 'staging', 'production'])
NODE_ENV: string;
@IsNumber()
@Min(1)
PORT: number;
@IsString()
DATABASE_HOST: string;
@IsNumber()
DATABASE_PORT: number;
@IsString()
JWT_SECRET: string;
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
throw new Error(
`Config validation failed:n${errors.map(e => Object.values(e.constraints)).join('n')}`
);
}
return validatedConfig;
}
// app.module.ts
ConfigModule.forRoot({
isGlobal: true,
validate, // 커스텀 검증 함수
})
결과: DATABASE_HOST가 누락되면 앱이 시작되지 않고 명확한 에러 메시지를 출력한다:
Error: Config validation failed:
"DATABASE_HOST" is required
"JWT_SECRET" must be at least 32 characters in production
4. 동적 모듈에서 설정 사용 — forRootAsync 패턴
NestJS Dynamic Module 심화에서 다룬 것처럼, 외부 모듈의 설정에 ConfigService를 주입하는 패턴이다.
// MikroORM 설정 — registerAs + forRootAsync
@Module({
imports: [
MikroOrmModule.forRootAsync({
inject: [databaseConfig.KEY],
useFactory: (dbConfig: ConfigType<typeof databaseConfig>) => ({
type: 'postgresql',
host: dbConfig.host,
port: dbConfig.port,
dbName: dbConfig.name,
user: dbConfig.username,
password: dbConfig.password,
pool: { min: dbConfig.pool.min, max: dbConfig.pool.max },
entities: ['./dist/**/*.entity.js'],
entitiesTs: ['./src/**/*.entity.ts'],
}),
}),
],
})
export class DatabaseModule {}
// BullMQ 설정
@Module({
imports: [
BullModule.forRootAsync({
inject: [redisConfig.KEY],
useFactory: (redis: ConfigType<typeof redisConfig>) => ({
connection: {
host: redis.host,
port: redis.port,
password: redis.password,
db: redis.db,
},
prefix: redis.keyPrefix,
}),
}),
],
})
export class QueueModule {}
5. 환경별 .env 파일 전략
# 디렉터리 구조
├── .env # 공통 기본값 + 로컬 개발
├── .env.development # 개발 서버
├── .env.staging # 스테이징
├── .env.production # 프로덕션 (Git에 커밋 금지!)
├── .env.test # 테스트
└── .env.example # 템플릿 (Git에 커밋)
// envFilePath 우선순위 — 먼저 나온 파일이 우선
ConfigModule.forRoot({
envFilePath: [
`.env.${process.env.NODE_ENV}.local`, // 로컬 오버라이드 (gitignore)
`.env.${process.env.NODE_ENV}`, // 환경별
'.env.local', // 로컬 공통 (gitignore)
'.env', // 기본
],
})
# .gitignore
.env*.local
.env.production
.env.staging
# .env.example — 팀 공유용 템플릿
DATABASE_HOST=
DATABASE_PORT=5432
DATABASE_NAME=
DATABASE_USERNAME=
DATABASE_PASSWORD=
JWT_SECRET=
# REDIS_HOST=localhost (optional)
Kubernetes 환경: .env 파일 대신 K8s ConfigMap/Secret에서 환경변수를 주입한다. ConfigModule.forRoot({ ignoreEnvFile: true })로 .env 파일 로딩을 비활성화하라.
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: process.env.NODE_ENV === 'production', // prod에서는 .env 무시
load: [databaseConfig, jwtConfig, redisConfig],
})
6. 테스트에서 설정 오버라이드
// 테스트용 설정
describe('OrderService', () => {
let service: OrderService;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// 테스트용 값 직접 주입
load: [
() => ({
database: {
host: 'localhost',
port: 5432,
name: 'test_db',
},
jwt: {
secret: 'test-secret',
expiresIn: 3600,
},
}),
],
}),
],
providers: [OrderService],
}).compile();
service = module.get(OrderService);
});
});
// 또는 ConfigService를 모킹
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
OrderService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
const config = {
'database.host': 'localhost',
'jwt.secret': 'test-secret',
};
return config[key];
}),
getOrThrow: jest.fn((key: string) => {
const value = config[key];
if (!value) throw new Error(`Missing ${key}`);
return value;
}),
},
},
],
}).compile();
});
// registerAs 설정을 오버라이드
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
DatabaseConnectionService,
{
provide: databaseConfig.KEY,
useValue: {
host: 'test-host',
port: 5433,
pool: { min: 1, max: 5 },
},
},
],
}).compile();
});
7. 운영 체크리스트
| 항목 | 권장 사항 | 위반 시 증상 |
|---|---|---|
| 타입 안전 | registerAs + ConfigType으로 주입 | 오타를 런타임에야 발견 |
| 부팅 검증 | Joi 또는 class-validator로 validate | 설정 누락을 첫 요청 시 발견 |
| isGlobal | true (각 모듈에서 import 불필요) | 모듈마다 ConfigModule import 반복 |
| 시크릿 관리 | .env.production을 Git에 커밋 금지 | 비밀번호·API 키 유출 |
| K8s 환경 | ignoreEnvFile: true + ConfigMap/Secret | .env 파일과 환경변수 충돌 |
| .env.example | 필수 변수 목록을 Git에 커밋 | 신규 팀원이 필요한 변수를 모름 |
마무리 — 설정은 앱의 첫 번째 방어선이다
NestJS ConfigModule은 단순한 process.env 래퍼가 아니다. registerAs()로 네임스페이스를 분리하고, ConfigType으로 타입 안전을 보장하며, validate()로 부팅 시점에 잘못된 설정을 차단한다.
핵심은 세 가지다. 첫째, registerAs() + @Inject(config.KEY)로 configService.get('string.key') 대신 타입 안전한 객체를 주입받아라. 둘째, Joi 또는 class-validator로 부팅 검증을 반드시 설정하라. 셋째, 프로덕션에서는 ignoreEnvFile: true로 .env 파일 대신 K8s ConfigMap/Secret을 사용하라. 설정 오류는 가장 빨리 실패하는 것이 최선이다.