
내 API 키가 왜 undefined지? (Next.js 환경변수와 보안 사고)
로컬에서는 잘 되던 API 키가 브라우저 콘솔에서는 `undefined`로 뜨는 현상. Next.js의 보안 철학인 'Server-Client Boundary'를 이해하지 못해 3시간을 삽질하고, 실수로 비밀 키를 노출할 뻔했던 아찔한 경험을 공유합니다.

로컬에서는 잘 되던 API 키가 브라우저 콘솔에서는 `undefined`로 뜨는 현상. Next.js의 보안 철학인 'Server-Client Boundary'를 이해하지 못해 3시간을 삽질하고, 실수로 비밀 키를 노출할 뻔했던 아찔한 경험을 공유합니다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

Next.js로 날씨 앱을 만들던 중이었습니다.
OpenWeatherMap API 키를 발급받고, .env 파일에 소중히 모셔뒀죠.
# .env.local
WEATHER_API_KEY=abcdef123456
그리고 컴포넌트에서 자신 있게 로그를 찍어봤습니다.
// WeatherComponent.tsx
'use client';
console.log(process.env.WEATHER_API_KEY); // 결과: undefined
"어라? 오타 났나?"
서버를 껐다 켜보고, 캐시를 지워보고, 컴퓨터를 재부팅해봐도 결과는 똑같이 undefined였습니다.
근데 이상하게 console.log가 터미널(서버)에는 찍히는데, 브라우저 콘솔에만 안 찍히는 겁니다.
알고 보니 이건 버그가 아니라 Next.js의 강력한 보안 기능이었습니다.
브라우저는 사용자의 컴퓨터입니다. 누구나 F12(개발자 도구)를 눌러서 코드를 볼 수 있죠.
만약 process.env의 모든 변수가 브라우저로 전송된다면?
AWS_SECRET_KEY 노출DB_PASSWORD 노출그래서 Next.js는 기본적으로 모든 환경 변수를 서버(Server Side)에만 둡니다. 브라우저(Client Side)로 보내는 건 명시적으로 허용된 것들뿐입니다. 이것이 바로 Next.js의 Server-Client Boundary(서버-클라이언트 경계) 정책입니다.
브라우저에서 환경 변수를 쓰고 싶다면, 변수명 앞에 NEXT_PUBLIC_을 붙여야 합니다.
"이건 공개되어도 안전한 키야"라고 Next.js에게 알려주는 거죠.
# .env.local
NEXT_PUBLIC_WEATHER_API_KEY=abcdef123456
console.log(process.env.NEXT_PUBLIC_WEATHER_API_KEY); // 결과: abcdef123456
이제 잘 나옵니다.
주의: Google Analytics ID나 Firebase Public Key 같은 것만 이렇게 써야 합니다.
서버 비밀 키에 NEXT_PUBLIC_을 붙이는 순간, 여러분의 서버는 공공재가 됩니다.
사실 API 키는 클라이언트에 노출하지 않는 게 가장 좋습니다. 아무리 Public Key라 해도, 누군가 내 키를 훔쳐서 무단으로 사용하면 요금 폭탄을 맞을 수 있거든요.
가장 좋은 방법은 Next.js API Route를 프록시(Proxy)로 쓰는 겁니다.
/* src/app/api/weather/route.ts */
export async function GET() {
// 여기는 서버라서 접두사 없이도 읽을 수 있음!
const apiKey = process.env.WEATHER_API_KEY;
const res = await fetch(`https://api.weather.com...?key=${apiKey}`);
const data = await res.json();
return Response.json(data);
}
이렇게 하면 API 키는 서버에 안전하게 숨겨지고, 브라우저는 키의 존재조차 모르게 됩니다.
많은 분들이 오해하는 게 있습니다.
"브라우저에서 process.env를 읽는구나!"
아닙니다. 브라우저에는 process라는 객체 자체가 없습니다. (이건 Node.js 거니까요.)
그럼 어떻게 읽히는 걸까요?
Next.js는 빌드 타임(Build Time)에 코드를 스캔합니다.
그리고 process.env.NEXT_PUBLIC_KEY라는 문자열을 발견하면, 그걸 실제 값으로 바꿔치기(Replace) 해버립니다.
console.log(process.env.NEXT_PUBLIC_API_URL);
빌드된 결과물 (브라우저가 받는 파일):
console.log("https://api.example.com");
이게 바로 "인라이닝"입니다. 그래서 빌드할 때 환경 변수가 없으면, 브라우저에서는 영원히 undefined가 뜹니다. Docker로 배포할 때, 이미지를 빌드하는 시점(CI/CD)에 환경 변수를 안 넣고, 컨테이너 실행 시점(Runtime)에 넣으면 값이 비어있는 이유가 바로 이겁니다.
Next.js의 Standalone 모드를 쓰거나 Docker를 쓴다면, NEXT_PUBLIC_ 변수는 빌드 타임에 확정된다는 걸 명심해야 합니다.
런타임에 동적으로 바꾸고 싶다면? window.ENV 같은 전역 객체를 주입하거나, 별도의 API를 통해 설정을 받아오는 복잡한 방법을 써야 합니다.
process.env가 undefined라고 화내지 마세요.
Next.js가 여러분의 실수로 데이터베이스가 털리는 걸 막아준 겁니다.
NEXT_PUBLIC_만 읽을 수 있다. (이건 빌드 타임에 박제된다)NEXT_PUBLIC_ 붙이지 말고, API Route 뒤에 숨겨라. (프록시 패턴)이 3가지만 기억하면, 환경 변수 때문에 3시간을 날리는 일은 없을 겁니다.
I was building a weather app with Next.js.
I got an OpenWeatherMap API key and carefully placed it in .env.
# .env.local
WEATHER_API_KEY=abcdef123456
Then I confidently logged it in my component.
// WeatherComponent.tsx
'use client';
console.log(process.env.WEATHER_API_KEY); // Result: undefined
"Huh? Is it a typo?"
I restarted the server, cleared the cache, rebooted my Mac. Still undefined.
Strangely, console.log printed fine in the Terminal (Server), but not in the Browser Console.
It turned out, this wasn't a bug. It was a Security Feature.
The browser runs on the user's computer. Anyone can press F12 and inspect the code.
If all variables in process.env were sent to the browser?
AWS_SECRET_KEY LeakedDB_PASSWORD LeakedSo Next.js defaults to keeping ALL environment variables on the Server Side only. It only sends variables to the Browser (Client Side) if explicitly allowed. This is the implementation of the Server-Client Boundary principle.
If you want to access a variable in the browser, you must prefix it with NEXT_PUBLIC_.
This tells Next.js, "It's okay to expose this key."
# .env.local
NEXT_PUBLIC_WEATHER_API_KEY=abcdef123456
console.log(process.env.NEXT_PUBLIC_WEATHER_API_KEY); // Result: abcdef123456
Now it works.
Warning: Only use this for Google Analytics IDs or Firebase Public Keys.
If you put NEXT_PUBLIC_ on a Server Secret, your server becomes public property.
Ideally, you shouldn't expose API keys to the client at all. Even if it's a "Public" key, someone could steal it and exhaust your quota, causing a billing spike.
The best practice is to use Next.js API Routes as a Proxy.
/* src/app/api/weather/route.ts */
export async function GET() {
// Server-side: Can read without prefix!
const apiKey = process.env.WEATHER_API_KEY;
const res = await fetch(`https://api.weather.com...?key=${apiKey}`);
const data = await res.json();
return Response.json(data);
}
This way, the API Key stays safely on the server, and the browser never even knows it exists.
Many developers misunderstand how this works.
"Oh, so the browser reads process.env from the server?"
No. The browser doesn't have a process object (that's a Node.js thing).
So how does it read the value?
Next.js scans your code at Build Time.
When it sees process.env.NEXT_PUBLIC_KEY, it literally Finding & Replacing it with the string value.
console.log(process.env.NEXT_PUBLIC_API_URL);
Code bundled to browser:
console.log("https://api.example.com");
This is called Inlining.
Because of this, if the environment variable is missing at Build Time, it will remain undefined forever in the browser.
This is a classic trap when using Docker.
.env here because you think "I'll pass it when I run the container."undefined for NEXT_PUBLIC_API_URL because the variable wasn't there. It hardcodes undefined into the JS bundle.docker run -e NEXT_PUBLIC_API_URL=....undefined baked in.ARG) in Dockerfile.window object in layout.tsx).If you are tired of debugging undefined, I highly recommend using t3-env or just standard Zod validation.
Don't just trust process.env. Validate it.
// env.mjs
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
OPEN_AI_API_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
// ...
},
});
If you use this, your app will crash immediately at build time if a key is missing, instead of silently failing with undefined in production.
A loud error during build is 100x better than a silent bug in production.
Don't get mad that process.env is undefined.
Next.js just saved you from leaking your database password to the entire world.
Key Takeaways:
NEXT_PUBLIC_. This is a safety mechanism. Warning: These values are Inlined at build time.NEXT_PUBLIC_ for database credentials or private API keys. Always proxy these requests.This "Server-Client Boundary" is one of the most important concepts in modern web development. It might feel like an obstacle when you are just trying to get a simple API key to work, but it is the firewall that keeps your application secure.
Remember these rules, and you won't waste 3 hours debugging environment variables again. Next time you see undefined, smile and thank Next.js for watching your back.