환경 변수를 설정과 비밀값으로 나누기
환경 변수를 설정과 비밀값으로 나누기
PORT, LOG_LEVEL, DATABASE_PASSWORD를 모두 process.env로 읽는다고 해서 같은 종류의 값은 아니다. 환경 변수는 전달 채널이고, 설정과 비밀값은 데이터의 성격이다. 공개 가능한 운영 설정에는 문서화와 안전한 기본값이 필요하고, 비밀값에는 접근 제어·마스킹·회전·감사가 필요하다.
목차
- #환경 변수라는 이름이 경계를 흐린다
- #먼저 값의 성격을 네 가지로 나눈다
- #환경 변수 자체가 비밀 저장소는 아니다
- #브라우저로 전달되는 값은 비밀이 될 수 없다
- #애플리케이션에는 정제된 설정만 노출한다
- #비밀값을 로그와 오류에서 지킨다
- #저장과 주입 방식을 분리해서 선택한다
- #비밀값 회전은 저장보다 어려운 문제다
- #로컬 개발과 배포 환경의 규칙을 나눈다
- #테스트와 운영에서 확인할 항목
- #마무리
- #참고 자료
- #관련 노트
환경 변수라는 이름이 경계를 흐린다
Node.js 서버를 만들면 다음과 같은 코드로 시작하기 쉽다.
const port = Number(process.env.PORT ?? 3000);
const logLevel = process.env.LOG_LEVEL ?? "info";
const databaseUrl = process.env.DATABASE_URL!;
const paymentApiKey = process.env.PAYMENT_API_KEY!;
네 값 모두 process.env에서 읽으므로 한 덩어리처럼 보인다. 하지만 운영 관점에서는 전혀 다른 성질을 가진다.
| 값 | 공개되어도 되는가 | 기본값 | 회전 필요 | 접근 감사 |
|---|---|---|---|---|
PORT |
대체로 가능 | 가능 | 불필요 | 불필요 |
LOG_LEVEL |
대체로 가능 | 가능 | 불필요 | 불필요 |
DATABASE_URL |
구성에 따라 민감 | 보통 금지 | 자격 증명 포함 시 필요 | 권장 |
PAYMENT_API_KEY |
불가능 | 금지 | 필요 | 필요 |
PORT가 없으면 3000을 사용해도 될 수 있다. 반대로 결제 API 키가 없다고 개발용 공용 키를 기본값으로 넣으면, 운영 요청이 잘못된 계정으로 전송될 수 있다. 로그 레벨은 진단 화면에 보여도 큰 문제가 없지만 데이터베이스 URL에 사용자 이름과 비밀번호가 포함되어 있다면 그대로 출력해서는 안 된다.
문제의 원인은 “환경 변수”를 데이터 분류처럼 사용한 데 있다.
- 이 값을 어떤 채널로 프로세스에 전달할 것인가?
- 이 값은 어떤 보안·운영 정책을 가져야 하는가?
환경 변수는 첫 번째 질문의 답 중 하나다. 설정인지 비밀값인지는 두 번째 질문의 답이다.
이 구분이 없으면 다음과 같은 일이 반복된다.
- 디버깅을 위해
process.env전체를 출력했다가 토큰까지 로그에 남긴다. .env.production을 편리한 설정 파일로 생각해 저장소에 커밋한다.- 프론트엔드 빌드 변수에 외부 서비스의 비밀 키를 넣는다.
- 비밀값을 바꿨지만 이미 실행 중인 프로세스에는 반영되지 않는다.
- 어떤 서비스가 어떤 비밀값을 읽는지 알 수 없어 과도한 권한을 준다.
먼저 값의 성격을 네 가지로 나눈다
설정 항목을 작성할 때 적어도 다음 네 범주로 분류하면 정책을 정하기 쉬워진다.
1. 일반 런타임 설정
서비스의 동작을 조절하지만 노출 자체가 보안 사고로 이어지지 않는 값이다.
PORT=3000
LOG_LEVEL=info
REQUEST_TIMEOUT_MS=5000
FEATURE_RECOMMENDATION=true
이 값들은 다음 특성을 갖는 편이 좋다.
- 저장소의 문서나
.env.example에 이름과 예시를 공개할 수 있다. - 합리적인 기본값을 둘 수 있다.
- 현재 적용된 값을 시작 로그에 기록할 수 있다.
- 타입, 범위, 허용값을 시작 시점에 검증한다.
2. 연결 정보
호스트와 포트만 있는 연결 정보는 일반 설정에 가깝지만, 사용자 이름·비밀번호·서명 파라미터가 붙으면 비밀값이 된다.
# 비교적 공개 가능한 위치 정보
DATABASE_HOST=db.internal
DATABASE_PORT=5432
# 자격 증명이 포함되므로 비밀값
DATABASE_URL=postgresql://app-user:password@db.internal:5432/app
URL 하나에 모든 정보를 넣으면 라이브러리 사용은 간단해진다. 대신 로그, 오류 메시지, 추적 데이터에 URL 전체가 섞일 위험이 커진다. 가능하다면 공개 가능한 위치와 비밀 자격 증명을 별도 필드로 유지하고 마지막 연결 단계에서만 합친다.
3. 비밀값
노출되면 다른 시스템에 인증하거나 데이터를 해독·서명할 수 있는 값이다.
DATABASE_PASSWORD
PAYMENT_API_KEY
JWT_SIGNING_PRIVATE_KEY
OAUTH_CLIENT_SECRET
WEBHOOK_SIGNING_SECRET
비밀값에는 기본값을 두지 않는다. 저장 위치, 읽을 수 있는 주체, 로그 마스킹, 회전 주기, 폐기 방법을 함께 설계해야 한다.
4. 클라이언트 공개 설정
브라우저나 모바일 앱에 전달해야 하는 값이다.
PUBLIC_API_BASE_URL
PUBLIC_SENTRY_DSN
PUBLIC_MAP_STYLE_ID
이 값은 “환경 변수에서 왔다”는 이유로 보호되지 않는다. 최종 번들, 네트워크 응답, 앱 바이너리에서 사용자가 확인할 수 있다. 따라서 이름부터 PUBLIC_처럼 공개 범위를 드러내면 실수를 줄일 수 있다.
flowchart TD
A[새 설정 항목] --> B{클라이언트에 전달되는가?}
B -->|예| C[공개 설정]
B -->|아니오| D{노출되면 인증·서명·복호화가 가능한가?}
D -->|예| E[비밀값]
D -->|아니오| F{자격 증명을 포함한 연결 정보인가?}
F -->|예| E
F -->|아니오| G[일반 런타임 설정]분류는 이름표 붙이기로 끝나지 않는다. 코드 리뷰에서 secret, public, runtime에 따라 다른 규칙을 적용할 수 있어야 의미가 있다.
환경 변수 자체가 비밀 저장소는 아니다
환경 변수는 배포 시스템이 값을 프로세스에 전달하기에 편리하다. 이미지와 설정을 분리할 수 있고, 언어와 프레임워크에 덜 의존한다. 그러나 환경 변수에 넣었다는 사실만으로 암호화, 접근 제어, 회전, 감사가 생기지는 않는다.
환경 변수의 값은 다음 경로로 노출될 수 있다.
- 시작 로그에서 설정 객체 전체를 출력
- 오류 추적 도구가 프로세스 상태를 수집
- 자식 프로세스에 환경을 그대로 상속
- 배포 매니페스트나 CI 스크립트에 평문으로 작성
- 운영자가 디버깅 명령으로 프로세스 환경을 조회
- 침해된 프로세스가 자신의 환경을 읽음
다음 코드는 편리하지만 위험하다.
logger.info(
{
env: process.env,
},
"application booting",
);
안전한 방법은 출력 가능한 항목을 명시적으로 고르는 것이다.
logger.info(
{
nodeEnv: config.runtime.nodeEnv,
port: config.runtime.port,
logLevel: config.runtime.logLevel,
paymentProvider: config.runtime.paymentProvider,
},
"application booting",
);
로그에서 ****로 가린다고 비밀값의 저장 방식이 안전해지는 것은 아니다. 반대로 비밀 관리 시스템에 저장했다고 애플리케이션 로그가 자동으로 안전해지는 것도 아니다. 저장, 전달, 사용, 기록, 폐기를 각각 보호해야 한다.
Kubernetes의 Secret도 이름만 믿어서는 안 된다. 공식 문서는 기본 상태에서 Secret 데이터가 API 서버의 기반 저장소인 etcd에 암호화되지 않은 채 저장될 수 있다고 경고한다. 저장 시 암호화, 최소 권한 RBAC, 컨테이너별 접근 제한 같은 별도 조치가 필요하다.
브라우저로 전달되는 값은 비밀이 될 수 없다
프론트엔드 프로젝트의 환경 변수는 특히 오해하기 쉽다.
VITE_API_BASE_URL=https://api.example.test
VITE_PAYMENT_SECRET=do-not-put-a-secret-here
Vite에서 VITE_ 접두사가 붙은 값은 빌드 과정에서 클라이언트 소스에 포함된다. 개발자 도구에서 직접 참조하지 않더라도 생성된 JavaScript 파일을 검색하면 찾을 수 있다.
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
이 치환은 대략 다음과 같은 결과를 만든다고 생각할 수 있다.
const apiBaseUrl = "https://api.example.test";
따라서 프론트엔드에는 공개되어도 괜찮은 식별자와 설정만 둔다.
| 값 | 클라이언트 포함 | 이유 |
|---|---|---|
| API 기본 URL | 가능 | 사용자가 요청 주소를 볼 수 있음 |
| 공개 분석 프로젝트 ID | 조건부 가능 | 공급자가 공개 사용을 전제로 한 경우 |
| OAuth Client ID | 가능 | 비밀값이 아닌 공개 식별자 |
| OAuth Client Secret | 불가능 | 서버가 보관해야 하는 인증 자격 증명 |
| 결제 Secret Key | 불가능 | 서버 권한으로 결제를 수행할 수 있음 |
| DB 비밀번호 | 불가능 | 데이터 저장소에 직접 접근 가능 |
클라이언트가 비밀 권한이 필요한 작업을 해야 한다면 비밀 키를 전달하는 대신 서버가 중개한다.
sequenceDiagram
participant Browser
participant API as Backend API
participant Provider as External Provider
Browser->>API: 결제 준비 요청
Note over API: Secret Key는 서버에만 존재
API->>Provider: Secret Key로 서명한 요청
Provider-->>API: 짧은 수명의 client token
API-->>Browser: 제한된 client token
Browser->>Provider: 허용된 범위의 후속 작업여기서도 브라우저에 전달된 짧은 수명의 토큰은 노출 가능성을 전제로 권한과 유효 시간을 제한해야 한다.
애플리케이션에는 정제된 설정만 노출한다
애플리케이션 곳곳에서 process.env를 직접 읽으면 어떤 코드가 어떤 비밀값에 접근하는지 추적하기 어렵다.
// 주문 서비스
await request(process.env.PAYMENT_API_URL!, {
headers: {
authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
},
});
// 정산 서비스
const timeout = Number(process.env.PAYMENT_TIMEOUT ?? 3000);
환경 변수 읽기는 시작 경계 한 곳으로 모으고, 검증된 설정 객체를 필요한 컴포넌트에만 전달하는 편이 낫다.
type RuntimeConfig = Readonly<{
nodeEnv: "development" | "test" | "production";
port: number;
logLevel: "debug" | "info" | "warn" | "error";
paymentApiUrl: URL;
paymentTimeoutMs: number;
}>;
type SecretConfig = Readonly<{
databasePassword: string;
paymentApiKey: string;
}>;
type AppConfig = Readonly<{
runtime: RuntimeConfig;
secrets: SecretConfig;
}>;
값을 읽는 함수도 일반 설정과 비밀값의 의도를 구분한다.
function requiredText(
env: NodeJS.ProcessEnv,
name: string,
): string {
const value = env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function requiredSecret(
env: NodeJS.ProcessEnv,
name: string,
): string {
const value = env[name];
if (!value) {
// 값은 절대 오류 메시지에 포함하지 않는다.
throw new Error(`required secret ${name} is missing`);
}
return value;
}
그리고 조립 단계 외에는 원본 환경 객체를 전달하지 않는다.
function loadConfig(env: NodeJS.ProcessEnv): AppConfig {
return Object.freeze({
runtime: Object.freeze({
nodeEnv: parseNodeEnv(env.NODE_ENV),
port: parsePort(env.PORT ?? "3000"),
logLevel: parseLogLevel(env.LOG_LEVEL ?? "info"),
paymentApiUrl: new URL(
requiredText(env, "PAYMENT_API_URL"),
),
paymentTimeoutMs: parsePositiveInteger(
env.PAYMENT_TIMEOUT_MS ?? "3000",
"PAYMENT_TIMEOUT_MS",
),
}),
secrets: Object.freeze({
databasePassword: requiredSecret(
env,
"DATABASE_PASSWORD",
),
paymentApiKey: requiredSecret(
env,
"PAYMENT_API_KEY",
),
}),
});
}
이 객체도 모든 계층에 통째로 넘기지 않는다. 결제 클라이언트에는 결제에 필요한 값만 전달한다.
type PaymentClientOptions = Readonly<{
baseUrl: URL;
apiKey: string;
timeoutMs: number;
}>;
function createPaymentClient(
options: PaymentClientOptions,
): PaymentClient {
// 예시를 위한 가상의 클라이언트 구현
return new PaymentClient(options);
}
const paymentClient = createPaymentClient({
baseUrl: config.runtime.paymentApiUrl,
apiKey: config.secrets.paymentApiKey,
timeoutMs: config.runtime.paymentTimeoutMs,
});
이 구조의 장점은 단순히 타입이 생기는 데 있지 않다.
- 원본
process.env를 읽는 위치가 한 곳으로 제한된다. - 비밀값에 접근하는 컴포넌트를 검색할 수 있다.
- 테스트에서 실제 환경 변수를 변경하지 않고 가짜 설정을 주입할 수 있다.
- 로그에 출력 가능한
runtime과 출력하면 안 되는secrets를 구조로 구분한다. - 다음 글에서 다룰 시작 시점 검증을 일관되게 적용할 수 있다.
비밀값을 로그와 오류에서 지킨다
비밀값은 직접 출력하지 않아도 간접적으로 새어 나갈 수 있다.
try {
await paymentClient.charge(command);
} catch (error) {
logger.error(
{
config,
error,
},
"payment failed",
);
}
config에 비밀값이 있고, HTTP 라이브러리의 오류 객체에는 요청 헤더가 들어 있을 수 있다. 두 객체 모두 직렬화되면 API 키가 남는다.
허용 목록 기반 로깅
설정 전체를 마스킹하려고 하기보다 로그에 넣을 필드를 선택한다.
function publicConfigSnapshot(config: AppConfig) {
return {
nodeEnv: config.runtime.nodeEnv,
port: config.runtime.port,
logLevel: config.runtime.logLevel,
paymentApiOrigin: config.runtime.paymentApiUrl.origin,
paymentTimeoutMs: config.runtime.paymentTimeoutMs,
};
}
logger.info(
{ config: publicConfigSnapshot(config) },
"configuration loaded",
);
구조적 마스킹
HTTP 클라이언트 오류처럼 외부 객체를 기록해야 한다면 로거에서 민감 경로를 마스킹한다.
const logger = createLogger({
redact: [
"req.headers.authorization",
"req.headers.cookie",
"error.request.headers.authorization",
"config.secrets",
],
censor: "[REDACTED]",
});
키 이름만으로 모든 비밀을 찾는 방식에는 한계가 있다. token, password라는 이름이 아닌 값도 있고, URL의 사용자 정보나 쿼리 문자열에 포함될 수도 있다. 그래서 허용 목록과 마스킹을 함께 사용한다.
비밀 문자열을 타입으로 감싸는 방법
실수로 문자열 보간하는 것을 줄이고 싶다면 비밀값 래퍼를 둘 수도 있다.
class SecretString {
readonly #value: string;
private constructor(value: string) {
this.#value = value;
}
static from(value: string): SecretString {
return new SecretString(value);
}
reveal(): string {
return this.#value;
}
toString(): string {
return "[REDACTED]";
}
toJSON(): string {
return "[REDACTED]";
}
}
const secret = SecretString.from("example-secret");
console.log(String(secret)); // [REDACTED]
console.log(JSON.stringify(secret)); // "[REDACTED]"
paymentClient.authenticate(secret.reveal());
이 래퍼는 완전한 보안 장벽이 아니다. reveal()을 호출할 수 있고 메모리에는 원문이 존재한다. 다만 평범한 로깅과 직렬화에서 발생하는 실수를 줄이는 안전장치다.
PAYMENT_API_KEY has invalid value: sk_live_...처럼 친절한 메시지는 로그 유출 경로가 된다. 변수 이름과 실패 이유만 남기고 실제 값은 출력하지 않는다.
저장과 주입 방식을 분리해서 선택한다
비밀값을 어디에 저장할지와 프로세스에 어떻게 전달할지는 별개의 결정이다.
| 저장 위치 | 프로세스 전달 | 장점 | 주의점 |
|---|---|---|---|
| 전용 Secret Manager | 시작 시 API 조회 | 중앙 감사·버전·회전 | 부트스트랩 인증과 장애 처리 필요 |
| 전용 Secret Manager | 배포 시스템이 환경 변수 주입 | 앱 구현 단순 | 실행 중 변경 반영 어려움 |
| Kubernetes Secret | 환경 변수 | 사용 간단 | Pod 재시작 전 갱신되지 않음 |
| Kubernetes Secret | 읽기 전용 파일 마운트 | 파일 갱신 감지 가능 | 앱이 재로딩을 구현해야 함 |
로컬 .env |
환경 변수 로드 | 개발 편의성 | 저장소 커밋과 공유 금지 |
환경 변수 주입은 모든 서비스에 충분히 좋은 기본 선택일 수 있다. 다만 환경 변수를 곧 비밀 관리 시스템이라고 부르지 않는 것이 중요하다. 실제 통제는 값을 보관하고 주입하는 상위 시스템에서 온다.
파일 마운트 방식은 인증서나 여러 줄의 개인 키처럼 환경 변수로 표현하기 번거로운 값에도 유용하다.
import { readFile } from "node:fs/promises";
async function loadSigningKey(
path: string,
): Promise<SecretString> {
const pem = await readFile(path, "utf8");
if (!pem.includes("BEGIN PRIVATE KEY")) {
throw new Error("signing key file has an invalid format");
}
return SecretString.from(pem);
}
그러나 파일을 사용한다고 자동 회전되는 것은 아니다. 파일 내용이 바뀌었을 때 애플리케이션이 다시 읽을지, 새 연결에만 적용할지, 기존 연결을 끊을지를 정해야 한다.
비밀값 회전은 저장보다 어려운 문제다
비밀 관리 시스템을 도입해도 애플리케이션이 재시작할 때만 값을 읽는다면 회전에는 배포가 필요하다. 무중단으로 회전하려면 공급자와 소비자가 구 키와 신 키가 공존하는 시간을 가져야 한다.
sequenceDiagram
participant Operator
participant Provider as Credential Provider
participant App as Application
Operator->>Provider: 새 자격 증명 V2 생성
Operator->>App: V2 주입 또는 재로딩
App->>Provider: V2로 인증 확인
Operator->>Provider: V1 비활성화
App->>App: V1 메모리·캐시 폐기단일 비밀값을 즉시 덮어쓰는 방식은 배포 순서에 따라 장애를 만든다.
1. 공급자에서 기존 키 V1을 폐기한다.
2. 일부 애플리케이션 인스턴스는 아직 V1을 사용한다.
3. 해당 인스턴스의 요청만 인증 실패한다.
4. 롤링 재시작이 끝날 때까지 부분 장애가 지속된다.
안전한 회전 절차는 대체로 다음과 같다.
- 새 자격 증명 V2를 만든다.
- V1과 V2가 모두 유효한 중첩 구간을 만든다.
- 모든 인스턴스가 V2를 사용하도록 배포하거나 재로딩한다.
- V2 인증 성공률과 V1 사용량을 관찰한다.
- V1 사용이 사라진 뒤 폐기한다.
- 노출 사고였다면 관련 세션·토큰·파생 자격 증명도 함께 무효화한다.
JWT 서명 키처럼 검증자가 과거 토큰을 확인해야 하는 경우에는 kid로 키 버전을 구분하고, 이전 공개 키를 토큰 최대 수명 동안 유지하는 식의 별도 전략이 필요하다.
시작할 때 문자열 하나를 영구 보관하는 인터페이스보다 CredentialProvider.getCurrent()처럼 현재 자격 증명을 제공하는 추상화가 회전에 유리하다. 다만 매 요청마다 원격 Secret Manager를 호출하지 말고 짧은 캐시와 실패 시 마지막 정상 버전 사용 정책을 함께 설계한다.
로컬 개발과 배포 환경의 규칙을 나눈다
로컬에서는 .env가 편리하다. Node.js도 .env 형식과 --env-file 옵션을 공식 지원한다. 다만 .env 파일의 모든 값은 파싱된 뒤 문자열이 된다.
PORT=3000
FEATURE_RECOMMENDATION=false
REQUEST_TIMEOUT_MS=5000
console.log(typeof process.env.PORT); // "string"
console.log(process.env.FEATURE_RECOMMENDATION); // "false"
따라서 다음 코드는 잘못된다.
const recommendationEnabled =
Boolean(process.env.FEATURE_RECOMMENDATION);
// Boolean("false")는 true다.
타입 변환과 검증은 반드시 별도로 수행해야 한다. 자세한 내용은 설정 검증을 애플리케이션 시작 시점에 해야 하는 이유에서 이어서 다룬다.
저장소에는 값이 없는 계약 파일만 둔다.
# .env.example
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=example-user
DATABASE_PASSWORD=replace-me
PAYMENT_API_URL=https://sandbox.example.test
PAYMENT_API_KEY=replace-me
replace-me는 실행 가능한 공용 자격 증명이 아니라 자리표시자여야 한다. 애플리케이션이 이 값을 실제 비밀로 받아들이지 않게 막을 수도 있다.
const forbiddenPlaceholders = new Set([
"replace-me",
"changeme",
"example-secret",
]);
function requiredSecret(
env: NodeJS.ProcessEnv,
name: string,
): string {
const value = env[name];
if (!value || forbiddenPlaceholders.has(value)) {
throw new Error(`required secret ${name} is missing`);
}
return value;
}
.gitignore만 믿는 것도 부족하다. 이미 추적된 파일에는 규칙이 적용되지 않고, git add -f나 잘못된 파일명으로 우회될 수 있다.
.env
.env.*
!.env.example
추가로 커밋 전 secret scanner와 CI 검사를 사용하고, 실제 값이 한 번이라도 커밋되었다면 파일을 지우는 데서 끝내지 않고 즉시 자격 증명을 회전해야 한다. Git 기록에서 문자열을 제거해도 이미 복제된 저장소와 로그까지 회수할 수는 없다.
테스트와 운영에서 확인할 항목
설정과 비밀값의 분리가 코드 구조와 운영 절차에 실제 반영되었는지 확인한다.
코드 리뷰
배포
회전 훈련
실제 노출 사고가 난 뒤 처음 회전 절차를 실행하면 늦다. 비운영 환경에서 주기적으로 연습한다.
Given: V1 자격 증명으로 여러 인스턴스가 실행 중이다.
When: V2를 생성하고 롤링 배포한다.
Then:
- 요청 실패율이 증가하지 않는다.
- 모든 인스턴스가 V2를 사용함을 확인할 수 있다.
- V1을 폐기한 뒤에도 정상 동작한다.
- 로그와 배포 기록에 V1, V2 원문이 남지 않는다.
비밀값 원문을 관측할 수 없으므로 버전 식별자를 별도로 기록하는 방식이 유용하다.
logger.info(
{
paymentCredentialVersion:
process.env.PAYMENT_CREDENTIAL_VERSION,
},
"payment credential initialized",
);
버전 문자열 자체도 조직 정책에 따라 민감할 수 있으므로 공개 범위를 검토한다. 핵심은 원문 대신 “어떤 버전이 적용되었는가”를 관측하는 것이다.
마무리
환경 변수는 설정을 외부에서 주입하는 좋은 수단이지만 비밀 저장소와 동의어는 아니다. 같은 process.env에서 읽더라도 값의 성격에 따라 규칙이 달라야 한다.
일반 설정은 문서화·기본값·타입 검증의 대상이고, 비밀값은 최소 권한·마스킹·회전·감사의 대상이다. 클라이언트에 들어가는 값은 처음부터 공개 정보로 취급해야 한다.
가장 실용적인 출발점은 설정을 runtime, public, secrets로 나누고 원본 환경 변수를 한 곳에서만 읽는 것이다. 그 위에 안전한 로그 스냅샷, 전용 비밀 관리 시스템, 회전 절차를 차례로 얹을 수 있다. 다음 단계는 이 값들이 존재하는지만 볼 것이 아니라 타입과 범위, 값 사이의 관계까지 애플리케이션 시작 시점에 검증하는 것이다.
참고 자료
- Node.js Environment Variables
- Vite Env Variables and Modes
- Kubernetes Secrets
- Kubernetes Secrets Good Practices
- OWASP Cryptographic Storage Cheat Sheet