Published on

Next.js 14 빌드 OOM·느려짐 해결 - SWC 캐시·메모리 튜닝

Authors

서버에서 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 --from=deps /app/node_modules ./node_modules
COPY . .

# BuildKit 캐시 마운트로 .next/cache 유지
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
    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 혼재 정리

혼재 자체가 문제는 아니지만, 레거시 pagesapp이 함께 있을 때 라우팅/번들 경로가 복잡해져 빌드 비용이 증가하는 경우가 있습니다. 가능하면 점진적으로 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 캐시 미스 → 재컴파일 폭증, 혹은 소스맵/대형 의존성/병렬성이 만든 피크 메모리 문제로 귀결됩니다.

  1. 먼저 .next/cache를 CI/Docker에서 안정적으로 살리고, 2) 클라이언트 번들에 들어간 큰 의존성을 서버로 격리하며, 3) 소스맵/트레이싱 같은 옵션을 재검토하고, 4) 마지막으로 Node 힙과 컨테이너 limit을 함께 맞추면 재현성 있게 해결되는 경우가 많습니다.

빌드가 안정화된 뒤에도 서버 액션/캐시 레이어 문제로 런타임이 흔들릴 수 있으니, 증상이 섞여 보인다면 아래 글로 원인 분리를 병행하는 것을 권합니다.