NestJS ConfigModule 타입 안전 설정

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을 사용하라. 설정 오류는 가장 빨리 실패하는 것이 최선이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux