DEV BOX

'26/11/02 UPDATE

WEB / NEXT.JS / FIELD NOTES

환경변수를 바꾸고 다시 시작했는데, Next.js 화면에는 왜 예전 주소가 나올까

NEXT_PUBLIC_API_BASE를 바꾸고 next start를 다시 했는데 화면은 예전 API 주소를 부릅니다. Next.js 16.4.3에서 A 값으로 빌드하고 B 값으로 시작해 보니, NEXT_PUBLIC_ 변수와 미리 만든 페이지는 빌드 때 값에 머물고 NEXT_PUBLIC_가 없는 서버 변수만 요청 때 값을 읽었습니다. 어떤 값이 빌드 때 굳는지 가려내는 법과 고치는 세 가지 길을 실제 실행 결과로 정리합니다.

스테이징에서 만든 Docker 이미지를 운영 서버에 그대로 올리고, NEXT_PUBLIC_API_BASE만 운영 주소로 바꿔 next start를 다시 했다고 해 봅시다. 서버는 새 값으로 떴는데 화면은 여전히 스테이징 API를 부릅니다. 설정을 잘못 넣은 것이 아니라, 그 값이 이미 next build 때 코드 안에 글자로 박혔기 때문입니다. 이 글을 읽고 나면 코드에서 환경변수를 읽는 자리마다 그 값이 빌드 때 굳는지, 요청마다 새로 읽히는지 가려낼 수 있고, 환경마다 달라야 하는 값을 어디서 읽어야 하는지 정할 수 있습니다.

예제는 2026-10-25에 macOS 27.0.1, Node.js v24.21.0, npm으로 설치한 Next.js 16.4.3에서 실행했습니다. 별도 옵션 없이 next build를 돌리면 Turbopack으로 빌드되었고, 비교를 위해 --webpack 빌드도 같은 순서로 돌렸습니다. build.example.test와 runtime.example.test는 시험용으로 예약된 .test 도메인(RFC 2606)의 가상 주소입니다.

빌드 때는 A, 시작할 때는 B를 넣어 보기

App Router 앱을 세 파일로 만들었습니다. 빈 레이아웃(app/layout.js), 요청마다 렌더링되는 페이지, 그리고 그 페이지가 품은 클라이언트 컴포넌트입니다. 페이지는 두 변수를 읽고, 클라이언트 컴포넌트는 NEXT_PUBLIC_ 변수 하나를 읽습니다.

// app/page.js
import { connection } from 'next/server';
import ClientBase from './client-base';

export default async function Page() {
  await connection(); // render this page on each request
  return (
    <main>
      <p>{`server NEXT_PUBLIC_API_BASE=${process.env.NEXT_PUBLIC_API_BASE}`}</p>
      <p>{`server API_BASE=${process.env.API_BASE}`}</p>
      <ClientBase />
    </main>
  );
}

// app/client-base.js
'use client';
export default function ClientBase() {
  return <p>{`client NEXT_PUBLIC_API_BASE=${process.env.NEXT_PUBLIC_API_BASE}`}</p>;
}

두 변수를 모두 https://build.example.test로 두고 빌드한 뒤, 같은 빌드 결과를 두 변수 모두 https://runtime.example.test로 바꿔 시작했습니다. 빌드 때 미리 만들어지는 페이지와 비교하려고 connection() 없이 API_BASE만 읽는 /static 페이지도 하나 더 두었습니다.

Next.js 16.4.3 실행 결과. NEXT_PUBLIC_API_BASE와 API_BASE를 https://build.example.test로 두고 next build(Turbopack, exit 0): / 와 /api/config, /lookup, /props는 ƒ(요청마다 렌더링), /static은 ○(미리 렌더링). 두 값을 https://runtime.example.test로 두고 next start 뒤 curl /: server NEXT_PUBLIC_API_BASE=https://build.example.test, server API_BASE=https://runtime.example.test, client NEXT_PUBLIC_API_BASE=https://build.example.test. curl /static: static API_BASE=https://build.example.test. .next/static에서 build.example.test가 든 파일은 chunks/3xaa8z2t_e59x.js 하나이고 그 문자열은 "client NEXT_PUBLIC_API_BASE=https://build.example.test". runtime.example.test가 든 파일은 0개.
build 주소로 빌드하고 runtime 주소로 시작한 실제 출력입니다. NEXT_PUBLIC_ 변수는 서버와 브라우저 양쪽에서 빌드 때 주소를 냈고, 미리 만든 페이지도 빌드 때 값에 머물렀습니다.

요청마다 렌더링되는 페이지인데도 NEXT_PUBLIC_API_BASE는 서버 쪽과 클라이언트 쪽 모두 빌드 때 주소였습니다. 시작할 때 준 주소를 읽은 것은 NEXT_PUBLIC_가 없는 API_BASE뿐이었습니다. 같은 페이지를 Chromium 계열 브라우저(Chrome 152)로 열어 하이드레이션이 끝난 뒤 읽어 보아도 세 줄은 같았고, 콘솔 경고는 없었습니다. 빌드 결과 폴더를 찾아보면 이유가 보입니다. 브라우저로 내려가는 JS 파일 안에 빌드 때 주소가 문자열로 들어 있고, 시작할 때 준 주소는 .next 어디에도 없습니다.

