Next.js 빌드 과정, .next 폴더, standalone 배포

X Facebook

Written by

in

📌 Next.js 16 공식 문서(Building, output, Self-Hosting, Deploying, next CLI)와 board-api/web 의 실제 빌드 결과(Next.js 16.3.5, Turbopack, 2026-09-23 빌드)를 기준으로 정리했다. 폴더 크기는 이 빌드에서 잰 값이다.

정의

빌드는 개발할 때 쓰던 소스 코드(TypeScript, JSX, CSS)를 운영 서버에서 바로 돌릴 수 있는 결과물로 바꾸는 작업이다. Next.js 에서는 next build 가 이 일을 하고, 결과물을 .next/ 폴더에 쓴다.

.next/ 는 Next.js 가 만드는 작업 폴더다. 빌드 결과물, 다음 빌드를 빠르게 하는 캐시, 개발 서버(next dev)의 결과물이 모두 여기에 들어간다. 직접 고치는 폴더가 아니고, 지워도 다시 만들어진다. git 에도 올리지 않는다.

standalone 은 next.config 에 output: 'standalone' 을 넣었을 때 나오는 배포용 결과물이다. 서버 실행 파일(server.js)과 실행에 꼭 필요한 node_modules 만 골라 .next/standalone/ 에 담는다. 이 폴더만 있으면 pnpm install 없이 node server.js 로 서버가 뜬다.

개요

Next.js 앱을 운영에 올리는 흐름은 크게 세 단계다.

  1. 빌드: next build 가 코드를 묶고 최적화하고, 미리 만들 수 있는 페이지는 HTML 로 만들어 둔다.

  2. 패키징: 빌드 결과를 서버로 옮길 형태로 만든다. Node.js 서버로 그대로 올리거나, Docker 이미지로 만들거나, 정적 파일만 뽑는다.

  3. 실행: 운영 서버에서 next start 또는 standalone 의 node server.js 로 띄운다.

공식 문서가 안내하는 배포 방식은 네 가지다.

방식 실행 방법 기능 지원
Node.js 서버 next build → next start 전부
Docker 컨테이너 보통 output: 'standalone' 으로 빌드하고 node server.js 로 실행 전부
정적 내보내기 output: 'export' → HTML·CSS·JS 만 웹 서버에 올림 일부. 서버가 필요한 기능(API, 요청마다 렌더링 등)은 안 된다
어댑터 Vercel 같은 플랫폼이 빌드·배포 방식을 맞춰 줌 플랫폼마다 다름

