"로컬에서는 잘 되는데 배포만 하면 죽어요!"
주니어 시절 가장 많이 하는 실수 중 하나가 바로 환경 변수(Environment Variables) 누락입니다. 서버를 띄울 때 필요한 DATABASE_URL이나 JWT_SECRET 같은 필수 값들이 .env 파일에 없거나 타입이 잘못되었을 때, Node.js는 아무런 불평 없이 일단 서버를 띄웁니다.
그러다 사용자가 특정 API를 호출해서 해당 환경 변수에 접근하는 순간, undefined 에러를 뱉으며 애플리케이션이 뻗어버리는 대참사가 발생합니다. 환경 변수 오류는 런타임(Runtime)이 아니라, 서버가 켜지는 부트스트랩(Bootstrap) 단계에서 즉시 터지게 만들어야 합니다. 이것이 서버 개발의 철칙이자 'Fail Fast(빠르게 실패하기)' 전략의 핵심입니다.
NestJS 커뮤니티와 업계 표준에서는 이 문제를 해결하기 위해 주로 @nestjs/config 패키지와 함께 두 가지 검증 방식을 사용합니다.
최근의 업계 트렌드는 NestJS의 생태계와 가장 잘 어울리는 class-validator 방식을 선호하는 추세입니다. 하지만 레거시 시스템이나 팀의 성향에 따라 Joi를 쓰는 곳도 많으므로 두 가지를 모두 알아보겠습니다.
NestJS의 @nestjs/config 모듈은 내부적으로 .env 파일을 읽어들입니다. 이때 모듈의 설정 옵션으로 validationSchema (Joi용) 또는 validate (class-validator 커스텀 함수용)를 넘겨주면, 환경 변수를 서버 메모리에 올리기 전에 유효성 검사를 수행합니다.
만약 이 검사에서 단 하나라도 실패하면, NestJS 애플리케이션은 즉시 부트스트랩을 멈추고 어떤 환경 변수가 누락되었는지 명확한 에러 로그를 남기고 종료(Exit) 됩니다.
먼저 joi 패키지를 설치해야 합니다.
npm install joi
app.module.ts에서 ConfigModule을 초기화할 때 validationSchema를 넘겨줍니다.
// app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Joi를 이용한 검증 스키마 정의 validationSchema: Joi.object({ NODE_ENV: Joi.string() .valid('development', 'production', 'test', 'provision') .default('development'), PORT: Joi.number().default(3000), DATABASE_URL: Joi.string().required(), // 필수 값! JWT_SECRET: Joi.string().required(), }), }), ], }) export class AppModule {}
이 방식은 깔끔하지만, 코드 내에서 process.env.PORT를 꺼내 쓸 때 타입이 여전히 string | undefined로 잡힌다는 단점이 있습니다.
먼저 클래스와 데코레이터를 이용해 우리가 기대하는 환경 변수의 형태를 정의합니다.
// env.validation.ts import { plainToInstance } from 'class-transformer'; import { IsEnum, IsNumber, IsString, validateSync } from 'class-validator'; enum Environment { Development = 'development', Production = 'production', Test = 'test', } // 1. 검증할 환경 변수 클래스 (DTO와 유사) export class EnvironmentVariables { @IsEnum(Environment) NODE_ENV: Environment; @IsNumber() PORT: number; @IsString() DATABASE_URL: string; } // 2. 검증을 수행할 커스텀 함수 export function validate(config: Record<string, unknown>) { // 평범한 객체(config)를 우리가 정의한 클래스의 인스턴스로 변환합니다. const validatedConfig = plainToInstance( EnvironmentVariables, config, { enableImplicitConversion: true }, // '3000' 같은 문자열을 숫자로 자동 변환! ); const errors = validateSync(validatedConfig, { skipMissingProperties: false }); if (errors.length > 0) { throw new Error(`환경 변수 검증 실패: ${errors.toString()}`); } return validatedConfig; }
이제 app.module.ts에 만든 validate 함수를 연결합니다.
// app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { validate } from './env.validation'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validate, // 우리가 만든 커스텀 validate 함수 연결 }), ], }) export class AppModule {}
.env에 DATABASE_URL을 적지 않았다면, 서버는 켜지지 않고 즉시 에러 로그를 뿜으며 죽습니다. 장애를 사전에 완벽히 차단한 것입니다..env 파일의 모든 값은 본질적으로 무조건 string(문자열)입니다. 하지만 class-validator의 enableImplicitConversion: true 옵션을 사용하면, PORT=3000이라는 문자열이 서버 내부에서는 깔끔하게 Number 타입으로 캐스팅되어 들어옵니다. 코드를 짤 때 parseInt()를 남발할 필요가 없어집니다.NestJS를 사용하신다면 프레임워크의 철학(데코레이터와 클래스 기반)과 가장 잘 어울리는 class-validator 방식을 사용하는 것을 강력히 추천합니다. API의 DTO를 검증할 때 쓰던 지식을 그대로 환경 변수 검증에도 사용할 수 있으니 일석이조입니다. "서버가 켜졌다는 것은, 모든 환경 변수가 완벽하게 준비되었다는 뜻이다."라는 믿음을 가질 수 있도록 지금 당장 환경 변수 검증 로직을 추가해 보세요!