설정 검증을 애플리케이션 시작 시점에 해야 하는 이유
설정 검증을 애플리케이션 시작 시점에 해야 하는 이유
환경 변수는 모두 문자열이고 값 사이의 관계도 보장하지 않는다. 애플리케이션이 트래픽을 받기 전에 읽기 → 파싱 → 검증 → 정규화 → 의존성 초기화 → 준비 완료 순서를 거치면 잘못된 배포를 빠르고 일관되게 실패시킬 수 있다. 오류에는 변수 이름과 이유만 남기고 비밀값 원문은 남기지 않는다.
목차
- #늦게 실패하는 설정 오류가 더 위험하다
- #존재 여부만 확인해서는 부족하다
- #설정 로딩을 순수한 변환으로 만든다
- #문자열을 타입으로 바꾸는 작은 파서
- #항목 사이의 관계도 검증한다
- #시작 과정을 단계별로 분리한다
- #설정 오류와 의존성 장애를 구분한다
- #여러 오류를 한 번에 안전하게 보여준다
- #검증된 설정은 불변 값으로 전달한다
- #배포와 Readiness에 연결한다
- #테스트해야 할 실패 조건
- #운영에서 남겨야 할 관측 정보
- #마무리
- #참고 자료
- #관련 노트
늦게 실패하는 설정 오류가 더 위험하다
필수 환경 변수가 빠졌는데도 서버가 포트를 열 수는 있다.
import express from "express";
const app = express();
app.post("/payments", async (req, res) => {
const apiKey = process.env.PAYMENT_API_KEY;
if (!apiKey) {
throw new Error("PAYMENT_API_KEY is missing");
}
// 예시를 위한 가상의 결제 요청
const result = await requestPayment({
apiKey,
amount: req.body.amount,
});
res.json(result);
});
app.listen(3000);
배포 시스템이 보는 흐름은 이렇다.
sequenceDiagram
participant Deploy as Deployment
participant App as Application
participant Probe as Health Probe
participant User
Deploy->>App: 새 버전 시작
App-->>Deploy: 포트 열림
Probe->>App: GET /health
App-->>Probe: 200 OK
Note over Deploy,Probe: 배포 성공으로 판단
User->>App: POST /payments
App-->>User: 500 설정 오류애플리케이션은 실행 중이고 헬스 체크도 성공하지만 핵심 기능은 사용할 수 없다. 트래픽이 적은 기능이라면 몇 시간 뒤 처음 호출될 때 발견될 수도 있다. 같은 버전의 인스턴스 중 일부만 잘못된 설정을 받았다면 간헐적인 오류처럼 보인다.
설정 오류는 재시도로 회복되지 않는 경우가 많다.
| 실패 | 재시도로 회복 가능성 | 적절한 대응 |
|---|---|---|
| 일시적인 DNS 조회 실패 | 있음 | 지수 백오프 후 재시도 |
| DB가 잠시 재시작 중 | 있음 | 제한된 재연결 |
PORT=hello |
없음 | 시작 즉시 실패 |
| 필수 API 키 누락 | 없음 | 시작 즉시 실패 |
| 운영에서 테스트 결제 URL 사용 | 없음 | 정책 검증 후 실패 |
| 타임아웃이 음수 | 없음 | 범위 검증 후 실패 |
회복 불가능한 설정 오류를 런타임까지 미루면 요청마다 같은 실패를 반복하며 로그와 알림만 늘어난다. 그래서 트래픽을 받기 전에 명확하게 실패하는 편이 더 안전하다.
프로세스를 빨리 죽이는 것 자체가 목적은 아니다. 잘못된 인스턴스가 정상 인스턴스처럼 보이지 않게 하고, 배포 시스템과 운영자가 원인을 즉시 식별하도록 만드는 것이 목적이다.
존재 여부만 확인해서는 부족하다
환경 변수는 값이 있더라도 올바르지 않을 수 있다. Node.js의 .env 파서는 숫자, 불리언, JSON처럼 보이는 값도 모두 문자열로 제공한다.
PORT=3000
ENABLE_RECOMMENDATION=false
REQUEST_TIMEOUT_MS=5000
ALLOWED_ORIGINS=https://app.example.test
console.log(typeof process.env.PORT); // "string"
console.log(typeof process.env.ENABLE_RECOMMENDATION); // "string"
다음 코드는 모두 예상과 다르게 동작할 수 있다.
const port = Number(process.env.PORT);
const enabled = Boolean(process.env.ENABLE_RECOMMENDATION);
const timeoutMs =
Number(process.env.REQUEST_TIMEOUT_MS) || 5000;
Number(undefined)는NaN이다.Boolean("false")는true다.Number("")는0이다.Number("12ms")는NaN이다.0 || 5000은5000이라 의도적으로 0을 허용해도 사라진다.Infinity도 JavaScript의 숫자이지만 타임아웃으로 쓸 수 없다.
설정 검증에는 여러 층이 있다.
1. 존재: 필수 값이 있는가?
2. 구문: 정수, URL, enum 등으로 파싱되는가?
3. 범위: 허용되는 최소·최대 안에 있는가?
4. 관계: 다른 설정과 함께 사용할 수 있는가?
5. 정책: 현재 환경에서 허용되는 값인가?
6. 연결: 실제 외부 의존성에 접근 가능한가?
앞의 다섯 단계는 입력 문자열만으로 판단할 수 있다. 마지막 연결 검사는 네트워크와 외부 상태에 의존하므로 분리해서 다루는 것이 좋다.
설정 로딩을 순수한 변환으로 만든다
설정 로더가 전역 process.env를 직접 읽고 연결까지 만들어 버리면 테스트하기 어렵다.
// 피하고 싶은 형태
export const database = connectDatabase({
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DB_POOL_SIZE ?? 10),
});
모듈을 import하는 순간 부작용이 일어나고, 테스트마다 환경 변수를 바꾸거나 모듈 캐시를 비워야 한다. 대신 원시 입력을 받아 검증된 값으로 변환하는 순수 함수로 만든다.
type RawConfig = Readonly<Record<string, string | undefined>>;
type AppConfig = Readonly<{
runtime: Readonly<{
nodeEnv: "development" | "test" | "production";
port: number;
logLevel: "debug" | "info" | "warn" | "error";
requestTimeoutMs: number;
}>;
database: Readonly<{
host: string;
port: number;
user: string;
password: string;
poolMin: number;
poolMax: number;
}>;
payment: Readonly<{
baseUrl: URL;
apiKey: string;
retryEnabled: boolean;
maxAttempts: number;
}>;
}>;
function parseConfig(raw: RawConfig): AppConfig {
// 입력만 읽고 네트워크, 파일, 전역 상태를 변경하지 않는다.
return {
runtime: {
nodeEnv: parseEnum(
raw.NODE_ENV ?? "development",
["development", "test", "production"] as const,
"NODE_ENV",
),
port: parseInteger(
raw.PORT ?? "3000",
{ min: 1, max: 65535 },
"PORT",
),
logLevel: parseEnum(
raw.LOG_LEVEL ?? "info",
["debug", "info", "warn", "error"] as const,
"LOG_LEVEL",
),
requestTimeoutMs: parseInteger(
raw.REQUEST_TIMEOUT_MS ?? "5000",
{ min: 100, max: 60_000 },
"REQUEST_TIMEOUT_MS",
),
},
database: {
host: required(raw, "DATABASE_HOST"),
port: parseInteger(
raw.DATABASE_PORT ?? "5432",
{ min: 1, max: 65535 },
"DATABASE_PORT",
),
user: required(raw, "DATABASE_USER"),
password: requiredSecret(raw, "DATABASE_PASSWORD"),
poolMin: parseInteger(
raw.DB_POOL_MIN ?? "2",
{ min: 0, max: 100 },
"DB_POOL_MIN",
),
poolMax: parseInteger(
raw.DB_POOL_MAX ?? "10",
{ min: 1, max: 100 },
"DB_POOL_MAX",
),
},
payment: {
baseUrl: parseUrl(
required(raw, "PAYMENT_API_URL"),
"PAYMENT_API_URL",
),
apiKey: requiredSecret(raw, "PAYMENT_API_KEY"),
retryEnabled: parseBoolean(
raw.PAYMENT_RETRY_ENABLED ?? "true",
"PAYMENT_RETRY_ENABLED",
),
maxAttempts: parseInteger(
raw.PAYMENT_MAX_ATTEMPTS ?? "3",
{ min: 1, max: 5 },
"PAYMENT_MAX_ATTEMPTS",
),
},
};
}
사용할 때만 실제 환경 변수를 전달한다.
const config = parseConfig(process.env);
테스트에서는 작은 객체를 전달하면 된다.
const config = parseConfig({
NODE_ENV: "test",
DATABASE_HOST: "localhost",
DATABASE_USER: "test-user",
DATABASE_PASSWORD: "test-only-password",
PAYMENT_API_URL: "https://sandbox.example.test",
PAYMENT_API_KEY: "test-only-api-key",
});
이 예시 값들은 실제 시스템과 관계없는 재구성된 테스트 문자열이다.
문자열을 타입으로 바꾸는 작은 파서
설정 라이브러리를 사용해도 되지만 어떤 검증이 필요한지 이해하려면 작은 파서를 직접 작성해 보는 것이 도움이 된다.
필수 문자열
class ConfigError extends Error {
constructor(
readonly field: string,
readonly reason: string,
) {
super(`${field}: ${reason}`);
this.name = "ConfigError";
}
}
function required(
raw: RawConfig,
name: string,
): string {
const value = raw[name]?.trim();
if (!value) {
throw new ConfigError(name, "is required");
}
return value;
}
문자열의 앞뒤 공백을 제거할지 여부도 값의 종류에 따라 다르다. 일반 호스트 이름에는 적절하지만 비밀번호는 공백 자체가 값일 수 있다.
function requiredSecret(
raw: RawConfig,
name: string,
): string {
const value = raw[name];
if (value === undefined || value.length === 0) {
throw new ConfigError(name, "secret is required");
}
// 오류에 value를 절대 포함하지 않는다.
return value;
}
정수와 범위
parseInt("3000px", 10)은 3000을 반환하므로 설정 검증에는 지나치게 관대할 수 있다. 전체 문자열이 정수 형식인지 먼저 확인한다.
function parseInteger(
text: string,
range: { min: number; max: number },
name: string,
): number {
if (!/^-?\d+$/.test(text)) {
throw new ConfigError(
name,
"must be an integer",
);
}
const value = Number(text);
if (!Number.isSafeInteger(value)) {
throw new ConfigError(
name,
"must be a safe integer",
);
}
if (value < range.min || value > range.max) {
throw new ConfigError(
name,
`must be between ${range.min} and ${range.max}`,
);
}
return value;
}
불리언
허용하는 표현을 명시한다.
function parseBoolean(
text: string,
name: string,
): boolean {
if (text === "true") {
return true;
}
if (text === "false") {
return false;
}
throw new ConfigError(
name,
'must be either "true" or "false"',
);
}
1, yes, on까지 허용할 수 있지만 입력 방법이 늘어날수록 문서와 도구마다 해석 차이가 생긴다. 특별한 이유가 없다면 두 표현만 허용하는 편이 단순하다.
열거형
function parseEnum<const T extends readonly string[]>(
text: string,
allowed: T,
name: string,
): T[number] {
if (!allowed.includes(text)) {
throw new ConfigError(
name,
`must be one of: ${allowed.join(", ")}`,
);
}
return text as T[number];
}
URL과 프로토콜
new URL()이 성공한다고 애플리케이션 정책에 맞는 URL은 아니다.
function parseUrl(
text: string,
name: string,
): URL {
let url: URL;
try {
url = new URL(text);
} catch {
throw new ConfigError(name, "must be a valid URL");
}
if (!["http:", "https:"].includes(url.protocol)) {
throw new ConfigError(
name,
"must use http or https",
);
}
if (url.username || url.password) {
throw new ConfigError(
name,
"must not contain inline credentials",
);
}
return url;
}
운영 환경에서는 HTTPS만 허용하는 추가 정책도 둘 수 있다.
function assertProductionPolicy(
config: AppConfig,
): void {
if (
config.runtime.nodeEnv === "production" &&
config.payment.baseUrl.protocol !== "https:"
) {
throw new ConfigError(
"PAYMENT_API_URL",
"must use https in production",
);
}
}
항목 사이의 관계도 검증한다
각 값이 개별적으로 올바르더라도 조합이 잘못될 수 있다.
DB_POOL_MIN=20
DB_POOL_MAX=10
둘 다 허용 범위 안의 정수지만 최소 연결 수가 최대보다 크다.
function assertCrossFieldRules(
config: AppConfig,
): void {
if (config.database.poolMin > config.database.poolMax) {
throw new ConfigError(
"DB_POOL_MIN",
"must not exceed DB_POOL_MAX",
);
}
if (
!config.payment.retryEnabled &&
config.payment.maxAttempts !== 1
) {
throw new ConfigError(
"PAYMENT_MAX_ATTEMPTS",
"must be 1 when retry is disabled",
);
}
}
기능 플래그에 따라 다른 설정이 필수가 되는 경우도 있다.
type RecommendationConfig =
| Readonly<{ enabled: false }>
| Readonly<{
enabled: true;
endpoint: URL;
apiKey: string;
}>;
function parseRecommendation(
raw: RawConfig,
): RecommendationConfig {
const enabled = parseBoolean(
raw.RECOMMENDATION_ENABLED ?? "false",
"RECOMMENDATION_ENABLED",
);
if (!enabled) {
return { enabled: false };
}
return {
enabled: true,
endpoint: parseUrl(
required(raw, "RECOMMENDATION_API_URL"),
"RECOMMENDATION_API_URL",
),
apiKey: requiredSecret(
raw,
"RECOMMENDATION_API_KEY",
),
};
}
판별 가능한 유니온으로 표현하면 사용하는 코드에서도 안전하다.
if (config.recommendation.enabled) {
await config.recommendationClient.fetchSuggestions();
}
반면 기능이 꺼져 있는데도 관련 비밀값을 무조건 요구하면 사용하지 않는 외부 시스템 때문에 애플리케이션이 시작되지 않는다. 조건부 필수 규칙 역시 설정 계약의 일부다.
시작 과정을 단계별로 분리한다
설정 검증이 언제 실행되는지 명확해야 한다.
flowchart LR
A[원시 설정 읽기] --> B[파싱·검증]
B -->|실패| X[비정상 종료]
B --> C[검증된 설정 생성]
C --> D[로거 초기화]
D --> E[DB·Redis·외부 클라이언트 초기화]
E -->|실패| X
E --> F[HTTP 서버 포트 열기]
F --> G[Readiness true]코드에서도 부트스트랩 순서를 드러낸다.
async function bootstrap(
raw: RawConfig,
): Promise<RunningApplication> {
// 1. 외부 I/O 없이 결정 가능한 오류를 먼저 찾는다.
const config = parseConfig(raw);
assertCrossFieldRules(config);
assertProductionPolicy(config);
// 2. 검증된 설정으로만 컴포넌트를 만든다.
const logger = createLogger({
level: config.runtime.logLevel,
});
logger.info(
{ config: publicConfigSnapshot(config) },
"configuration validated",
);
// 3. 네트워크 의존성을 초기화한다.
const database = await connectDatabase({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
min: config.database.poolMin,
max: config.database.poolMax,
});
// 4. 모든 필수 의존성이 준비된 뒤 서버를 연다.
const server = await startHttpServer({
port: config.runtime.port,
database,
logger,
});
return { config, logger, database, server };
}
최상위에서는 설정 오류를 잡아 종료 코드를 정한다.
async function main(): Promise<void> {
try {
const app = await bootstrap(process.env);
registerShutdownHandlers(app);
} catch (error) {
writeSafeStartupError(error);
process.exitCode = 1;
}
}
void main();
process.exit(1)을 즉시 호출하면 비동기 로그 출력이 끝나기 전에 프로세스가 종료될 수 있다. 시작 단계에서 열린 핸들이 없다면 process.exitCode = 1로 두고 자연스럽게 이벤트 루프가 끝나게 할 수 있다. 일부 자원을 연 뒤 실패했다면 종료 관리자를 호출해 정리한 후 끝내야 한다.
기본 콘솔 로거로 초기 검증 오류를 남긴 뒤 정식 로거를 구성하거나, 로그 레벨처럼 안전한 최소 설정만 먼저 파싱하는 두 단계 접근을 사용할 수 있다. 어떤 방식을 쓰든 비밀값 원문을 출력하지 않는 규칙은 동일하다.
설정 오류와 의존성 장애를 구분한다
DATABASE_HOST가 비어 있는 것과 데이터베이스가 잠시 재시작 중인 것은 대응이 다르다.
순수 설정 검증
외부 I/O 없이 항상 같은 결과를 낸다.
- 필수 값 존재 여부
- 숫자·불리언·URL 파싱
- 허용 범위와 enum
- 설정 간 관계
- 환경별 정책
잘못되면 재시도하지 않고 종료한다.
시작 시 의존성 확인
네트워크와 외부 시스템 상태에 영향을 받는다.
- DNS 해석
- DB 인증과 연결
- 메시지 브로커 연결
- 필수 파일 읽기
- 외부 서비스 자격 증명 확인
일시 장애일 수 있으므로 제한된 재시도를 둘 수 있다.
async function connectWithRetry(
connect: () => Promise<Database>,
options: {
maxAttempts: number;
initialDelayMs: number;
},
): Promise<Database> {
let lastError: unknown;
for (
let attempt = 1;
attempt <= options.maxAttempts;
attempt += 1
) {
try {
return await connect();
} catch (error) {
lastError = error;
if (attempt === options.maxAttempts) {
break;
}
const delayMs =
options.initialDelayMs * 2 ** (attempt - 1);
await delay(delayMs);
}
}
throw new StartupDependencyError(
"database connection failed",
{ cause: lastError },
);
}
재시도를 무한히 하면 잘못된 자격 증명도 영원히 반복한다. 시도 횟수와 전체 시작 제한 시간을 두고, 인증 실패처럼 회복 가능성이 낮은 오류는 즉시 중단하는 편이 좋다.
| 검사 | 실패 분류 | 재시도 | Readiness |
|---|---|---|---|
| 포트 형식 | 설정 오류 | 안 함 | 시작 전 실패 |
| 비밀값 누락 | 설정 오류 | 안 함 | 시작 전 실패 |
| DB 비밀번호 거부 | 보통 설정/자격 증명 오류 | 안 함 | 시작 전 실패 |
| DB 연결 시간 초과 | 일시 의존성 장애 가능 | 제한적으로 | false |
| 선택 기능의 외부 API 장애 | 설계에 따라 런타임 장애 | 백오프 | 핵심 기능 기준 결정 |
모든 외부 서비스가 시작을 막아야 하는 것도 아니다. 추천 서비스가 선택 기능이라면 서버는 시작하되 해당 기능을 비활성화하고 상태를 관측할 수 있다. 무엇이 필수 의존성인지 먼저 정해야 한다.
여러 오류를 한 번에 안전하게 보여준다
첫 번째 오류에서 바로 예외를 던지면 배포자가 하나를 고친 뒤 다음 오류를 발견하게 된다.
1차 배포: PORT 오류
2차 배포: DB_POOL_MAX 오류
3차 배포: PAYMENT_API_URL 오류
설정 검증은 외부 I/O가 없으므로 여러 문제를 모아서 한 번에 보여주기 좋다.
type ConfigIssue = Readonly<{
field: string;
reason: string;
}>;
class ConfigValidationError extends Error {
constructor(
readonly issues: readonly ConfigIssue[],
) {
super(`configuration has ${issues.length} issue(s)`);
this.name = "ConfigValidationError";
}
}
민감하지 않은 필드 이름과 규칙만 수집한다.
function formatConfigError(
error: ConfigValidationError,
): string {
const lines = error.issues.map(
({ field, reason }) => `- ${field}: ${reason}`,
);
return [
error.message,
...lines,
].join("\n");
}
configuration has 3 issue(s)
- PORT: must be between 1 and 65535
- DB_POOL_MIN: must not exceed DB_POOL_MAX
- PAYMENT_API_URL: must use https in production
비밀값 검증 실패도 값은 보여주지 않는다.
- PAYMENT_API_KEY: secret is required
아래처럼 원문이나 일부 접두사를 친절하게 보여주지 않는다.
# 위험한 오류 메시지의 예
PAYMENT_API_KEY "live_abc..." is too short
로그 수집기, 배포 UI, 채팅 알림으로 오류가 복제될 수 있기 때문이다.
검증된 설정은 불변 값으로 전달한다
시작할 때 검증했더라도 런타임에 값을 다시 읽으면 보장이 사라진다.
const config = parseConfig(process.env);
// 다른 모듈에서 다시 원본을 읽음
const timeoutMs = Number(process.env.REQUEST_TIMEOUT_MS);
또는 설정 객체가 수정 가능하면 어느 요청 이후 값이 바뀔 수 있다.
config.runtime.requestTimeoutMs = -1;
타입 수준의 Readonly와 런타임 Object.freeze를 함께 사용할 수 있다.
function deepFreeze<T extends object>(value: T): Readonly<T> {
Object.freeze(value);
for (const nested of Object.values(value)) {
if (
nested !== null &&
typeof nested === "object" &&
!Object.isFrozen(nested)
) {
deepFreeze(nested);
}
}
return value;
}
const config = deepFreeze(parseConfig(process.env));
검증된 설정을 의존성으로 전달하면 비즈니스 코드는 환경 변수의 존재를 몰라도 된다.
class PaymentService {
constructor(
private readonly client: PaymentClient,
private readonly retryPolicy: {
enabled: boolean;
maxAttempts: number;
},
) {}
async charge(command: ChargeCommand) {
// process.env를 읽지 않는다.
return this.client.charge(command, this.retryPolicy);
}
}
실행 중 설정을 바꿔야 한다면 전역 환경 변수를 수정하는 대신 별도의 동적 설정 시스템으로 취급한다.
| 정적 설정 | 동적 설정 |
|---|---|
| 프로세스 시작 시 고정 | 런타임 변경 가능 |
| 포트, DB 호스트, 자격 증명 | 기능 플래그, 비율, 제한값 |
| 잘못되면 시작 실패 | 잘못되면 마지막 정상값 유지 가능 |
| 배포·재시작으로 적용 | 버전·구독·폴링으로 적용 |
동적 설정에도 스키마 검증, 버전, 원자적 교체, 실패 시 마지막 정상값 보존이 필요하다. 단지 검증 시점이 시작에서 갱신 이벤트로 확장될 뿐이다.
배포와 Readiness에 연결한다
시작 시 검증이 의미 있으려면 준비 완료 신호와 순서가 맞아야 한다.
let ready = false;
app.get("/ready", (_req, res) => {
if (!ready) {
res.status(503).json({ status: "not_ready" });
return;
}
res.status(200).json({ status: "ready" });
});
async function bootstrap(): Promise<void> {
const config = parseConfig(process.env);
assertCrossFieldRules(config);
await initializeRequiredDependencies(config);
await listen(config.runtime.port);
ready = true;
}
실제로는 헬스 체크용 포트를 언제 열지, 애플리케이션 포트와 분리할지에 따라 구현이 달라진다. 핵심은 검증과 필수 초기화가 끝나기 전에 Readiness가 성공하지 않는 것이다.
Kubernetes 롤링 배포에서 새 Pod의 설정이 잘못되면 새 Pod는 Ready가 되지 않는다. 기존 Pod가 유지되도록 maxUnavailable, maxSurge 같은 배포 전략을 설정하면 잘못된 설정이 전체 트래픽으로 확산되는 것을 줄일 수 있다. 다만 모든 새 Pod가 실패하는 상태를 자동으로 무한 방치하지 말고 배포 타임아웃과 롤백 정책을 둔다.
stateDiagram-v2
[*] --> Validating
Validating --> Failed: 설정 오류
Validating --> Initializing: 검증 성공
Initializing --> Failed: 필수 의존성 실패
Initializing --> Ready: 초기화 성공
Ready --> Draining: 종료 신호
Draining --> [*]
Failed --> [*]: exit code 1Liveness는 설정 오류를 복구하지 못한다. 잘못된 환경 변수로 계속 재시작하면 CrashLoop만 만든다. 이 상태 자체는 설정 배포 실패를 빠르게 드러내는 신호가 될 수 있지만, 알림에는 “프로세스가 죽었다”보다 설정 오류의 구체적인 필드와 배포 버전이 함께 있어야 한다.
테스트해야 할 실패 조건
설정 로더가 순수 함수라면 테이블 테스트로 경계를 촘촘하게 검증할 수 있다.
import { describe, expect, it } from "vitest";
describe("PORT", () => {
const base = validRawConfig();
it.each([
["0", "must be between 1 and 65535"],
["65536", "must be between 1 and 65535"],
["3000px", "must be an integer"],
["3.5", "must be an integer"],
["", "must be an integer"],
])(
"rejects %s",
(port, expectedReason) => {
expect(() =>
parseConfig({
...base,
PORT: port,
}),
).toThrow(expectedReason);
},
);
});
불리언의 흔한 함정도 고정한다.
describe("PAYMENT_RETRY_ENABLED", () => {
it("parses false as false", () => {
const config = parseConfig({
...validRawConfig(),
PAYMENT_RETRY_ENABLED: "false",
});
expect(config.payment.retryEnabled).toBe(false);
});
it.each(["FALSE", "0", "yes", ""])(
"rejects ambiguous value %s",
(value) => {
expect(() =>
parseConfig({
...validRawConfig(),
PAYMENT_RETRY_ENABLED: value,
}),
).toThrow('must be either "true" or "false"');
},
);
});
관계 검증도 따로 확인한다.
it("rejects a pool minimum greater than maximum", () => {
const raw = {
...validRawConfig(),
DB_POOL_MIN: "20",
DB_POOL_MAX: "10",
};
expect(() => parseAndValidateConfig(raw)).toThrow(
"DB_POOL_MIN: must not exceed DB_POOL_MAX",
);
});
비밀값이 오류에 노출되지 않는지도 테스트해야 한다.
it("never includes a secret value in an error", () => {
const secret = "test-only-invalid-secret-value";
try {
parseAndValidateConfig({
...validRawConfig(),
PAYMENT_API_KEY: secret,
PAYMENT_API_URL: "not-a-url",
});
} catch (error) {
expect(String(error)).not.toContain(secret);
}
});
마지막으로 실제 프로세스 수준의 테스트를 둔다.
it("exits before listening when configuration is invalid", async () => {
const child = spawnTestServer({
...validRawConfig(),
PORT: "invalid",
});
const result = await child.waitForExit();
expect(result.exitCode).toBe(1);
expect(result.stdout).not.toContain("server listening");
expect(result.stderr).toContain("PORT");
});
단위 테스트만 통과해도 실제 main()이 검증 전에 포트를 열 수 있다. 프로세스 테스트는 부트스트랩 순서까지 확인한다.
운영에서 남겨야 할 관측 정보
설정값을 모두 출력하지 않고도 어떤 설정이 적용되었는지 관측할 수 있어야 한다.
공개 가능한 설정 스냅샷
function publicConfigSnapshot(config: AppConfig) {
return {
nodeEnv: config.runtime.nodeEnv,
port: config.runtime.port,
logLevel: config.runtime.logLevel,
requestTimeoutMs: config.runtime.requestTimeoutMs,
dbHost: config.database.host,
dbPort: config.database.port,
dbPoolMin: config.database.poolMin,
dbPoolMax: config.database.poolMax,
paymentApiOrigin: config.payment.baseUrl.origin,
paymentRetryEnabled: config.payment.retryEnabled,
paymentMaxAttempts: config.payment.maxAttempts,
};
}
데이터베이스 호스트 같은 항목도 조직에 따라 민감한 내부 정보일 수 있다. 공개 가능 범위는 위협 모델과 로그 접근 범위에 맞게 조정한다.
설정 지문
설정 원문 대신 안전하게 정규화한 공개 설정의 해시를 남기면 인스턴스 간 차이를 찾을 수 있다.
import { createHash } from "node:crypto";
function configFingerprint(config: AppConfig): string {
const snapshot = JSON.stringify(
publicConfigSnapshot(config),
);
return createHash("sha256")
.update(snapshot)
.digest("hex")
.slice(0, 12);
}
logger.info(
{
configFingerprint: configFingerprint(config),
deploymentVersion: process.env.DEPLOYMENT_VERSION,
},
"application ready",
);
비밀값을 해시해서 비교하는 것은 권장하지 않는다. 낮은 엔트로피의 비밀은 해시로 추측될 수 있고, 해시 자체가 장기 식별자가 되어 노출 범위를 넓힌다. 비밀 관리 시스템이 제공하는 안전한 버전 ID를 별도로 사용한다.
실패 지표
설정 오류로 프로세스가 시작되지 않으면 애플리케이션 내부 메트릭을 전송하지 못할 수도 있다. 따라서 배포 시스템과 오케스트레이터에서도 관찰한다.
- 컨테이너 종료 코드와 재시작 횟수
- Ready가 되지 못한 Pod 수
- 배포 진행 제한 시간 초과
- 설정 오류 필드별 로그 카운트
- 설정 버전과 애플리케이션 버전의 조합
알림 메시지는 비밀 원문 없이도 행동 가능해야 한다.
service=checkout-api
deployment=2026.09.02-3
status=startup_failed
reason=config_validation
fields=PAYMENT_API_URL,DB_POOL_MIN
마무리
설정은 외부에서 들어오는 입력이다. 코드 리뷰와 타입 검사만으로 올바름을 보장할 수 없고, 환경 변수는 결국 문자열이므로 명시적인 파싱과 검증이 필요하다.
시작 시점 검증의 핵심은 필수 값 확인에 그치지 않는다. 타입, 범위, 값 사이의 관계, 환경별 정책을 확인해 검증된 불변 설정으로 바꾸고, 그 뒤에만 외부 의존성을 초기화하고 Readiness를 열어야 한다.
설정 오류는 재시도로 고쳐지지 않으므로 즉시 실패하고, 일시적인 의존성 장애는 제한적으로 재시도한다. 오류 메시지에는 필드와 이유를 충분히 남기되 비밀값 원문은 포함하지 않는다. 이렇게 하면 잘못된 설정은 첫 사용자 요청이 아니라 배포 단계에서 발견되고, 운영자는 반복 장애 대신 수정 가능한 진단 정보를 받게 된다.
참고 자료
- Node.js Environment Variables
- Node.js Command-line API:
--env-file - Kubernetes Configure Liveness, Readiness and Startup Probes