Void 환경변수: env.ts로 타입 안전하게, 시크릿까지

Void 환경변수: env.ts로 타입 안전하게, 시크릿까지

Void 인증을 붙이고 나면 곧바로 BETTER_AUTH_SECRET이라는 비밀 키를 마주하게 됩니다. 세션 토큰을 서명하는 값인데, 코드에 적을 수도 없고 깃에 올릴 수도 없죠. 비단 인증만의 문제도 아닙니다. Stripe 키, 웹훅 URL, 외부 API 토큰까지, 앱을 키우다 보면 “코드 밖에서 주입해야 하는 값”이 계속 늘어나거든요.

그런데 우리가 환경변수로 늘 겪던 불편이 있습니다. process.env.PORT는 항상 문자열이라 매번 숫자로 바꿔야 하고, 키 이름에 오타가 나도 런타임에야 터지고, 어떤 값이 필수인지는 README에 적어둔 메모에 의존하죠. Void는 이걸 env.ts 파일 하나로 풀어냅니다. 타입과 검증을 입히고, 비밀값은 암호화해서 따로 관리하는 방식이에요. 이번 글에서 그 흐름을 처음부터 끝까지 따라가 보겠습니다.

2026년 9월에 나온 Void 0.20.3 기준으로 고쳐 썼습니다. 0.20에서는 .env.local, .env.production 같은 여러 파일을 쓰던 모델이 없어지고, 로컬용 .env 하나와 원격 시크릿만 남았습니다. 예전 방식으로 설정한 프로젝트라면 이 글의 “예전 설정에서 옮겨오기” 섹션을 먼저 확인하세요.

환경변수는 env.ts 한 곳에서

Void에서 환경변수의 출발점은 프로젝트 루트의 env.ts 파일 하나입니다. 여기에 스키마를 정의하면 세 가지 일이 한꺼번에 일어나요. 런타임에 값이 올바른지 검증하고, TypeScript 타입을 자동으로 입히고, 배포 직전에 필수 키가 빠지지 않았는지 확인해줍니다.

env.ts
import { defineEnv, string, number, url } from "void/env";

export default defineEnv({
  STRIPE_KEY: string(),
  PORT: number().default(3000),
  WEBHOOK_URL: url(),
  VITE_APP_TITLE: string(),
});

이렇게 선언해두면 앱 어디서든 env를 가져와 값을 읽을 수 있는데, 여기서 진가가 드러납니다.

타입이 살아 있는 접근
import { env } from "void/env";

console.log(env.STRIPE_KEY); // string으로 추론됩니다
console.log(env.PORT); // number로 추론됩니다 (문자열이 아니라!)

도입부에서 말한 그 불편이 여기서 사라집니다. number()로 선언한 키는 진짜 숫자로 파싱돼서 돌아오고, 오타로 없는 키를 읽으면 타입 에러가 나거든요. Node.js 시절 process.env나 dotenv로 하던 일을, 한 단계 위에서 타입과 검증까지 묶어 처리하는 셈입니다.

내장 검증 헬퍼는 다음과 같습니다. 각각은 .optional()과 .default(값)을 체이닝할 수 있어요.

  • string(): 문자열
  • number(): 숫자로 파싱
  • boolean(): 불리언으로 파싱
  • url(): URL 형식 검증
  • email(): 이메일 형식 검증
  • oneOf([...]): 정해진 값 중 하나인지 검증
  • json<T>(): JSON 문자열을 파싱

.default()에 넣은 값은 소스 코드에 그대로 박히는 상수입니다. 포트 번호처럼 공개돼도 괜찮은 기본값에만 쓰고, 자격 증명을 기본값으로 넣지는 마세요.

이 헬퍼로 표현하기 어려운 까다로운 규칙이 필요하면, Zod나 Valibot, ArkType 같은 Standard Schema 호환 라이브러리를 그대로 끼워 넣을 수 있습니다. 라우팅의 입력 검증과 똑같은 생태계를 환경변수에도 쓰는 거죠.

Standard Schema로 정교한 검증
import { defineEnv, string } from "void/env";
import * as v from "valibot";

export default defineEnv({
  STRIPE_KEY: string(),
  WEBHOOK_URL: v.pipe(v.string(), v.url(), v.endsWith("/webhook")),
});

클라이언트와 서버 변수 가르기

환경변수에서 가장 사고가 잦은 부분이 바로 “이 값이 브라우저까지 새어 나가는가”입니다. Stripe 비밀 키가 클라이언트 번들에 박혀 배포되는 일은 상상만 해도 아찔하죠. 😰

