프론트엔드 개발자가 착각하기 쉬운 .env의 진실
2025-11-14"브라우저에서 환경변수를 볼 수 있나요?"라고 물으면, 많은 분들이 "아니요, .env 파일은 배포 안 하잖아요" 라고 답해요. 저도 처음 개발을 배울 때 그렇게 생각했고, 강의에서도 .env에 시크릿 키처럼 보안이 필요한 값들을 넣는 게 자연스럽게 느껴졌거든요.
하지만 실제 답은 반대예요. 브라우저에서 볼 수 있어요. 그것도 아주 쉽게요.
핵심은 이거예요. 환경변수 값이 빌드된 JavaScript 파일에 문자열로 박혀서 배포돼요. .env 파일 자체가 아니라, 그 안에 담긴 값이 번들에 인라인되어 나가는 거죠.
// 코드에서 이렇게 작성하면
const apiUrl = process.env.REACT_APP_API_URL;
// 빌드 후 실제 JS 파일에는 이렇게 변환됩니다
const apiUrl = "https://api.example.com";
Chrome DevTools의 Sources 탭에서 번들 파일을 열고 Ctrl+F로 검색하면 모든 환경변수 값이 평문으로 보여요. 네트워크 탭에서 JavaScript 파일을 직접 다운로드해도 마찬가지고요. 소스맵이 있다면 원본 코드와 환경변수 사용 위치까지 전부 확인 가능해요.
클라이언트에서 쓰는 모든 환경변수는 사실상 공개 정보예요. API 키나 비밀 토큰은 절대 클라이언트 환경변수에 넣어서는 안 돼요. 난독화나 암호화로도 막을 수 없거든요. 결국 브라우저가 실행해야 하는 코드라면, 그 내용을 숨기는 건 불가능해요.
왜 .env 값이 번들에 박히는가: 빌드 타임 치환 메커니즘
프론트엔드 번들러(webpack, esbuild 등)는 코드 내의 환경변수 참조를 빌드 시점에 상수로 치환해요.
// CRA
const apiUrl = process.env.REACT_APP_API_URL;
// Vite
const apiUrl = import.meta.env.VITE_API_URL;
// Next.js (클라이언트)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// 실제 번들된 JS 파일
const apiUrl = "https://api.example.com/v1";
치환 후에는 실제 문자열이 결과 JS/HTML에 박혀 배포돼요. 따라서 브라우저, 소스 보기, 소스맵을 통해 값이 노출되는 거죠.
React(SPA)에서의 환경변수
React의 .env 파일 그 자체는 배포 서버에 업로드되지 않아요. 대신 .env 값은 빌드 시점에 번들에 인라인돼서 main.*.js, index.html 같은 산출물에 들어가요.
CRA와 Vite는 모두 접두사 규칙으로 클라이언트에 노출할 변수를 제어해요.
| 도구 | 접두사 | 사용 방법 |
|---|---|---|
| CRA | REACT_APP_* | process.env.REACT_APP_* |
| Vite | VITE_* | import.meta.env.VITE_* |
접두사가 없는 환경변수는 빌드 과정에서 무시되고 번들에 포함되지 않아요. 이 접두사 규칙이 실수로 민감한 값이 번들에 포함되는 것을 막아주는 첫 번째 방어선이에요.
Next.js에서의 환경변수
Next.js는 환경변수를 두 가지 타입으로 구분해요.
| 타입 | 접두사 | 노출 범위 | 사용 위치 |
|---|---|---|---|
| 클라이언트 노출 | NEXT_PUBLIC_* | 브라우저에 노출 | 클라이언트 컴포넌트, 서버 컴포넌트 |
| 서버 전용 | 없음 | 서버에서만 접근 | 서버 컴포넌트, API Routes, getServerSideProps |
API Route는 서버에서만 실행되니까 접두사 없는 환경변수를 안전하게 쓸 수 있어요.
// pages/api/user.ts 또는 app/api/user/route.ts
export async function GET() {
// 서버에서만 실행 - 비밀키 사용 가능(브라우저 노출 위험 없음)
const dbPassword = process.env.DB_PASSWORD; // 접두사 없음
const apiSecret = process.env.EXTERNAL_API_SECRET;
// 데이터 처리 후 결과만 반환
const data = await fetchUserData(apiSecret, dbPassword);
return Response.json(data);
}
클라이언트 컴포넌트에서는 NEXT_PUBLIC_*만 접근 가능해요. 접두사 없는 변수는 undefined로 떨어져요.
// components/Map.tsx
"use client";
export default function Map() {
// NEXT_PUBLIC_*는 클라이언트에서 사용 가능
const mapKey = process.env.NEXT_PUBLIC_MAP_KEY;
// 접두사 없는 변수는 클라이언트에서 undefined
const secret = process.env.SECRET_KEY; // undefined!
return <div>Map with key: {mapKey}</div>;
}
서버 컴포넌트(App Router)에서는 모든 환경변수에 접근할 수 있어요. 코드가 서버에서만 실행되니까요.
// app/dashboard/page.tsx
export default async function Dashboard() {
// 서버 컴포넌트에서는 모든 환경변수 접근 가능
const dbUrl = process.env.DATABASE_URL;
const publicUrl = process.env.NEXT_PUBLIC_API_URL;
const data = await fetchData(dbUrl);
return <div>{/* UI */}</div>;
}
그럼 클라이언트 .env에 뭘 저장해야 하나?
공개되어도 괜찮은 것들만 넣으면 돼요.
# 1. API 엔드포인트 (공개 정보)
REACT_APP_API_URL=https://api.myapp.com
# 2. 공개 ID (추적용, 분석용)
REACT_APP_GA_ID=G-XXXXXXXXXX
REACT_APP_SENTRY_DSN=https://xxx@sentry.io/xxx
# 3. 기능 플래그 (공개되어도 무방)
REACT_APP_FEATURE_BETA=true
# 4. 공개 메타 정보
REACT_APP_APP_VERSION=1.0.0
REACT_APP_BUILD_ENV=production
# 5. 도메인 제한이 걸린 공개 키
REACT_APP_GOOGLE_MAPS_KEY=AIza... (도메인 제한 설정 필수)
REACT_APP_FIREBASE_API_KEY=AIza...
Google Maps API 키처럼 도메인 제한을 걸어둔 공개 키는 노출돼도 타 도메인에서 쓸 수 없으니까 비교적 안전해요. 반면 DB 패스워드, JWT 시크릿, 외부 API 비밀 키 같은 값들은 절대로 클라이언트 번들에 포함돼서는 안 돼요. 이런 값들은 Next.js의 서버 컴포넌트나 API Route, 또는 BFF 서버에서만 다뤄야 해요.