- Published on
Next.js App Router 빌드 OOM·메모리 폭증 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트와 정적 최적화가 결합된 Next.js App Router는 성능과 DX가 뛰어나지만, 빌드 타임에는 생각보다 많은 일을 합니다. 라우트 트리 분석, RSC 번들링, 정적 페이지 생성, 이미지/폰트 최적화, 소스맵 생성, 타입체크까지 한 번에 몰리면서 CI나 로컬에서 메모리가 급격히 치솟고 결국 OOM으로 터지는 케이스가 자주 나옵니다.
이 글은 "일단 NODE_OPTIONS=--max-old-space-size=...로 늘리면 되죠" 수준이 아니라, 어떤 패턴이 메모리를 잡아먹는지 분류하고, 어떤 순서로 진단하면 시간을 아끼는지, 그리고 App Router에서 특히 많이 밟는 지뢰를 코드와 설정으로 정리합니다.
빌드/런타임 장애를 추적할 때는 브라우저 성능 이슈를 5분 안에 좁히는 방법론이 유사하게 통합니다. 원인 후보를 넓게 두고, 계측으로 빠르게 제거하는 방식이죠. 필요하면 Chrome INP 점수 급락 - Long Task 5분 추적법처럼 "측정 가능한 지표"를 먼저 확보하는 습관이 도움이 됩니다.
증상 패턴: OOM은 보통 4가지로 갈린다
App Router 빌드 OOM은 대개 아래 중 하나(또는 복합)입니다.
- 정적 생성(SG/SSG) 단계에서 데이터가 너무 큼
generateStaticParams가 너무 많은 경로를 뿜거나, 각 경로에서 대용량 데이터를 가져와 JSON으로 빌드 산출물에 포함
- 번들러/소스맵/미니파이 단계가 과도
- 소스맵 생성, 대형 의존성 번들링, 트리쉐이킹 실패로 메모리 급증
- 서버 컴포넌트에서 빌드 타임 실행되는 코드가 무거움
- 모듈 최상단에서 DB/SDK 초기화, 대형 JSON 로드, 동적 import 남발 등
- 이미지/폰트/MDX 등 자산 처리 파이프라인이 과함
- 빌드 중 이미지 최적화, 대량의 MDX/마크다운 파싱, 하이라이팅 등
이제부터는 "당장 죽지 않게" 만드는 완화책과 "근본 원인 제거"를 분리해서 설명합니다.
0단계: 재현 조건을 고정하고 로그를 확보한다
OOM은 재현이 흔들리면 시간만 날립니다. 아래를 먼저 고정하세요.
- Node 버전: 로컬과 CI가 다르면 메모리 프로파일이 크게 달라집니다.
- Next.js 버전: App Router는 마이너 버전에서도 빌드/캐시 동작이 바뀝니다.
- 빌드 커맨드:
next build외에next lint,tsc가 같이 도는지 분리합니다.
빌드 로그는 최소한 아래를 켭니다.
# 리눅스/맥
NODE_OPTIONS='--trace-gc --trace-gc-verbose' next build
# 윈도우 PowerShell
$env:NODE_OPTIONS='--trace-gc --trace-gc-verbose'; next build
GC 로그만으로도 "어느 시점부터 old space가 계속 증가하는지"가 보입니다.
1단계: 임시 완화책 (CI를 살려야 할 때)
1) Node 힙 상한 올리기
가장 흔한 처방입니다. 다만 근본 해결이 아니라, 원인이 남아 있으면 결국 다시 터집니다.
NODE_OPTIONS='--max-old-space-size=6144' next build
- CI가 7GB 메모리인데 6GB로 올리면 OS/다른 프로세스가 죽을 수 있습니다.
- 컨테이너라면 메모리 limit과 함께 조정해야 합니다.
2) 소스맵을 줄이거나 끄기
프로덕션 소스맵은 메모리와 빌드 시간을 크게 올립니다. 필요할 때만 켜세요.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: false,
}
module.exports = nextConfig
Sentry 같은 도구를 쓴다면 "빌드에서 소스맵 생성"과 "업로드"가 같이 메모리를 올릴 수 있으니 분리하거나, 업로드를 별도 잡으로 빼는 것도 방법입니다.
3) CI에서 병렬성 낮추기
타입체크/린트/빌드가 동시에 돌면 피크 메모리가 합산됩니다.
next build와tsc --noEmit을 분리 실행- 워크플로우에서 잡을 분리하거나 순차 실행
2단계: App Router에서 OOM을 만드는 대표 원인과 해결
원인 A: generateStaticParams 폭발 (경로 수가 너무 많음)
App Router에서 가장 흔한 빌드 OOM은 "정적 경로 생성이 너무 많다"입니다.
문제 코드 예시
// app/posts/[id]/page.tsx
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
// posts가 수십만이면 빌드가 사실상 크롤링이 됨
return posts.map((p: { id: string }) => ({ id: p.id }))
}
이 패턴은 빌드가 API 전체를 긁고, 수십만 라우트를 렌더링하며, 결과를 파일로 저장하면서 메모리가 급증합니다.
해결 전략 1: 정적 생성 범위를 제한
- 상위 N개만 정적 생성하고 나머지는 런타임 렌더링
- 또는 "최근 7일" 같은 기준으로 제한
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/posts?limit=2000', {
// 빌드 시점 캐시를 쓰되, 범위를 제한
next: { revalidate: 3600 },
})
const posts = await res.json()
return posts.map((p: { id: string }) => ({ id: p.id }))
}
해결 전략 2: dynamicParams로 미리 생성 강제하지 않기
// app/posts/[id]/page.tsx
export const dynamicParams = true
정적 params에 없는 경로도 요청 시 렌더링하도록 열어두면, 빌드가 모든 경로를 떠안지 않습니다.
해결 전략 3: 페이지를 SG에서 ISR 또는 동적 렌더링으로 전환
- 빌드 OOM을 피하려면 "빌드에서 렌더링"을 줄여야 합니다.
// app/posts/[id]/page.tsx
export const revalidate = 60
또는 완전 동적이면:
export const dynamic = 'force-dynamic'
주의: force-dynamic은 캐시/성능에 영향이 있으니, 먼저 ISR로 충분한지 확인하는 게 좋습니다.
원인 B: 서버 컴포넌트 모듈 최상단에서 무거운 작업
App Router의 서버 컴포넌트는 빌드 단계에서도 분석/번들링 과정에서 모듈 로딩이 얽히며, 최상단 코드가 예상치 못하게 비용을 만들 수 있습니다.
문제 패턴
// lib/heavy.ts
import bigJson from './big.json'
// 모듈 로드만으로 메모리 점유
export const dict = new Map(Object.entries(bigJson))
또는 SDK/클라이언트를 전역에서 초기화:
// lib/db.ts
import { Client } from 'pg'
export const db = new Client({ connectionString: process.env.DATABASE_URL })
await db.connect()
해결: 지연 로딩과 경량화
// lib/heavy.ts
export async function getDict() {
const bigJson = (await import('./big.json')).default
return new Map(Object.entries(bigJson))
}
DB 연결은 빌드에서 필요하지 않게 분리하고, 런타임에서만 초기화되도록 구성합니다.
원인 C: fetch 캐시 오해로 빌드가 데이터 웨어하우스화
App Router는 fetch에 캐시 의미가 붙습니다. 하지만 다음 조합이 나오면 빌드가 대량 데이터를 끌어와 메모리를 씁니다.
generateStaticParams에서 대량 목록 호출- 각 페이지에서 다시 상세 호출
- 응답 JSON이 크고, 페이지가 많음
해결: 목록/상세 호출 구조를 바꾸기
- 빌드에서 목록 전체를 가져오지 말고, "정적 생성할 소수"만 가져오기
- 상세는 ISR로 전환
- 응답 필드를 줄이는 전용 API 사용
// 빌드용 경량 엔드포인트를 따로 둔다
const res = await fetch('https://api.example.com/posts/build-index?limit=2000', {
next: { revalidate: 3600 },
})
API 자체 최적화가 필요할 때는 백엔드 풀 고갈/타임아웃처럼 "병목이 어디서 시작되는지"를 먼저 분리하는 게 중요합니다. 진단 접근은 Spring Boot HikariCP 커넥션 고갈 원인 8가지에서 소개한 방식과 유사합니다.
원인 D: 클라이언트 컴포넌트로 인한 번들 비대화
빌드 OOM이 "정적 생성"이 아니라 "번들링"에서 터진다면, 흔히 다음이 원인입니다.
- 최상위 레이아웃에
use client를 붙여 서버 트리가 클라이언트로 승격 - 무거운 라이브러리(차트, 에디터, 하이라이터)를 페이지 공통 레이아웃에 포함
문제 예시
// app/layout.tsx
'use client'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
이 한 줄 때문에 전체가 클라이언트 번들로 번질 수 있습니다.
해결: 클라이언트 경계를 최소화
- 레이아웃은 서버 컴포넌트로 유지
- 클라이언트가 필요한 부분만 별도 컴포넌트로 분리
// app/layout.tsx (server component)
import ClientProviders from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ClientProviders>{children}</ClientProviders>
</body>
</html>
)
}
// app/providers.tsx
'use client'
export default function ClientProviders({ children }: { children: React.ReactNode }) {
return children
}
그리고 무거운 라이브러리는 동적 로딩으로 분리합니다.
// app/report/page.tsx
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('../../components/HeavyChart'), {
ssr: false,
})
export default function Page() {
return <HeavyChart />
}
원인 E: 이미지 최적화가 빌드 파이프라인을 압박
정적 이미지가 많고, 빌드에서 최적화가 한꺼번에 수행되면 메모리 피크가 커집니다.
- 가능한 한 원본 사이즈를 줄이고
- 필요하면 외부 이미지 최적화(이미지 CDN)로 넘기며
next/image를 쓰더라도 "빌드 시점"에 무거운 처리를 강제하지 않게 구성합니다.
3단계: 빌드 메모리 계측을 제대로 하는 방법
"얼마나 올릴까"보다 "어디서 새는가"가 중요합니다.
1) 힙 스냅샷으로 누수 후보 찾기
Node는 힙 스냅샷을 뜰 수 있습니다.
NODE_OPTIONS='--heapsnapshot-near-heap-limit=3 --max-old-space-size=4096' next build
- 힙 한계 근처에서 스냅샷 파일이 생성됩니다.
- Chrome DevTools에서 Memory 탭으로 열어 어떤 객체가 남는지 확인합니다.
2) 단계 분리로 범인 좁히기
next build만 실행했을 때 터지는지next build이후next export같은 추가 단계가 있는지tsc --noEmit만 돌려도 메모리가 치솟는지
예시:
{
"scripts": {
"typecheck": "tsc --noEmit",
"build:next": "next build",
"build": "pnpm run typecheck && pnpm run build:next"
}
}
타입체크에서 터지면 Next 문제가 아니라 TS 설정/제네릭 폭발/skipLibCheck 여부가 원인일 수 있습니다.
4단계: 실전 체크리스트 (가장 효과 큰 순서)
generateStaticParams의 반환 개수부터 확인- 만 개가 넘어가면 거의 항상 위험 신호
- 정적 생성 페이지의 데이터 페이로드 크기 확인
- 페이지당 JSON이 크면 빌드 산출물과 메모리가 같이 증가
- 최상위 레이아웃에
use client가 있는지 확인 - 소스맵/미니파이 옵션으로 빌드 피크 줄이기
- 무거운 의존성의 import 위치를 아래로 내리기
- 이미지/MDX/하이라이트 등 자산 파이프라인 분리
- 최후 수단으로
--max-old-space-size상향
네트워크/인프라 환경에서 재현이 어렵거나 "특정 구간에서만" 죽는다면, 추적 관점은 EKS에서 Pod egress만 502? Envoy/NLB 추적기처럼 "구간을 나눠 증상을 고립"시키는 방식이 그대로 적용됩니다. 빌드도 결국 파이프라인이므로, 단계별로 쪼개면 범인이 빨리 드러납니다.
5단계: 권장 구성 예시 (App Router에서 안전한 패턴)
아래 예시는 "상위 일부만 정적 생성"하고 나머지는 ISR로 처리하는 전형적인 타협안입니다.
// app/products/[slug]/page.tsx
export const revalidate = 300
export const dynamicParams = true
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/products?sort=popular&limit=1000', {
next: { revalidate: 3600 },
})
const items = await res.json()
return items.map((p: { slug: string }) => ({ slug: p.slug }))
}
export default async function Page({ params }: { params: { slug: string } }) {
const res = await fetch(`https://api.example.com/products/${params.slug}`, {
next: { revalidate: 300 },
})
const product = await res.json()
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
</main>
)
}
이 구성의 핵심은 다음입니다.
- 빌드가 모든 상품을 렌더링하지 않음
- 인기 상품만 정적 생성해서 초기 트래픽을 방어
- 나머지는 요청 시 생성하되 캐시로 비용을 상쇄
결론: OOM은 "빌드에서 너무 많이 한다"는 신호다
Next.js App Router 빌드 OOM은 대부분 메모리 상한 자체가 낮아서가 아니라, 빌드 단계에서 정적 생성/번들링/자산 처리 중 하나가 과도하게 커진 결과입니다.
우선 generateStaticParams와 데이터 페이로드를 줄여 "빌드에서 렌더링하는 양"을 감소시키고, 클라이언트 경계를 최소화해 번들러 부담을 낮추세요. 그 다음에야 소스맵/병렬성/힙 상한 조정이 의미가 있습니다.
원하시면 아래 정보를 주면, 프로젝트에 맞춘 "OOM 원인 후보 Top 3"와 수정 포인트를 더 구체적으로 짚어드릴 수 있습니다.
- Next.js 버전, Node 버전
- OOM이 나는 빌드 로그 일부(마지막 200줄)
- 정적 라우트 개수(대략)
generateStaticParams사용 여부와 반환 개수- CI 메모리 제한(컨테이너 limit 포함)