icon
10장 : 보안과 모범 사례

환경 변수 관리와 비밀 정보 보호


지난 절에서는 웹 애플리케이션에서 흔히 발생하는 CORS, CSRF, XSS 공격에 대한 NestJS 기반의 대응 전략을 알아보았습니다. 이제 10장의 두 번째 절로, 애플리케이션의 설정과 민감한 정보(비밀 정보)를 안전하게 관리하는 방법에 대해 다루며, 특히 환경 변수 관리비밀 정보 보호에 집중하여 살펴보겠습니다.

애플리케이션은 데이터베이스 연결 문자열, API 키, 클라우드 자격 증명 등 다양한 민감한 정보를 필요로 합니다. 이러한 비밀 정보가 코드에 하드코딩되거나, 버전 관리 시스템(Git)에 노출되거나, 안전하지 않은 방식으로 저장되면 심각한 보안 취약점이 됩니다. 따라서 개발 및 배포 과정에서 이러한 정보를 안전하게 다루는 것이 매우 중요합니다.


환경 변수 관리의 중요성

환경 변수(Environment Variables) 는 애플리케이션의 동작을 제어하거나, 실행 환경에 따라 변경되어야 하는 설정 값을 저장하는 데 사용되는 메커니즘입니다. 환경 변수를 사용하는 주된 이유는 다음과 같습니다.

  • 환경별 설정 분리: 개발, 스테이징, 운영 등 각 환경마다 데이터베이스 주소, 로깅 수준, API 엔드포인트 등이 다를 수 있습니다. 환경 변수를 사용하면 코드 변경 없이 동일한 애플리케이션 바이너리(또는 Docker 이미지)를 다른 환경에 배포할 수 있습니다.
  • 비밀 정보 보호: 민감한 정보(데이터베이스 비밀번호, API 키, OAuth 비밀 등)를 코드베이스에서 분리하여 Git 레포지토리 등에 노출되는 것을 방지합니다. 이는 Do not commit secrets to Git 원칙의 핵심입니다.
  • 유연성: 애플리케이션을 재빌드하거나 재배포하지 않고도 설정을 변경할 수 있습니다.

NestJS에서 환경 변수 관리

NestJS는 @nestjs/config 패키지를 통해 환경 변수 및 설정 관리를 위한 강력한 ConfigModule을 제공합니다. 이는 .env 파일을 로드하고, 환경 변수에 접근하며, 스키마 유효성 검사 등을 지원합니다.

ConfigModule 기본 사용법

단계 1: 필요한 패키지 설치

npm install @nestjs/config

단계 2: .env 파일 생성

프로젝트 루트에 .env 파일을 생성하고 환경 변수를 정의합니다. 이 파일은 .gitignore에 추가하여 Git에 커밋되지 않도록 해야 합니다.

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=myuser
DATABASE_PASSWORD=mypassword123
DATABASE_NAME=mydb

API_KEY_GOOGLE=some_google_api_key_123
JWT_SECRET=supersecretjwtkey

NODE_ENV=development
PORT=3000

단계 3: AppModuleConfigModule 등록

ConfigModule을 임포트하고, forRoot() 메서드를 사용하여 전역으로 사용할 수 있도록 설정합니다.

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // ConfigModule 임포트
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // ConfigModule을 전역 모듈로 만들어 모든 모듈에서 ConfigService를 주입 가능하게 함
      envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, // 환경별 .env 파일 로드 (선택 사항)
      // ignoreEnvFile: true, // .env 파일 로드 무시 (Docker, Kubernetes 등에서 환경 변수만 사용할 때 유용)
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • isGlobal: true: ConfigModule을 전역으로 설정하여 다른 모듈에서 별도로 임포트하지 않고도 ConfigService를 주입받아 사용할 수 있게 합니다.
  • envFilePath: process.env.NODE_ENV 값에 따라 다른 .env 파일을 로드할 수 있도록 합니다. (예: NODE_ENV=production이면 .env.production 로드) 기본적으로 .env 파일을 찾습니다.

단계 4: ConfigService 주입 및 사용

애플리케이션의 서비스, 컨트롤러 등 필요한 곳에서 ConfigService를 주입받아 환경 변수 값에 접근할 수 있습니다.

src/app.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; // ConfigService 임포트

@Injectable()
export class AppService {
  private readonly databaseHost: string;
  private readonly jwtSecret: string;
  private readonly nodeEnv: string;
  private readonly appPort: number;

