콘텐츠로 이동
Study Note프론트엔드

Next.js 실전 — 빌드 타임과 런타임 설정

결론부터
NEXT_PUBLIC_은 런타임 공개 설정이 아니라 빌드 결과에 박는 공개 상수다 — 한 이미지를 여러 환경에 배포하려면 환경별 값을 서버 런타임에서 읽어 브라우저에 명시적으로 전달한다
이 장에서 처음 나오는 말3개
빌드 타임 인라이닝Build-time Inlining
next build가 환경변수 참조를 실제 문자열로 바꾸어 클라이언트 bundle에 넣는 과정이다.
런타임 설정Runtime Configuration
이미지를 다시 만들지 않고 container가 시작되거나 요청을 받을 때 결정하는 설정이다.
논리 키Logical Key
landing처럼 코드가 안정적으로 참조하고, 환경별 실제 값과는 배포 설정에서 연결하는 이름이다.

문제는 공개 여부보다 결정 시점이다

섹션 제목: “문제는 공개 여부보다 결정 시점이다”

한 Next.js 이미지를 온프렘 Kubernetes의 DEV와 PRD에 배포한다고 하자. Counter service는 하나지만 같은 화면도 환경마다 발급된 counter_id가 다르다.

화면DEVPRD
랜딩dev-counter-1001prd-counter-9001
상품 목록dev-counter-1002prd-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는 배포 설정이 소유한다.

소스의 landing 논리 키가 한 Docker 이미지로 빌드된 뒤 DEV와 PRD ConfigMap의 서로 다른 counter ID에 연결되고, 두 브라우저가 공용 Counter service를 직접 호출하는 흐름

이 분리는 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 매핑
      • 디렉터리prd/
        • counter-config.yaml PRD counter_id 매핑

Helm을 쓴다면 같은 역할을 values-dev.yaml과 values-prd.yaml이 맡으면 된다. ConfigMap 자체의 생성법과 env·volume 주입 방식은 Kubernetes 6장에서 더 깊게 다룬다.

apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-runtime-config
namespace: dev
data:
COUNTER_IDS_JSON: |
{
"landing": "dev-counter-1001",
"products": "dev-counter-1002"
}

공통 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_JSON

ConfigMap은 image에서 환경별 설정을 분리하기 위한 리소스다. Dockerfile에 ARG나 환경별 ENV를 선언할 필요가 없다. Dockerfile에는 image를 만드는 절차만 두고, 어느 환경의 값인지는 Deployment가 container 시작 시 결정한다.

App Router의 Route Handler가 Pod의 일반 환경변수를 읽어 공개해도 되는 항목만 반환한다. connection()은 이 handler가 build 때 정적 JSON으로 굳지 않고 요청 시 실행되어야 한다는 의도를 명시한다. 최신 Next.js에서 Route Handler는 기본적으로 캐시하지 않지만, 중간 proxy와 브라우저까지 고려해 응답에도 no-store를 붙인다.

app/api/runtime-config/route.ts
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에 넘긴다.

app/page.tsx
import { PageCounter } from '@/components/page-counter'
export default function LandingPage() {
return (
<>
<PageCounter counterKey="landing" />
<main>{/* 정적 랜딩 콘텐츠 */}</main>
</>
)
}

Client Component는 설정을 한 번 읽고 그 환경의 실제 ID로 공용 Counter를 직접 호출한다.

components/page-counter.tsx
'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에 실행한다.

둘을 섞지 않는다. output: 'export'인데 Route Handler에서 런타임 값을 읽으려 하거나, Next.js server가 있는데 entrypoint shell script로 bundle 문자열을 치환하는 것은 실행 모델을 흐리게 만든다.

  1. 논리 키 목록을 코드 리뷰한다

    화면의 counterKey와 DEV·PRD ConfigMap의 key가 모두 일치해야 한다. ID 누락을 조용히 무시하지 않는다.

  2. ConfigMap을 먼저 적용하고 새 Pod를 띄운다

    ConfigMap에서 가져온 환경변수는 실행 중인 process에 자동 반영되지 않는다. Kubernetes도 새 값을 받으려면 Pod를 교체해야 한다고 명시한다. Helm checksum annotation이나 배포 pipeline의 rollout 단계로 이를 보장한다.

  3. 환경별 설정 응답을 확인한다

    DEV와 PRD에서 /api/runtime-config를 열어 같은 논리 키가 서로 다른 ID로 해석되는지 본다. 응답에 secret이나 관계없는 환경변수가 섞이지 않았는지도 함께 본다.

  4. 브라우저 Network에서 실제 호출 경로를 확인한다

    Counter 요청이 기대한 ID를 쓰는지, frontend Route Handler가 실제 Counter 요청을 중계하지 않는지 확인한다.

  5. 롤백도 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한다