Zod를 활용한 환경변수 시스템 구현하기

뻔한 접근에서 벗어나서, 우리팀 맞춤형으로 환경변수 시스템을 구축한 방법을 공유합니다.

시작은…

큰 조직에서는 항상 마주치는 문제입니다. 한번은 꼭 사고가 일어납니다. 사고 수습후에 어떻게 환경변수 배포 사고를 막을까? 에서 시작합니다.

process.env.NEXT_PUBLIC_ENV // string | undefined ???
  1. 정확히 무슨값이 들어가는지 알아 내려면 코드베이스를 훑어야합니다.
  2. 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)
  1. 이러면 NEXT_PUBLIC_ENV 에 정확히 어떤 값이 들어가는지, 직관적으로 알아낼수있습니다.
  2. clientEnvSchema 가 zod object로 strict하게 명세되어있으므로, IDE 도움을 받을수있습니다.
  3. 해당 모듈이 사용되는 순간 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을 수행합니다.

문제의식

  1. 번들러는 process.env.NODE_ENV 라던가, nextjs 인경우 process.env.NEXT_PUBLIC_* 에 대한 정적변환을 수행하면서 dead code elimination을 수행하는데, env 는 설계상 런타임 Validation 이므로, dead code elimination이 안됩니다.
  2. 즉, 원래 번들러가 의도한 환경변수 시스템에 역행하는 시스템입니다. 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(`${key} 는 정의되지않은 env 입니다.`)
	}
	const value = process.env[key];
	const result = fragment.safeParse(value);
	if (!result.success) {
		throw new Error(`올바른 환경변수 타입이 아닙니다. "${key}" = "${value}" : ${result.error.message}`)
	}
	
	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')
}
  1. 위와같이 dead code elimination을 수행할수있게됩니다. 번들러가 해주던 정적변환에 가장 가깝게 구현했기때문입니다.
  2. runtimeEnv 가 더이상 필요없습니다. env 는 빌드타임에 실행되기때문입니다. 즉, 런타임 오버헤드가 사라집니다.
  3. 코드 컴파일 단계에서 에러를 더 빨리잡아냅니다.

한계

매크로 시스템과 Zod를 결합하여 번들러의 Dead Code Elimination 최적화를 살리면서도, 컴파일 타임에 환경변수 사고를 막는 꽤 Solid한 시스템을 구축 할 수 있었습니다. 그러나…

  1. turbopack 사용하는경우 사용할 수 없을겁니다. webpack, vite같은 번들러를 사용하는 프레임워크가 강제됩니다.
  2. 정적 코드의 특성상 동적으로 key 를 전달할 수 없습니다.

Reference