레시피 목록

프론트엔드 개발자가 착각하기 쉬운 .env의 진실

2025년 11월 14일 4분 읽기
환경변수

처음 개발을 배울 때, 저는 .env가 비밀을 담아두는 금고 같은 거라고 생각했습니다. 강의에서도 시크릿 키처럼 보안이 필요한 값을 .env에 넣는 흐름이 자연스러웠고, 파일 자체를 배포하지 않으니 당연히 안전하다고 믿었습니다.

"브라우저에서 환경변수를 볼 수 있나요?"라고 물으면, 많은 분들이 이렇게 답합니다.

"아니요, .env 파일은 배포 안 하잖아요."

저도 똑같이 답했을 겁니다. 하지만 실제 답은 반대입니다. 브라우저에서 볼 수 있습니다. 그것도 아주 쉽게 말이죠.

핵심은 이겁니다. 환경변수 값이 빌드된 JavaScript 파일에 문자열로 박혀서 배포됩니다. .env 파일 자체가 아니라, 그 안에 담긴 값이 번들에 인라인되어 나가는 거죠.

javascript
// 코드에서 이렇게 작성하면
const apiUrl = process.env.REACT_APP_API_URL;

// 빌드 후 실제 JS 파일에는 이렇게 변환됩니다
const apiUrl = "https://api.example.com";

Chrome DevTools의 Sources 탭에서 번들 파일을 열고 Ctrl+F로 검색하면 모든 환경변수 값이 평문으로 보입니다. 네트워크 탭에서 JavaScript 파일을 직접 다운로드해도 마찬가지고요. 소스맵이 있다면 원본 코드와 환경변수 사용 위치까지 전부 확인 가능합니다.

클라이언트에서 쓰는 모든 환경변수는 사실상 공개 정보입니다. API 키나 비밀 토큰은 절대 클라이언트 환경변수에 넣어서는 안 됩니다. 난독화나 암호화로도 막을 수 없으니까요. 결국 브라우저가 실행해야 하는 코드라면, 그 내용을 숨기는 건 불가능합니다.

파일이 아니라 값이 실려 나간다

프론트엔드 번들러(webpack, esbuild 등)는 코드 내의 환경변수 참조를 빌드 시점에 상수로 치환합니다.

javascript
// 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;
javascript
// 실제 번들된 JS 파일
const apiUrl = "https://api.example.com/v1";

치환 후에는 실제 문자열이 결과 JS/HTML에 박혀 배포됩니다. 따라서 브라우저, 소스 보기, 소스맵을 통해 값이 노출되는 겁니다.

React에서는 접두사가 첫 번째 방어선이다

React의 .env 파일 그 자체는 배포 서버에 업로드되지 않습니다. 대신 .env 값은 빌드 시점에 번들에 인라인돼서 main.*.js, index.html 같은 산출물에 들어갑니다.

CRA와 Vite는 모두 접두사 규칙으로 클라이언트에 노출할 변수를 제어합니다.

도구접두사사용 방법
CRAREACT_APP_*process.env.REACT_APP_*
ViteVITE_*import.meta.env.VITE_*

접두사가 없는 환경변수는 빌드 과정에서 무시되고 번들에 포함되지 않습니다. 이 접두사 규칙이 실수로 민감한 값이 번들에 포함되는 것을 막아주는 첫 번째 방어선입니다.

Next.js는 노출 범위를 두 갈래로 나눈다

Next.js는 환경변수를 두 가지 타입으로 구분합니다.

타입접두사노출 범위사용 위치
클라이언트 노출NEXT_PUBLIC_*브라우저에 노출클라이언트 컴포넌트, 서버 컴포넌트
서버 전용없음서버에서만 접근서버 컴포넌트, API Routes, getServerSideProps

API Route는 서버에서만 실행되니까 접두사 없는 환경변수를 안전하게 쓸 수 있습니다.

typescript
// 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로 떨어집니다.

typescript
// 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)에서는 모든 환경변수에 접근할 수 있습니다. 코드가 서버에서만 실행되니까요.

typescript
// 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에는 뭘 남겨야 하나

공개되어도 괜찮은 것들만 넣으면 됩니다.

bash
# 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 서버에서만 다뤄야 합니다.

혹시 지금 운영 중인 서비스가 있다면, 배포된 번들을 열어 Ctrl+F로 익숙한 키 이름을 한 번 검색해보면 어떨까 싶습니다. 생각보다 많은 게 그대로 보일지도 모르겠습니다.