Void는 VITE_ 접두사로 클라이언트와 서버를 가릅니다. 이 접두사로 시작하는 키만 클라이언트 코드에 노출되고 나머지는 전부 서버 전용으로 남아요. 그래서 앞선 예제의 VITE_APP_TITLE은 브라우저에서 읽을 수 있지만 STRIPE_KEY는 그럴 수 없습니다. 0.20부터는 이 경계가 VITE_로 고정돼서, 프레임워크가 Vite의 envPrefix를 바꿔도 CLI와 빌드, 시크릿 명령이 모두 같은 기준으로 키를 분류합니다.

여기서 멈추지 않고, Void는 한 겹 더 안전망을 깝니다. 서버 전용 키를 클라이언트 모듈에서 참조하면 void:env-client-guard가 빌드 에러를 냅니다. 페이지 컴포넌트에서 env.STRIPE_KEY를 읽고 빌드해보면 이렇게 멈춰요.

bun run build
[plugin void:env-client-guard] pages/index.tsx:4:13
RolldownError: Server-only env "STRIPE_KEY" referenced from client code at pages/index.tsx:4:13.
If this should be available in the browser, rename it with the VITE_* prefix in env.ts.
If it must stay server-only, remove this reference and read the value via a server route or loader.

실수로 비밀 키를 컴포넌트에서 읽었다면, 배포는커녕 빌드 단계에서 막히는 거예요. 에러 메시지가 해결 방법(VITE_ 접두사를 붙이거나, 서버 라우트나 로더에서 읽기)까지 알려주는 점도 친절합니다.

반대로 클라이언트에 노출되는 키는 프로덕션 빌드에서 한 가지 최적화를 더 받습니다. 정적으로 읽은 값이 그대로 문자열 리터럴로 인라인되거든요. 덕분에 죽은 코드가 제거됩니다.

빌드 타임 상수 폴딩
// 작성한 소스
if (env.MODE === "production") {
  initAnalytics();
}

// 프로덕션 번들에서는 이렇게 접힙니다
initAnalytics();

env.MODE가 빌드 시점에 "production"으로 확정되니 if 조건 자체가 사라지고 본문만 남는 거죠. 이 최적화는 접두사가 맞는 키를 env.FOO나 env["FOO"]처럼 정적으로 읽었을 때만 적용됩니다.

로컬 값은 .env 하나에

그럼 실제 값은 어디에 적느냐. 로컬 개발용 값은 프로젝트 루트의 .env 파일 하나에 적습니다.

.env
STRIPE_KEY=sk_test_123
BASE_URL=https://api.example.com
WEBHOOK_URL=${BASE_URL}/webhook
VITE_APP_TITLE=My App

.env 안에서는 이미 정의한 키를 ${VAR}나 $VAR 문법으로 참조할 수 있어서, 위의 WEBHOOK_URL은 https://api.example.com/webhook로 펼쳐집니다. $ 기호를 문자 그대로 두고 싶다면 \$처럼 백슬래시로 이스케이프하면 돼요.

0.20에서 가장 크게 바뀐 부분이 여기입니다. 예전에는 Vite처럼 .env.local, .env.production, .env.production.local을 나눠 쓰고, 파일마다 배포에 포함되는지가 달랐는데요. 이제 .env는 로컬 개발 전용이라 절대 배포되지 않고, 다른 .env.* 파일은 아예 거부합니다. .env.local이 남아 있는 프로젝트에서 검사를 돌려보면 바로 멈춰요.

bunx void env check
┌  void env check
│
■  env: only .env is supported for local development. Remove .env.local and follow the env migration guide.

.env.development, .env.example, Wrangler의 .dev.vars도 마찬가지로 거부됩니다. 파일이 여러 개면 어느 값이 이기는지 우선순위를 외워야 하는데, 그 숨은 규칙 자체를 없앤 거죠. .env는 반드시 .gitignore에 넣어두세요. void init이 만드는 .gitignore에는 이미 들어 있습니다.

셸에서 넘긴 값은 .env보다 우선합니다. 예전 버전은 셸에 export한 값을 무시했는데, 0.20에서는 반대로 셸 값이 .env를 덮어써요. 그래서 mise나 direnv로 주입한 값도 이제 그대로 읽히고, 잠깐 다른 값으로 테스트하고 싶을 때도 명령 앞에 붙이기만 하면 됩니다.

WEBHOOK_URL=not-a-url bunx void env check
결과
┌  void env check
│
◇  Env validation failed ───────────────────────────────────╮
│                                                           │
│  Invalid env vars:                                        │
│    • WEBHOOK_URL: Expected a valid URL, got "<redacted>"  │
│                                                           │
├───────────────────────────────────────────────────────────╯

.env에 적힌 올바른 URL 대신 셸에서 넘긴 잘못된 값이 검사된 걸 볼 수 있습니다.

클라이언트 값은 빌드할 때 넘긴다

