Next.js 실전 — 빌드 타임과 런타임 설정
NEXT_PUBLIC_은 런타임 공개 설정이 아니라 빌드 결과에 박는 공개 상수다 — 한 이미지를 여러 환경에 배포하려면 환경별 값을 서버 런타임에서 읽어 브라우저에 명시적으로 전달한다이 장에서 처음 나오는 말3개
빌드 타임 인라이닝Build-time Inliningnext build가 환경변수 참조를 실제 문자열로 바꾸어 클라이언트 bundle에 넣는 과정이다.런타임 설정Runtime Configuration- 이미지를 다시 만들지 않고 container가 시작되거나 요청을 받을 때 결정하는 설정이다.
논리 키Logical Keylanding처럼 코드가 안정적으로 참조하고, 환경별 실제 값과는 배포 설정에서 연결하는 이름이다.
문제는 공개 여부보다 결정 시점이다
섹션 제목: “문제는 공개 여부보다 결정 시점이다”한 Next.js 이미지를 온프렘 Kubernetes의 DEV와 PRD에 배포한다고 하자. Counter service는 하나지만
같은 화면도 환경마다 발급된 counter_id가 다르다.
| 화면 | DEV | PRD |
|---|---|---|
| 랜딩 | dev-counter-1001 | prd-counter-9001 |
| 상품 목록 | dev-counter-1002 | prd-counter-9002 |
두 ID는 브라우저가 Counter를 호출할 때 필요하므로 공개값이다. 그렇다고 NEXT_PUBLIC_에 넣으면
런타임 설정이 되는 것은 아니다. Next.js는 process.env.NEXT_PUBLIC_* 직접 참조를
next build 때 클라이언트 JavaScript에 인라이닝한다.
// 소스const id = process.env.NEXT_PUBLIC_LANDING_COUNTER_ID
// DEV 값으로 next build한 bundle의 의미const id = 'dev-counter-1001'그 뒤 같은 Docker image를 PRD에 올리면서 환경변수를 prd-counter-9001로 바꿔도 이미 만들어진
bundle은 변하지 않는다. 공개냐 비밀이냐와 빌드 때 정할지 실행할 때 정할지는 서로 다른 축이다.
| 값의 성격 | 읽는 곳 | 방식 |
|---|---|---|
| 비밀이며 서버에서만 필요 | 서버 | 일반 환경변수 + server-only |
| 공개이며 빌드마다 달라도 됨 | 브라우저 | NEXT_PUBLIC_* |
| 공개이며 같은 이미지에서 환경마다 다름 | 브라우저 | 일반 환경변수 → 런타임 설정 응답 |
| 완전 정적 export에서 환경마다 다름 | 브라우저 | web server가 제공하는 별도 JSON |
한 이미지와 두 설정
섹션 제목: “한 이미지와 두 설정”코드에는 화면의 의미인 논리 키만 둔다. 환경별 실제 counter_id는 배포 설정이 소유한다.
이 분리는 URL을 키로 쓰는 것보다 안정적이다. /products가 /catalog로 바뀌거나 locale prefix가
붙어도 화면 코드의 counterKey="products"는 유지할 수 있다. 실제 ID가 재발급되어도 application
code와 image는 그대로 두고 배포 설정만 바꾼다.
실제 ID는 Kubernetes 배포 설정에 둔다
섹션 제목: “실제 ID는 Kubernetes 배포 설정에 둔다”counter_id는 브라우저에 노출되는 값이므로 Secret이 아니라 ConfigMap 대상이다. 같은 이름의
ConfigMap을 DEV와 PRD namespace에 각각 만들면 공통 Deployment가 환경별 값을 받는다.
배포 파일을 저장소에서 관리한다면 다음처럼 코드와 환경별 값을 분리할 수 있다.
디렉터리deploy/
디렉터리base/
- deployment.yaml 공통 image와 환경변수 참조
디렉터리overlays/
디렉터리dev/
- counter-config.yaml DEV
counter_id매핑
- counter-config.yaml DEV
디렉터리prd/
- counter-config.yaml PRD
counter_id매핑
- counter-config.yaml PRD
Helm을 쓴다면 같은 역할을 values-dev.yaml과 values-prd.yaml이 맡으면 된다. ConfigMap 자체의
생성법과 env·volume 주입 방식은 Kubernetes 6장에서 더 깊게 다룬다.
apiVersion: v1kind: ConfigMapmetadata: name: frontend-runtime-config namespace: devdata: COUNTER_IDS_JSON: | { "landing": "dev-counter-1001", "products": "dev-counter-1002" }apiVersion: v1kind: ConfigMapmetadata: name: frontend-runtime-config namespace: prddata: COUNTER_IDS_JSON: | { "landing": "prd-counter-9001", "products": "prd-counter-9002" }공통 Deployment는 ConfigMap key를 일반 환경변수로 주입한다.
spec: template: spec: containers: - name: frontend image: registry.example.com/frontend@sha256:<same-image-digest> env: - name: COUNTER_IDS_JSON valueFrom: configMapKeyRef: name: frontend-runtime-config key: COUNTER_IDS_JSONConfigMap은 image에서 환경별 설정을 분리하기 위한
리소스다. Dockerfile에 ARG나 환경별 ENV를 선언할 필요가 없다. Dockerfile에는 image를 만드는
절차만 두고, 어느 환경의 값인지는 Deployment가 container 시작 시 결정한다.
Next.js 서버는 설정만 전달한다
섹션 제목: “Next.js 서버는 설정만 전달한다”App Router의 Route Handler가 Pod의 일반 환경변수를 읽어 공개해도 되는 항목만 반환한다.
connection()은 이 handler가 build 때 정적 JSON으로 굳지 않고 요청 시 실행되어야 한다는 의도를
명시한다. 최신 Next.js에서 Route Handler는 기본적으로 캐시하지 않지만,
중간 proxy와 브라우저까지 고려해 응답에도 no-store를 붙인다.
import { connection } from 'next/server'
function readCounterIds(): Record<string, string> { const raw = process.env.COUNTER_IDS_JSON if (!raw) throw new Error('COUNTER_IDS_JSON is not configured')
const value: unknown = JSON.parse(raw) if ( typeof value !== 'object' || value === null || Array.isArray(value) || Object.values(value).some((id) => typeof id !== 'string') ) { throw new Error('COUNTER_IDS_JSON must be a string map') }
return value as Record<string, string>}
export async function GET() { await connection()
try { return Response.json( { counterIds: readCounterIds() }, { headers: { 'Cache-Control': 'no-store' } }, ) } catch { return Response.json( { message: 'Runtime configuration is invalid' }, { status: 500 }, ) }}이 endpoint는 Counter service를 호출하지 않는다. 브라우저가 사용할 ID 매핑만 돌려준다. 전체
process.env를 반환하거나 prefix 규칙으로 자동 공개하지 말고, 위처럼 공개 가능한 필드만 allowlist한다.
페이지 본문은 그대로 정적 렌더링할 수 있다. 별도 Route Handler를 브라우저에서 조회한다고 그 페이지의 Server Component까지 동적 렌더링되는 것은 아니다.
화면은 논리 키로 실제 ID를 찾는다
섹션 제목: “화면은 논리 키로 실제 ID를 찾는다”각 화면은 배포 환경을 모르고 자기 논리 키만 PageCounter에 넘긴다.
import { PageCounter } from '@/components/page-counter'
export default function LandingPage() { return ( <> <PageCounter counterKey="landing" /> <main>{/* 정적 랜딩 콘텐츠 */}</main> </> )}Client Component는 설정을 한 번 읽고 그 환경의 실제 ID로 공용 Counter를 직접 호출한다.
'use client'
import { useEffect, useRef } from 'react'
type RuntimeConfig = { counterIds: Record<string, string>}
let runtimeConfigPromise: Promise<RuntimeConfig> | undefined
function getRuntimeConfig() { runtimeConfigPromise ??= fetch('/api/runtime-config', { cache: 'no-store', }).then((response) => { if (!response.ok) throw new Error('Runtime config request failed') return response.json() as Promise<RuntimeConfig> })
return runtimeConfigPromise}
export function PageCounter({ counterKey }: { counterKey: string }) { const sentKey = useRef<string | null>(null)
useEffect(() => { if (sentKey.current === counterKey) return sentKey.current = counterKey
void getRuntimeConfig() .then(({ counterIds }) => { const counterId = counterIds[counterKey] if (!counterId) throw new Error(`Unknown counter key: ${counterKey}`)
// 실제 Counter service의 path와 method 계약에 맞춘다. return fetch(`/counter/${encodeURIComponent(counterId)}`, { cache: 'no-store', }).then((response) => { if (!response.ok) throw new Error('Counter request failed') }) }) .catch((error) => { sentKey.current = null console.error(error) }) }, [counterKey])
return null}Counter가 이미 별도 origin으로 공개되어 있고 해당 frontend origin을 CORS로 허용한다면 마지막 URL만
절대 주소로 바꾸면 된다. 같은 host의 /counter를 Ingress가 Counter service로 보내게 하면 요청은
브라우저 → Ingress → Counter Pod로 가며 frontend Pod가 중계하지 않는다.
Node server와 완전 정적 export는 갈린다
섹션 제목: “Node server와 완전 정적 export는 갈린다”지금까지 방식은 next start나 output: 'standalone'처럼 Pod 안에 Next.js runtime server가 있을 때
사용한다. 한 image를 여러 환경에 승격하는 공식 경로도 서버의 dynamic rendering에서 일반 환경변수를
읽는 방식이다.
output: 'export'는 다르다. build 결과가 HTML·JavaScript·CSS뿐이라 요청 시 process.env를 읽을
Next.js server가 없다. GET Route Handler도 build 때 정적 파일로 생성되므로
환경별 ID를 전달할 수 없다.
ConfigMap → Pod env → /api/runtime-config → 브라우저페이지는 정적으로 렌더링해도 된다. 설정 endpoint만 request time에 실행한다.
ConfigMap file → Nginx의 /runtime-config.json → 브라우저ConfigMap의 JSON 파일을 /usr/share/nginx/html/runtime-config.json 같은 실제 static root에 mount하고,
브라우저가 /runtime-config.json을 읽게 한다. 이 파일은 Next.js build 산출물이 아니라 container가
시작될 때 web server에 붙는 배포 설정이다.
둘을 섞지 않는다. output: 'export'인데 Route Handler에서 런타임 값을 읽으려 하거나, Next.js server가
있는데 entrypoint shell script로 bundle 문자열을 치환하는 것은 실행 모델을 흐리게 만든다.
배포와 검증 순서
섹션 제목: “배포와 검증 순서”-
논리 키 목록을 코드 리뷰한다
화면의
counterKey와 DEV·PRD ConfigMap의 key가 모두 일치해야 한다. ID 누락을 조용히 무시하지 않는다. -
ConfigMap을 먼저 적용하고 새 Pod를 띄운다
ConfigMap에서 가져온 환경변수는 실행 중인 process에 자동 반영되지 않는다. Kubernetes도 새 값을 받으려면 Pod를 교체해야 한다고 명시한다. Helm checksum annotation이나 배포 pipeline의 rollout 단계로 이를 보장한다.
-
환경별 설정 응답을 확인한다
DEV와 PRD에서
/api/runtime-config를 열어 같은 논리 키가 서로 다른 ID로 해석되는지 본다. 응답에 secret이나 관계없는 환경변수가 섞이지 않았는지도 함께 본다. -
브라우저 Network에서 실제 호출 경로를 확인한다
Counter 요청이 기대한 ID를 쓰는지, frontend Route Handler가 실제 Counter 요청을 중계하지 않는지 확인한다.
-
롤백도 image와 config의 조합으로 검증한다
이전 image가 새 ConfigMap의 논리 키를 이해하는지 확인한다. 호환되지 않으면 image만 되돌려도 복구되지 않는다.
NODE_ENV로 DEV와 PRD를 구분하지 않는다. 두 배포 모두 최적화된 production server라면
NODE_ENV=production이다. 환경의 차이는 namespace와 ConfigMap 같은 배포 설정으로 표현한다.
런타임 설정 요약
섹션 제목: “런타임 설정 요약”NEXT_PUBLIC_*는 public + build time이고 public runtime 변수가 아니다- 같은 Docker image를 DEV와 PRD에 배포하면 일반 환경변수를 request time에 읽는다
- 코드에는
landing같은 논리 키, ConfigMap에는 환경별 실제counter_id를 둔다 - Next.js Route Handler는 설정만 전달하고 실제 Counter는 브라우저가 직접 호출할 수 있다
output: 'export'에는 runtime server가 없으므로 web server가 별도 JSON을 제공한다- ConfigMap을 env로 주입했다면 변경 뒤 반드시 새 Pod로 rollout한다