Published on

Next.js App Router 빌드 OOM·메모리 폭증 해결

Authors

서버 컴포넌트와 정적 최적화가 결합된 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은 대개 아래 중 하나(또는 복합)입니다.

  1. 정적 생성(SG/SSG) 단계에서 데이터가 너무 큼
    • generateStaticParams가 너무 많은 경로를 뿜거나, 각 경로에서 대용량 데이터를 가져와 JSON으로 빌드 산출물에 포함
  2. 번들러/소스맵/미니파이 단계가 과도
    • 소스맵 생성, 대형 의존성 번들링, 트리쉐이킹 실패로 메모리 급증
  3. 서버 컴포넌트에서 빌드 타임 실행되는 코드가 무거움
    • 모듈 최상단에서 DB/SDK 초기화, 대형 JSON 로드, 동적 import 남발 등
  4. 이미지/폰트/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 buildtsc --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단계: 실전 체크리스트 (가장 효과 큰 순서)

  1. generateStaticParams의 반환 개수부터 확인
    • 만 개가 넘어가면 거의 항상 위험 신호
  2. 정적 생성 페이지의 데이터 페이로드 크기 확인
    • 페이지당 JSON이 크면 빌드 산출물과 메모리가 같이 증가
  3. 최상위 레이아웃에 use client가 있는지 확인
  4. 소스맵/미니파이 옵션으로 빌드 피크 줄이기
  5. 무거운 의존성의 import 위치를 아래로 내리기
  6. 이미지/MDX/하이라이트 등 자산 파이프라인 분리
  7. 최후 수단으로 --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 포함)