[Next.js] 브라우저(Client)로 환경변수 주입하기 (Pages와 App Router 모두 지원)
더이상의 NEXT_PUBLIC_XXX 환경변수는 없다! Next.js에서 브라우저로 손쉽게 환경변수를 주입하여 빌드를 복잡하게 해야 하는 상황을 해결합니다.

목차
- 요약
- 환경변수와 Next.js
- 불편한 점이 한두 가지가 아니다!
- 환경변수를 끼워넣을 수 있다는 희망
- 끼워넣기 Next.js 15 Pages Router
- 끼워넣기 Next.js 15 App Router
- 실패한 시도
- 한계
- 번외
- 레퍼런스
요약
- 환경변수로 Next.js 앱의 동작을 설정할 수 있다. 그 중
NEXT_PUBLIC_XXX
는 빌드 시점에 결정되는 환경변수다. NEXT_PUBLIC_XXX
가 꼭 필요한 상황은 그리 많지 않다. 그러니 사용하지 말자.- 브라우저에서 필요한 데이터는 직접 주입하자. (가장 빠르게 로딩되는
env.js
파일)
환경변수와 Next.js
환경변수(Environment Variables)란 무엇일까요? 간단하게 말하면 프로그램(프로세스)이 시작하는 시점에 읽을 수 있는 운영체제 차원의 전역 변수입니다. 보통 우리가 원하는 방향으로 프로그램을 실행하려면 인자값(Arguments)이나 옵션 등을 적절히 줍니다(ls
vs ls -al
처럼). 환경변수로도 할 수 있는 것들이 많습니다. 예를 들어 TZ=America/New_York ls -al
로 한다면 뉴욕 시간 기준으로 시간이 나옵니다. 환경변수는 명령어나 프로그램이 시작하기 전이라면 언제든지 미리 설정할 수 있지만 프로그램이 일단 시작하면 환경변수를 수정하는 건 의미가 없습니다.
그렇다면 웹서비스와 환경변수의 관계는 어떻게 될까요? 전통적인 웹은 HTML, CSS, JavaScript 파일만 잘 전달해주면 됐습니다. 사이트의 동작을 바꾸려면 이 파일을 수정해서 새로 업로드하면 됐고 환경변수와는 큰 연관이 없었습니다. React도 3개 종류의 파일을 생성(빌드)하고 어딘가에 업로드하는 식으로 배포가 진행됩니다. 다만 개발할 때와 실제 배포 환경에서 사용하는 값들이 달라(예: API 서버 주소) 빌드 시점에 적절한 값으로 파일들이 생성되어야 했고, 이 때 환경변수가 사용되는 거죠.
CRA(Create React App)에 있던 webpack은 온갖 잡다구리한 것들을 잘 처리할 수 있습니다. 소스코드에서 process.env.REACT_APP_XXX
토큰을 찾아 빌드 시점에 고정된 값으로 치환하는 건 일도 아니었죠. 빌드 시점에 환경변수를 다르게 설정하여 결과물을 적절하게 만들어내는 방식이 흔해졌습니다. Next.js에는 같은 방식으로 동작하는 NEXT_PUBLIC_
으로 시작하는 특수한 환경변수 기능이 2020년 5월, Next.js 9.4
버전에 릴리즈 되었습니다. 이 기능을 소개하면서 fully backward-compatible features 라고 하는 걸로 보아 CRA 시절부터 있었던 기능을 잘 지원해준다는 느낌인 것 같아요.
- 쓸데없는 지식: 사실
5.1
버전에서부터 일찌감치 Runtime Config라는 기능으로 configuration을 통합하려는 시도가 있었지만 결국 deprecated 되었습니다. 환경변수란게 워낙에 개발자 사이에서 익숙해서 였을까요?
그런데 Next.js와 간단한 React 앱은 다릅니다. React에는 서버란 없습니다. 단지 정적 파일을 잘 생성해주는 역할을 열심히 할 뿐입니다. 그러나 Next.js는 Node.js 기반으로 돌아가는 서버입니다. 서버는 실행 시작! 종료! 하는 프로그램입니다. 서버도 환경변수를 읽을 수 있습니다. 이제 헷갈립니다. 빌드 시점에 치환되는 환경변수와 서버 실행 시점에 읽어들이는 런타임 환경변수 두 가지를 생각해야 합니다.
불편한 점이 한두 가지가 아니다!
빌드 시점에 결정되는 NEXT_PUBLIC_
계열의 환경변수는 여러모로 불편한데요,
- 환경변수를 수정하려면 빌드를 새로 해야 합니다. 빌드는 오래 걸립니다.
- 배포 환경에 따라 이 환경변수가 달라진다면 배포 환경마다 다르게 빌드해야 합니다.
- Docker 이미지로 빌드본을 관리하고 있다면 그만큼 이미지의 개수도 많아집니다.
- 이미지를 빌드하는 job도 n개가 될 수 있습니다.
- 이 환경변수를 런타임에 세팅할 필요가 없다는 사실을 곧바로 인지하기 힘듭니다. (환경변수라는 용어를 쓰지 말아야 한다는 생각도 듭니다)
환경변수를 끼워넣을 수 있다는 희망
저는 그런 생각이 들었습니다. 어차피 우리의 Next.js 서버는 HTML
파일을 만들어서 클라이언트로 전달할 운명일 텐데, 거기에 내가 원하는 환경 변수만 어떻게든 끼워넣을 수 있다면, NEXT_PUBLIC_
환경변수를 사용할 필요는 없지 않을까?
그래서 이 환경변수가 가장 흔하게 사용되는 사례를 생각해봤습니다.
- GA4, Sentry, Meta Pixel, Sentry 등 브라우저에서 동작하는 SDK 초기화에 필요한 값
- API Endpoint
- 소셜 로그인 관련 Client ID (특히 redirct url 만들 때)
- 피쳐 플래그
- SSG 페이지 만들 때
각각은 끼워넣기로 어지간해선 해결이 될 것 같고, 피쳐플래그 같은 경우 다시 빌드하는 것보다 환경변수 바꿔서 재시작하는 게 훨씬 운영에 부담이 없을 것 같아요.
SSG 페이지는 빌드 시점에 뭔가 결정하려고 한다는 뜻이기 때문에 NEXT_PUBLIC_
함수를 적극적으로 쓰는게 어색하지 않습니다. 그리고 빠른 페이지 로딩 등의 이득을 보기 위한 거라 목적도 뚜렷합니다. 런타임 때 행동을 다양하게 바꾸겠다라는 목적과 모순됩니다. 서버를 시작할 때 생성되는 정적 페이지(일종의 ISR)를 상상해볼 수도 있겠지만, 음, 그건 좀 특이한 케이스일 거 같아서 이 글에서는 다루지 않겠습니다.
끼워넣기를 했다 칩시다. 그런데 SSR에서도 지원되어야 하지 않냐고요? 래핑 함수를 만들면 되지 않을까요?
export const getAPIEndpoint = () =>typeof window === "undefined"? process.env.API_ENDPOINT: window.API_ENDPOINT; // 어디선가 끼워넣어졌다고 가정
제 결론은 끼워넣기가 가능하다면 NEXT_PUBLIC_
환경변수를 둘 이유는 없다입니다. 그리고 실제로 끼워넣기를 해본 결과 최신 Next.js 15에서도 잘 되는 것 같습니다. Pages, App Router 상관없이요!
끼워넣기 Next.js 15 Pages Router
우선 소스코드. https://github.com/echoja/inject-env-nextjs-pages-router
실습에서는 API_ENDPOINT
환경변수를 다르게 넣어주는 걸로 하겠습니다.
_document.tsx
파일에서 환경변수를 넣어줍시다. 문서에 따르면 이 파일은 서버에서만 렌더링된다고 합니다. 즉 process.env
에만 접근할 수 있다는 이야기지요. script
태그에 dangerouslySetInnerHTML
로 직접 넣어주면 될 것 같지만 빌드 타임에 치환되는 것 같더라구요. 그래서 env.js
라는 파일을 서버 시작 시점에 만들어서 public
폴더에 넣도록 하겠습니다.
일단 env.js
파일을 즉시 가져올 수 있도록 _document.tsx
파일을 수정해줍니다.
import { Html, Head, Main, NextScript } from "next/document";export default function Document() {return (<Html lang="en"><Head><script src="/env.js" /></Head><body><Main /><NextScript /></body></Html>);}
env.js
파일은 다른 파일보다 먼저 로딩되어야 하므로 async
나 defer
를 붙여주지 않습니다.
이제 Next.js 앱이 시작할 때 env.js
파일이 생성될 수 있도록 코드를 짜줍니다. next.config.ts
파일을 수정해줍니다.
import type { NextConfig } from "next";import { getAPIEndpoint } from "./lib/config";import { writeFileSync } from "node:fs";import { join } from "node:path";const envFilePath = join(process.cwd(), "public", "env.js");writeFileSync(envFilePath,`window.API_ENDPOINT = "${getAPIEndpoint()}";\n`,"utf8",);console.log("Environment file created at:", envFilePath);const nextConfig: NextConfig = {/* config options here */reactStrictMode: true,};export default nextConfig;
env.js
파일의 내용은 window.API_ENDPOINT = "${getAPIEndpoint()}";\n
이게 끝입니다. NEXT_PUBLIC_
여부를 떠나서 우리가 원하는 환경변수를 브라우저에서 바로 접근할 수 있도록 window
에 전역변수로 넣어줍니다.
서버 시작 전 무조건 next.config.ts
파일을 읽기 때문에 여기에 env.js
파일 생성 코드를 썼는데요, 매끄러운 코드는 아닙니다. next start
를 하기 직전에 다른 스크립트로 구현해도 괜찮습니다. 저 public
폴더 안에 있는 다양한 에셋 파일은 보통 빌드 과정에 채위지긴 하지만 꼭 그때에만 채워질 필요는 없습니다.
위 코드를 Docker 환경에서 잘 실행되도록 하려면 Docker Image 안에 config.ts
파일도 최종적으로 포함되어 있어야 합니다. 포함되어 있지 않으면 next.config.ts
파일을 읽을 때 config
파일 없다고 에러가 뜹니다.
# 빌드된 결과물만 복사COPY --from=builder /app/public ./publicCOPY --from=builder /app/.next ./.nextCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package.json ./package.jsonCOPY --from=builder /app/next.config.ts ./next.config.tsCOPY --from=builder /app/lib/config.ts ./lib/config.tsEXPOSE 3000CMD ["npm", "start"]
예제 컴포넌트를 만들어봤습니다.
"use client";import { getAPIEndpoint } from "@/lib/config";import useSWR from "swr";const fetcher = (...args: Parameters<typeof fetch>) =>fetch(...args).then((res) => res.json());export function Todo({ id }: { id: string }) {const { data, error, isLoading } = useSWR(`${getAPIEndpoint()}/todos/${id}`,fetcher,);if (error) {return <div>failed to load</div>;}if (isLoading) {return <div>loading...</div>;}return <div>title: {data.title ?? "NO_DATA"}</div>;}
getAPIEndpoint()
함수를 호출해서 API 경로를 가져오는 걸 확인할 수 있습니다. 이제 빌드 후 실행해봅시다. Next.js에서는 개발 서버와 production 빌드 서버 사이의 동작이 다른 경우가 있으므로 확실히 확인하기 위해서는 npm run build
및 npm run start
를 하도록 합니다.
npm run buildAPI_ENDPOINT=https://jsonplaceholder.typicode.com npm run start
실행하면 아래와 같이 출력되는 걸 확인할 수 있습니다.
> inject-env-page-router@0.1.0 start> next start▲ Next.js 15.4.2- Local: http://localhost:3000- Network: http://192.168.45.73:3000✓ Starting...Environment file created at: /Users/th.kim/Desktop/inject-env-nextjs-pages-router/public/env.jsEnvironment file created at: /Users/th.kim/Desktop/inject-env-nextjs-pages-router/public/env.js✓ Ready in 359ms
실제 동작을 확인해볼까요?

API_ENDPOINT
환경변수를 잘 가져다 쓰고 있는 모습훌륭합니다. 이로써 우리는 하나로 빌드 해놓고 환경변수를 다양하게 줘서 브라우저쪽 행동도 다르게 할 수 있음을 보였습니다.
이제 docker에서도 잘 동작하는지 확인해볼까요?
docker build --force-rm=true -t inject-env .docker run -p 3000:3000 --rm -it -e API_ENDPOINT=https://jsonplaceholder.typicode.com inject-env
로컬 빌드 + 실행과 똑같이 잘 된다는 걸 확인할 수 있습니다!
끼워넣기 Next.js 15 App Router
소스코드: https://github.com/echoja/inject-env-nextjs-app-router
Pages Router와 흐름은 똑같습니다. 시작할 때마다 env.js
파일을 만들어주고, 이 파일을 가장 먼저 로딩하도록 공통 레이아웃 코드를 수정해줍니다. App Router에서는 최상위의 layout.tsx 파일입니다. App Router는 Pages와 달리 env.js
가 head
의 뒷부분에 있어서 약간 불안한 느낌은 있습니다. 혹시나 해서 console.log
도 추가해봤습니다.
export default function RootLayout({children,}: Readonly<{children: React.ReactNode;}>) {return (<html lang="en"><head><script src="/env.js" /><scriptdangerouslySetInnerHTML={{__html: `console.log("API Endpoint:", window.API_ENDPOINT);`,}}/></head><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>);}
그 외에 env.js
파일을 생성하는 부분, 환경변수를 설정하는 부분 등은 모두 똑같아서 패스하겠습니다. 빌드와 실행도 똑같습니다.
# 로컬 빌드 및 실행npm run buildAPI_ENDPOINT=https://jsonplaceholder.typicode.com npm run start# 로컬에서 도커 빌드 및 실행docker build --force-rm=true -t inject-env .docker run -p 3000:3000 --rm -it -e API_ENDPOINT=https://jsonplaceholder.typicode.com inject-env
실패한 시도
🥲 아래 방법은 실패했습니다.
<Head><scriptdangerouslySetInnerHTML={{__html: `console.log('This script runs on the server side and is injected into the head of the document.');window.API_ENDPOINT = "${process.env.API_ENDPOINT}";`,}}/></Head>
처음에는 위처럼 직접 script
와 dangerouslySetInnerHTML
를 이용하여 환경변수를 넣어줬지만 빌드 시점 환경변수로 고정되어서 영영 사용할 수 없었습니다. Static Site Generation 페이지인지 아닌지 판단하는 기준을 정확히 알 순 없지만, 일단 SSG로 빌드됐다면 그 페이지는 실행 시점에 변경할 수 없으므로 환경변수로도 제어할 수 없습니다.
한계
env.js
를 삽입하는 방식은 Next.js 가 언급하는 패턴도 아니고, 저런 단순무식한 script
태그 삽입과 관련하여 next build
의 상세한 과정이나 로직이 문서에 명시되어 있는 것도 아니라서 좀 불안합니다. 우리가 임의로 삽입한 스크립트가 Next.js 내부 빌드 로직 변화에 의해 언제든지 영향받을 수 있습니다.
조금 더 검증된 방법이 필요하다면 next-runtime-env 라이브러리를 뜯어보시는 것도 좋을 것 같아요. 저도 여기 소스코드를 크게 참조하여 이 글의 아이디어를 얻었습니다. 어떻게 동작하는지 유심히 살펴보시려면 next-runtime-env 원리 파헤치기(by 개발자 류준열) 글도 참조해주시면 좋습니다.
Next.js가 공식적으로 추천하는 방법은 getServerSideProps를 쓰거나 App Router의 서버 컴포넌트로 하라 하네요. 그렇게 해서 해결할 수 있다면 굿.
번외
env.js
파일을 생성할 때 instrumentation.ts 파일(App Router)을 사용할 수도 있겠습니다. 저는 에너지가 다했으니 여러분이 한번 시도 해주세요...- 써놓고 보니 kakao ENT. 테크 블로그의 글 - Runtime 환경 변수 설정으로 빌드 프로세스 개선하기 과 상당히 유사하네요. 여기서는
.env
파일 생성도 스크립트로 넣었습니다. 참조해주시면 좋을 것 같습니다.
레퍼런스
- 소스코드(Pages Router): https://github.com/echoja/inject-env-nextjs-pages-router
- 소스코드(App Router): https://github.com/echoja/inject-env-nextjs-app-router
- next-runtme-env - npm
- Adding Custom Environment Variables | Create React App
- Routing: Custom Document | Next.js
NEXT_PUBLIC_
환경변수를 빌드 이후에 수정할 순 없나? 함께 알아보자.(by Jihan)- next-runtime-env 원리 파헤치기(by 개발자 류준열)
- Runtime 환경 변수 설정으로 빌드 프로세스 개선하기 | kakao ENT. TECH BLOG