
프로그래밍을 처음 배울 때 환경변수는 보통 코드 밖에 비밀번호나 API 키를 저장하는 변수라고 배운다.
const databaseUrl = process.env.DATABASE_URL;
애플리케이션은 DATABASE_URL이라는 이름으로 외부에서 전달된 데이터베이스 주소를 읽는다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 값을 코드 밖으로 옮기는 것보다 더 많은 판단이 필요하다.
환경변수는 비밀값을 저장하는 변수가 아니다.
환경변수는 같은 코드를 서로 다른 실행 환경에서 사용할 수 있도록 코드와 배포별 설정을 분리하는 경계다.
크리스가 온라인 강의 서비스를 개발한다고 생각해 보자.
서비스는 강의 정보 데이터베이스, 동영상 저장소, 이메일 발송 시스템에 연결된다. 처음에는 개발 환경의 주소를 코드에 직접 작성할 수 있다.
const databaseUrl =
"postgres://localhost:5432/course_dev";
const courseAssetBaseUrl =
"http://localhost:4000/videos";
이 코드는 크리스의 컴퓨터에서는 동작한다. 그러나 테스트 환경과 운영 환경에서는 다른 데이터베이스와 저장소를 사용해야 한다.
환경마다 조건문을 추가하는 방식도 생각할 수 있다.
const databaseUrl =
process.env.NODE_ENV === "production"
? "postgres://production-db/course"
: "postgres://localhost:5432/course_dev";
이제 운영 인프라 주소가 코드 안에 들어왔다. 새로운 스테이징 환경이 생기면 조건을 더 추가해야 하고, 연결 주소를 변경하려면 코드를 다시 수정하고 배포해야 한다.
실행 환경에 따라 달라지는 값은 코드 바깥에서 전달하는 편이 낫다.
const databaseUrl = process.env.DATABASE_URL;
const courseAssetBaseUrl =
process.env.COURSE_ASSET_BASE_URL;
같은 애플리케이션 코드가 개발 환경에서는 개발용 데이터베이스를, 운영 환경에서는 운영용 데이터베이스를 사용한다. 달라지는 것은 코드가 아니라 배포 시 전달되는 설정이다.
같은 애플리케이션 코드
+
개발 환경의 설정 → 개발 서비스
+
운영 환경의 설정 → 운영 서비스
코드와 환경 설정을 분리하면 환경을 추가하거나 연결 대상을 변경할 때 비즈니스 로직까지 수정하지 않아도 된다.
환경변수는 애플리케이션 안에서 직접 선언한 값이 아니다. 배포 시스템, 운영체제, 컨테이너 설정, CI/CD 파이프라인 등 프로세스 바깥에서 들어온다.
따라서 환경변수도 외부 입력이며 검증 전까지 신뢰할 수 없다.
다음 코드는 타입이 맞는 것처럼 보이지만 실제로는 그렇지 않다.
const port = process.env.PORT;
const trialEnabled =
Boolean(process.env.ENABLE_TRIAL_COURSE);
Node.js에서 환경변수 값은 문자열 또는 undefined로 다뤄진다. PORT=4000은 숫자 4000이 아니라 문자열 "4000"이다.
Boolean("false")도 true가 된다. 비어 있지 않은 문자열이기 때문이다.
애플리케이션이 시작될 때 형식과 필수 여부를 검증할 수 있다.
import { z } from "zod";
const EnvironmentSchema = z.object({
NODE_ENV: z.enum([
"development",
"test",
"staging",
"production",
]),
DATABASE_URL: z.string().min(1),
COURSE_ASSET_BASE_URL: z.string().url(),
EMAIL_API_KEY: z.string().min(1),
PORT: z.coerce
.number()
.int()
.min(1)
.max(65535)
.default(3000),
ENABLE_TRIAL_COURSE: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
});
const result =
EnvironmentSchema.safeParse(process.env);
if (!result.success) {
throw new Error(
`Invalid environment configuration: ${
result.error.message
}`
);
}
export const environment = result.data;
이 코드는 문자열을 애플리케이션이 사용할 타입으로 변환하고, 필수 설정이 누락되거나 허용하지 않은 값이 들어오면 시작을 중단한다.
설정 오류를 첫 번째 사용자 요청이 들어온 뒤 발견하는 것보다 애플리케이션 시작 단계에서 발견하는 편이 낫다.
모든 설정에 기본값을 제공하면 애플리케이션은 쉽게 실행된다.
const databaseUrl =
process.env.DATABASE_URL ??
"postgres://localhost:5432/course_dev";
개발 환경에서는 편리할 수 있다. 하지만 운영 배포에서 DATABASE_URL 전달을 빠뜨려도 애플리케이션이 실행될 수 있다.
운영 서버가 로컬 데이터베이스에 연결을 시도하거나, 더 위험하게는 의도하지 않은 공용 데이터베이스를 사용할 수 있다.
기본값의 적절성은 설정의 책임에 따라 달라진다.
| 설정 | 기본값 판단 |
|---|---|
| 로컬 개발 서버 포트 | 안전한 기본값을 둘 수 있다 |
| 로그 상세 수준 | 환경에 맞는 보수적 기본값을 검토할 수 있다 |
| 운영 데이터베이스 주소 | 누락 시 실행을 중단하는 편이 낫다 |
| 이메일 서비스 인증 키 | 누락 시 관련 기능 또는 실행을 명확히 실패시켜야 한다 |
| 결제·서명·암호화 키 | 임의의 기본값을 사용해서는 안 된다 |
| 선택적 실험 기능 | 비활성화 상태를 기본값으로 둘 수 있다 |
필수 인프라와 보안 설정에 조용한 기본값을 두면 배포 오류가 정상 실행처럼 보인다.
function requireEnvironmentValue(
name: string
): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}`
);
}
return value;
}
const databaseUrl =
requireEnvironmentValue("DATABASE_URL");
이 코드는 중요한 설정이 없을 때 추측하지 않는다. 애플리케이션을 명확히 실패시켜 잘못된 배포를 조기에 발견하게 한다.
환경변수는 코드 밖의 값이지만, 코드 밖에 있는 모든 값을 환경변수로 관리해야 한다는 뜻은 아니다.
온라인 강의 서비스에는 여러 종류의 설정이 존재한다.
| 값의 성격 | 적절한 관리 위치 |
|---|---|
| 배포마다 다른 데이터베이스 주소 | 환경변수 또는 배포 설정 |
| 서버가 사용하는 외부 서비스 인증 정보 | 비밀값 관리 시스템 |
| 강의 동영상 저장소 이름 | 환경변수 또는 배포 설정 |
| 무료 체험 기간 정책 | 데이터베이스 또는 운영 설정 시스템 |
| 사용자별 자막 언어 | 사용자 데이터베이스 |
| 일부 사용자에게만 공개할 기능 | 기능 플래그 시스템 |
| 강의 수료 조건 | 도메인 로직 또는 운영 정책 저장소 |
| 애플리케이션 모듈 연결 방식 | 코드 |
| 브라우저에 공개할 서비스 이름 | 공개 프론트엔드 설정 |
무료 체험 기간이 14일에서 7일로 자주 바뀌고 운영자가 직접 변경해야 한다면 환경변수는 불편한 저장 위치다.
const trialPeriodDays = Number(
process.env.TRIAL_PERIOD_DAYS
);
이 방식에서는 정책을 변경할 때 애플리케이션을 다시 시작하거나 배포해야 할 수 있다. 누가 언제 값을 바꿨는지 기록하기도 어렵다.
운영 정책으로 다루면 책임이 더 명확해진다.
const trialPolicy =
await coursePolicyRepository.getTrialPolicy();
const trialEndsAt = addDays(
enrollment.startedAt,
trialPolicy.periodDays
);
이 코드는 체험 기간을 배포 설정이 아니라 서비스가 관리하는 정책에서 읽는다. 변경 이력, 관리자 권한, 적용 시점도 함께 설계할 수 있다.
환경변수에 적합한 기준은 “변경 가능한가”가 아니다.
코드 배포와 독립적이면서도 실행 환경별로 달라지는 값인가를 먼저 물어야 한다.
API 키를 소스 코드에 직접 작성하는 것보다 환경변수로 분리하는 편이 낫다.
const emailApiKey =
process.env.EMAIL_API_KEY;
그러나 환경변수에 저장했다는 사실만으로 값이 안전해지는 것은 아니다.
환경변수는 다음 위치에서 노출될 수 있다.
다음 로그는 설정 확인에 도움이 되는 것처럼 보이지만 비밀값 전체를 남길 수 있다.
console.log("Environment:", process.env);
로그에는 데이터베이스 비밀번호, API 키, 서명 키가 포함될 수 있다. 환경 전체를 기록해서는 안 된다.
필요한 비밀값은 전용 시스템에서 관리하고 최소 권한으로 애플리케이션에 전달할 수 있다.
const emailApiKey = await secretStore.get(
"course-service/email-api-key"
);
비밀값 관리 시스템은 저장 위치만 제공하는 것이 아니다. 시스템에 따라 접근 제어, 감사 기록, 버전 관리, 교체, 만료와 폐기 과정을 지원할 수 있다.
환경변수는 비밀값의 전달 수단이 될 수 있지만 완전한 비밀값 관리 체계는 아니다.
프론트엔드 코드에서도 빌드 도구를 통해 환경 설정을 참조할 수 있다.
const publicApiBaseUrl =
import.meta.env.VITE_API_BASE_URL;
이 값이 브라우저용 번들에 포함되면 사용자가 내려받은 JavaScript에서 확인할 수 있다. 변수 이름에 API_KEY나 SECRET을 붙여도 비밀이 되지 않는다.
프론트엔드에 공개할 수 있는 설정과 서버에만 존재해야 하는 설정을 구분해야 한다.
type PublicCourseConfig = {
apiBaseUrl: string;
supportEmail: string;
};
type ServerCourseConfig = {
databaseUrl: string;
emailApiKey: string;
sessionSigningKey: string;
};
PublicCourseConfig는 사용자가 확인해도 되는 값이다. ServerCourseConfig는 브라우저 번들, HTML, 공개 API 응답에 포함되면 안 된다.
다음과 같은 구조는 피해야 한다.
export const config = {
publicApiBaseUrl:
process.env.PUBLIC_API_BASE_URL,
emailApiKey:
process.env.PUBLIC_EMAIL_API_KEY,
};
PUBLIC_ 같은 접두사는 값을 보호하지 않는다. 일부 빌드 시스템에서는 오히려 해당 값을 클라이언트 번들에 포함하라는 의미로 사용된다.
비밀값이 필요한 작업은 서버에서 수행하고 프론트엔드는 필요한 결과만 받아야 한다.
서버 애플리케이션은 보통 프로세스가 시작될 때 환경변수를 읽는다.
프론트엔드 빌드는 다른 생명주기를 가질 수 있다. 빌드 도구가 환경변수 값을 JavaScript 파일에 삽입하면 그 값은 빌드 결과물에 고정된다.
const apiBaseUrl =
import.meta.env.VITE_API_BASE_URL;
이 코드가 빌드 시 "https://staging-api.example.com"으로 치환되었다면, 같은 파일을 운영 환경으로 옮겨도 운영 API 주소로 자동 변경되지 않는다.
따라서 배포 방식을 먼저 정해야 한다.
하나의 결과물을 여러 환경에서 사용해야 한다면 공개 런타임 설정을 따로 제공할 수 있다.
type RuntimePublicConfig = {
apiBaseUrl: string;
};
const runtimeConfig: RuntimePublicConfig =
await fetch("/runtime-config.json")
.then((response) => response.json());
브라우저는 실행 시점에 현재 환경의 공개 설정을 불러온다. 이 파일에는 누구나 볼 수 있는 값만 포함해야 한다.
설정의 저장 위치뿐 아니라 값을 읽는 시점도 설계해야 한다.
.env 파일은 로컬 개발 도구이지 비밀 저장소가 아니다크리스는 로컬 개발을 위해 .env 파일을 사용할 수 있다.
DATABASE_URL=postgres://localhost:5432/course_dev
COURSE_ASSET_BASE_URL=http://localhost:4000/videos
ENABLE_TRIAL_COURSE=true
이 파일은 반복해서 환경변수를 입력하지 않아도 되게 해준다. 그러나 .env라는 이름이 파일을 암호화하거나 접근을 제한하지는 않는다.
실제 비밀값이 들어 있는 파일은 버전 관리 대상에서 제외해야 한다.
.env
.env.local
.env.*.local
다만 .gitignore는 앞으로의 추가를 막을 뿐 이미 커밋된 비밀값을 없애지 않는다. 비밀값이 저장소에 들어갔다면 기록에서 파일을 지우는 것만으로 끝내지 말고 해당 값을 폐기하고 새 값으로 교체해야 한다.
필요한 설정 이름은 예제 파일로 공유할 수 있다.
# .env.example
DATABASE_URL=
COURSE_ASSET_BASE_URL=
EMAIL_API_KEY=
ENABLE_TRIAL_COURSE=false
예제 파일은 필요한 키와 안전한 예시만 설명한다. 실제 인증 정보는 포함하지 않는다.
.env 파일은 로컬 환경변수를 편리하게 전달하는 형식이다. 운영 비밀값의 Source of Truth로 취급해서는 안 된다.
애플리케이션 곳곳에서 process.env를 직접 읽으면 어떤 설정이 필요한지 파악하기 어렵다.
// course-service.ts
const trialEnabled =
process.env.ENABLE_TRIAL_COURSE === "true";
// email-service.ts
const emailApiKey =
process.env.EMAIL_API_KEY;
// upload-service.ts
const assetUrl =
process.env.COURSE_ASSET_BASE_URL;
각 모듈이 문자열 변환과 누락 처리를 제각각 수행하게 된다. 테스트도 실행 중인 컴퓨터의 환경에 의존할 수 있다.
시작 단계에서 한 번 읽고 검증한 설정을 명시적으로 전달할 수 있다.
export type CourseServiceConfig = {
trialEnabled: boolean;
courseAssetBaseUrl: string;
emailApiKey: string;
};
export function createCourseService(
config: CourseServiceConfig
) {
return new CourseService({
trialEnabled: config.trialEnabled,
courseAssetBaseUrl:
config.courseAssetBaseUrl,
emailApiKey: config.emailApiKey,
});
}
이제 CourseService는 환경변수의 존재를 알 필요가 없다. 이미 검증된 애플리케이션 설정만 받는다.
테스트에서도 필요한 설정을 직접 전달할 수 있다.
const courseService = createCourseService({
trialEnabled: true,
courseAssetBaseUrl:
"https://assets.test.example",
emailApiKey: "test-key",
});
테스트 결과가 개발자의 컴퓨터에 우연히 설정된 환경변수에 좌우되지 않는다.
환경변수는 프로세스 경계에서 읽고, 내부에서는 의미가 분명한 타입으로 사용하는 편이 낫다.
실행 중인 process.env는 설정의 영구적인 원본이 아닐 수 있다.
배포 설정 또는 비밀값 관리 시스템
↓
프로세스에 환경변수로 전달
↓
시작 시 검증
↓
타입이 있는 애플리케이션 설정
↓
서비스 코드가 사용
이를 흐름으로 표현하면 다음과 같다.
flowchart TD
A[배포 설정] --> C[프로세스 환경]
B[비밀값 관리 시스템] --> C
C --> D[시작 시 검증]
D --> E[타입이 있는 설정]
E --> F[서비스 코드]
각 단계의 책임은 다르다.
배포 플랫폼에서 값을 변경해도 이미 실행 중인 프로세스가 자동으로 새 값을 읽는다고 가정해서는 안 된다. 많은 환경에서는 재시작이나 재배포가 필요하다.
반대로 실행 중 즉시 바뀌어야 하는 운영 정책이라면 환경변수보다 데이터베이스나 동적 설정 시스템이 더 적합할 수 있다.
개발, 테스트, 스테이징, 운영 환경에 서로 다른 이름을 붙였더라도 같은 인증 정보와 저장소를 공유하면 실제 경계는 약하다.
온라인 강의 서비스는 환경별로 다음 자원을 분리할 수 있다.
예를 들어 스테이징 환경에서 실제 수강생에게 이메일이 발송되지 않도록 수신자를 제한할 수 있다.
const recipient =
environment.NODE_ENV === "production"
? student.email
: environment.TEST_EMAIL_RECIPIENT;
이 코드는 비운영 환경의 이메일을 지정된 테스트 주소로 보낸다. 실제 구현에서는 발송 서비스 자체도 테스트 계정이나 샌드박스로 분리하는 편이 더 안전하다.
환경 분리는 설정값의 차이뿐 아니라 장애와 실수의 영향 범위를 제한하는 설계다.
이메일 API 키를 환경변수로 전달하는 것만으로 비밀값 관리가 끝나지는 않는다.
비밀값에는 다음 과정이 존재한다.
키를 교체할 때 두 값을 잠시 함께 허용해야 하는 시스템도 있다.
type EmailCredentials = {
currentKey: string;
previousKey?: string;
};
이 타입은 교체 기간 동안 현재 키와 이전 키가 함께 존재할 수 있음을 표현한다. 실제 허용 기간과 폐기 시점은 외부 이메일 서비스의 정책에 맞춰야 한다.
환경변수는 특정 시점의 키를 프로세스에 전달할 수 있다. 하지만 누가 키를 읽었는지, 언제 교체할지, 유출된 키를 어떻게 폐기할지는 별도의 비밀값 관리 정책이 담당해야 한다.
process.env를 원본이 아니라 전달된 값으로 이해하고 있는가?"false"를 참으로 처리하거나 숫자 범위를 검사하지 않는 문제가 생긴다.
애플리케이션 시작 시 타입 변환과 검증을 수행해야 한다.
운영 데이터베이스 주소나 서명 키의 누락이 정상 실행처럼 보일 수 있다.
안전한 선택 설정과 반드시 필요한 설정을 구분해야 한다.
환경변수도 로그, 배포 화면, 진단 도구와 클라이언트 번들에서 노출될 수 있다.
비밀값에는 별도의 접근 제어와 교체·폐기 정책이 필요하다.
자주 변경되는 체험 기간이나 수료 기준을 바꿀 때마다 재배포가 필요해진다.
변경 주체와 변경 주기에 맞는 저장 위치를 선택해야 한다.
브라우저 번들에 포함된 값은 사용자에게 공개된다.
클라이언트 공개 설정과 서버 전용 비밀값을 명시적으로 나눠야 한다.
process.env를 직접 읽는다필수 설정을 한눈에 파악하기 어렵고 모듈마다 변환 규칙이 달라진다.
프로세스 시작 시 한 번 검증하고 타입이 있는 설정 객체를 전달해야 한다.
실행 중인 프로세스는 시작할 때 받은 값을 계속 사용할 수 있다.
변경 적용에 필요한 재시작과 배포 절차를 정의해야 한다.
프로그래밍을 처음 배울 때는 환경변수를 코드 밖에서 비밀값을 읽는 방법이라고 이해해도 충분하다.
const emailApiKey =
process.env.EMAIL_API_KEY;
하지만 실제 서비스에서는 값을 읽는 문법보다 설정의 책임과 생명주기를 먼저 정의해야 한다.
환경변수는 비밀값을 저장하는 변수가 아니다.
환경변수는 같은 코드를 서로 다른 실행 환경에서 사용할 수 있도록 코드와 배포별 설정을 분리하는 경계다.