VITE_로 시작하는 클라이언트 값에는 규칙이 하나 더 있습니다. 프로덕션 빌드는 .env에 적힌 VITE_ 값을 읽지 않아요. 개발자 노트북의 로컬 값이 조용히 프로덕션 설정이 되는 사고를 막으려는 장치입니다. 실제로 .env에 VITE_APP_TITLE을 적어두고 빌드하면 이렇게 실패합니다.

bun run build
Error: Client env validation failed:
  • VITE_APP_TITLE is required — set it in the build shell or add a schema default

해결 방법은 에러 메시지 그대로 두 가지예요. 빌드하는 셸에서 값을 넘기거나, 스키마에 기본값을 두면 됩니다.

VITE_APP_TITLE="My App" bunx void deploy
env.ts
export default defineEnv({
  VITE_APP_TITLE: string().default("My App"),
});

클라이언트 값은 어차피 브라우저 번들에 그대로 들어가는 공개 값이라, 코드에 기본값으로 박아도 문제가 없습니다. CI에서 배포한다면 워크플로우의 env에 넣어주면 되고요.

프로덕션 서버 값은 모두 시크릿으로

그럼 프로덕션에서 쓸 서버 값은 어디에 둘까요? 0.20의 규칙은 단순합니다. VITE_ 접두사가 없는 서버 키는 모두 원격 시크릿 저장소에 암호화해서 올립니다. 예전처럼 “비밀처럼 보이는 값만 시크릿으로, 나머지는 평문 파일로” 가르지 않아요. 포트 번호든 Void 인증에서 만난 BETTER_AUTH_SECRET이든 서버 값이면 같은 경로를 탑니다.

시크릿 관리
# 값을 대화형으로 입력받아 등록
bunx void secret put STRIPE_KEY

# 이름=값 형태로 바로 등록
bunx void secret put STRIPE_KEY=sk_live_xxx

# dotenv 형식 파일의 키들을 한 번에 업로드
bunx void secret sync /secure/path/production-secrets.txt

# 등록된 시크릿 이름 확인
bunx void secret list

# 시크릿 삭제
bunx void secret delete STRIPE_KEY

secret put과 secret sync는 올리기 전에 값을 env.ts 스키마로 먼저 검증합니다. 스키마에 없는 이름이나 형식이 틀린 값, VITE_로 시작하는 클라이언트 키는 거부해요. 한 번 올린 값은 암호화돼서 이후로는 이름만 조회되고, Worker가 실행될 때 실제 값을 한 번 더 검증합니다.

secret sync에 넘길 파일은 프로덕션 서버 값만 담아서 프로젝트 밖에 따로 준비하는 걸 권합니다. .env를 그대로 올리면 로컬 개발용 테스트 키가 프로덕션으로 새어 나갈 수 있으니까요. 원격에만 있고 파일에 없는 시크릿은 지우지 않으니, 정리할 때는 secret delete를 따로 써야 합니다.

CI처럼 사람이 입력할 수 없는 환경에서는 표준 입력으로 값을 넘기는 방법이 가장 확실합니다. secret sync에는 여전히 비대화형 옵션이 따로 없어서, Void 마이그레이션에서도 이 패턴으로 키를 하나씩 등록했어요.

printf '%s' "$STRIPE_KEY" | bunx void secret put STRIPE_KEY

본인 Cloudflare 계정으로 직접 배포한다면 먼저 Cloudflare를 배포 대상으로 연결하고 같은 명령을 쓰면 됩니다. 이 경우 값은 Worker의 암호화된 시크릿으로 저장돼요.

bunx void connect --platform cloudflare
bunx void secret put STRIPE_KEY
bunx void deploy

Void는 env.ts에 선언된 서버 키가 wrangler.jsonc의 평문 vars로 들어가 있으면 배포를 거부합니다. 시크릿을 평문 설정 파일에 적어 커밋하는 실수까지 막아주는 셈이죠.

검증은 언제 어떻게 일어나나

타입 안전한 환경변수의 진짜 가치는 “틀린 값을 일찍 잡아준다”는 데 있습니다. Void는 단계마다 다른 강도로 검증을 겁니다.

  • 개발 서버 시작 시: 누락되거나 잘못된 키를 경고만 합니다. 개발을 막지는 않아요(non-blocking).
  • 첫 런타임 접근 시: 잘못된 값을 실제로 읽으면 EnvValidationError를 던집니다.
  • bunx void env check: .env와 셸 값을 로컬 개발 기준으로 검사합니다. --remote를 붙이면 빌드 셸의 클라이언트 값과 원격 시크릿 이름을 검사해요.
  • bunx void deploy: 필수 서버 키가 원격 시크릿에 없으면 업로드 전에 아예 에러로 멈춥니다(hard-error). .env 값은 배포에 절대 포함되지 않습니다.