next build가 NEXT_PUBLIC_ 자리를 글자로 바꿔 둔다

Next.js 환경변수 안내서는 이 동작을 '인라인'이라고 부릅니다. next build는 이름이 NEXT_PUBLIC_로 시작하는 변수에 대해 코드 속 process.env.NEXT_PUBLIC_… 참조를 모두 빌드를 실행한 환경의 값으로 바꿔 둡니다. 브라우저로 보내는 JS뿐 아니라 Node.js 쪽 코드도 대상이라서, 서버 컴포넌트에서 읽어도 빌드 때 값이 나옵니다. 안내서는 빌드가 끝난 앱은 이 변수들이 바뀌어도 더는 반응하지 않는다고 적고, 하나의 Docker 이미지를 여러 환경에 올리면 모든 NEXT_PUBLIC_ 변수가 빌드 때 값으로 얼어 있다고 경고합니다.

개발 노트

next build가 굳히는 값, 요청마다 읽는 값

A 값으로 빌드하고 B 값으로 시작했을 때 각 자리에서 나온 값

  • 브라우저로 가는 코드

    client … = build.example.test

    • process.env.NEXT_PUBLIC_*가 빌드 때 값으로 바뀜
    • JS 파일에 주소가 글자 그대로 남음
  • 서버 코드의 NEXT_PUBLIC_

    server NEXT_PUBLIC_… = build

    • 서버 번들에서도 같은 자리가 바뀜
    • next start 때 준 값은 쓰이지 않음
  • 빌드 때 미리 만든 페이지

    static API_BASE = build

    • connection() 같은 요청 API를 쓰지 않음
    • 빌드 때 만든 HTML을 그대로 돌려줌
  • 요청 때 읽는 서버 변수

    server API_BASE = runtime

    • NEXT_PUBLIC_가 없는 이름
    • await connection() 뒤에서 읽음

2026-10-25 Next.js 16.4.3에서 Turbopack과 webpack 빌드 모두 이렇게 나왔습니다. build·runtime.example.test는 가상의 주소입니다.

NEXT_PUBLIC_ 변수와 미리 만든 페이지는 next build 때 값으로 굳습니다. 요청 때 값을 읽는 것은 NEXT_PUBLIC_가 없는 서버 변수를 동적 렌더링에서 읽을 때뿐이었습니다.

안내서는 process.env[varName]이나 const env = process.env처럼 변수를 거쳐 읽으면 인라인되지 않는다고 예를 듭니다. 이 두 방식을 그대로 옮긴 /lookup 페이지를 같은 앱에 넣어 보니 결과는 문서와 달랐습니다. Turbopack 빌드에서는 두 방식 모두 빌드 때 주소가 나왔고, webpack 빌드에서는 process.env[varName]만 시작할 때 주소를 읽었습니다. 컴파일된 서버 코드를 열어 보니 빌드 때 주소가 나온 자리에는 그 주소가 문자열로 들어 있었습니다. 실행 중에 문자열을 이어 붙여 만든 이름(['NEXT_PUBLIC', 'API_BASE'].join('_'))만 두 빌드 모두에서 시작할 때 값을 읽었습니다.

Next.js 16.4.3 실행 결과. 기본(Turbopack) 빌드에서 curl /lookup: lookup process.env[varName]=https://build.example.test, lookup env.NEXT_PUBLIC_API_BASE=https://build.example.test, lookup process.env[joined]=https://runtime.example.test. next build --webpack(exit 0)과 next start 뒤 curl /lookup: lookup process.env[varName]=https://runtime.example.test, lookup env.NEXT_PUBLIC_API_BASE=https://build.example.test, lookup process.env[joined]=https://runtime.example.test.
Next.js 안내서가 인라인되지 않는다고 든 두 조회 방식을 같은 페이지에서 읽은 실제 출력입니다. 번들러에 따라 결과가 달랐고, 두 번들러 모두에서 빌드 때 값이 나온 방식도 있었습니다.

그러니 조회 방식을 바꿔 인라인을 피하려 하지 않는 편이 낫습니다. 같은 16.4.3에서도 빌드 도구에 따라 결과가 갈렸습니다. 요청 때 읽어야 하는 값은 이름에서 NEXT_PUBLIC_를 빼는 것이 문서가 안내하는 길입니다.

요청 때 읽히는 것은 동적 렌더링 안의 서버 변수다

안내서는 환경변수가 기본적으로 서버에서만 쓰이고, 동적 렌더링 중에는 서버에서 안전하게 읽을 수 있다며 await connection() 뒤에서 process.env를 읽는 예를 듭니다. connection()은 실제 요청이 올 때까지 렌더링을 기다리게 하므로 그 뒤의 코드는 요청 때 실행됩니다. 문서는 이 방식으로 Docker 이미지 하나를 값만 바꿔 여러 환경에 올릴 수 있다고 설명합니다. process.env는 그 프로세스가 받은 사용자 환경을 담은 객체(Node.js 문서)이므로, next start에 준 값은 서버 코드가 요청 때 이 객체를 읽을 때 쓰입니다.