board-api/web 은 Docker 방식이다. /api/graphql BFF 와 /api/auth/* 쿠키 프록시가 이 서버에서 돌기 때문에, 정적 파일만으로는 안 되고 계속 떠 있는 Node.js 서버가 필요하다. 그래서 standalone 으로 빌드해 Railway 에 Docker 이미지로 올린다.

역할

next build 가 하는 일 (6단계)

공식 문서는 next build 를 아래 순서로 설명한다.

  1. 준비: .env* 파일을 읽고, next.config 가 올바른지 확인하고, 이번 빌드를 구별할 빌드 ID 를 만든다.

  2. 라우트 찾기: app/, pages/ 폴더를 훑어 어떤 URL 이 있는지 찾는다. proxy, instrumentation 같은 루트 파일도 이때 찾고, 라우트 타입 정의를 만든다.

  3. 컴파일: 브라우저용·서버용·edge 용 코드를 각각 묶는다. 기본 번들러는 Turbopack 이고 --webpack 으로 바꿀 수 있다. TypeScript·JSX 를 JS 로 바꾸고, 안 쓰는 코드를 지우고(tree-shaking), CSS 와 폰트를 최적화한다. 타입 검사도 함께 돈다.

  4. 정적 분석: 라우트마다 빌드 때 미리 만들지, 요청이 올 때 만들지 정한다. generateStaticParams 결과를 모으고, 미리 만들기를 막는 오류가 있는지 검사한다.

  5. 미리 렌더링: 미리 만들 수 있는 페이지를 HTML 로 만든다. 클라이언트 쪽 페이지 이동에 쓰는 RSC 데이터(.rsc)도 함께 만든다.

  6. 출력: 결과를 .next/ 에 쓴다. output: 'standalone' 이면 실행에 필요한 파일만 따로 모으고, output: 'export' 면 정적 사이트 전체를 만든다. 마지막에 라우트 표를 출력한다.

라우트 표 읽기

빌드가 끝나면 터미널에 라우트마다 기호가 붙은 표가 나온다.

기호 이름 뜻
○ Static 빌드 때 완성된 HTML. 요청이 와도 렌더링하지 않고 그대로 보낸다
◐ Partial Prerender 빈 틀(셸)은 미리 만들고, 바뀌는 부분은 요청 때 스트리밍으로 채운다 (Cache Components 사용 시)
● SSG generateStaticParams 등으로 미리 만든 HTML
ƒ Dynamic 요청이 올 때마다 서버에서 렌더링

ƒ 가 많으면 그만큼 서버가 일을 많이 한다는 뜻이다. 의도하지 않은 ƒ 가 보이면 그 페이지에서 cookies(), headers(), 캐시되지 않은 fetch 같은 요청 시점 기능을 쓰고 있는지 확인한다.

.next/ 폴더 안에 무엇이 있나

board-api/web/.next 를 열어 본 결과다. 전체 405MB 였는데, 대부분은 실행에 필요 없는 개발 서버 결과물과 캐시였다.

경로 크기 하는 일 운영 서버에 필요한가
BUILD_ID 21B 이번 빌드의 ID (예: mDMCQFnYD2-WdjEnw94Ci) 필요
server/ 11MB 서버에서 실행할 코드. server/app/ 에 미리 만든 페이지(index.html, login.html), RSC 데이터(.rsc), 메타 정보(.meta)가 있고, server/chunks/ 에 서버용 JS 묶음이 있다 필요
static/ 5.8MB 브라우저가 내려받는 JS·CSS·폰트. 파일 이름에 해시가 붙어 있다(chunks/0tvl1xarz4sc5.css). static/<빌드ID>/ 에는 _buildManifest.js 같은 빌드별 목록이 있다. URL 로는 /_next/static/… 이다 필요
*-manifest.json 수 KB 라우트 목록(routes-manifest.json), 미리 만든 페이지 목록(prerender-manifest.json), 페이지별 JS 목록(build-manifest.json) 같은 지도. 서버가 요청을 받으면 이 파일들을 보고 어디서 응답할지 찾는다 필요
required-server-files.json 10KB 서버 실행에 필요한 설정과 파일 목록 필요
next-server.js.nft.json 257KB Next.js 서버가 실행 중에 읽는 파일 목록(파일 추적 결과). standalone 이 이 목록을 보고 파일을 고른다 standalone 을 만들 때 씀
standalone/ 39MB output: 'standalone' 일 때만 생기는 배포용 묶음 이것만 옮긴다
cache/ 110MB 다음 next build 를 빠르게 하는 캐시(cache/turbopack) 필요 없음. 빌드 서버(CI)에서만 남겨 둔다
dev/ 237MB next dev 의 결과물과 캐시. Next.js 16 부터 빌드와 폴더가 나뉘어 next dev 와 next build 를 동시에 돌려도 서로 덮어쓰지 않는다 필요 없음
types/, trace, diagnostics/ 작음 라우트 타입 정의, 빌드 단계별 소요 시간 기록, 진단 정보 필요 없음

standalone 이 해결하는 문제

next start 로 운영 서버를 띄우려면 package.json 의 dependencies 가 전부 설치돼 있어야 한다. 모노레포라면 워크스페이스 전체를 설치해야 하는 경우도 많다. board-api 의 루트 node_modules 는 1.7GB 였다.

standalone 은 빌드할 때 @vercel/nft 로 코드의 import, require, fs 사용을 분석해서 실제로 읽히는 파일만 골라 복사한다. board-api/web 의 standalone/node_modules 는 36MB 였다. 들어간 패키지는 next, react, react-dom, @next/env, styled-jsx, sharp(이미지 최적화)와 그 하위 패키지까지 14개였다.

실전예제

1. next.config.ts 설정

board-api/web/next.config.ts 실제 내용이다.

import path from 'node:path'
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // 배포용 자립 실행본을 만든다
  output: 'standalone',
  // 모노레포라 파일 추적 기준점을 레포 루트로 잡는다
  outputFileTracingRoot: path.join(import.meta.dirname, '..'),
}

export default nextConfig

outputFileTracingRoot 가 필요한 이유는 모노레포 구조 때문이다. 기본값이면 web/ 폴더를 기준으로 파일을 추적하는데, pnpm 은 실제 패키지를 레포 루트의 node_modules/.pnpm/ 에 두고 web/node_modules 에는 심볼릭 링크만 둔다. 기준점이 web/ 이면 루트 쪽 실제 파일을 따라가지 못해 모듈이 빠진다.

2. 빌드 후 standalone 구조

기준점을 레포 루트로 잡았기 때문에, standalone 안에도 레포 구조가 그대로 재현된다.

경로 (.next/standalone/ 기준) 내용
node_modules/.pnpm/ 골라낸 실제 패키지들
web/server.js 서버 실행 파일. next start 대신 이걸 실행한다
web/.next/ BUILD_ID, server/, 각종 manifest. static/ 은 없다
web/node_modules/next 루트의 .pnpm 을 가리키는 심볼릭 링크
web/package.json 원래 package.json 사본

그래서 실행 명령이 node server.js 가 아니라 node web/server.js 다.

server.js 를 열어 보면 하는 일은 단순하다. NODE_ENV 를 production 으로 정하고, PORT(기본 3000)와 HOSTNAME(기본 0.0.0.0) 환경 변수를 읽는다. 빌드 때의 next.config 값을 JSON 으로 박아 둔 채 Next.js 의 startServer 를 호출한다. 빌드 뒤에 next.config 를 고쳐도 다시 빌드하기 전에는 반영되지 않는 이유가 이것이다.

3. 정적 파일은 직접 옮겨야 한다

standalone 은 public/ 과 .next/static/ 을 복사하지 않는다. 공식 문서는 이 두 폴더를 CDN 에서 내보내는 것을 기본으로 보기 때문이다. 서버가 직접 내보내게 하려면 아래처럼 복사한다. 복사해 두면 server.js 가 알아서 내보낸다.

cp -r public .next/standalone/web/
cp -r .next/static .next/standalone/web/.next/

board-api/web 의 standalone 을 복사 없이 3999 포트로 잠깐 띄워 확인했다.

요청 응답 이유
/ (페이지) 200 server/app/index.html 은 standalone 안에 있다
/_next/static/chunks/0tvl1xarz4sc5.css 404 .next/static 을 복사하지 않았다
/next.svg (public/ 파일) 404 public/ 을 복사하지 않았다
/favicon.ico 200 public/ 이 아니라 app/favicon.ico 라서 빌드 결과에 들어 있다

HTML 은 오는데 CSS·JS 가 404 라서, 화면이 스타일 없이 깨지고 버튼도 동작하지 않는다. standalone 배포에서 가장 흔한 실수다.

4. Dockerfile (멀티 스테이지)

board-api/web/Dockerfile 의 구조다. HEALTHCHECK 줄은 빼고, 주석은 노트용으로 새로 달았다. 레포 루트에서 docker build -f web/Dockerfile . 로 빌드한다.

FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app

# 1) 의존성 설치: lockfile 이 같으면 이 층은 캐시를 그대로 쓴다
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY web/package.json web/
RUN pnpm install --frozen-lockfile --filter board-api-web...

# 2) 빌드: 소스를 넣고 next build
FROM deps AS build
COPY web/ web/
RUN pnpm --filter board-api-web build

# 3) 실행: standalone 과 정적 파일만 옮긴다
FROM base AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app/web/.next/standalone ./
COPY --from=build /app/web/.next/static ./web/.next/static
COPY --from=build /app/web/public ./web/public
USER node
EXPOSE 3000
CMD ["node", "web/server.js"]
  • deps 단계: package.json 과 lockfile 만 먼저 복사한다. 소스만 바뀌고 의존성이 그대로면 Docker 가 이 단계를 캐시에서 꺼내 쓰므로, 매번 설치하지 않는다.

  • build 단계: 여기서 next build 가 돈다. 이 단계의 이미지에는 node_modules 전체와 .next/cache 가 들어 있어 무겁다.

  • runtime 단계: build 단계에서 standalone, static, public 세 덩어리만 꺼내 온다. 무거운 node_modules 와 캐시는 최종 이미지에 들어가지 않는다.

  • USER node: 루트 권한이 아닌 사용자로 서버를 돌린다.

Railway 설정(railway.json)은 이 Dockerfile 로 이미지를 만들고 node web/server.js 로 띄운 뒤, / 에 요청을 보내 서버가 살아 있는지 확인한다(healthcheckPath).

심화내용

빌드한 곳과 실행하는 곳의 OS 가 같아야 한다

board-api/web 의 standalone 에는 @img/sharp-darwin-arm64 가 들어 있었다. 맥(Apple Silicon)에서 빌드했기 때문에 맥용 이미지 처리 바이너리가 골라진 것이다. 이 폴더를 그대로 리눅스 서버에 올리면 sharp 가 동작하지 않는다. 그래서 standalone 은 실행할 환경과 같은 OS 에서 빌드해야 한다. Dockerfile 안에서 빌드하면 리눅스(alpine)용 바이너리가 골라지므로 이 문제가 없다.

빌드 때 고정되는 것들

  • NEXT_PUBLIC_ 환경 변수: 빌드할 때 JS 에 문자열로 박힌다. 이미지 하나를 개발·운영에 같이 쓰면 모든 곳에서 빌드 때 값이 쓰인다.

  • next.config 값: server.js 안에 JSON 으로 박힌다.

  • 미리 렌더링된 페이지(○): 빌드 때 데이터로 만든 HTML 이다. 데이터가 바뀌면 다시 빌드하거나 revalidate 해야 한다.

  • 서버 액션 암호화 키: 빌드마다 새로 만들어진다.

서버 쪽 환경 변수는 동적 렌더링(await connection() 이후 등) 안에서 읽으면 실행할 때의 값을 쓴다. 이 내용은 .env 노트에 따로 정리했다.

서버를 여러 대 띄울 때

컨테이너를 여러 개 띄우거나 무중단 배포(롤링)를 하면 아래를 맞춰야 한다.

  • 같은 빌드 결과를 쓴다. 컨테이너마다 따로 빌드하면 빌드 ID 와 파일 해시가 달라진다. 환경마다 다시 빌드해야 한다면 generateBuildId 로 git 커밋 해시 같은 같은 값을 쓴다.

  • 서버 액션 키를 고정한다. 빌드마다 키가 새로 생기므로, 다른 빌드의 서버가 요청을 받으면 Failed to find Server Action 오류가 난다. NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 를 빌드할 때 지정해 같은 키를 쓴다.

  • deploymentId 로 버전 차이를 막는다. 배포 중에 브라우저는 옛 버전 JS 를 갖고 있는데 서버는 새 버전일 수 있다. deploymentId 를 정해 두면 버전이 다를 때 Next.js 가 페이지를 새로 고쳐 맞춘다.

  • 캐시를 공유한다. ISR·use cache 결과는 기본으로 각 서버의 메모리(기본 50MB)와 디스크에 저장된다. 서버끼리 공유되지 않으므로, 필요하면 Redis 같은 저장소를 쓰는 cache handler 를 붙인다.

빌드 캐시

.next/cache 는 다음 빌드를 빠르게 한다. 같은 폴더에서 다시 빌드하면 자동으로 쓰이지만, Docker 빌드는 매번 빈 상태에서 시작하므로 따로 캐시를 마운트하지 않으면 쓰이지 않는다. CI 에서는 .next/cache 를 캐시 대상으로 지정해야 한다. 지정하지 않으면 빌드 로그에 No Cache Detected 경고가 나온다.

모노레포에서 파일 추적 다듬기

파일 추적이 필요한 파일을 놓치거나 쓸데없는 파일을 넣을 때가 있다. 이때는 next.config 에서 직접 지정한다.

const nextConfig = {
  outputFileTracingIncludes: {
    '/*': ['node_modules/sharp/**/*'], // 빠진 파일 넣기
  },
  outputFileTracingExcludes: {
    '/api/*': ['./big-fixtures/**/*'], // 필요 없는 파일 빼기
  },
}

