Skip to main content

Command Palette

Search for a command to run...

NestJS 모듈단위 환경변수 검증

Published
•6 min read•View as Markdown

문제 인식

팀의 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 {}

기대 효과

  1. 관심사 분리: 각 모듈이 자신의 설정만 책임

  2. 독립적 개발: 모듈별로 필요한 환경변수만 설정하면 됨

  3. 명확한 에러: 어떤 모듈의 어떤 환경변수가 문제인지 즉시 파악

  4. 유연한 배포: 필요한 모듈만 선택적으로 활성화 가능

라이브러리 설계

설계 원칙

  1. 기존 코드 호환성: 최소한의 변경으로 마이그레이션 가능

  2. NestJS 표준 준수: ConfigModule.forFeature 활용

  3. 타입 안전성: TypeScript 타입 추론 지원

  4. 간단한 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

More from this blog

Ash Typescript

TL;DRElixir의 Ash Framework로 정의한 리소스를 TypeScript와 바로 연결해 주는 툴.백엔드 스펙을 따로 문서화하지 않아도 타입 안정성과 동기화를 자동으로 보장한다. 왜 사용하는가? Elixir로 백엔드를, TypeScript로 프런트엔드를 작성하다 보면 변경되는 API 스펙을 항상 동기화된 상태로 관리하기 쉽지 않다.결국 동기화를 위해서는 Graphql 을 사용하거나, 별도의 툴이 필요하다.AshTypescript ...

Sep 28, 20253 min read

Ash Reactor

TLDR; Reactor는 복잡한 워크플로우를 단계별로 정의하고 실행하는 라이브러리다. 각 단계가 독립적으로 돌아가며, 병렬 처리와 에러 핸들링도 쉽게 할 수 있다. 왜 쓸까? 프로젝트에서 "데이터 조회 → 가공 → 외부 API 호출 → 저장" 같은 복잡한 로직을 처리할 때가 있다.이런걸 그냥 함수로 쭉 연결하면 코드가 복잡해지고 에러 처리도 어려워진다. Reactor를 쓰면 각 단계를 명확히 분리하고 선언적으로 워크플로우를 관리할 수 있다. ...

Sep 21, 20254 min read

Ash StateMachine

TLDR; Ash StateMachine 은 상태 변경이 가능한 케이스를 명시하고, 미리 선언된 상태 변경만 가능하도록 한다. 왜 쓸까? 예를 들면, “새로 생성 → 진행 중 → 완료” 처럼 정해진 workflow 가 있다면, 로직으로 if/else 를 사용하는 것보다 상태머신으로 선언해 두는 게 안전하고 읽기 쉽다. 예제: Task 요구사항: 기본 상태는 :new :start 액션이 :new → :in_progress :finish ...

Sep 14, 20252 min read

Ash Oban

최근 작업중에 이메일 발송을 처리해야 했다.after_action 에 이마일 발송을 고려했는데 최종적으로는 AshOban 을 사용했다. AshOban이 뭔가? Ash Framework와 Oban을 이어주는 라이브러리다. 백그라운드 job을 Ash 스타일로 쓸 수 있게 해준다. 멤버 초대 이메일 보내기 클럽에 새 멤버를 초대하면 이메일을 보내야 하는 상황이다. 이걸 어떻게 처리할까? Action Hook으로 하면: 사용자가 서버에서 이메일 발송...

Aug 31, 20252 min read

kkbz.hop

14 posts