Void 환경변수: env.ts로 타입 안전하게, 시크릿까지
Void 인증을 붙이고 나면 곧바로 BETTER_AUTH_SECRET이라는 비밀 키를 마주하게 됩니다. 세션 토큰을 서명하는 값인데, 코드에 적을 수도 없고 깃에 올릴 수도 없죠. 비단 인증만의 문제도 아닙니다. Stripe 키, 웹훅 URL, 외부 API 토큰까지, 앱을 키우다 보면 “코드 밖에서 주입해야 하는 값”이 계속 늘어나거든요.
그런데 우리가 환경변수로 늘 겪던 불편이 있습니다. process.env.PORT는 항상 문자열이라 매번 숫자로 바꿔야 하고, 키 이름에 오타가 나도 런타임에야 터지고, 어떤 값이 필수인지는 README에 적어둔 메모에 의존하죠. Void는 이걸 env.ts 파일 하나로 풀어냅니다. 타입과 검증을 입히고, 비밀값은 암호화해서 따로 관리하는 방식이에요. 이번 글에서 그 흐름을 처음부터 끝까지 따라가 보겠습니다.
환경변수는 env.ts 한 곳에서
Void에서 환경변수의 출발점은 프로젝트 루트의 env.ts 파일 하나입니다. 여기에 스키마를 정의하면 세 가지 일이 한꺼번에 일어나요. 런타임에 값이 올바른지 검증하고, TypeScript 타입을 자동으로 입히고, 배포 직전에 필수 키가 빠지지 않았는지 확인해줍니다.
import { defineEnv, string, number, boolean, oneOf, url } from "void/env";
export default defineEnv({
STRIPE_KEY: string(),
PORT: number().default(3000),
NODE_ENV: oneOf(["development", "production"]),
WEBHOOK_URL: url(),
DEBUG: boolean().optional(),
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 문자열을 파싱
이 헬퍼로 표현하기 어려운 까다로운 규칙이 필요하면, Zod나 Valibot, ArkType 같은 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의 envPrefix 규칙을 그대로 따릅니다. 기본 접두사는 VITE_인데, 이 접두사로 시작하는 키만 클라이언트 코드에 노출되고 나머지는 전부 서버 전용으로 남아요. 그래서 앞선 예제의 VITE_APP_TITLE은 브라우저에서 읽을 수 있지만 STRIPE_KEY는 그럴 수 없습니다.
여기서 멈추지 않고, Void는 한 겹 더 안전망을 깝니다. 서버 전용 키를 클라이언트 모듈에서 참조하면 void:env-client-guard가 빌드 에러를 냅니다. 실수로 비밀 키를 컴포넌트에서 읽었다면, 배포는커녕 빌드 단계에서 막히는 거예요. vite.config.ts에서 envPrefix를 직접 바꿨다면 그 설정도 자동으로 존중됩니다.
반대로 클라이언트에 노출되는 키는 프로덕션 빌드에서 한 가지 최적화를 더 받습니다. 정적으로 읽은 값이 그대로 문자열 리터럴로 인라인되거든요. 덕분에 죽은 코드가 제거됩니다.
// 작성한 소스
if (env.MODE === "production") {
initAnalytics();
}
// 프로덕션 번들에서는 이렇게 접힙니다
initAnalytics();
env.MODE가 빌드 시점에 "production"으로 확정되니 if 조건 자체가 사라지고 본문만 남는 거죠. 이 최적화는 접두사가 맞는 키를 env.FOO나 env["FOO"]처럼 정적으로 읽었을 때만 적용됩니다.
.env 파일과 변수 확장
그럼 실제 값은 어디에 적느냐. 로컬에서는 익숙한 .env 파일을 씁니다. Void는 Vite의 표준 .env 컨벤션을 그대로 따르는데, 파일별로 개발과 배포에서의 동작이 다릅니다.
| 파일 | 개발(Dev) | 배포(Deploy) |
|---|---|---|
.env | O | O |
.env.local | O | X |
.env.production | O | O |
.env.production.local | O | X |
여기서 핵심 규칙은 .local이 붙은 파일은 배포에 포함되지 않는다는 거예요. 그래서 깃에 올리면 안 되는 개인용 값은 .env.local에, 팀이 공유하는 기본값은 .env에 두는 식으로 가르면 됩니다.
.env 안에서는 이미 정의한 키를 ${VAR}나 $VAR 문법으로 참조할 수 있습니다. 같은 값을 반복해서 적을 필요가 없죠.
BASE_URL=https://api.example.com
API_URL=${BASE_URL}/v1
BUILD=$BASE_URL/build.json
LITERAL=\$NOT_EXPANDED
API_URL은 https://api.example.com/v1로 펼쳐집니다. 만약 $ 기호를 문자 그대로 두고 싶다면 마지막 줄처럼 백슬래시로 이스케이프하면 돼요.
여기서 실전 함정을 하나 짚고 가야 합니다. Void는 환경변수를 .env 파일과 시크릿에서만 읽고, 셸에 export해둔 값이나 mise·direnv 같은 도구가 주입한 process.env 값은 무시합니다. 내부적으로 한 번 걸러내거든요. 그래서 Void 인증의 GitHub 로그인을 로컬에서 테스트하려고 AUTH_GITHUB_CLIENT_ID를 셸에 export해두면, 분명 터미널에는 값이 있는데 앱은 자격 증명이 없다고 합니다. 이런 값은 반드시 .env.local에 적어야 Void가 읽어요.
프로덕션 시크릿은 암호화해서 올린다
.env.production은 배포에 포함된다고 했지만, Void 인증에서 만난 BETTER_AUTH_SECRET이나 STRIPE_KEY 같은 진짜 비밀값을 평문 파일에 적어 깃에 올릴 수는 없습니다. 이런 민감한 값은 암호화된 시크릿 바인딩으로 따로 업로드해요.
# 값을 대화형으로 입력받아 등록
bunx void secret put BETTER_AUTH_SECRET
# 파일 내용을 시크릿으로 등록
bunx void secret put STRIPE_KEY < key.txt
# .env.production의 키들을 한 번에 동기화
bunx void secret sync .env.production
# 등록된 시크릿 목록 확인
bunx void secret list
# 시크릿 삭제
bunx void secret delete STRIPE_KEY
이렇게 올린 원격 시크릿은 배포 검증에서 “값이 존재하는 것”으로 인정됩니다. 즉 env.ts에서 필수로 선언한 키라도, 로컬 .env에 없고 원격 시크릿으로만 등록돼 있으면 배포가 통과해요. 비밀값을 코드에서 떼어내면서도 검증은 그대로 받는 거죠.
여러 키를 한꺼번에 올릴 때는 secret sync가 특히 편합니다. .env 파일을 읽어 어떤 키를 올릴지 보여주고, 덮어쓰기 전에 확인을 받은 뒤 한 번에 동기화해줘요. 예를 들어 GitHub 소셜 로그인용 클라이언트 ID와 시크릿을 올리면 이런 모습입니다.
┌ void secret
│
● Found 2 secret(s):
│
│ AUTH_GITHUB_CLIENT_ID
│
│ AUTH_GITHUB_CLIENT_SECRET
│
◇ This will overwrite 2 secret(s) in the remote environment. Continue?
│ Yes
│
◆ Set AUTH_GITHUB_CLIENT_ID
│
◆ Set AUTH_GITHUB_CLIENT_SECRET
│
└ Synced 2 secret(s).
.env.local에서 발견한 키 두 개를 나열하고, “원격 환경의 시크릿 2개를 덮어쓴다”는 확인을 받은 뒤 하나씩 등록하는 흐름이 한눈에 들어옵니다. 다만 이 확인 프롬프트가 발목을 잡을 때가 있는데요. Void 마이그레이션에서 겪었듯이 secret sync에는 비대화형 플래그가 없어서, CI처럼 사람이 “Yes”를 눌러줄 수 없는 환경에서는 printf '%s' "$VALUE" | bunx void secret put KEY 패턴으로 키를 하나씩 우회 등록하는 게 안전합니다.
검증은 언제 어떻게 일어나나
타입 안전한 환경변수의 진짜 가치는 “틀린 값을 일찍 잡아준다”는 데 있습니다. Void는 단계마다 다른 강도로 검증을 겁니다.
- 개발 서버 시작 시 — 누락되거나 잘못된 키를 경고만 합니다. 개발을 막지는 않아요(non-blocking).
- 첫 런타임 접근 시 — 잘못된 값을 실제로 읽으면
EnvValidationError를 던집니다. bunx void env check—.env와.env.production을 검사합니다.bunx void deploy— 필수 키가 빠져 있으면 업로드 전에 아예 에러로 멈춥니다(hard-error).
특히 마지막 단계가 중요한데요. 비밀 키 하나를 빠뜨린 채 배포 버튼을 눌러도, Void가 업로드 직전에 막아주기 때문에 “배포는 됐는데 로그인이 안 되는” 상황을 미리 차단합니다. CI에서는 배포 전에 bunx void env check --remote를 한 번 돌려서 원격 시크릿까지 포함해 검증하는 흐름을 권합니다.
CLI에는 일상적으로 쓰는 보조 명령도 있습니다.
# env.ts 변경 후 타입 재생성
bunx void env types
# .env.example 생성 (--force로 덮어쓰기)
bunx void env example
env example은 .env.example 안에 마커로 구분된 블록을 관리해서, 마커 바깥에 직접 적어둔 설명은 건드리지 않고 보존합니다. 그리고 이미 .env나 .env.example이 있는 프로젝트에서 void init을 돌리면, 그 값들을 토대로 보수적으로 타입을 추론한 env.ts를 만들어주고 “개발 전에 스키마를 검토하라”는 안내 배너를 붙여줘요.
에러 메시지에 시크릿이 새지 않도록
마지막으로 작지만 고마운 기능 하나. 검증 에러가 났을 때 비밀처럼 보이는 값은 자동으로 <redacted>로 가려집니다. 에러 로그가 CI 콘솔이나 채팅에 그대로 붙여넣어지는 일이 흔하니, 이런 자동 마스킹은 생각보다 자주 우리를 구해줘요.
기본적으로 키 이름이 /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PRIVATE|AUTH/i 패턴에 걸리면 가려집니다. 이름만으로 판단이 애매한 경우엔 .secret()과 .public()으로 직접 지정할 수 있어요.
import { defineEnv, string } from "void/env";
export default defineEnv({
STRIPE_KEY: string().secret(), // 항상 가린다
PUBLIC_KEY: string().public(), // 가리지 않는다
});
로컬에서 디버깅하느라 실제 값을 봐야 한다면 VOID_ENV_UNMASK=1 환경변수를 붙여 실행하면 됩니다.
마치며
Void의 환경변수는 env.ts라는 단일 진실 공급원에서 출발합니다. 여기에 타입과 검증을 입히면 런타임 안전성, TypeScript 타이핑, 배포 검증이 한꺼번에 따라오고, 클라이언트/서버 분리와 시크릿 마스킹이 비밀값 유출이라는 가장 흔한 사고를 막아줍니다. 흩어지기 쉬운 설정값을 한곳에 모으고, 그중 민감한 것만 void secret으로 암호화해 올리는 구조가 깔끔했어요.
이로써 화면(라우팅), 데이터(데이터베이스), 변경(폼과 액션), 인증, 그리고 환경변수까지 풀스택 앱의 뼈대가 거의 다 갖춰졌습니다. 이 모든 걸 실제 프로덕션 서비스에 적용하며 시크릿 동기화의 함정까지 밟은 기록은 Void로 프로덕션 앱 이전하기에 정리해뒀으니 이어서 읽어보시길 권합니다.
더 자세한 내용은 Void 환경변수 가이드를 참고하세요.
This work is licensed under
CC BY 4.0