  constructor(private configService: ConfigService) {
    this.databaseHost = this.configService.get<string>('DATABASE_HOST');
    this.jwtSecret = this.configService.get<string>('JWT_SECRET');
    this.nodeEnv = this.configService.get<string>('NODE_ENV', 'development'); // 기본값 설정 가능
    this.appPort = this.configService.get<number>('PORT'); // 타입 추론 또는 명시
    console.log(`Database Host: ${this.databaseHost}`);
    console.log(`JWT Secret: ${this.jwtSecret ? 'Loaded' : 'Not Loaded'}`); // 보안상 실제 값 출력 지양
    console.log(`Node Environment: ${this.nodeEnv}`);
    console.log(`App Port: ${this.appPort}`);
  }

  getHello(): string {
    return `Hello from ${this.nodeEnv} environment!`;
  }
}
  • configService.get<T>(key: string, defaultValue?: T): 환경 변수 값을 가져옵니다. 제네릭 타입 T를 사용하여 타입 안정성을 높일 수 있습니다. 두 번째 인자로 기본값을 설정할 수 있습니다.

환경 변수 유효성 검사

민감한 환경 변수나 필수적인 환경 변수가 누락되거나 형식이 잘못되면 애플리케이션이 제대로 작동하지 않거나 보안 취약점이 발생할 수 있습니다. ConfigModule은 Joi 또는 @nestjs/class-validator를 사용하여 환경 변수의 유효성을 검사할 수 있습니다.

단계 1: 필요한 패키지 설치 (Joi 사용 예시)

npm install joi
npm install --save-dev @types/joi

단계 2: 유효성 검사 스키마 정의

src/config/validation.schema.ts
import * as Joi from 'joi';

export const validationSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test', 'provision')
    .default('development'),
  PORT: Joi.number().default(3000),
  DATABASE_HOST: Joi.string().required(),
  DATABASE_PORT: Joi.number().default(5432),
  DATABASE_USER: Joi.string().required(),
  DATABASE_PASSWORD: Joi.string().required(), // 비밀번호는 항상 'required'
  DATABASE_NAME: Joi.string().required(),
  JWT_SECRET: Joi.string().required(),
  API_KEY_GOOGLE: Joi.string().optional(), // 선택적
});

단계 3: AppModule에 스키마 적용

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { validationSchema } from './config/validation.schema'; // 스키마 임포트

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: validationSchema, // 유효성 검사 스키마 적용
      // validationOptions: {
      //   abortEarly: true, // 모든 에러를 한 번에 보고할지 (false) 아니면 첫 에러에서 중단할지 (true)
      // },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • validationSchema: 정의한 Joi 스키마를 전달합니다. 애플리케이션 시작 시 환경 변수가 이 스키마에 따라 유효성 검사를 통과하지 못하면 애플리케이션이 시작되지 않고 에러를 발생시킵니다. 이는 배포 전 환경 설정 문제를 조기에 발견하는 데 매우 유용합니다.

비밀 정보 보호 모범 사례

환경 변수는 개발 환경에서 .env 파일을 통해 쉽게 관리할 수 있지만, 운영 환경에서는 더욱 강력하고 안전한 비밀 정보 관리 전략이 필요합니다. .env 파일은 서버 내부에 직접 저장되므로, 서버가 침해당하면 즉시 노출될 위험이 있습니다.

버전 관리 시스템에서 비밀 정보 제외

가장 기본적이면서도 중요한 원칙입니다. .env 파일이나 민감한 정보가 포함된 설정 파일은 절대 Git 레포지토리에 커밋하면 안 됩니다.

# .gitignore
.env
.env.*.local
dist/
node_modules/
# ... 기타

CI/CD 파이프라인에서 환경 변수 주입

지속적 통합/배포(CI/CD) 파이프라인에서는 민감한 환경 변수를 빌드 또는 배포 과정에 주입하는 방법을 사용합니다. CI/CD 서비스는 대부분 환경 변수를 안전하게 저장하고 관리하는 기능을 제공합니다.

  • GitHub Actions Secrets: GitHub 레포지토리 설정에서 Secrets를 정의하고, 워크플로우 파일에서 이를 환경 변수로 사용할 수 있습니다.

    .github/workflows/deploy.yml (부분 예시)
    env:
      DATABASE_HOST: ${{ secrets.PROD_DB_HOST }}
      DATABASE_USER: ${{ secrets.PROD_DB_USER }}
      DATABASE_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} # 민감 정보는 secrets로 관리
    
    steps:
      - name: Deploy to Server
        run: |
          ssh user@your-server-ip "
            cd /app/your-nestjs-app &&
            export DATABASE_HOST=${{ secrets.PROD_DB_HOST }} &&
            export DATABASE_USER=${{ secrets.PROD_DB_USER }} &&
            export DATABASE_PASSWORD=${{ secrets.PROD_DB_PASSWORD }} &&
            npm run start:prod
          "
  • Jenkins Credentials: Jenkins에서는 비밀 정보(Secret Text, Secret File, Username/Password)를 Jenkins Credential Provider에 저장하고 파이프라인 스크립트에서 안전하게 사용할 수 있습니다.

  • GitLab CI/CD Variables: GitLab 레포지토리 설정에서 CI/CD Variables를 정의하고 보호할 수 있습니다.