특히 마지막 단계가 중요한데요. 비밀 키 하나를 빠뜨린 채 배포 버튼을 눌러도, Void가 업로드 직전에 막아주기 때문에 “배포는 됐는데 로그인이 안 되는” 상황을 미리 차단합니다. CI에서는 배포 전에 bunx void env check --remote를 한 번 돌려서 원격 시크릿까지 포함해 검증하는 흐름을 권합니다.

env.ts를 고친 뒤 타입이 따라오지 않으면 bunx void env types로 .void/env.d.ts를 다시 만들면 됩니다. 개발 서버를 띄우면 자동으로 갱신되니, 주로 새로 클론한 저장소나 CI에서 씁니다. 예전에 있던 void env example 명령은 .env.example 파일 자체가 거부되면서 함께 없어졌어요.

그리고 env.ts가 없는 프로젝트에서 .env를 둔 채 void init을 돌리면, 그 키들을 토대로 보수적으로 타입을 추론한 env.ts를 만들어주고 “스키마를 검토하라”는 안내 배너를 붙여줍니다. env.ts는 Vite 변환 전에 Node의 import()로 불러오기 때문에, 이 파일 안에서는 TypeScript 경로 별칭 대신 상대 경로나 패키지 이름으로 import해야 한다는 점도 기억해두세요.

에러 메시지에 시크릿이 새지 않도록

마지막으로 작지만 고마운 기능 하나. 검증 에러가 나면 입력된 값은 자동으로 <redacted>로 가려집니다. 앞에서 본 WEBHOOK_URL: Expected a valid URL, got "<redacted>"가 바로 그 예예요. 에러 로그가 CI 콘솔이나 채팅에 그대로 붙여넣어지는 일이 흔하니, 이런 자동 마스킹은 생각보다 자주 우리를 구해줍니다.

예전에는 키 이름 패턴으로 가릴지를 판단하고 .secret()과 .public()으로 직접 지정할 수 있었는데, 0.20에서는 이 둘이 없어지고 “잘못된 값은 항상 가린다”는 규칙 하나만 남았습니다. 저장 위치도 이름이 아니라 VITE_ 접두사로 정해지니 더 이상 필요 없어진 거죠. 기존 env.ts에 남아 있으면 스키마를 불러오는 단계에서 바로 실패합니다.

bunx void env check
■  env: Failed to load '.../env.ts': 'string(...).secret is not a function'.

로컬에서 디버깅하느라 실제 값을 봐야 한다면 VOID_ENV_UNMASK=1 환경변수를 붙여 실행하면 됩니다. 마스킹이 꺼졌다는 안내도 함께 출력돼요.

예전 설정에서 옮겨오기

0.10 이전 방식으로 설정한 프로젝트라면 다음 순서로 옮기면 됩니다. 호환 모드가 따로 없으니 한 번에 정리해야 해요.

  1. 프로덕션 값을 프로젝트 밖에 안전하게 백업하고, .env.local과 .env.development에서 로컬에 필요한 값만 .env로 합칩니다.
  2. env.ts에서 .secret()과 .public()을 지웁니다. 시크릿 명령이 스키마를 불러오기 때문에 이 단계를 먼저 해야 합니다.
  3. VITE_가 없는 서버 키를 void secret put이나 void secret sync로 올리고, void secret list로 확인합니다.
  4. VITE_ 값은 CI의 빌드 셸이나 스키마 기본값으로 옮깁니다.
  5. 모든 값이 옮겨진 걸 확인한 뒤에 .env.local, .env.production 같은 옛 파일을 지웁니다.

마치며

Void의 환경변수는 env.ts라는 단일 진실 공급원에서 출발합니다. 여기에 타입과 검증을 입히면 런타임 안전성, TypeScript 타이핑, 배포 검증이 한꺼번에 따라오고, 클라이언트/서버 분리와 시크릿 마스킹이 비밀값 유출이라는 가장 흔한 사고를 막아줍니다. 0.20에서는 여기에 “로컬 값은 .env 하나, 서버 값은 모두 시크릿, 클라이언트 값은 빌드 셸”이라는 규칙이 더해지면서, 어느 파일이 이기는지 고민할 일 자체가 사라졌어요. 규칙이 엄격해진 대신 헷갈릴 여지가 줄었습니다.

이로써 화면(라우팅), 데이터(데이터베이스), 변경(폼과 액션), 인증, 그리고 환경변수까지 풀스택 앱의 뼈대가 거의 다 갖춰졌습니다. 이 모든 걸 실제 프로덕션 서비스에 적용하며 시크릿 동기화의 함정까지 밟은 기록은 Void로 프로덕션 앱 이전하기에 정리해뒀으니 이어서 읽어보시길 권합니다.

더 자세한 내용은 Void 환경변수 가이드를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord