Zod를 활용한 환경변수 시스템 구현하기
뻔한 접근에서 벗어나서, 우리팀 맞춤형으로 환경변수 시스템을 구축한 방법을 공유합니다.
시작은…
큰 조직에서는 항상 마주치는 문제입니다. 한번은 꼭 사고가 일어납니다. 사고 수습후에 어떻게 환경변수 배포 사고를 막을까? 에서 시작합니다.
process.env.NEXT_PUBLIC_ENV // string | undefined ???
- 정확히 무슨값이 들어가는지 알아 내려면 코드베이스를 훑어야합니다.
NEXT_PUBLIC_ENV가 빌드타임에 엉뚱한 값이 들어가면 반드시 사고가 나고, 어떤값인지 모르는 상태에서NEXT_PUBLIC_ENV를 사용하면 사고로 이어집니다.
첫 설계
import { z } from 'zod';
const clientEnvSchema = z.object({
NEXT_PUBLIC_ENV: z.union([
z.literal('develop'),
z.literal('staging'),
z.literal('production'),
])
// ...etc
})
export const env = clientEnvSchema.parse(process.env)
- 이러면
NEXT_PUBLIC_ENV에 정확히 어떤 값이 들어가는지, 직관적으로 알아낼수있습니다. clientEnvSchema가 zod object로 strict하게 명세되어있으므로, IDE 도움을 받을수있습니다.- 해당 모듈이 사용되는 순간 process.env 에 대한 zod 스키마 검증을
parse로 수행하므로, 만약 잘못된 환경변수가 빌드타임에 삽입되는경우 빌드타임에 에러를 내고, 빌드를 중지시킵니다. 하지만 저 코드는 절대 실전에서 사용할수없습니다. 다음과같이 수정해야 사용할 수 있습니다.
import { z } from 'zod';
const clientEnvSchema = z.object({
NEXT_PUBLIC_ENV: z.union([
z.literal('develop'),
z.literal('staging'),
z.literal('production'),
])
// ...etc
})
// client측에서는 process.env를 못 읽습니다.
const runtimeEnv = {
NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV,
};
// export const env = clientEnvSchema.parse(process.env)
export const env = clientEnvSchema.parse(runtimeEnv)
브라우저 런타임에서는 process 에 접근할 수 없기때문에, 빌드타임에 nextjs 번들러의 특성을 활용해서 runtimeEnv 에 원시 process.env.NEXT_PUBLIC_ENV 를 명시해야합니다. 그러면 nextjs 번들러가 이렇게 이해합니다.
// NEX_PUBLIC_ENV=production next build
import { z } from 'zod';
const clientEnvSchema = z.object({
NEXT_PUBLIC_ENV: z.union([
z.literal('develop'),
z.literal('staging'),
z.literal('production'),
])
})
const runtimeEnv = {
NEXT_PUBLIC_ENV: 'production',
};
export const env = clientEnvSchema.parse(runtimeEnv)
즉, nextjs 번들러가 process.env.NEXT_PUBLIC_* 을 정적변환한다는걸 이용해서, nextjs 번들러가 이해한 값들을 runtimeEnv에 저장하는 방식입니다. 번들에 환경변수 컴파일 결과가 남게되고, 컴파일 결과에 대해서 zod schema validation을 수행합니다.
문제의식
- 번들러는
process.env.NODE_ENV라던가, nextjs 인경우process.env.NEXT_PUBLIC_*에 대한 정적변환을 수행하면서 dead code elimination을 수행하는데,env는 설계상 런타임 Validation 이므로, dead code elimination이 안됩니다. - 즉, 원래 번들러가 의도한 환경변수 시스템에 역행하는 시스템입니다. zod validation과 좋은 dx를 얻었지만, 죽은코드가 제거되지않고 어딘가에
runtimeEnv모듈이 남아있어야 동작하므로, 번들러 입장에서는 원래 컴파일 하지않아도되는 코드를 남겨야하는 시스템입니다.
두번째 설계
import { z } from 'zod';
const clientEnvSchema = z.object({
NEXT_PUBLIC_ENV: z.union([
z.literal('develop'),
z.literal('staging'),
z.literal('production'),
])
});
const cache = new Map();
function env(this, key) {
if (this == null || this === globalThis) {
throw new Error(`env 는 'with { type: 'macro' }' 와 함께 import 해야합니다.`)
}
if (cache.has(key)) {
return cache.get(key);
}
const fragment = clientEnvSchema.shape[key];
if (!fragment) {
throw new Error(` 는 정의되지않은 env 입니다.`)
}
const value = process.env[key];
const result = fragment.safeParse(value);
if (!result.success) {
throw new Error(`올바른 환경변수 타입이 아닙니다. "" = "" : `)
}
cache.set(key, result.data)
return result.data
}
매크로 시스템을 도입했습니다. 물론 저 함수를 바로 사용할 수 는없고, 번들러에 맞는 플러그인 이 필요합니다.
import { env } from '@foundation/clientEnv' with { type: 'macro' }
if (env('NEXT_PUBLIC_ENV') === 'staging') {
import('./HeavyComponent')
}
// staging이 아닌경우
if ('production' === 'staging') {
// 죽은 코드가 되어서 production 인경우 minify 단계에서 없어집니다.
import('./HeavyComponent')
}
- 위와같이 dead code elimination을 수행할수있게됩니다. 번들러가 해주던 정적변환에 가장 가깝게 구현했기때문입니다.
runtimeEnv가 더이상 필요없습니다.env는 빌드타임에 실행되기때문입니다. 즉, 런타임 오버헤드가 사라집니다.- 코드 컴파일 단계에서 에러를 더 빨리잡아냅니다.
한계
매크로 시스템과 Zod를 결합하여 번들러의 Dead Code Elimination 최적화를 살리면서도, 컴파일 타임에 환경변수 사고를 막는 꽤 Solid한 시스템을 구축 할 수 있었습니다. 그러나…
- turbopack 사용하는경우 사용할 수 없을겁니다. webpack, vite같은 번들러를 사용하는 프레임워크가 강제됩니다.
- 정적 코드의 특성상 동적으로
key를 전달할 수 없습니다.