도커 실전 강좌 #3 React/Next.js 빌드 컨테이너 — standalone과 NEXT_PUBLIC의 의미
#1 FastAPI와 #2 Django + DB가 백엔드 두 형태였다면, 이번 글은 프런트엔드입니다. 백엔드와 프런트엔드는 컨테이너화의 결이 좀 다릅니다. 빌드 산출물(.next, dist/)이 서버보다 더 중요하고, 환경변수가 빌드 시점에 굳어버리는 경우도 있습니다.
도커 실전 강좌에서 이번 글의 위치:
- #1 FastAPI 컨테이너화
- #2 Django + PostgreSQL compose
- #3 React/Next.js 빌드 컨테이너 — standalone과 NEXT_PUBLIC의 의미 ← 이번 글
- #4 CI에서 이미지 빌드 — GitHub Actions
- #5 레지스트리 푸시와 태그 전략
- #6 클라우드 배포 — Fly.io / Railway / ECS
Next.js로 블로그 만들기 시리즈의 결과물에 가까운 앱을 도커에 담는다고 가정하겠습니다. 마지막 절에서 Vite 같은 SPA를 nginx로 정적 호스팅하는 옵션도 짚습니다.
프런트엔드 컨테이너화의 결 #
먼저 결을 잡아 둡니다.
| 백엔드 (FastAPI/Django) | 프런트엔드 (Next.js/Vite) | |
|---|---|---|
| 런타임 산출물 | 인터프리터 + 코드 + venv | 빌드 산출물 + (선택) Node |
| 의존성 | 런타임에도 존재 | 거의 빌드 시점만 (devDeps 큼) |
| 환경변수 | 런타임에 읽음 | 일부는 빌드 시점에 박힘 |
| 컨테이너의 용도 | API 서버 | (a) Next 서버 또는 (b) 정적 파일 서버 |
특히 두 번째와 세 번째 차이가 결정적입니다.
- devDependencies — TypeScript, ESLint, Tailwind, build 도구. 빌드에는 필요하지만 런타임엔 불필요. 멀티스테이지가 거의 필수.
- 빌드 시점 환경변수 —
NEXT_PUBLIC_*, Vite의VITE_*. 빌드할 때 정적 치환되므로 같은 이미지를 환경마다 다르게 쓸 수 없습니다. 이 부분은 한 번 짚어둘 가치가 있습니다.
Next.js standalone output — 빌드를 컨테이너 친화적으로 #
Next.js의 기본 빌드는 node_modules 전체를 들고 다녀야 동작합니다. 이미지를 슬림하게 만들고 싶다면 next.config.ts에서 standalone 옵션을 켜는 게 정석입니다.
import type { NextConfig } from 'next';
const config: NextConfig = {
output: 'standalone',
};
export default config;켜면 next build가 .next/standalone/ 디렉터리에 꼭 필요한 의존성만 트레이싱해서 묶어 줍니다. node_modules 전체가 아니라, 실제로 import 된 파일만.
빌드 후 디렉터리:
.next/
├── standalone/
│ ├── server.js # 진입점
│ ├── node_modules/ # 트레이싱된 부분만
│ └── ...
└── static/ # 별도로 복사 필요
public/ # 별도로 복사 필요이 세 항목(standalone/, .next/static, public/)를 runtime stage에 가져가면 됩니다.
Dockerfile — 세 단계 구성 #
deps → builder → runner의 정석 패턴으로 구성합니다.
# ─── 1. deps — 의존성 설치 ───────────────────
FROM node:22-alpine AS deps
WORKDIR /app
# 패키지 매니저별로: pnpm 권장. npm/yarn도 비슷.
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
# ─── 2. builder — 빌드 ──────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* 가 필요하면 ARG로 받음 (다음 절 참고)
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm build
# ─── 3. runner — 실행 ───────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# standalone + static + public만 복사
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
EXPOSE 3000
CMD ["node", "server.js"]핵심 포인트를 짚으면 다음과 같습니다.
- deps stage는
package.json+ lock 파일만 먼저 복사 → 의존성 변경이 없으면 캐시 적중. 코드만 바뀌면 이 stage는 통과. - builder stage는 deps의
node_modules를 가져와 빌드. devDeps가 여기까지만 있으면 됨. - runner stage는 standalone 산출물만 가져옴. devDeps도 없고, 빌드 도구도 없음. 결과 이미지 ~150MB 수준 (베이스 alpine 자체가 ~50MB).
HOSTNAME=0.0.0.0은 Next.js standalone 서버가 0.0.0.0에 바인딩하도록. 빠뜨리면 컨테이너 밖에서 접근 안 됨.node server.js가 standalone의 진입점.npm start(=next start)가 아닙니다.
.dockerignore — 빌드 컨텍스트가 핵폭탄이 되지 않도록
#
Node 프로젝트에서 가장 흔히 빠뜨려서 사고가 나는 지점입니다.
node_modules
.next
.next-build
.git
.env
.env.*
!.env.production.example
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
.DS_Store
*.log
coverage
.vscode
.idea
README.mdnode_modules가 빠지면 호스트의 거대한 node_modules가 매 빌드마다 컨텍스트로 전송됩니다. 수 GB가 도커 데몬에 흘러가서 빌드가 2~3 분씩 걸립니다. 반드시 첫 줄에.
.next도 마찬가지. 호스트의 빌드 산출물이 컨테이너로 들어가면 next build가 깨질 수 있습니다.
NEXT_PUBLIC의 의미 — 빌드 시점에 박힌다 #
여기가 프런트엔드 컨테이너화에서 가장 자주 함정에 빠지는 지점입니다.
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';NEXT_PUBLIC_* 접두어가 붙은 환경변수는 빌드 시점에 정적 치환됩니다. 즉 pnpm build가 도는 순간 process.env.NEXT_PUBLIC_API_URL 위치가 실제 값으로 바뀐 채 번들에 포함됩니다. 런타임에 컨테이너에 -e NEXT_PUBLIC_API_URL=...을 줘도 효과가 없습니다. 번들에는 빌드 시점 값이 굳어 있기 때문입니다.
이게 의미하는 바:
- “한 이미지를 모든 환경(stage/prod)에 쓴다” 가 안 됩니다. 환경별로 다른 이미지를 빌드해야 합니다.
- CI에서 빌드할 때 환경변수를
--build-arg로 주입해야 합니다.
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 빌드 시 주입받음
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm builddocker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.prod.example.com \
-t myapp:prod .서버 사이드에서만 쓰는 환경변수(접두어 없음)는 런타임에 줘도 됩니다. 거기엔 함정이 없습니다.
// app/api/route.ts
const dbUrl = process.env.DATABASE_URL; // 빌드에 안 박힘, 런타임에 읽음
정적 export 옵션 — output: 'export'
#
서버 기능(SSR, API routes, ISR)을 안 쓰고 정적 사이트로 충분하다면 standalone 대신 정적 export가 더 가볍습니다.
import type { NextConfig } from 'next';
const config: NextConfig = {
output: 'export',
};
export default config;pnpm build 결과가 out/ 디렉터리에 정적 HTML/JS/CSS로 떨어집니다. 그러면 컨테이너는 굳이 Node일 필요가 없습니다 — nginx 같은 정적 서버가 더 가벼움.
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN pnpm build
FROM nginx:1-alpine AS runner
COPY --from=builder /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
# 정적 자산 — 캐시 길게
location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA 라우팅 폴백
location / {
try_files $uri $uri.html $uri/ /index.html;
}
}listen 8080으로 둔 이유 — 비루트 사용자도 열 수 있는 포트이기 때문. 1024 미만은 못 엽니다 (#1의 non-root 주제에서 짚은 점).
try_files의 /index.html 폴백은 SPA 라우팅을 위한 것입니다. 이 폴백이 없으면 /posts/123 같은 경로로 새로고침했을 때 404가 납니다.
Vite SPA의 경우 #
React 또는 다른 SPA를 Vite로 만들었다면 형태가 거의 같습니다. 빌드 산출물이 dist/ 라는 점만 다르고, 환경변수는 NEXT_PUBLIC_* 대신 VITE_*가 빌드 시점에 박힙니다.
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm build
FROM nginx:1-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080서버 컴포넌트가 필요 없는 React 앱이라면 이 형태가 가장 작고 빠릅니다. 결과 이미지 ~50MB 수준.
환경변수 분리 전략 — 정리 #
빌드 시점 vs 런타임에 어떤 값이 들어가는지 한 번 더 정리합니다.
| 변수 | Next.js | Vite | 어디서 주입 |
|---|---|---|---|
| 클라이언트에 노출되는 값 | NEXT_PUBLIC_* | VITE_* | 빌드 시 --build-arg |
| 서버에서만 쓰는 값 | 접두어 없음 | (Vite는 클라이언트 전용) | 런타임 -e/--env-file |
| API 키 같은 시크릿 | 절대 NEXT_PUBLIC 금지 | 절대 VITE_ 금지 | 서버 측에서만, 런타임 |
가장 흔한 실수는 API 키를 NEXT_PUBLIC로 노출하는 것입니다. 클라이언트 번들에 그대로 포함되고, 브라우저 DevTools → Sources에서 노출됩니다. 평문 그대로 외부에 드러나는 셈입니다.
compose에 함께 묶기 — 백엔드 + 프런트엔드 #
#2의 백엔드 구도에 프런트엔드를 추가해 봅니다.
services:
db:
image: postgres:17
env_file: .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 5s
retries: 10
api:
build: ./backend
env_file: .env
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
web:
build:
context: ./frontend
args:
NEXT_PUBLIC_API_URL: http://localhost:8000
ports:
- "3000:3000"
depends_on:
- api
volumes:
pg-data:build.args는 빌드 시점에 --build-arg로 전달되는 값입니다. compose에서 이걸 명시해 두면 환경별 compose 파일로 다른 값을 줄 수 있습니다. (예: compose.prod.yaml에서 NEXT_PUBLIC_API_URL: https://api.prod.example.com)
브라우저가 호출할 API URL은 컨테이너 네트워크 이름(api)이 아니라 호스트에서 보이는 URL 이어야 한다는 점이 미묘합니다. 브라우저는 도커 네트워크 안에 있지 않기 때문입니다.
흔한 함정 #
이미지가 1GB 넘게 부풀어 있음 — devDeps가 runtime stage까지 따라간 것. output: 'standalone' 또는 정적 export 후 nginx 패턴을 쓰세요.
빌드는 됐는데 컨테이너 시작 시 “Cannot find module …” — standalone 트레이싱이 일부 동적 import를 놓친 경우. 보통 serverComponentsExternalPackages 또는 outputFileTracingIncludes 옵션으로 해결.
NEXT_PUBLIC_*가 적용 안 됨 — 런타임에만 설정한 것. --build-arg로 빌드 시 주입했는지 확인.
Apple Silicon 빌드가 클라우드(amd64)에서 안 돔 — docker buildx build --platform linux/amd64 .... 이 문제는 고급 #2 멀티 아키와 정확히 같습니다.
HMR이 안 됨 (개발 모드) — Next는 컨테이너 안에서 fs 변경 감지가 잘 안 되는 환경이 있습니다. compose.override.yaml로 코드를 bind mount 하고, WATCHPACK_POLLING=true 환경변수를 줘야 동작하기도 합니다.
정리 #
- 프런트엔드 컨테이너화는 devDependencies 분리와 빌드 시점 환경변수 두 요소가 핵심.
- Next.js는
output: 'standalone'으로 트레이싱된 산출물만 runner stage에 복사 — 이미지 ~150MB. - 정적 사이트라면
output: 'export'+ nginx가 더 가벼움 — 이미지 ~50MB. SPA 라우팅 폴백(try_files ... /index.html)을 잊지 말 것. NEXT_PUBLIC_*/VITE_*는 빌드 시점에 정적 치환 됨. 환경별로 다른 이미지를 빌드해야 함.--build-arg로 주입.- 절대 시크릿/API 키를
NEXT_PUBLIC/VITE_로 노출하지 말 것 — 브라우저 번들에 평문으로 박힘. - compose에서
build.args로 빌드 시점 값 관리. 브라우저가 보는 URL은 컨테이너 네트워크 이름이 아니라 호스트 URL.
다음 글(#4 CI에서 이미지 빌드)에서는 로컬 빌드를 떠나 CI로 갑니다. GitHub Actions의 docker/build-push-action, BuildKit 캐시 (GHA cache), 멀티 아키 빌드, 빌드 시간 최적화, 시크릿 다루기까지 정리합니다.