환경 변수 관리와 비밀 정보 보호
ConfigModule로 환경별 설정을 분리·검증하고 API 키와 자격 증명을 저장소와 배포 산출물에서 보호합니다.
앞 글에서는 브라우저와 주고받는 요청을 보호했습니다. 이 글에서는 애플리케이션 설정과 민감한 정보를 관리하는 방법, 특히 환경 변수 관리와 비밀 정보 보호를 다룹니다.
애플리케이션에는 데이터베이스 연결 문자열, API 키, 클라우드 자격 증명 같은 민감한 정보가 필요합니다. 이런 비밀 값이 코드에 직접 적히거나, Git 저장소에 올라가거나, 안전하지 않은 곳에 저장되면 심각한 보안 취약점이 됩니다. 그래서 개발과 배포의 모든 단계에서 이 값을 안전하게 다뤄야 합니다.
Nest · Secret trust zones
배포와 비밀 값의 전달 경로
코드·이미지 배포와 비밀 값 전달을 분리하는 운영 설계입니다. CI는 OIDC 단기 자격을, Pod는 워크로드 아이덴티티를 사용합니다. 서명·승인·배포 게이트는 본문 부분 YAML에 추가 구성해야 합니다.
Git·이미지에는 이름과 규칙만 둔다
코드에는 설정 키와 검증 스키마를, 이미지에는 실행 산출물만 둡니다. 실제 값은 버전·소유자·감사 기록이 있는 Vault나 Secrets Manager에 저장합니다.
OIDC 단기 자격과 배포 게이트를 통과한다
CI는 장기 클라우드 키 대신 OIDC로 짧은 배포 자격을 받고, 승인된 이미지 digest와 정책을 확인한 뒤 런타임에 배포합니다.
Pod가 자기 워크로드 아이덴티티로 읽는다
EKS Pod Identity나 IRSA로 필요한 버전만 가져오고 시작 시 스키마를 검증합니다. 비밀 누락·형식 오류는 fail-fast로 시작을 중단합니다.
Git · image · log · error response
실제 비밀 값이 네 경로에 남지 않게 합니다. GitHub 로그 마스킹은 불완전할 수 있으므로 출력 자체를 만들지 않습니다.
런타임 환경 변수가 .env보다 우선한다
get<T>는 런타임 변환이 아닙니다. Joi의 number().port()와 registerAs·ConfigType로 검증 결과와 소비 타입을 맞춥니다.
환경 변수는 암호화 저장소가 아니다
환경 변수는 전달 수단일 뿐 실행 프로세스 침해를 막지 못합니다. 최소 권한 워크로드 아이덴티티와 전용 저장소, 값 없는 감사를 함께 사용합니다.
마스킹보다 노출 경로를 만들지 않는다
인코딩·분할·다중 행 값은 GitHub 마스킹을 벗어날 수 있습니다. OIDC 단기 자격을 우선하고 로그와 오류 응답에는 값 대신 키·버전·상태만 남깁니다.
승인된 길은 코드·이미지 배포와 비밀 값 전달을 분리합니다. 배포 산출물은 게이트를 지나고, 실제 값은 런타임 주체가 자기 아이덴티티로 가져옵니다.
위 그림은 코드·이미지의 배포 경로와 비밀 값의 전달 경로를 세 영역으로 나누어 보여 줍니다. Git 저장소와 컨테이너 이미지에는 비밀 값을 남기지 않고, CI는 OIDC로 받은 단기 자격으로 배포 검사를 통과한 결과물만 배포합니다. 실행 중인 NestJS Pod는 워크로드 아이덴티티로 전용 저장소에서 비밀을 직접 가져옵니다. 즉 비밀 값은 승인된 경로로만, 실행 시점에 도착합니다.
환경 변수를 쓰는 이유
환경 변수(environment variable)는 애플리케이션의 동작을 제어하거나, 실행 환경에 따라 바뀌어야 하는 설정 값을 전달하는 방법입니다. 환경 변수를 쓰는 주된 이유는 다음과 같습니다.
- 환경별 설정 분리: 개발, 스테이징, 운영 환경마다 데이터베이스 주소, 로그 수준, API 주소가 다를 수 있습니다. 환경 변수를 쓰면 코드를 바꾸지 않고 같은 빌드 결과나 Docker 이미지를 여러 환경에 배포할 수 있습니다.
- 비밀 정보 분리: 데이터베이스 비밀번호, API 키, OAuth 비밀 같은 값을 코드에서 떼어 내 Git 저장소에 올라가지 않게 합니다. 다만 환경 변수는 값을 전달하는 수단일 뿐 암호화된 저장소가 아니며, 실행 중인 프로세스가 침해당했을 때 값이 노출되는 것까지 막지는 못합니다.
- 유연성: 코드나 이미지를 다시 만들지 않고도 실행 환경의 설정을 바꿀 수 있습니다. 다만 NestJS 애플리케이션은 보통 시작할 때 환경 변수를 읽으므로, 바꾼 값은 재시작이나 재배포 뒤에 반영됩니다.
Nest 공식 설정 문서에 따르면 ConfigModule.forRoot()가 .env 파일과 실행 환경을 함께 읽을 때, 이미 설정된 실행 환경의 변수(process.env)가 .env 파일의 같은 키보다 우선합니다. .env 쪽을 우선하려면 override: true를 줍니다. 개발 편의를 위한 .env와, 운영 비밀을 보관하고 주입하는 경로는 구분해야 합니다.
NestJS에서 환경 변수 관리하기
NestJS는 @nestjs/config 패키지로 ConfigModule을 제공합니다. .env 파일을 불러오고, 환경 변수에 접근하며, 스키마로 값을 검증할 수 있습니다.
ConfigModule 기본 사용법
먼저 패키지를 설치합니다. NestJS 12와 함께 쓰는 현재 버전은 12.1입니다.
npm install @nestjs/config로컬 개발에서는 프로젝트 루트에 .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=replace_with_at_least_32_chars_secret
NODE_ENV=development
PORT=3000각 줄은 키=값 형식이며, 이 파일에서 읽은 값은 모두 문자열입니다. 그래서 DATABASE_PORT=5432도 숫자 5432가 아니라 문자열 '5432'로 들어오고, 숫자로 바꾸는 일은 뒤의 검증 스키마에서 합니다. 이제 ConfigModule을 가져오고 forRoot()로 전역에서 쓸 수 있게 설정합니다. 아래 코드에서는 forRoot()에 넘긴 옵션 객체를 보면 됩니다.
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를 주입 가능하게 함
// 기본값은 프로젝트 루트의 .env 파일을 로드합니다.
// envFilePath: '.env.development', // 실제 파일명을 쓸 때만 명시
// ignoreEnvFile: true, // 운영에서는 플랫폼의 런타임 주입만 사용할 때 권장
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}isGlobal: true:ConfigModule을 전역 모듈로 만들어, 다른 모듈에서 따로 가져오지 않아도ConfigService를 주입받을 수 있게 합니다.envFilePath: 기본값은 프로젝트 루트의.env입니다. 다른 파일을 쓰려면.env.development처럼 실제 파일 이름을 적습니다. 운영에서는.env.production파일을 만들어 두기보다ignoreEnvFile: true로 파일을 읽지 않고, 플랫폼이 실행 시점에 주입하는 값만 씁니다.- 실행 환경의 변수와
.env에 같은 키가 있으면 실행 환경의 값이 우선합니다. 이 우선순위를 배포 환경별 덮어쓰기에 쓰되, 비밀의 원본은 전용 저장소에서 관리합니다.
서비스나 controller에서 ConfigService를 주입받아 값을 읽습니다.
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AppService {
private readonly databaseHost: string;
private readonly nodeEnv: string;
private readonly appPort: number;
constructor(private readonly configService: ConfigService) {
this.databaseHost = this.configService.getOrThrow<string>('DATABASE_HOST');
this.nodeEnv = this.configService.get<string>('NODE_ENV', 'development'); // 기본값 지정 가능
// 아래 검증 스키마가 PORT를 숫자로 변환해 두므로 숫자로 읽습니다.
this.appPort = this.configService.getOrThrow<number>('PORT');
// 비밀 값 자체는 로그에 출력하지 않습니다.
console.log(`Database Host: ${this.databaseHost}`);
console.log(`Node Environment: ${this.nodeEnv}`);
console.log(`App Port: ${this.appPort}`);
}
getHello(): string {
return `Hello from ${this.nodeEnv} environment!`;
}
}configService.get<T>(key, defaultValue?): 설정 값을 읽습니다. 제네릭 타입은 TypeScript를 위한 표시일 뿐 실행 중에 값을 변환하거나 검증하지 않습니다.configService.getOrThrow<T>(key): 값이undefined이면 오류를 던지므로, 필수 값이 빠진 것을 시작 단계에서 바로 알아챌 수 있습니다.
환경 변수 검증하기
필수 환경 변수가 빠지거나 형식이 잘못되면 애플리케이션이 제대로 동작하지 않거나 보안 문제가 생길 수 있습니다. 공식 문서에 따르면 @nestjs/config 12는 두 가지 검증 방법을 지원합니다. 하나는 validationSchema에 Standard Schema 규격을 따르는 스키마를 넘기는 방법이고, 다른 하나는 직접 만든 validate() 함수를 넘기는 방법입니다. Zod, Valibot, ArkType처럼 이 규격을 구현한 라이브러리는 모두 쓸 수 있으며, 문서의 예제를 따라 여기서는 Zod를 씁니다. 현재 Zod의 버전은 4.6입니다.
npm install zod검증 스키마를 정의합니다. 스키마의 키는 .env의 변수 이름과 같고, 키마다 형식과 허용 범위, 기본값을 정합니다. 읽을 때는 z.coerce.number()가 붙은 두 포트, .default()가 붙은 키, 그리고 하나뿐인 .optional()을 눈여겨봅니다.
import { z } from 'zod';
export const validationSchema = z.object({
NODE_ENV: z
.enum(['development', 'production', 'test', 'provision'])
.default('development'),
// 환경 변수는 항상 문자열로 들어오므로 숫자로 변환한 뒤 범위를 검사합니다.
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_HOST: z.string().min(1),
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(5432),
DATABASE_USER: z.string().min(1),
DATABASE_PASSWORD: z.string().min(1),
DATABASE_NAME: z.string().min(1),
JWT_SECRET: z.string().min(32),
API_KEY_GOOGLE: z.string().optional(), // 선택 값
});default()가 붙은 NODE_ENV, PORT, DATABASE_PORT는 값이 없으면 기본값으로 채워지지만, 나머지 데이터베이스 변수와 JWT_SECRET은 빠지면 검사에 실패합니다. NODE_ENV에 staging처럼 enum 목록에 없는 값을 넣어도 실패합니다. API_KEY_GOOGLE은 optional()이므로 빠져도 통과하며, 이때는 기본값 없이 undefined로 남습니다. 이 스키마는 forRoot()의 validationSchema 옵션에 넘겨야 실제로 쓰이므로, AppModule에 적용합니다.
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,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}validationSchema의 검사를 통과하지 못하면 애플리케이션은 시작하지 않고 오류를 냅니다. 공식 문서에 따르면 기본적으로 PATH나 HOME처럼 스키마에 없는 환경 변수는 오류로 취급하지 않고, 실패한 변수는 첫 번째 것만이 아니라 모두 보고합니다. 또 ConfigService는 스키마가 돌려준 값을 제공하므로, 위 스키마의 z.coerce.number() 변환과 기본값이 애플리케이션이 읽는 설정에 그대로 반영됩니다. 그래서 앞의 AppService는 PORT를 숫자로 읽을 수 있습니다.
기존 프로젝트에서 Joi를 쓰고 있다면 계속 쓸 수 있지만, Standard Schema를 구현한 Joi 18 이상이 필요하고 allowUnknown, abortEarly 같은 Joi 옵션은 validationOptions.libraryOptions 아래로 옮겨야 합니다. 새 프로젝트에는 공식 문서도 Zod 같은 Standard Schema 라이브러리를 권합니다.
registerAs와 ConfigType으로 설정 묶기
검증을 통과한 값을 도메인별 설정 객체로 묶어 두면, 문자열 key를 여러 서비스에 흩어 놓지 않아도 됩니다. ConfigType은 설정 factory가 반환하는 타입을 사용하는 코드에 연결해 줍니다.
여기서 주의할 점이 있습니다. 공식 문서의 설명대로 validationSchema는 환경 변수에만 적용되며, factory가 계산하거나 다른 파일에서 읽어 만든 설정 객체까지 자동으로 검증하지는 않습니다. factory는 process.env의 문자열을 직접 읽으므로, 형식은 앞의 스키마로 확인하고 변환은 factory 안에서 다시 합니다.
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
port: Number.parseInt(process.env.PORT ?? '3000', 10),
database: {
host: process.env.DATABASE_HOST!,
port: Number.parseInt(process.env.DATABASE_PORT ?? '5432', 10),
},
}));registerAs('app', ...)는 factory가 돌려준 객체를 app이라는 이름공간(namespace)으로 등록합니다. 그래서 configService.get('app.port')처럼 이름공간과 속성을 점으로 이은 경로로도 읽을 수 있습니다. 다만 factory는 정의만으로는 실행되지 않으며, 아래처럼 forRoot()의 load 배열에 넣어야 설정에 포함됩니다.
import { ConfigModule } from '@nestjs/config';
import appConfig from './config/app.config';
import { validationSchema } from './config/validation.schema';
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig],
validationSchema,
});서비스에서는 문자열 키 대신 appConfig.KEY를 주입 토큰으로 써서 이름공간 객체 전체를 받습니다. ConfigType<typeof appConfig>는 factory의 반환 타입을 그대로 가져오므로, TypeScript는 this.config.port가 number이고 this.config.database.host가 string이라는 것을 압니다.
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import appConfig from './config/app.config';
@Injectable()
export class AppService {
constructor(
@Inject(appConfig.KEY)
private readonly config: ConfigType<typeof appConfig>,
) {}
getHello(): string {
return `NestJS listens on ${this.config.port}`;
}
}이 글의 AppModule과 AppService 예제는 같은 파일을 단계별로 바꾸는 것입니다. (설정 부분)의 ConfigModule.forRoot(...)는 기존 @Module()의 imports에 넣고, controller와 provider 등록은 그대로 둡니다. getHello()가 설정의 포트를 출력한다고 HTTP 서버의 포트가 바뀌지는 않으며, 실제 포트는 main.ts의 app.listen(...)에 같은 설정 값을 넘겼을 때 정해집니다. 또 JWT_SECRET의 32자 이상 조건은 길이만 검사할 뿐, 예측할 수 없게 만들어졌는지나 안전하게 보관·교체되는지까지 확인하지는 않습니다.
비밀 정보 보호 모범 사례
개발 환경에서는 .env 파일로 환경 변수를 쉽게 관리할 수 있지만, 운영 환경에는 더 안전한 비밀 정보 관리 방법이 필요합니다. .env 파일은 서버 안에 그대로 저장되므로, 서버가 침해당하면 바로 노출될 위험이 있습니다.
버전 관리 시스템에서 비밀 정보 빼기
가장 기본적이면서도 중요한 원칙입니다. .env 파일이나 민감한 정보가 담긴 설정 파일은 절대 Git 저장소에 커밋하지 않습니다. 예시 값만 담은 .env.example은 커밋해 다른 개발자가 필요한 키를 알 수 있게 합니다.
.env
.env.*
!.env.example
dist/
node_modules/
# ... 기타.env.*는 .env.local, .env.production 같은 변형 파일까지 모두 제외하고, 바로 아래의 !.env.example이 예시 파일 하나만 다시 포함합니다. .gitignore는 뒤에 나온 규칙이 앞의 규칙을 덮어쓰므로 두 줄의 순서를 바꾸면 예시 파일도 제외됩니다. 또 이미 커밋된 파일은 .gitignore에 적어도 계속 추적되므로, 실수로 올린 .env는 저장소에서 따로 빼고 그 안의 비밀 값을 교체해야 합니다.
CI/CD 파이프라인에서 자격 증명 다루기
CI/CD 파이프라인에서는 비밀 값을 이미지 빌드에 넣지 않고, 배포 대상의 실행 시점 주입 기능과 워크로드 아이덴티티를 씁니다. CI/CD가 클라우드 API를 호출해야 한다면, 저장해 둔 장기 액세스 키보다 OIDC로 발급받는 단기 자격 증명을 우선합니다.
GitHub Actions의 OIDC를 쓰면 워크플로가 클라우드 역할을 잠깐 위임받아, 승인된 이미지와 배포 명세만 전달하게 할 수 있습니다. 애플리케이션 비밀은 대상 플랫폼이 실행 시점에 주입하거나 워크로드가 비밀 저장소에서 가져옵니다.
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v7
- uses: aws-actions/[email protected]
with:
role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-2
- name: Deploy immutable image
run: ./scripts/deploy.sh이 예시는 .env.production 파일을 만들지 않습니다. ConfigModule은 운영에서 ignoreEnvFile: true로 두고, 대상 플랫폼이 실행 시점에 환경 변수를 주입하거나 Pod가 전용 저장소에서 값을 가져오게 합니다. GitHub의 로그 마스킹은 알려진 원문을 가려 주는 보조 장치일 뿐입니다. 인코딩하거나 나누거나 변환한 값, 여러 줄 출력은 완전히 가려지지 않을 수 있으므로 비밀을 출력하지 말고, 배포용 비밀을 저장하기보다 OIDC 단기 자격을 먼저 고릅니다.
Jenkins에서는 Secret Text, Secret File, 사용자 이름과 비밀번호 같은 비밀을 Credentials에 저장하고 파이프라인에서 안전하게 쓸 수 있으며, GitLab에서는 저장소 설정의 CI/CD Variables로 비밀을 정의하고 보호할 수 있습니다.
비밀 정보 관리 도구
큰 애플리케이션이나 마이크로서비스 구조에서는 전용 비밀 관리 도구를 쓰는 것이 가장 안전하고 효율적입니다.
- HashiCorp Vault: 비밀을 한곳에 저장하고 접근을 제어하며, 데이터베이스 임시 자격 증명처럼 비밀을 동적으로 만드는 기능도 제공합니다.
- AWS Secrets Manager와 Parameter Store: AWS의 관리형 비밀 저장 서비스입니다. EC2는 인스턴스 역할을, EKS Pod는 EKS Pod Identity나 IRSA 같은 워크로드 아이덴티티를 써서 장기 액세스 키 없이 필요한 값만 읽게 합니다.
- Google Cloud Secret Manager: GCP의 관리형 비밀 서비스입니다.
- Azure Key Vault: Azure의 비밀 관리 서비스입니다.
AWS Secrets Manager를 쓰는 흐름은 다음과 같습니다. 먼저 데이터베이스 비밀번호나 API 키를 Secrets Manager에 저장합니다. 다음으로 EC2에는 인스턴스 역할을, EKS Pod에는 EKS Pod Identity나 IRSA를 연결하고, 해당 비밀을 읽는 최소한의 권한만 줍니다. 정적 액세스 키를 Pod 환경 변수에 넣지 않습니다. 마지막으로 애플리케이션 코드는 AWS SDK의 기본 자격 증명 체인으로 워크로드 아이덴티티를 쓰고, 실행 시점에 Secrets Manager에서 비밀을 가져옵니다. 이렇게 하면 .env 파일에 비밀을 둘 필요가 없습니다.
npm install @aws-sdk/client-secrets-managerimport { Injectable, OnModuleInit } from '@nestjs/common';
import {
GetSecretValueCommand,
SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';
import { z } from 'zod';
const databaseCredentialsSchema = z.object({
username: z.string().min(1),
password: z.string().min(16),
});
type DatabaseCredentials = z.infer<typeof databaseCredentialsSchema>;
@Injectable()
export class SecretsService implements OnModuleInit {
private readonly client = new SecretsManagerClient({
region: 'ap-northeast-2',
});
private databaseCredentials!: DatabaseCredentials;
async onModuleInit(): Promise<void> {
this.databaseCredentials = await this.loadDatabaseCredentials();
}
private async loadDatabaseCredentials(): Promise<DatabaseCredentials> {
const response = await this.client.send(
new GetSecretValueCommand({ SecretId: 'DATABASE_CREDENTIALS' }),
);
if (!response.SecretString) {
throw new Error('DATABASE_CREDENTIALS is required');
}
let parsed: unknown;
try {
parsed = JSON.parse(response.SecretString) as unknown;
} catch {
throw new Error('DATABASE_CREDENTIALS must be valid JSON');
}
const result = databaseCredentialsSchema.safeParse(parsed);
if (!result.success) {
// 값이 아니라 문제가 된 필드 이름만 기록합니다.
const fields = result.error.issues.map((issue) => issue.path.join('.'));
throw new Error(`DATABASE_CREDENTIALS schema error: ${fields.join(', ')}`);
}
return result.data;
}
getDatabaseCredentials(): Readonly<DatabaseCredentials> {
return this.databaseCredentials;
}
}다른 서비스에서는 SecretsService를 주입받아 this.secretsService.getDatabaseCredentials()처럼 씁니다. 이 코드는 필수 비밀의 조회 실패, 빈 값, JSON 파싱 실패, 스키마 오류를 감추지 않으므로 하나라도 생기면 애플리케이션 시작이 멈춥니다. 오류 메시지에는 비밀 원문이 아니라 문제가 된 필드 이름만 남깁니다. 이 서비스를 해당 모듈의 providers에 등록하고, 다른 모듈에서 주입한다면 exports와 사용하는 모듈의 imports를 연결해야 합니다. AWS의 해당 비밀과 JSON 구조, 지역, 워크로드의 읽기 권한도 미리 준비되어 있어야 하며, 이 예시가 클라우드 자원을 만들어 주지는 않습니다.
주입받은 시점과 비밀 조회가 끝난 시점은 다르다는 점에 주의해야 합니다. onModuleInit()은 인스턴스가 만들어진 뒤에 실행되는 비동기 초기화 hook입니다. Nest 공식 수명 주기 문서에 따르면 NestJS 12는 onModuleInit()을 모듈 단위로, import 그래프에서 루트로부터 먼 모듈부터 차례로 호출하고 각 모듈의 hook이 끝날 때까지 기다립니다. 하지만 모든 생성자는 이 hook들보다 먼저 실행됩니다. 그래서 다른 서비스의 생성자에서 getter를 호출하거나, 초기 DB 연결용 forRootAsync()의 factory가 이 hook의 결과를 읽게 해서는 안 됩니다. databaseCredentials!의 느낌표는 TypeScript 검사를 조정할 뿐 값을 미리 채워 주지 않습니다.
DB 연결 설정처럼 인스턴스를 만드는 단계에서 비밀이 필요하다면, 비밀 조회와 검증을 끝내고 값을 반환하는 비동기 provider를 만들고, DB 설정 factory가 그 provider를 주입받게 합니다. 순서는 비밀 조회와 검증 완료, 값 provider 준비, DB 설정과 연결 생성입니다. 이 클래스 구조를 그대로 쓴다면, app.init()이나 app.listen()의 초기화가 모두 끝난 뒤 요청을 처리하는 코드에서만 값을 씁니다.
예시는 시작할 때 값을 메모리에 보관합니다. 비밀 저장소에서 버전을 교체했다면 캐시를 안전하게 새로 고치거나 애플리케이션을 재배포해 새 버전을 쓰는지 확인하고, 그 뒤에 이전 버전과 필요 없어진 권한을 회수합니다.
비밀 정보는 목록화, 생성, 저장, 권한 부여와 주입, 검증과 사용, 관측, 교체와 회수, 검토와 재배포까지 하나의 수명 주기로 관리해야 합니다.
소유자와 감사 증거를 누적하는 비밀 정보 수명 주기
목록·생성·저장·권한/주입·사용/관측·교체/회수·재검토에서 버전과 감사 증거가 누적된다.
Nest · Secret lifecycle
비밀 값의 생성·교체·회수
소유자·용도·기한을 정하고 전용 저장소에서 비밀의 버전을 관리합니다. 최소 권한으로 주입하고 검증·관측·교체·회수를 반복해 다음 운영 기준을 갱신합니다.
- 목록화·생성
비밀의 소유자, 용도, 소비 서비스, 교체 기한을 정하고 관리 도구에서 값을 생성합니다.
- 전용 저장
Vault나 클라우드 Secret Manager에 버전으로 저장하고 Git·이미지·공유 문서에는 값이 남지 않게 합니다.
- 권한·주입
EKS Pod Identity나 IRSA 같은 워크로드 아이덴티티에 최소 읽기 권한을 주고 런타임에만 전달합니다.
- 검증·사용
필수 값과 파싱한 구조를 스키마로 검증합니다. 누락되거나 잘못된 비밀이면 애플리케이션 시작을 중단합니다.
- 관측
값 대신 키 이름, 버전, 접근 주체, 성공·실패 상태를 감사 증거로 기록합니다.
- 교체·회수
새 버전을 만든 뒤 캐시를 새로고침하거나 재배포하고, 사용 확인 후 이전 버전과 권한을 회수합니다.
- 검토·재배포
소유권·권한·기한·장애 복구 결과를 검토하고 필요한 변경을 재배포한 뒤 다음 목록화로 돌아갑니다.
누가 왜 쓰는지부터 버전으로 남긴다
소유자·소비자·용도·교체 기한 없는 비밀은 관리할 수 없습니다. 값은 전용 저장소에 두고 이름과 스키마만 코드에 둡니다.
최소 권한으로 가져와 시작 전에 검증한다
워크로드 아이덴티티로 필요한 버전만 읽고, JSON을 unknown으로 파싱한 뒤 스키마를 통과시킵니다. 필수 비밀 실패를 삼키지 않습니다.
새 버전을 확인한 뒤 이전 값을 회수한다
교체 후 캐시 refresh 또는 재배포로 새 값을 읽고 정상 동작을 확인합니다. 그 다음 이전 버전·권한을 revoke하고 증거를 검토합니다.
실선 고리는 실제 운영 순서, 안쪽 점선은 각 단계가 중앙 기록에 쓰는 증거입니다. 마지막 검토와 재배포가 다음 목록화를 바꾸므로 실제 폐루프입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 목록화와 생성, 전용 저장, 권한과 주입, 검증과 사용, 관측, 교체와 회수, 검토와 재배포가 시계 방향으로 이어지고 마지막 검토가 다음 목록화로 돌아간다. 각 단계는 중앙 수명 주기 증거에 소유자, 버전과 감사 결과를 기록한다.
- 목록화·생성부터 전용 저장, 권한·주입, 검증·사용, 값 없는 관측, 교체·회수, 검토·재배포까지 실선으로 진행한다. 검토는 다음 목록화를 갱신하는 폐루프이다. 각 단계의 점선은 값 없는 소유자·버전·감사 근거를 중앙 기록에 남기는 관계이며 추가 업무 실행 순서가 아니다. 새 버전의 사용을 확인한 뒤 이전 버전과 권한을 회수한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 소유자와 감사 증거를 누적하는 비밀 정보 수명 주기 |
| 63 | 교체 후 캐시 refresh 또는 재배포로 새 값을 읽고 정상 동작을 확인합니다. 그다음 이전 버전·권한을 revoke하고 증거를 검토합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 목록·저장·주입 비밀 소유자·버전을 목록화하고 전용 저장과 권한·주입을 준비합니다. | 증거: 소유자·버전 · 비밀: 전용 저장 | 사용할 비밀의 관리 근거가 생깁니다. |
| 검증·사용·관측 주입값을 검증·사용하고 관측 증거를 기록합니다. | 상태: 사용 중 · 증거: 검증·관측 | 사용 중인 버전의 근거가 누적됩니다. |
| 교체·회수·검토 새 버전 교체와 이전 회수 뒤 검토가 다음 목록화로 돌아갑니다. | 변경: 교체·회수 · 증거: 감사·재배포 | 버전 소유와 사용 범위를 갱신합니다. |
소유자와 감사 증거를 누적하는 비밀 정보 수명 주기
목록·생성·저장·권한/주입·사용/관측·교체/회수·재검토에서 버전과 감사 증거가 누적된다.
Nest · Secret lifecycle
비밀 값의 생성·교체·회수
소유자·용도·기한을 정하고 전용 저장소에서 비밀의 버전을 관리합니다. 최소 권한으로 주입하고 검증·관측·교체·회수를 반복해 다음 운영 기준을 갱신합니다.
- 목록화·생성
비밀의 소유자, 용도, 소비 서비스, 교체 기한을 정하고 관리 도구에서 값을 생성합니다.
- 전용 저장
Vault나 클라우드 Secret Manager에 버전으로 저장하고 Git·이미지·공유 문서에는 값이 남지 않게 합니다.
- 권한·주입
EKS Pod Identity나 IRSA 같은 워크로드 아이덴티티에 최소 읽기 권한을 주고 런타임에만 전달합니다.
- 검증·사용
필수 값과 파싱한 구조를 스키마로 검증합니다. 누락되거나 잘못된 비밀이면 애플리케이션 시작을 중단합니다.
- 관측
값 대신 키 이름, 버전, 접근 주체, 성공·실패 상태를 감사 증거로 기록합니다.
- 교체·회수
새 버전을 만든 뒤 캐시를 새로고침하거나 재배포하고, 사용 확인 후 이전 버전과 권한을 회수합니다.
- 검토·재배포
소유권·권한·기한·장애 복구 결과를 검토하고 필요한 변경을 재배포한 뒤 다음 목록화로 돌아갑니다.
누가 왜 쓰는지부터 버전으로 남긴다
소유자·소비자·용도·교체 기한 없는 비밀은 관리할 수 없습니다. 값은 전용 저장소에 두고 이름과 스키마만 코드에 둡니다.
최소 권한으로 가져와 시작 전에 검증한다
워크로드 아이덴티티로 필요한 버전만 읽고, JSON을 unknown으로 파싱한 뒤 스키마를 통과시킵니다. 필수 비밀 실패를 삼키지 않습니다.
새 버전을 확인한 뒤 이전 값을 회수한다
교체 후 캐시 refresh 또는 재배포로 새 값을 읽고 정상 동작을 확인합니다. 그 다음 이전 버전·권한을 revoke하고 증거를 검토합니다.
실선 고리는 실제 운영 순서, 안쪽 점선은 각 단계가 중앙 기록에 쓰는 증거입니다. 마지막 검토와 재배포가 다음 목록화를 바꾸므로 실제 폐루프입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 목록화와 생성, 전용 저장, 권한과 주입, 검증과 사용, 관측, 교체와 회수, 검토와 재배포가 시계 방향으로 이어지고 마지막 검토가 다음 목록화로 돌아간다. 각 단계는 중앙 수명 주기 증거에 소유자, 버전과 감사 결과를 기록한다.
- 목록화·생성부터 전용 저장, 권한·주입, 검증·사용, 값 없는 관측, 교체·회수, 검토·재배포까지 실선으로 진행한다. 검토는 다음 목록화를 갱신하는 폐루프이다. 각 단계의 점선은 값 없는 소유자·버전·감사 근거를 중앙 기록에 남기는 관계이며 추가 업무 실행 순서가 아니다. 새 버전의 사용을 확인한 뒤 이전 버전과 권한을 회수한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 소유자와 감사 증거를 누적하는 비밀 정보 수명 주기 |
| 63 | 교체 후 캐시 refresh 또는 재배포로 새 값을 읽고 정상 동작을 확인합니다. 그다음 이전 버전·권한을 revoke하고 증거를 검토합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 목록·저장·주입 비밀 소유자·버전을 목록화하고 전용 저장과 권한·주입을 준비합니다. | 증거: 소유자·버전 · 비밀: 전용 저장 | 사용할 비밀의 관리 근거가 생깁니다. |
| 검증·사용·관측 주입값을 검증·사용하고 관측 증거를 기록합니다. | 상태: 사용 중 · 증거: 검증·관측 | 사용 중인 버전의 근거가 누적됩니다. |
| 교체·회수·검토 새 버전 교체와 이전 회수 뒤 검토가 다음 목록화로 돌아갑니다. | 변경: 교체·회수 · 증거: 감사·재배포 | 버전 소유와 사용 범위를 갱신합니다. |
위 그림은 비밀 정보의 수명 주기를 일곱 단계의 반복으로 보여 줍니다. 소유자와 용도, 기한을 정해 목록에 올리고, 전용 저장소에서 버전을 관리하며, 최소 권한으로 실행 시점에 주입합니다. 사용 중에는 접근을 관측하고, 정해진 주기나 사고가 있을 때 교체한 뒤 이전 버전을 회수하며, 검토 결과를 다음 배포에 반영합니다. 각 단계에서 값이 코드 저장소에 남지 않도록 어디에 보관하고 무엇을 막는지가 함께 정리되어 있습니다.
정리
- 환경 변수는 환경별 설정과 비밀을 코드에서 분리하는 수단이지만, 암호화된 저장소나 실행 중 침해에 대한 방어선은 아닙니다.
ConfigModule.forRoot()는.env와 실행 환경을 함께 읽으며, 같은 키가 있으면 실행 환경의 값이 우선합니다.@nestjs/config12는 Zod 같은 Standard Schema 스키마로 환경 변수를 검증하고,ConfigService는 스키마가 변환한 값을 제공합니다. Joi를 쓰려면 18 이상이 필요합니다.- 운영에서는
.env파일 대신 비밀 저장소와 실행 시점 주입을 쓰고, CI에는 OIDC 단기 자격을, 애플리케이션에는 워크로드 아이덴티티를 줍니다. onModuleInit()에서 불러온 비밀은 생성자 단계에서 아직 준비되지 않았으므로, 초기화에 필요한 비밀은 비동기 provider로 순서를 정합니다.
연습 문제
1. 잘못된 PORT 값
필수 DB 설정과 비밀은 모두 올바르고 PORT만 바꿔 본다고 가정합니다. PORT를 설정하지 않았을 때, PORT=30oops일 때, PORT= 처럼 빈 문자열일 때 이 글의 스키마로 각각 어떻게 될까요? Number.parseInt()로 직접 읽었다면 결과가 어떻게 달랐을까요?
해설 보기
설정하지 않으면 스키마의 기본값 3000이 쓰입니다. 30oops는 z.coerce.number()가 NaN으로 바꾸고, Zod의 숫자 검사는 NaN을 거부하므로 시작이 멈춥니다. 빈 문자열은 JavaScript의 숫자 변환에서 0이 되지만, 스키마의 .min(1) 조건에 걸려 역시 시작이 멈춥니다.
Number.parseInt('30oops', 10)은 앞부분만 읽어 30을 돌려주므로 잘못된 설정이 그대로 통과합니다. .min(1)을 빼고 z.coerce.number()만 썼다면 빈 문자열이 0으로 통과해 임의의 포트로 서버가 뜰 수도 있으므로, 변환 뒤에도 범위를 꼭 검사합니다.
2. 생성자에서 비밀을 읽으면
비밀 조회에 200ms가 걸리는 위 SecretsService를, 다른 서비스의 생성자에서 주입받자마자 getDatabaseCredentials()로 읽었습니다. 반환 타입과 ! 표시가 있으니 자격 증명이 준비되었다고 볼 수 있을까요? DB 설정이 시작 단계에서 이 값을 필요로 한다면 무엇을 기다려야 할까요?
해설 보기
준비되었다고 볼 수 없습니다. 값을 넣는 코드는 나중에 실행되는 onModuleInit()에 있고, 생성자는 그보다 먼저 실행됩니다. 반환 타입 표시와 달리 이 시점의 필드는 아직 undefined일 수 있습니다.
DB 설정이 먼저 값을 요구한다면, 비밀 조회와 검증을 마친 값을 반환하는 비동기 provider를 만들고 DB 설정 factory가 그 provider를 주입받게 합니다. 모든 초기화가 끝난 뒤의 요청 처리에서만 쓰는 값이라면, 애플리케이션 초기화가 성공했다는 사실이 사용 조건이 됩니다.
3. 운영 서버에 남은 .env
운영 서버에 배포할 때 실행 환경에서 DATABASE_PASSWORD를 주입하고 있는데, 누군가 디버깅하다 서버의 작업 폴더에 예전 비밀번호가 든 .env 파일을 남겨 두었습니다. ignoreEnvFile은 설정하지 않았습니다. 애플리케이션은 어느 비밀번호를 쓸까요? 무엇을 바꿔야 할까요?
해설 보기
ConfigModule은 .env 파일과 실행 환경을 함께 읽고, 같은 키가 있으면 실행 환경의 값이 우선하므로 주입한 새 비밀번호를 씁니다. 하지만 override: true가 켜져 있었다면 .env의 예전 값이 이겼을 것이고, .env에만 있는 다른 키는 지금도 설정으로 들어옵니다.
또 서버에 비밀번호가 평문 파일로 남아 있다는 사실 자체가 위험합니다. 운영에서는 ignoreEnvFile: true로 파일을 아예 읽지 않게 하고, 남은 파일을 지운 뒤 노출되었을 수 있는 예전 비밀번호가 데이터베이스에서 정말로 폐기되었는지 확인합니다.
다음 글 로깅과 감사 추적에서는 애플리케이션에서 일어난 일을 안전하게 기록하고, 보안 사고를 추적할 수 있는 감사 로그를 남기는 방법을 다룹니다.