금요일 저녁, 3시간이 지난 시점에 애플리케이션이 TypeError를 던진다. 스택 트레이스는 설정 객체의 깊게 중첩된 속성을 가리킨다. 값은 undefined다. 누군가 검증 로직을 갱신하지 않은 채 .env 변경 사항을 프로덕션에 푸시했다.
이것은 런타임 버그가 아니다. 정책 실패다. 신뢰할 수 없는 데이터를 사전 검사 없이 시스템에 유입시켰다.
왜 런타임 설정 검증은 너무 늦은가
대부분의 팀은 설정을 반응적으로 검증한다. 누락된 환경 변수가 충돌을 일으킨다. 잘못된 URL 문자열은 fetch()가 던질 때까지 전파된다. JSON 설정 파일의 잘못 명명된 키는 그것을 읽는 기능이 활성화될 때까지 수 일 또는 수 주 동안 잠들어 있다.
문제는 경계를 명시한 맥락 안에서 도메인 특화 언어를 도입할수록 악화된다. 각 맥락은 고유한 설정 형태를 갖는다. 결제 서비스는 STRIPE_WEBHOOK_SECRET을 신경 쓴다. 알림 서비스는 SNS_TOPIC_ARN을 신경 쓴다. 형태는 다르지만, 장애 양상은 동일하다. 잘못된 설정이 시스템에 들어오고, 그 잘못된 데이터가 마침내 사용될 때 시스템이 실패한다.
TypeScript는 여기서 구원하지 못한다. process.env는 string | undefined를 반환한다. Config 인터페이스는 문자열을 보장하지만, 무언가가 그것을 강제하기 전까지는 거짓말이다.
해결책은 설정이 런타임에 도달하기 전에 경계에서 검증하는 것이다.
시스템 경계에서의 스키마 검증
설정은 신뢰할 수 없는 입력으로 다뤄야 한다. 그것이 사실이기 때문이다. 환경 변수, JSON 파일, 원격 설정 서비스는 모두 애플리케이션이 사용하기 전에 스키마 검증기를 통과해야 한다.
이것이 동작하는 형태다.
- 도메인의 기대에 부합하는 스키마를 정의한다.
- 원시 설정을 해당 스키마로 파싱한다.
- 파싱된 타입이 지정된 결과를 다른 모든 곳에서 사용한다.
파싱이 실패하면 애플리케이션은 명확한 오류와 함께 즉시 종료된다. 부분 기동은 없다. 누락된 필드를 가진 설정 객체가 코드의 나머지로 떠다니는 일도 없다.
Zod를 사용한 구체적인 예시는 다음과 같다.
import { z } from "zod";
const PaymentConfig = z.object({
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().min(20),
MAX_RETRY_ATTEMPTS: z
.string()
.transform((s) => parseInt(s, 10))
.pipe(z.number().min(1).max(10)),
});
const raw = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
MAX_RETRY_ATTEMPTS: process.env.MAX_RETRY_ATTEMPTS ?? "3",
};
const config = PaymentConfig.parse(raw);
STRIPE_SECRET_KEY가 누락되면 애플리케이션은 어떤 경로도 마운트하기 전에 던진다. MAX_RETRY_ATTEMPTS가 "fifteen"이면 파싱은 명확한 오류와 함께 실패한다. 파싱 후 config의 타입은 PaymentConfig가 기술하는 것과 정확히 일치하며, string | undefined가 아니다.
맥락별 스키마, 하나의 전역 객체가 아니다
설정을 경계를 명시한 맥락별로 모델링하면, 모든 환경 변수를 각 모듈이 임포트하는 단일 Config 객체에 쏟아 붓는 유혹을 피할 수 있다. 그 전역 객체는 숨겨진 결합을 만든다. 알림 서비스 설정의 변경이 결제 서비스도 임포트하는 파일을 건드리게 만든다.
대신 각 경계를 명시한 맥락은 자신만의 스키마를 정의한다.
// contexts/payment/config.ts
export const PaymentConfig = z.object({
STRIPE_SECRET_KEY: z.string(),
WEBHOOK_ENDPOINT: z.string().url(),
});
// contexts/notification/config.ts
export const NotificationConfig = z.object({
SNS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:"),
RATE_LIMIT_PER_MINUTE: z.number().default(100),
});
각 맥락은 필요한 것만 파싱한다. 결제 서비스는 알림 변수를 보지 않고, 그 반대도 마찬가지다. 이는 코드에서 이미 그은 도메인 경계를 반영한다.
생성된 타입을 사용한 빌드 타임 검증
시작 시 파싱으로 대부분의 문제를 잡아내지만, 더 나아갈 수 있다. 설정이 정적 파일에 있다면 빌드 타임에 검증하라.
환경별 기능 플래그를 정의하는 JSON 설정 파일을 생각해 보자.
{
"features": {
"newCheckout": {
"enabled": true,
"rolloutPercentage": 50
}
}
}
스키마를 정의한 다음, 빌드 단계의 일부로 JSON 파일을 검증한다.
import { z } from "zod";
import featureFlags from "./feature-flags.json";
const FeatureFlagConfig = z.object({
features: z.record(
z.object({
enabled: z.boolean(),
rolloutPercentage: z.number().min(0).max(100),
})
),
});
// This runs during `tsc` or `vite build` if you import the result
export const validatedFlags = FeatureFlagConfig.parse(featureFlags);
JSON 파일의 형식이 잘못되면 TypeScript는 컴파일을 거부한다. 오류는 프로덕션이 아닌 CI에서 표면화된다.
환경 변수는 본질적으로 런타임 데이터이므로 빌드 타임에 완전히 검증할 수는 없다. 하지만 시작 시 즉시 실패하는 타입 안전한 접근자를 생성할 수 있다.
function env(name: string, schema: z.ZodTypeAny): z.infer<typeof schema> {
const value = process.env[name];
const result = schema.safeParse(value);
if (!result.success) {
console.error(`Invalid env var ${name}:`, result.error.flatten());
process.exit(1);
}
return result.data;
}
const dbUrl = env("DATABASE_URL", z.string().url());
이 패턴은 실패를 중앙 집중화한다. 누락되거나 잘못된 환경 변수가 충돌을 일으키는 곳은 정확히 한 곳이며, 서버가 트래픽을 받기 시작하기 전에 발생한다.
트레이드오프: 엄격성 대 배포 마찰
스키마 검증은 공짜가 아니다. 주된 비용은 배포 마찰이다.
스키마가 너무 엄격하면 스테이징에는 추가되었지만 프로덕션에는 아직 추가되지 않은 새 환경 변수가 배포를 막는다. 팀원이 FEATURE_X_ENABLED=false를 스테이징 .env에 추가하고 이를 읽는 코드를 푸시한다. 프로덕션은 해당 변수가 없어 시작 시 충돌한다.
이를 처리할 방법은 두 가지다.
첫째, 안전한 폴백이 있는 변수에는 명시적 기본값을 사용한다.
const config = z.object({
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});
둘째, 필수 설정과 선택적 설정을 명확히 분리한다. 필수 필드는 애플리케이션이 실제로 없으면 작동할 수 없는 것을 나타내야 한다. 선택적 필드는 기본값을 갖거나 없을 때 비활성화된 기능으로 처리되어야 한다.
또 다른 트레이드오프는 스키마 라이브러리 크기다. Zod는 번들에 약 10KB를 추가한다. 제약이 심한 환경이라면 비슷한 API를 더 작은 풋프린트로 제공하는 Valibot이나 ArkType을 고려하라. 라이브러리보다 패턴이 더 중요하다.
생성된 타입만으로는 부족할 때
일부 팀은 설정 파일에서 TypeScript 타입을 생성하고 이를 검증되었다고 부른다. 타입 정의는 런타임에 아무것도 강제하지 않는다. 타입이 생성된 후 JSON 파일의 형태가 바뀌면, 해당 파일이 임포트되어 컴파일 중에 검사되지 않는 한 TypeScript는 이를 잡아내지 못한다.
런타임 파싱이 그 간극을 메운다. 스키마는 검증기이자 타입의 단일 진실 공간이다. 동기화가 어긋나는 별도의 타입 정의는 없다.
경계를 명시한 맥락에서 종합하기
단일 맥락을 위한 완전한 패턴은 다음과 같다.
// contexts/billing/config.ts
import { z } from "zod";
const RawBillingConfig = z.object({
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string(),
INVOICE_GRACE_PERIOD_DAYS: z
.string()
.transform(Number)
.pipe(z.number().min(0)),
});
export type BillingConfig = z.infer<typeof RawBillingConfig>;
export function loadBillingConfig(): BillingConfig {
const raw = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
INVOICE_GRACE_PERIOD_DAYS: process.env.INVOICE_GRACE_PERIOD_DAYS,
};
const result = RawBillingConfig.safeParse(raw);
if (!result.success) {
const issues = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Billing config invalid:\n${issues}`);
}
return result.data;
}
엔트리 포인트에서 loadBillingConfig()를 임포트하고 한 번 호출한 뒤, 그 결과를 해당 맥락의 초기화에 전달한다. 이 맥락 외부의 모듈은 직접 process.env를 읽어서는 안 된다.
다음에 할 일
설정 파일 외부에서 직접 process.env를 읽는 부분을 코드베이스에서 감사하라. 각각은 적절한 배포를 기다리며 발동될 잠재적 런타임 장애다.
이를 맥락별 스키마로 대체하라. 라이브러리를 고르고 형태를 정의한 뒤, 시작 시 즉시 실패시켜라. 누락된 환경 변수가 프로덕션에서 던지는 대신 CI에서 깔끔하게 애플리케이션을 종료시키는 첫 순간, 경계가 작동하고 있음을 알게 될 것이다.