다만 NEXT_PUBLIC_를 빼는 것만으로는 부족합니다. /static 페이지는 API_BASE를 읽었지만 빌드 때 주소를 냈습니다. connection()이나 cookies(), headers() 같은 요청 시점 API를 하나도 부르지 않는 페이지는 next build가 미리 HTML로 만들어 두기 때문입니다. 빌드 결과 표에서 /static은 ○(미리 렌더링), /는 ƒ(요청마다 렌더링)로 표시되었습니다. 응답 헤더도 달랐습니다. /static은 x-nextjs-cache: HIT와 Cache-Control: s-maxage=31536000을, /는 private, no-cache, no-store를 돌려주었습니다. 환경변수를 요청 때 읽게 하려면 그 페이지가 빌드 표에서 ƒ로 나오는지부터 확인해야 합니다.

변수마다 빌드 때 값인지, 요청 때 값인지 정하기

값마다 먼저 정할 것은 하나입니다. 빌드마다 하나로 정해져도 되는가, 환경마다 달라야 하는가. 답에 따라 길이 셋으로 갈립니다.

개발 노트

값마다 고를 세 가지 길

먼저 빌드 때 값인지, 요청 때 값인지 정한다

  • 1 · 환경마다 다시 빌드

    NEXT_PUBLIC_API_BASE=… next build

    • NEXT_PUBLIC_를 그대로 쓰는 방법
    • 값을 바꾸면 next build부터 다시
  • 2 · 서버에서 읽어 넘기기

    apiBase={process.env.API_BASE}

    • connection() 뒤 서버 컴포넌트가 읽음
    • 클라이언트 컴포넌트에 props로 전달
  • 3 · 직접 만든 엔드포인트

    GET /api/config

    • Route Handler가 요청 때 값을 돌려줌
    • 브라우저가 처음에 받아 감
  • 비밀값은 어느 길로도 보내지 않기

    API_KEY: server only

    • NEXT_PUBLIC_ 값은 JS 파일에 그대로 남음
    • 2·3번으로 보낸 값도 브라우저에 보임

2·3번은 2026-10-25 Next.js 16.4.3에서 실행해 next start 때 준 값이 나오는 것을 확인했습니다.

환경마다 달라야 하는 값은 NEXT_PUBLIC_를 빼고 서버에서 요청 때 읽습니다. 브라우저에 보내는 값은 어느 길로 보내든 공개된다고 보고, 비밀값은 서버에만 둡니다.

첫째, 빌드마다 정해져도 된다면 NEXT_PUBLIC_를 그대로 두고 환경마다 그 값으로 다시 빌드합니다. 안내서도 이 값들은 프로젝트를 빌드할 때 알맞게 넣어 두어야 한다고 말합니다. 둘째, 환경마다 달라야 한다면 이름을 API_BASE처럼 바꾸고, connection() 뒤의 서버 컴포넌트에서 읽어 클라이언트 컴포넌트에 props로 넘깁니다. 셋째, 브라우저가 따로 받아야 한다면 안내서 말대로 값을 돌려주는 API를 직접 만듭니다. 아래는 같은 빌드를 runtime 주소로 시작한 뒤 둘째와 셋째 방식을 호출한 실제 출력입니다. Turbopack과 webpack 빌드 모두 같았습니다.

$ curl -s localhost:4392/props | grep -oE '<p>[^<]+</p>' | sed 's/<[^>]*>//g'
client apiBase prop=https://runtime.example.test
$ curl -s localhost:4392/api/config
{"apiBase":"https://runtime.example.test"}

엔드포인트를 Route Handler로 만들 때는 안에서 await connection()을 먼저 부르는 편이 안전합니다. Next.js의 Route Handler 문서에 따르면 Route Handler는 기본으로 캐시되지 않지만, Cache Components를 켜면 요청 데이터를 쓰지 않는 GET 핸들러가 빌드 때 미리 만들어질 수 있고 connection()을 부르면 그렇게 되지 않습니다. 예제 앱은 Cache Components를 켜지 않았습니다.

비밀값은 어느 길로도 브라우저에 보내지 않습니다. NEXT_PUBLIC_ 값은 누구나 받아 가는 JS 파일에 글자 그대로 남고, props나 엔드포인트로 보낸 값도 브라우저에 닿는 순간 공개됩니다. API 주소처럼 보여도 되는 값만 이 길로 보내고, 키와 토큰은 서버 코드 안에서만 씁니다.

기억할 한 줄은 이것입니다. NEXT_PUBLIC_ 값은 next build를 실행한 그 순간의 값입니다. 운영에서 예전 주소가 보인다면 빌드 결과에서 grep -rl <예전 주소> .next/static을 한 번 돌려 보세요. 파일이 나온다면 다시 시작해서는 바뀌지 않으니, 다시 빌드하거나 그 값을 서버에서 요청 때 읽도록 옮겨야 합니다.

끝 · END OF NOTE
RSS 구독목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…