키는 라우트 경로이고, 값은 프로젝트 루트(next.config 가 있는 폴더) 기준 경로 패턴이다. 완전히 정적인 페이지와 Edge 런타임 라우트에는 적용되지 않는다.

직접 운영할 때 챙길 것

  • 리버스 프록시: 공식 문서는 Next.js 서버를 인터넷에 바로 열지 말고 nginx 같은 프록시를 앞에 두라고 권한다. 이상한 요청, 느린 연결 공격, 요청 크기 제한을 프록시가 먼저 걸러 준다.

  • 스트리밍: 프록시가 응답을 모았다가 한 번에 보내면 loading.tsx·Suspense 스트리밍이 동작하지 않는다. nginx 라면 X-Accel-Buffering: no 헤더로 버퍼링을 끈다.

  • 종료 신호: 서버를 끌 때 SIGTERM 을 보내고 잠시 기다리면 처리 중인 요청과 after() 작업을 마치고 꺼진다. 공식 문서는 10~30초의 대기 시간을 권한다. board-api 의 Railway 설정 drainingSeconds: 15 가 이 대기 시간이다.

빌드 오류 디버깅

운영 빌드는 서버 코드를 압축하고 소스맵을 만들지 않아서 오류 위치가 잘 안 보인다. next build --debug-prerender 로 다시 빌드하면 압축을 끄고 소스맵을 켜서, 오류가 난 파일과 줄을 보여 준다. 이 옵션으로 만든 결과물은 최적화가 빠져 있으므로 배포하지 않는다. 큰 앱에서 한 라우트만 빌드해 보려면 next build --debug-build-paths="app/products/[id]/page.tsx" 처럼 범위를 좁힌다.

