환경 변수 타이핑 — process.env 안전하게 다루기
process.env의 string | undefined 함정을 스키마 검증으로 막는 법을 정리합니다. 기본 타입의 한계, ProcessEnv 전역 보강의 위험, Zod 기반 파싱과 형 변환, 앱 시작 시 검증으로 fail-fast 하는 패턴까지 다룹니다.
지난 글에서 의존성을 타입 안전하게 주입하는 법을 봤다. 그 의존성들이 설정값을 어디서 받는지 따라가면 대개 환경 변수에 닿는다. process.env는 애플리케이션 경계 바깥에서 들어오는 신뢰할 수 없는 입력인데, TypeScript에서 가장 흔히 방치되는 타입 구멍이기도 하다. 이번 글은 process.env의 함정을 짚고, 시작 시점에 한 번 검증해 이후로는 안전하게 쓰는 패턴을 정리한다.
process.env의 진짜 타입
@types/node가 선언하는 process.env의 타입은 Record<string, string | undefined>다. 즉 모든 키가 undefined일 수 있고, 모든 값이 문자열이다.
const port = process.env.PORT; // string | undefined
const n = Number(port); // undefined면 NaN
listen(n); // NaN으로 조용히 망가진다
const mode = process.env.NODE_ENV; // string | undefined — 오타도 못 잡음
두 가지 문제가 보인다. 첫째, 값이 없을 수 있는데(undefined) 그걸 처리하지 않으면 Number(undefined)가 NaN이 되어 조용히 번진다. 둘째, 모든 값이 string이라 숫자·불리언으로 바로 쓸 수 없고 매번 변환해야 한다.
전역 보강의 유혹과 함정
흔히 ProcessEnv 인터페이스를 전역 보강해 키를 선언하는 방법을 쓴다. 자동완성은 되지만, 위험한 거짓말이다.
declare global {
namespace NodeJS {
interface ProcessEnv {
PORT: string; // undefined를 숨겼지만...
NODE_ENV: "development" | "production";
}
}
}
const port = process.env.PORT; // 타입은 string 이지만 런타임엔 여전히 undefined 가능
이 선언은 PORT가 항상 string이라고 주장하지만, 런타임에서 검증하지 않으므로 실제로 환경 변수가 없으면 여전히 undefined다. 타입과 현실이 어긋나는 전형적인 거짓 단언이다. 타입만 칠해서는 안 되고, 런타임 검증이 함께 가야 한다.
스키마로 검증하며 변환하기
견고한 해법은 앱 시작 시 스키마로 process.env를 검증하고, 그 결과를 단일 객체로 노출하는 것이다. Zod의 coerce를 쓰면 문자열을 숫자·불리언으로 변환까지 한 번에 처리한다.
import { z } from "zod";
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]),
DATABASE_URL: z.string().url(),
ENABLE_CACHE: z.coerce.boolean().default(false),
});
export const env = EnvSchema.parse(process.env);
// env.PORT: number, env.NODE_ENV: "development" | ..., 모두 검증·변환 완료
env.PORT는 이제 number이고, NODE_ENV는 정확히 세 리터럴 중 하나로 좁혀진다. z.infer<typeof EnvSchema>로 타입을 자동 도출할 수도 있으니, 스키마가 타입과 검증의 단일 진실 공급원이 된다.
시작 시점에 fail-fast
이 패턴의 가장 큰 이점은 잘못된 설정이 즉시 드러난다는 것이다. parse는 검증 실패 시 던지므로, 앱이 부팅하다가 바로 멈춘다.
// src/env.ts — 진입점에서 가장 먼저 import 한다
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error("❌ 잘못된 환경 변수:");
console.error(result.error.flatten().fieldErrors);
process.exit(1); // 부팅 중단 — 운영 중 터지는 것보다 낫다
}
export const env = result.data;
safeParse로 받아 실패 시 어떤 변수가 왜 틀렸는지 출력하고 종료한다. DATABASE_URL이 빠진 채 배포됐다면, 트래픽을 받기 전에 부팅 단계에서 멈춘다. 운영 중 첫 요청에서 undefined로 터지는 것보다 훨씬 안전하다.
사용처에서는 env 객체만 본다
검증을 통과한 뒤로는 코드 어디서도 process.env를 직접 만지지 않는다. 오직 타입이 보장된 env 객체만 import한다.
import { env } from "./env";
server.listen(env.PORT); // number, 보장됨
if (env.NODE_ENV === "production") enableMetrics();
const db = connect(env.DATABASE_URL); // 검증된 URL
process.env라는 신뢰할 수 없는 입구를 한 곳으로 모으고, 나머지 코드는 깨끗한 타입만 소비한다.
정리하면, 환경 변수 안전성의 핵심은 ① string | undefined라는 현실을 인정하고 ② 타입만 칠하는 전역 보강에 기대지 말며 ③ 시작 시점에 스키마로 검증·변환해 ④ 실패하면 즉시 멈추는 것이다. 이는 앞서 본 “외부 입력은 검증 전까지 신뢰하지 않는다”는 원칙의 또 다른 적용이다. 다음 글부터는 Node.js 자체의 타입, 즉 @types/node와 내장 모듈 타이핑으로 들어간다.
지난 글: 의존성 주입 타이핑 — 토큰과 컨테이너 설계
다음 글: Node.js 코어 타이핑 — @types/node와 내장 모듈
읽어주셔서 감사합니다. 😊