staging 환경은 작동한다. production 환경은 작동하지 않는다. 두 환경의 .env 파일 간 diff는 400줄이며, 그 중 절반은 아무도 더 이상 믿지 않는 주석이다. 누군가 지난달 staging에 FEATURE_X_ENABLED=true를 추가했다. production에는 아무도 추가하지 않았다. 애플리케이션은 어쨌든 시작되었고, 하드코딩된 기본값을 사용했으며, 이제 기능 플래그가 동기화되지 않는다.
이것이 환경 간 설정의 표준 상태다. 이는 비밀 관리 문제가 아니다. Terraform이 부족한 것도 아니다. 이는 언어 문제다. 구조와 환경, 의존성의 개념이 없는 형식에서 구조화되고 환경에 의존하는 설정을 표현하고 있다.
플랫 키-값 파일은 규모가 커지면 부패한다
환경 변수와 플랫 YAML 파일은 같은 결함을 공유한다: 설정을 구별되지 않는 문자열 더미로 취급한다. 모든 환경에서 동일해야 하는 값과, 의도적으로 다른 값과, 실수로 누락된 값을 구분할 수 없다.
전형적인 설정에서 staging.env와 production.env는 서로의 복사본으로 시작한다. 시간이 지나면서 분기한다. 팀원이 기능을 테스트하기 위해 staging에 변수를 추가한다. 다른 팀원이 배포가 긴급했기 때문에 production의 키 이름을 변경하지만 staging은 변경하지 않는다. 세 번째 팀원은 변수가 한 파일에서 누락되어 코드에서 기본값을 가정하지만, 그 기본값은 다른 환경에서는 틀리다.
부패는 인시던트를 일으킬 때까지 보이지 않는다. 그때쯤이면 .env 파일은 쓰기 전용 아티팩트가 되어 있다. 무언가를 망가뜨릴까 봐 두려워 정리를 하지 못한다. 혼란은 가중된다.
왜 바운디드 컨텍스트에 자체 설정 언어가 필요한가
도메인 주도 설계(Domain-Driven Design)는 시스템을 바운디드 컨텍스트(bounded context)로 나눈다. 각각은 자체 모델과 언어를 갖는다. 설정도 같은 경계를 따라야 한다. 결제 서비스는 STRIPE_WEBHOOK_SECRET과 INVOICE_GRACE_PERIOD_DAYS를 신경 쓴다. 알림 서비스는 SNS_TOPIC_ARN과 RATE_LIMIT_PER_MINUTE를 신경 쓴다. 이들의 설정 형태는 공통점이 없으므로 설정 파일을 공유해서는 안 된다.
대부분의 팀이 놓치는 통찰: 설정은 값만이 아니다. 그것은 스키마와, 기본값 집합과, 환경별 재정의 값 집합이다. 이 세 가지를 단일 플랫 파일로 취급하면, 그 어떤 것에 대해서도 추론할 능력을 잃는다.
바운디드 컨텍스트별 설정 DSL은 구조를 명시적으로 만든다. 공유 기본값을 환경 오버레이에서 분리하고, 애플리케이션이 시작되기 전에 병합 결과를 검증한다.
TypeScript에서 작동하는 설정 DSL
다음은 컨텍스트별로 설정을 정의하는 작은 DSL이다. 검증에는 Zod를 사용하고, 환경 오버레이에는 플레인 객체를 사용한다. 이 패턴은 Python, Rust, Go에서도 같다. 달라지는 것은 검증 라이브러리뿐이다.
먼저 DSL의 메커니즘:
import { z } from "zod";
type ConfigDef<S extends z.ZodTypeAny> = {
schema: S;
base: z.infer<S>;
environments: Record<string, Partial<z.infer<S>>>;
};
function defineConfig<S extends z.ZodTypeAny>(def: ConfigDef<S>) {
return (env: string): z.infer<S> => {
const overlay = def.environments[env] ?? {};
const merged = { ...def.base, ...overlay };
return def.schema.parse(merged);
};
}
defineConfig는 스키마, 기본 설정 객체, 환경 오버레이의 맵을 받는다. 환경 이름을 받아 기본값을 오버레이와 병합하고 결과를 검증하는 함수를 반환한다. 필수 필드가 누락되거나 타입이 틀리면, 서버가 시작되기 전에 파싱이 예외를 던진다.
이제 바운디드 컨텍스트가 이를 사용한다:
// contexts/payment/config.ts
const loadPaymentConfig = defineConfig({
schema: z.object({
stripeSecretKey: z.string().startsWith("sk_"),
webhookEndpoint: z.string().url(),
retryAttempts: z.number().min(1).max(10),
logLevel: z.enum(["debug", "info", "warn", "error"]),
}),
base: {
retryAttempts: 3,
logLevel: "info",
},
environments: {
development: {
stripeSecretKey: "sk_test_dummy",
webhookEndpoint: "http://localhost:3000/webhook",
logLevel: "debug",
},
staging: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
webhookEndpoint: "https://staging.example.com/webhook",
},
production: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
webhookEndpoint: "https://api.example.com/webhook",
retryAttempts: 5,
},
},
});
const config = loadPaymentConfig(process.env.APP_ENV ?? "development");
여기서 무슨 일이 일어나는지 주목하라. retryAttempts는 production을 제외한 모든 곳에서 기본값이 3이다. production에서는 명시적으로 5로 재정의된다. logLevel은 development에서는 "debug"이고, 그 외에는 "info"이다. 기본값이 "info"이고 development만 재정의하기 때문이다. 공유 값의 중복은 없다. 어떤 파일이 정규 기본값을 갖는지 추측할 필요가 없다.
누군가 staging에 STRIPE_SECRET_KEY를 설정하는 것을 잊으면, 애플리케이션은 시작 시 명확한 오류와 함께 종료된다. 장애는 CI에서 발생하고, production에서는 발생하지 않는다.
이것이 설정 드리프트를 멈추는 방법
드리프트는 두 환경이 실수로 분기할 때 발생한다. DSL은 세 가지 방법으로 이를 방지한다.
첫째, 공유 값은 한 곳, base 객체에 존재한다. 기본 재시도 정책을 변경하면 한 줄을 변경하면 된다. 명시적인 오버레이가 없는 한 모든 환경이 자동으로 그것을 받는다.
둘째, 환경 오버레이는 완전한 객체이지, 파일 간 행 단위 차이가 아니다. 하나의 객체만 보면 기본값과 다른 모든 값을 볼 수 있다. 두 개의 .env 파일을 diff하고 어떤 행이 중요한지 추측할 필요가 없다.
셋째, 스키마가 완전성을 강제한다. 스키마에 새로운 필수 필드를 추가하면, 그것을 제공하지 않는 모든 환경 객체는 검증에 실패한다. production에 필드를 추가하고 staging을 잊을 수는 없다. 컴파일러와 검증기가 상기시켜 줄 것이다.
트레이드오프: 명시적인 구조에는 명시적인 수고가 따른다
이 패턴은 공짜가 아니다. 사전 설계가 필요하다. 누군가 스키마를 정의하고, 어떤 값이 기본값이고 어떤 값이 오버레이인지 결정하며, DSL 메커니즘을 유지보수해야 한다.
팀이 작고 설정이 열 개 변수라면 .env 파일로 충분하다. 스키마와 병합 계층의 오버헤드는 드리프트의 고통을 느끼기 전까지는 값지 않다.
또 다른 비용은 과도한 추상화에 대한 유혹이다. DSL은 조직의 모든 서비스를 아우르는 범용 설정 프레임워크로 성장할 수 있다. 이에 저항하라. 이 패턴의 전체 요점은 설정을 각 바운디드 컨텍스트에 국소화하는 것이다. 결제 DSL과 알림 DSL이 같은 스키마로 강제된다면, 추가 단계를 거쳐 모놀리식 .env 파일을 재창조한 것이다.
비밀도 고려 사항이다. 예제는 process.env에서 STRIPE_SECRET_KEY를 읽지만, 금고(vault)에서 가져올 수도 있다. DSL은 값이 어디서 왔는지 신경 쓰지 않는다. 애플리케이션이 그것들을 사용하기 전에 스키마와 일치하는지만 신경 쓴다.
팀이 이것을 도입할 때 틀리는 것
가장 흔한 실수는 스키마를 선택적 문서로 취급하는 것이다. 팀은 TypeScript 인터페이스를 정의하지만 실행 시에는 절대 검증하지 않는다. 인터페이스는 약속이지, 검사가 아니다. staging 환경 변수가 누락되어도 TypeScript는 이를 잡지 못한다. process.env는 실행 시에 채워지기 때문이다.
검증은 어떤 요청도 처리되기 전의 시작 시에 일어나야 한다. 스키마는 타입과 값 모두에 대한 신뢰할 수 있는 정보 출처(source of truth)이다.
두 번째 실수는 환경을 하나의 설정 정의에 너무 일찍 병합하는 것이다. 하나의 바운디드 컨텍스트부터 시작하라. 그 설정을 스키마와 오버레이로 추출하라. 이 패턴이 스스로를 증명하도록 내버려 두라.
종합: 3단계 마이그레이션
.env 파일 더미에서 시작하는 경우, 빅뱅 재작성을 요구하지 않는 경로가 다음과 같다.
1단계: 설정 관련 인시던트가 가장 많은 바운디드 컨텍스트를 고른다. 그곳이 고통이 가장 크고 팀이 가장 빨리 이점을 느낄 곳이다.
2단계: 해당 컨텍스트가 읽는 모든 설정 값을 목록화한다. 비밀, 환경별 비비밀, 공유 기본값으로 분류한다. 형태와 제약을 포착하는 Zod 스키마를 작성한다.
3단계: 해당 컨텍스트의 직접적인 process.env 읽기를 스키마와 오버레이를 사용하는 단일 loadConfig() 호출로 대체한다. 한 환경에 배포하고, CI에서 누락된 값을 잡아내는 것을 지켜본 후, 수정한다. 검증은 즉시 그 가치를 증명할 것이다.
다음 컨텍스트에 대해 반복한다. 3~4개 컨텍스트 후, 이 패턴은 자체 지속 가능해진다. 팀은 지시받지 않아도 이를 채택한다. 인시던트를 예방하는 것을 보았기 때문이다.
FAQ
설정 DSL이란 무엇인가?
설정 DSL은 바운디드 컨텍스트 내에서 설정을 정의하기 위한 작은 도메인 특화 언어이다. 일반적으로 스키마, 기본값, 환경 오버레이를 포함한다. 설정을 명시적으로 만들고 실행 전에 검증하기에 충분한 구조이다.
왜 중앙집중식 설정 서비스를 사용하지 않는가?
중앙집중식 서비스는 기능 플래그와 같은 동적 값에 유용하다. API 엔드포인트나 재시도 정책과 같은 정적 설정은 실행 시에 거의 변경되지 않는다. 원격 서비스에서 가져오면 시작에 네트워크 의존성이 추가된다. DSL 패턴은 정적 설정을 로컬로 처리하고 동적 값은 서비스에 맡긴다.
비밀은 어떻게 다루는가?
비밀은 환경 변수나 금고를 통해 시스템에 들어가야 하지만, 여전히 스키마 검증기를 통과해야 한다. 스키마에서 필수 문자열로 정의하라. 환경 오버레이 객체 내에서 process.env나 금고 클라이언트로부터 읽어라. 비밀이 누락되면 검증이 실패하고 애플리케이션이 깔끔하게 종료된다.
TypeScript 없이도 사용할 수 있는가?
예. 이 패턴은 검증 라이브러리를 갖춘 모든 언어에서 작동한다. Python에는 Pydantic이 있다. Rust에는 serde와 validator가 있다. Go에는 go-playground/validator가 있다. DSL 계층은 기본 객체를 오버레이와 병합하고 결과를 검증하는 함수에 불과하다.
이것은 언제 과도한가?
애플리케이션의 설정 값이 열두 개 미만이고 환경이 하나라면, .env 파일이 더 간단하다. 여러 환경, 여러 팀, 또는 설정 관련 인시던트의 이력이 있을 때 DSL 패턴을 채택하라.
다음에 할 일
현재 .env 파일이나 설정 YAML을 감사하여 모든 환경에서 동일한 값을 찾아라. 그것들이 기본 기본값이다. 다른 모든 것은 오버레이다. 하나의 바운디드 컨텍스트를 고르고, 그 스키마를 정의하고, 그 값들을 차이를 명시적으로 만드는 구조로 옮겨라. CI가 누락된 staging 변수를 production에 도달하기 전에 잡아낸 첫 번째 순간에, 경계는 작동하고 있는 것이다.