정리

  • next build 는 준비 → 라우트 찾기 → 컴파일 → 정적 분석 → 미리 렌더링 → 출력 순서로 돌고, 결과를 .next/ 에 쓴다.

  • .next/ 에서 운영에 필요한 것은 server/, static/, manifest, BUILD_ID 다. cache/ 와 dev/ 는 운영에 필요 없다.

  • output: 'standalone' 은 실행에 필요한 파일만 .next/standalone/ 에 모은다. board-api/web 에서는 1.7GB node_modules 가 36MB 로 줄었다.

  • standalone 은 public/ 과 .next/static/ 을 넣지 않는다. 직접 복사하지 않으면 페이지는 뜨지만 CSS·JS 가 404 가 된다.

  • 모노레포에서는 outputFileTracingRoot 를 레포 루트로 잡는다. 그러면 standalone 안에 레포 구조가 재현되고, 실행 파일 경로가 web/server.js 가 된다.

  • standalone 은 실행할 OS 에서 빌드한다. 네이티브 바이너리(sharp 등)가 빌드한 OS 기준으로 골라진다.

  • NEXT_PUBLIC_ 값, next.config, 미리 만든 페이지, 서버 액션 키는 빌드 때 고정된다. 서버를 여러 대 띄우면 빌드 ID·서버 액션 키·deploymentId·캐시를 맞춘다.

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다