- Published on
Next.js 14 빌드 OOM·느려짐 해결 - SWC 캐시·메모리 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 next build가 갑자기 느려지거나, 로컬에서는 되는데 CI에서만 Killed, JavaScript heap out of memory, OOMKilled로 죽는 경우는 대부분 (1) SWC/webpack 단계의 피크 메모리, (2) 캐시 미스에 따른 재컴파일 폭증, (3) 소스맵/트레이싱/이미지 최적화 같은 부가 작업이 겹치면서 발생합니다. Next.js 14는 Turbopack 개발 경험이 좋아졌지만, 프로덕션 빌드는 여전히 SWC + webpack 파이프라인에 의해 좌우되는 구간이 많습니다.
이 글에서는 “원인 파악 → 캐시 고정 → 피크 메모리 낮추기 → CI/컨테이너 튜닝” 순서로, 재현 가능한 체크리스트와 설정 예제를 정리합니다.
관련해서 서버 액션/캐시 꼬임으로 빌드/런타임이 함께 흔들리는 케이스는 아래 글도 같이 보면 원인 분리가 빨라집니다.
1) 증상별로 먼저 “어디서” 터지는지 분리하기
OOM/느려짐은 같은 현상처럼 보여도 터지는 지점이 다릅니다.
1-1. 대표 로그 패턴
- Node 힙 부족
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
- 컨테이너/쿠버네티스 OOMKilled
- 파드 이벤트에
OOMKilled, exit code 137
- 파드 이벤트에
- 빌드가 끝나긴 하는데 극단적으로 느림
- 특정 단계(Collecting page data / Creating an optimized production build / Generating static pages)에서 정체
컨테이너에서 죽는다면 Node 힙을 올려도 컨테이너 limit을 넘으면 그대로 OOMKilled입니다. 쿠버네티스에서의 진단은 아래 글의 메모리 스파이크/limit/oom score 관점이 그대로 적용됩니다.
1-2. 가장 먼저 할 것: 빌드 단계 프로파일링
빌드 시간을 쪼개서 봐야 “최적화”가 됩니다. Next는 기본 로그만으로는 부족하니, 최소한 아래를 켭니다.
# 빌드 타임/단계 파악에 도움
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS="--trace-gc" \
next build
--trace-gc는 GC가 과도하게 도는지(=힙 압박) 힌트를 줍니다.- CI에서는 로그가 길어질 수 있으니, 문제 재현 브랜치에서만 켜는 편이 좋습니다.
2) SWC 캐시를 “안정적으로” 살리는 것이 1순위
Next 빌드가 느려지는 가장 흔한 이유는 캐시가 매번 날아가서 SWC/webpack이 풀 재컴파일을 하기 때문입니다.
2-1. 캐시 디렉터리 이해: .next/cache
- SWC 변환 결과, webpack 캐시 등이
.next/cache에 쌓입니다. - CI에서 워크스페이스가 매번 초기화되거나, Docker 레이어가 깨지면 캐시가 0이 됩니다.
2-2. GitHub Actions 캐시 예제
name: build
on: [push]
jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Restore Next.js cache
uses: actions/cache@v4
with:
path: |
.next/cache
key: next-cache-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('next.config.*', 'src/**', 'app/**', 'pages/**') }}
restore-keys: |
next-cache-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
- run: npm run build
포인트:
- 캐시 키에
next.config.*와 소스 해시를 섞어 설정/소스가 변했을 때만 무효화합니다. restore-keys로 부분 히트라도 살립니다.
2-3. Docker에서 캐시 살리기(멀티 스테이지 + BuildKit)
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
# BuildKit 캐시 마운트로 .next/cache 유지
RUN \
npm run build
- CI에서 Docker 빌드가 느린데 로컬은 빠르다면, 대부분 이
.next/cache가 매번 초기화됩니다.
3) “피크 메모리”를 낮추는 Next.js 14 설정들
캐시를 살렸는데도 OOM이 나면, 이제는 빌드 피크 메모리 자체를 낮추는 설정으로 갑니다.
3-1. 소스맵: production에서 정말 필요한지 재검토
프로덕션 빌드에서 소스맵은 메모리/시간을 크게 올릴 수 있습니다.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: false,
}
module.exports = nextConfig
- Sentry 같은 서비스로 소스맵 업로드가 필요하면, 빌드 컨테이너의 메모리 limit을 올리거나, 빌드/업로드 파이프라인을 분리하는 편이 안전합니다.
3-2. output: 'standalone' + 파일 트레이싱 최소화
standalone은 런타임 배포에는 유리하지만, 파일 트레이싱/번들링 과정에서 비용이 증가할 수 있습니다. 트레이싱 범위를 줄이거나(가능한 경우) 불필요한 서버 의존성을 줄이면 빌드 메모리도 같이 내려갑니다.
// next.config.js
const nextConfig = {
output: 'standalone',
experimental: {
// 프로젝트에 따라 효과가 다르므로 케이스별 검증 필요
// outputFileTracingRoot: path.join(__dirname, '../../'),
},
}
module.exports = nextConfig
3-3. 번들 크기/의존성 폭발 방지: 큰 라이브러리 서버 전용 분리
빌드 OOM의 본질은 “변환해야 할 코드가 너무 많다”인 경우가 많습니다. 특히 다음 패턴이 위험합니다.
- 클라이언트 컴포넌트에서 큰 라이브러리 import
app/에서 무심코use client를 넓게 적용- 폴리필/유틸 패키지 중복
해결은 간단히 말해 클라이언트 번들에서 제거하는 것입니다.
// app/report/page.tsx (서버 컴포넌트)
import { generateHeavyReport } from '@/lib/server/report' // 서버 전용
export default async function Page() {
const data = await generateHeavyReport()
return <pre>{JSON.stringify(data, null, 2)}</pre>
}
// lib/server/report.ts
import 'server-only'
import * as parquet from 'parquetjs-lite' // 예: 무거운 의존성
export async function generateHeavyReport() {
// ...
return { ok: true }
}
server-only로 클라이언트 유입을 막으면, 번들 크기와 SWC 변환량이 줄어듭니다.
4) Node 메모리 튜닝: 올리는 것보다 “맞추는 것”
OOM이 JavaScript heap out of memory라면 Node 힙 상한을 올리면 즉시 완화될 수 있습니다. 다만 컨테이너 limit과 함께 맞춰야 합니다.
4-1. 기본 처방: --max-old-space-size
# 4GB 힙
export NODE_OPTIONS="--max-old-space-size=4096"
next build
권장 감각:
- 컨테이너 메모리 limit이 6GB라면, 힙을 4GB 정도로 두고 나머지를 네이티브/버퍼/오버헤드로 남깁니다.
- limit이 4GB인데 힙을 4096으로 주면, 힙 외 영역 때문에 여전히 OOMKilled가 날 수 있습니다.
4-2. CI에서만 터질 때: 병렬성/CPU 차이 고려
CI 머신은 코어 수가 많아 병렬 작업이 늘어 피크 메모리가 커질 수 있습니다. 빌드가 병렬로 돌아가면서 메모리를 더 쓰는 형태라면:
- CI 러너를 작은 타입으로 바꾸거나
- 빌드 잡을 분리하거나
- (모노레포라면) 패키지별 빌드 순서를 직렬화
같은 방식이 더 효과적일 때가 많습니다.
5) 모노레포/대규모 코드베이스에서 자주 먹히는 전략
5-1. 빌드 타겟을 분리: app/pages 혼재 정리
혼재 자체가 문제는 아니지만, 레거시 pages와 app이 함께 있을 때 라우팅/번들 경로가 복잡해져 빌드 비용이 증가하는 경우가 있습니다. 가능하면 점진적으로 app으로 이동하면서 클라이언트 컴포넌트 범위를 최소화하세요.
5-2. 타입체크/린트를 빌드에서 분리
next build에 타입체크/린트가 붙어 있으면, 빌드 피크가 커집니다. CI에서는 다음처럼 분리하는 편이 안정적입니다.
{
"scripts": {
"lint": "next lint",
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "next build"
}
}
그리고 CI에서는:
npm run lint
npm run typecheck
npm run build
- 실패 지점이 명확해지고, 빌드 단계의 메모리 압박이 줄어듭니다.
6) 컨테이너/쿠버네티스에서의 실전 튜닝 포인트
6-1. limit과 request를 현실적으로
Next 빌드는 순간 피크가 큽니다. request를 너무 낮게 잡으면 노드가 압박을 받고, limit이 낮으면 OOMKilled로 바로 끝납니다.
- 빌드 전용 Job/Pod를 분리하고
- 빌드 Pod에만 높은 limit을 주며
- 런타임 Pod는 별도로 최적화
하는 구성이 가장 안전합니다.
6-2. OOMKilled 원인 추적 루틴
- 파드 이벤트/Exit code 137 확인
- 노드 메모리 압박 여부 확인
- 동일 커밋에서 캐시 히트/미스 비교
쿠버네티스 OOM 분석 루틴은 아래 글의 흐름을 그대로 적용할 수 있습니다.
7) 빠르게 적용하는 “체크리스트”
7-1. 느려짐(시간) 우선 체크
-
.next/cache가 CI/Docker에서 유지되는가? -
node_modules설치가 매번 풀로 도는가? (패키지 매니저 캐시) - 소스맵/트레이싱/이미지 최적화 같은 부가 비용이 과한가?
- 큰 라이브러리가 클라이언트 번들에 섞였는가?
7-2. OOM 우선 체크
- 에러가 Node 힙(OOM)인가, 컨테이너 OOMKilled인가?
-
NODE_OPTIONS=--max-old-space-size를 limit에 맞게 설정했는가? - 타입체크/린트를 빌드에서 분리했는가?
- 모노레포라면 빌드 병렬성이 피크를 키우지 않는가?
8) 결론: 캐시로 “재컴파일”을 줄이고, 튜닝으로 “피크”를 낮춘다
Next.js 14 빌드 OOM/느려짐은 대부분 SWC 캐시 미스 → 재컴파일 폭증, 혹은 소스맵/대형 의존성/병렬성이 만든 피크 메모리 문제로 귀결됩니다.
- 먼저
.next/cache를 CI/Docker에서 안정적으로 살리고, 2) 클라이언트 번들에 들어간 큰 의존성을 서버로 격리하며, 3) 소스맵/트레이싱 같은 옵션을 재검토하고, 4) 마지막으로 Node 힙과 컨테이너 limit을 함께 맞추면 재현성 있게 해결되는 경우가 많습니다.
빌드가 안정화된 뒤에도 서버 액션/캐시 레이어 문제로 런타임이 흔들릴 수 있으니, 증상이 섞여 보인다면 아래 글로 원인 분리를 병행하는 것을 권합니다.