비밀 정보 관리 도구

대규모 애플리케이션이나 마이크로서비스 아키텍처에서는 전용 비밀 정보 관리 도구를 사용하는 것이 가장 안전하고 효율적입니다.

  • HashiCorp Vault: 중앙 집중식으로 비밀 정보를 저장하고 접근을 제어하며, 동적 비밀 정보 생성(예: 데이터베이스 임시 자격 증명) 기능을 제공합니다.
  • AWS Secrets Manager / AWS Parameter Store (SSM): AWS에서 제공하는 관리형 비밀 정보 저장 및 검색 서비스입니다. 애플리케이션이 실행 중인 EC2 인스턴스나 컨테이너에서 IAM 역할을 통해 안전하게 접근할 수 있습니다.
  • Google Cloud Secret Manager: GCP의 관리형 비밀 정보 서비스.
  • Azure Key Vault: Azure의 비밀 정보 관리 서비스.

클라우드 기반 비밀 정보 관리 도구 활용 예시 (개념)

비밀 정보 저장: 데이터베이스 비밀번호, API 키 등을 Secrets Manager에 저장합니다.

IAM Role 할당: NestJS 애플리케이션이 실행되는 EC2 인스턴스 또는 EKS Pod에 Secrets Manager에 접근할 수 있는 IAM Role을 할당합니다.

애플리케이션에서 접근: 애플리케이션 코드는 AWS SDK 등을 사용하여 런타임에 Secrets Manager로부터 비밀 정보를 안전하게 가져옵니다. 이 경우 .env 파일에 비밀 정보를 저장할 필요가 없습니다.

src/config/secrets.service.ts (AWS Secrets Manager 예시 - 개념 코드)
import { Injectable, OnModuleInit } from '@nestjs/common';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

@Injectable()
export class SecretsService implements OnModuleInit {
  private client: SecretsManagerClient;
  private secretCache: Map<string, string> = new Map();

  constructor() {
    this.client = new SecretsManagerClient({ region: 'ap-northeast-2' }); // AWS 리전 설정
  }

  async onModuleInit() {
    // 애플리케이션 시작 시 필요한 비밀 정보를 미리 로드 (옵션)
    await this.loadSecret('DATABASE_CREDENTIALS');
    await this.loadSecret('API_KEY_GOOGLE');
  }

  private async loadSecret(secretName: string): Promise<void> {
    try {
      const command = new GetSecretValueCommand({ SecretId: secretName });
      const response = await this.client.send(command);

      if (response.SecretString) {
        this.secretCache.set(secretName, response.SecretString);
        console.log(`Secret '${secretName}' loaded.`);
      } else {
        console.warn(`Secret '${secretName}' has no SecretString value.`);
      }
    } catch (error) {
      console.error(`Failed to load secret '${secretName}':`, error);
      // 실제 환경에서는 애플리케이션 시작을 중단하거나 적절히 처리
    }
  }

  getSecret(secretName: string): string | undefined {
    return this.secretCache.get(secretName);
  }

  getDatabaseCredentials(): any {
    const credentials = this.getSecret('DATABASE_CREDENTIALS');
    return credentials ? JSON.parse(credentials) : {};
  }
}

이후 다른 서비스에서 SecretsService를 주입받아 this.secretsService.getDatabaseCredentials()처럼 사용합니다.


환경 변수 관리와 비밀 정보 보호는 애플리케이션 보안의 기본 중의 기본입니다. NestJS의 ConfigModule.env 파일 기반의 환경 변수 관리를 매우 편리하게 해주며, 유효성 검사를 통해 설정 오류를 방지합니다. 하지만 운영 환경에서는 CI/CD의 Secret 관리 기능이나 HashiCorp Vault, 클라우드 Secrets Manager와 같은 전용 비밀 정보 관리 도구를 사용하여 민감한 정보를 더욱 안전하게 보호해야 합니다. 이러한 모범 사례를 준수함으로써 애플리케이션의 보안 수준을 크게 향상시킬 수 있습니다.

이것으로 10장 "보안과 모범 사례"의 두 번째 절을 마칩니다.