- Published on
Next 빌드 메모리 최적화
- Authors

- Name
- 최영준 (Youngjun Choi)
인컴 프론트엔드 모노레포에 서비스가 늘면서 빌드 메모리가 급증해, 개발 환경에서 빌드가 자주 실패하는 현상이 나타났다. 이를 해결하기 위해 26년 5월부터 6월까지 한달간 진행했던 시행착오를 정리해보자.
1. 문제 배경
개발 파이프라인 빌드 과정에서 OOM(Out of memory) 에러가 잦아졌다. 해당 이슈를 해결하기 위해 --max-old-space-size를 올려봤지만 그때뿐이었고, 문제가 계속 재발해 결국 근본 원인을 깊게 파헤쳐보기 시작했다.
2. 이론적 배경
2-1 Node process
빌드는 결국 Node 프로세스 하나가 실행하는 작업이다. 그래서 빌드 메모리를 이해하려면 Node 프로세스가 메모리를 어떻게 쓰는지부터 봐야 했다.
OS 관점에서 Node 프로세스가 실제로 점유한 물리 메모리(RAM)를 RSS(Resident Set Size) 라고 부르고 아래와 같은 구조로 구성되어 있다.
RSS는 단일 덩어리가 아니라 여러 영역의 합이고, 빌드가 도는 쿠버네티스 컨테이너 안에서 보면 한도가 두 겹으로 존재한다.
컨테이너 메모리 리밋 (k8s / cgroup) ← 감시 주체: OS 커널
└─ RSS = 프로세스가 점유한 물리 메모리
├─ V8 Heap (new space / old space) ← 감시 주체: V8 / 천장: --max-old-space-size
├─ External (Buffer, ArrayBuffer · 힙 밖이지만 V8이 추적)
├─ Native (C++ 객체 · node 런타임 · native addon)
├─ Code/Stack (JIT 컴파일 코드 · 콜스택)
└─ 기타
여기서 핵심은 한도가 둘이고, 그 둘을 감시하는 주체도 다르다는 것이다. 그래서 한도를 넘었을 때 죽는 방식도 다르다.
- V8 Heap이
--max-old-space-size천장을 넘으면 → 더 못 버틴 V8 엔진이 직접 프로세스를 abort 시킨다. 에러는FATAL ERROR: ... JavaScript heap out of memory이고,abort()→SIGABRT(6)로 종료되어 exit134(=128+6)가 된다. - RSS 전체가 컨테이너 메모리 리밋을 넘으면 → Node는 자기가 한도를 넘는지조차 모른다. **OS 커널(cgroup OOM killer)**이 프로세스에
SIGKILL(9)을 날린다. exit137(=128+9)로 끝나고, 쿠버네티스에서는 Pod가OOMKilled로 찍힌다.
exit code
129리눅스에서 시그널로 죽은 프로세스의 exit code는 **
128 + 시그널 번호**로 정해진다. 그래서 heap OOM은134(=128+6,SIGABRT), 컨테이너 OOM은137(=128+9,SIGKILL)이 표준 코드다.그런데 정작 우리 빌드는 OOM이 날 때마다
129로 관측됐다. 빌드가 사내 CLI를 한 겹 거쳐 실행되다 보니, 컨테이너 OOM이 터지며 세션이 정리될 때 빌드 프로세스가SIGHUP(1)을 받아 exit129(=128+1)로 표현된 것으로 추측한다.
그러니 --max-old-space-size를 올리는 건 안쪽(heap) 천장만 올리는 처방으로 heap OOM은 피할 수 있지만, V8은 힙이 천장에 가까워질 때 GC를 적극적으로 도는데 천장을 높여주면 GC를 미루고 메모리를 계속 쥔다. heap이 안 줄어드니 RSS가 오히려 커져 바깥쪽(컨테이너) 리밋을 넘고, 이번엔 커널이 137로 죽이기 때문에 적절한 빌드메모리로 관리하는 게 필요하다는 것을 알 수 있다.
2-2 빌드란
빌드란 우리가 작성한 소스 코드를, 브라우저가 이해할 수 있는 HTML·CSS·JS로 변환하는 작업이다. TypeScript든 React든 결국 브라우저는 HTML·CSS·JS만 읽는다. 그 사이에서 번들러는:
- TypeScript → JavaScript 변환 (transpile)
- 최신 문법 → 구형 브라우저용 문법 (Babel, SWC)
- 흩어진 파일들의
import/require를 따라가며 의존성 그래프를 구성 (bundle) - 안 쓰는 코드 제거 (tree-shaking), 식별자 축약 (mangling) 등 최적화 와 같은 작업을 진행한다.
우리 프로젝트는 NEXT 15를 사용하고 있기 때문에 현재 Webpack을 기반으로 빌드가 동작하고 있고, 해당 문제를 이해하기 위해 Webpack의 동작과정에 대해 이해가 필요했다.
2-3 Webpack
Webpack은 앞서 설명한 번들러의 대표 격이고, 우리 빌드도 이 위에서 돈다. 그렇다면 이 과정 중 어디서 빌드 메모리가 불어나는 걸까? 그걸 이해하려면 Webpack이 빌드를 어떤 단계로 쪼개 처리하는지부터 봐야 한다.
Webpack의 구체적인 동작방식은 아래와 같이 진행된다.
- make — 모듈 그래프 구성 (모듈마다 재귀) resolve → 로더 적용(TS→JS 등) → AST parse → 의존성 발견 → 재귀
- seal — 최적화 tree-shaking, 코드 분할(chunk graph), mangling/minify
- emit — 청크를 파일로 출력
여기서 이해할 수 있는 것은, make 단계에서 원본 코드를 AST로 변환해 node process 메모리에 올려둔 모듈 그래프를 기반으로 seal 단계가 최적화를 진행하기 때문에, 원본(모듈 그래프)과 최적화 산출물이 한순간 메모리에 공존하는 구간이 생긴다는 점이다. 그리고 이 공존 구간이 곧 빌드 메모리 피크가 된다.
예를 들어 다음과 같은 모듈이 있다고 하자.
// math.ts
export function add(a: number, b: number) {
return a + b
}
export function subtract(a: number, b: number) {
return a - b
}
// entry.ts — add만 사용
import { add } from './math'
console.log(add(1, 2))
make 단계에서 webpack은 math.ts를 로더로 JS로 변환하고, AST로 파싱해 "이 모듈은 add·subtract를 export하고 entry는 add만 쓴다"는 정보를 모듈 그래프로 메모리에 올린다. 이때 상주하는 것은 대략 이렇다.
math 모듈 레코드
├─ source: "function add(a,b){return a+b} function subtract(a,b){return a-b}"
├─ ast: Program { ... }
└─ usedExports: ["add"] // subtract는 미사용으로 표시됨
seal 단계에서는 이 정보를 바탕으로 최적화된 출력 코드를 새로 만든다. tree-shaking으로 안 쓰는 subtract를 떨궈내고, mangling으로 식별자를 줄인다.
// 생성된 출력 (개념)
console.log(function(n,d){return n+d}(1,2))
핵심은, 이 출력을 만드는 동안 make가 올려둔 원본 모듈 레코드(source·ast)는 참조되고 있으므로 메모리에서 GC 되지 않았다는 것이다. 즉 원본 그래프 + 새로 생성한 출력 코드 + (minifier가 별도로 뜨는 AST)가 동시에 떠 있다. 모듈 한두 개면 무시할 양이지만, 모노레포에서 도달 가능한 모듈이 수천 개로 늘어나면 이 공존 구간의 총량이 그대로 RSS로 이어지게 된다.
3. 측정
3-1. next build --experimental-debug-memory-usage
Next는 빌드 과정에서의 메모리 사용량을 측정하기 위해 build시 다음 옵션을 제공하고 주기적으로 메모리 스냅샷을 남길 수 있다. (참고: https://nextjs.org/docs/app/guides/memory-usage#try-experimentalwebpackmemoryoptimizations)
next build --experimental-debug-memory-usage
출력 예시는 아래와 같이 RSS, heap 메모리 사용량, 할당량을 볼 수 있어, 해당 옵션을 개발 환경에서 켜두고 빌드를 진행함으로써 전후 비교의 데이터 기준으로 삼았다.
Memory usage report:
- Total time spent in GC: 1970.09ms
- Peak heap usage: 1890.14 MB
- Peak RSS usage: 5462.78 MB
3-2. bundle analyzer
bundle analyzer는 보통 Production build에서 번들 크기를 측정할 때 주로 사용하지만, 이번에는 어떤 번들이 빌드에 포함되어 있는지 또는 같은 패키지가 여러 버전이 포함되어 있지는 않은지 보기 위해 사용했다.

4. 작업
이론과 측정 도구를 갖췄으니 이제 본격적으로 빌드 메모리를 줄이는 작업에 들어갔다.
작업 전 상황을 정리하면 아래와 같다.
| 항목 | 값 |
|---|---|
| 컨테이너 메모리 한도 | 12 GB |
| 빌드 에이전트 점유 | 약 4 GB |
| 빌드 가용 여유 | 약 8 GB |
--max-old-space-size | 6,144 MB → 8,192 MB (임시 상향) |
| 측정 시점 최대 RSS | 7,140 MB |
컨테이너 한도에서 에이전트 몫을 빼면 빌드가 실제로 쓸 수 있는 여유는 8GB 남짓이었다. 임시로 heap 천장을 8192MB까지 올려 버텼지만, 서비스가 늘면서 최대 RSS가 7140MB까지 차올라 임시방편으로는 한계가 분명한 상황이었다.
그래서 대응은 두 갈래로 나뉘었다. devops팀에서는 빌드 에이전트를 메모리 사용이 더 적은 에이전트(약 1.5GB)로 교체해 컨테이너 안의 여유 공간을 확보해주셨고, 나는 빌드 그 자체가 먹는 RSS와 heap 사용량을 줄이는 작업을 맡았다. 아래는 그 과정에서 실제로 효과가 있었던 작업들이다.
4-1. Next Config / Sentry plugin
가장 먼저 간단하지만, 효과가 컸던 방법으로 빌드를 최적화하는 next 옵션 또는 개발환경에서는 효과대비 불필요하다는 판단이 든 옵션을 끄는 방식을 적용했다.
Sentry Plugin
센트리에는 다양한 옵션이 있는데, 이때 알파 환경에서는 상대적으로 유효성이 적다고 판단한 소스맵 관련 옵션(sourcemaps, widenClientFileUpload)과 autoInstrumentMiddleware, autoInstrumentServerFunctions 옵션을 비활성화했다. 빌드 시점 환경 변수 isLive를 기준으로 운영(live) 빌드가 아닐 때만 자동으로 꺼지도록 설정했다.
// next.config.js
const { withSentryConfig } = require('@sentry/nextjs')
const isLive = process.env.PHASE === 'live'
module.exports = withSentryConfig(nextConfig, {
//...
// live 빌드가 아니면(dev/alpha) 빌드 비용이 큰 계측을 끈다
autoInstrumentServerFunctions: isLive,
autoInstrumentMiddleware: isLive,
// 소스맵 업로드(.map 생성·유지)도 live 빌드에서만
widenClientFileUpload: isLive, // 클라이언트 소스맵 업로드 범위 확장
sourcemaps: {
disable: !isLive,
},
})
결과적으로 적지 않은 메모리 절감 효과를 확인할 수 있었다.
| 지표 | 적용 전 | 적용 후 | 감소율 |
|---|---|---|---|
| Peak RSS avg | 5,866 MB | 4,372 MB | ▼ 25% |
| Peak Heap avg | ~2,200 MB | ~2,000 MB | ▼ 9% |
알파환경에서도 활성화한다면 당연히 좋겠지만 현재 개발/알파환경에서 테스트 시의 이슈는 QA과정에서 대부분 모니터링 하고 있고, 소스맵이 없어도 어느정도 확인할 수 있기 때문에 효과대비 실익이 적다고 판단되어 비활성화하게 되었다.
해당 옵션들을 끄는게 유의미한 이유는 아래와 같이 볼 수 있다.
- sourcemaps, widenClientFileUpload: 소스맵 생성을 위해서는 원본 코드 + .map 파일 두가지를 빌드과정에 메모리에 올려두고 유지한다. widenClientFileUpload는 패키지들도 함께 포함하기 때문에 더 많은 메모리를 차지한다.
- autoInstrumentMiddleware, autoInstrumentServerFunctions : next 15에서 webpack build시에 client, server, middleware 세가지를 각각 진행하기 때문에 번들링 과정에 AST 패스를 추가적으로 실행하고 instrument sentry 로직을 추가하게 된다.
유효하지 않았던 Next Config
next 공식 문서를 보면 experimental.cpus나 experimental.webpackMemoryOptimization: true 등 옵션을 소개하고 있어 시도를 해봤지만 아쉽게도 큰 차이가 없었다.
4-2. 패키지 정리
다음으로 bundle analyzer를 들여다봤다. 크기가 큰 패키지일수록 빌드 과정에서 더 많은 메모리를 필요로 하기 때문에, 큰 번들부터 정리 작업에 들어갔다.
4-2-1. 번들 크기는 줄었지만, 빌드 메모리에는 영향이 없었던 개선
bundle analyzer로 들여다보니 _app에 불필요하게 끌려 들어오는 모듈들이 보였다. 대표적으로 에러 바운더리의 에러 화면 리소스가 그랬다. 화면이 실제로 렌더링되지 않아도 항상 _app에 포함되어 함께 빌드되고 있었다. 그래서 next/dynamic으로 감싸 에러가 발생한 시점에만 불러오도록 바꿨다.
// 기존: 에러 화면이 _app 번들에 항상 포함된다
import { ErrorFallback } from '@/components/ErrorFallback'
// 개선: 에러가 실제로 발생한 시점에만 청크를 로드한다
import dynamic from 'next/dynamic'
const ErrorFallback = dynamic(() =>
import('@/components/ErrorFallback').then((m) => m.ErrorFallback),
)
또 하나는 공통 유틸 workspace였다. barrel file(index.ts)에서 모든 모듈을 한 번에 re-export하다 보니, 무거운 암호화 패키지인 node-forge를 쓰는 모듈이 정작 그 기능을 쓰지 않는 화면에서도 항상 _app에 딸려 들어왔다.
// packages/common-utils/index.ts — barrel file
export * from './crypto' // node-forge 사용
export * from './format'
export * from './date'
barrel file은 import { format } from '@income/common-utils'처럼 일부만 가져와도, 번들러가 사이드 이펙트가 없다고 확신하지 못하면 crypto까지 그래프에 포함시킨다. 그래서 workspace의 package.json에 sideEffects: false를 명시해 tree-shaking이 가능하도록 했다.
// packages/common-utils/package.json
{
"name": "@income/common-utils",
"sideEffects": false
}
이 작업으로 번들 크기는 눈에 띄게 줄었다.
| 지표 | 적용 전 | 적용 후 | 감소율 |
|---|---|---|---|
_app chunk | 676 KB | 509 KB | ▼ 25% |
그런데 정작 빌드 메모리는 60MB 남짓밖에 줄지 않았다. 큰 번들을 떨궈내면 메모리도 그만큼 따라 줄 거라 기대했는데 결과는 달랐다.
생각해보면 당연한 결과였다. dynamic import나 tree-shaking은 webpack의 seal(최적화) 단계에서 작동하는 개선인데, 빌드 메모리 피크는 그 앞 make 단계에서 모든 모듈을 AST로 변환해 그래프로 올려둘 때 이미 결정된다. 최종 번들에서 빠지는 코드라도 make 단계에서는 똑같이 메모리에 올라갔다 내려가기 때문에, 산출물만 가벼워졌을 뿐 피크 메모리는 거의 그대로였던 것이다.
이 과정을 통해 번들 크기 최적화와 빌드 메모리 최적화는 접근 자체가 다르다는 걸 깨달았고, 방향을 번들에서 빼는 것이 아니라 패키지 자체를 그래프에서 없애는 것으로 틀었다.
4-2-2. 같은 패키지가 여러 버전으로 들어오는 경우
같은 패키지가 여러 버전으로 중복되는 문제는, 모노레포에서 공통 workspace를 만들고 이를 각 서비스가 의존성으로 끌어 쓰면서 생겼다. workspace와 서비스가 서로 다른 버전을 물고 있으면, 빌드 그래프에는 같은 패키지가 버전별로 따로 올라간다.
yarn why로 확인해보니 한 패키지가 버전별로 갈라져 들어오고 있었다. 아래는 예시다.
$ yarn why date-fns
├─ @income/common-utils@workspace:packages/common-utils
│ └─ date-fns@npm:2.30.0
├─ @income/service1@workspace:apps/service1
│ └─ date-fns@npm:3.6.0
└─ @income/service2@workspace:apps/service2
└─ date-fns@npm:4.1.0
사내 대형 모노레포는 100개가 넘는 서비스를 운영하면서도 전담 팀이 버전을 잘 관리하는 반면, 우리는 모바일 앱 하나에서 서비스1, 서비스2 등 여러 서비스로 늘고 공통 유틸까지 커지면서 서로 다른 버전이 뒤섞이게 됐다. 생각해보니 개발 서버에서도 공통 workspace를 분리해 쓰기 시작한 무렵부터 OOM이 잦아졌는데, 이 버전 중복이 원인 중 하나였던 것으로 추측된다.
우리는 Yarn PnP를 쓰고 있어서 Yarn이 지원하는 catalog를 도입했다. .yarnrc.yml에 버전을 한 번만 선언해두고, 각 workspace는 catalog: 프로토콜로 그 버전을 참조하게 하는 방식이다. 이렇게 디자인 시스템 패키지부터 사내 공용 유틸, date-fns 같은 오픈소스 패키지까지 하나의 버전으로 묶었다.
# .yarnrc.yml — 버전을 한 곳에서 선언한다
catalog:
date-fns: ^4.1.0
'@design-system': ^1.2.0
// 각 workspace의 package.json — 버전 대신 catalog를 참조한다
{
"dependencies": {
"date-fns": "catalog:",
"@design-system": "catalog:"
}
}
이렇게 하면 버전을 올릴 때도 .yarnrc.yml 한 곳만 고치면 되고, 서로 다른 버전이 그래프에 중복으로 올라오는 일도 막을 수 있다.
4-2-3. 같은 기능을 가진 패키지가 두 버전으로 공존하는 경우
버전이 아니라 아예 같은 역할의 다른 패키지가 공존하는 경우도 있었다. lodash-es와 es-toolkit, 구버전 overlay 패키지와 overlay-kit처럼 사내에서 구버전 라이브러리와 새로 개편된 버전이 섞여 들어오는 식이었다. 기능이 겹치는 만큼 둘 다 빌드 그래프에 올라가니, 새 패키지로 통일해 한쪽을 걷어냈다.
4-3. 불필요한 서비스 코드 정리
빌드 메모리는 결국 make 단계에서 그래프에 올라가는 모듈 수에 비례한다. 파일이 많을수록 resolution과 AST 변환 대상이 늘고, 그만큼 메모리에 더 많이 올라간다. 그래서 내가 담당하는 서비스 중 더 이상 쓰지 않는 코드를 함께 정리했다. 빌드 메모리에 미치는 영향 자체는 크지 않았지만, 관리 차원에서도 필요한 작업이었다.
여기까지의 작업으로 RSS가 눈에 띄게 내려왔는데, 구체적인 수치는 다음 장에서 구간별로 정리한다.
5. 성과 및 결론
한 달간의 작업으로 거둔 누적 절감은 아래와 같다.
| 지표 | 작업 전 | 작업 후 | 감소율 |
|---|---|---|---|
| Peak RSS avg | 5,866 MB | 3,831 MB | ▼ 35% |
| Peak RSS max | 7,140 MB | 4,068 MB | ▼ 43% |
| Peak Heap avg | 2,200 MB | 1,988 MB | ▼ 10% |
_app chunk | 676 KB | 496 KB | ▼ 27% |
작업 구간별 평균으로 뜯어보면, RSS는 단계마다 꾸준히 내려가는 반면 Heap은 2GB 안팎에 머문다.
| 구간 | 빌드 수 | RSS avg | Heap avg | _app | 빌드 시간 |
|---|---|---|---|---|---|
| baseline | 90 | 5,866 MB | 2,200 MB | 676 KB | 157 s |
| 4-1 적용 후 | 32 | 4,372 MB | 2,000 MB | 676 KB | 155 s |
| 4-2 적용 후 | 39 | 4,126 MB | 1,900 MB | 509 KB | 142 s |
| 최종 | 11 | 3,831 MB | 1,988 MB | 496 KB | 152 s |
여기서 한 가지 발견한 점은, Heap은 ▼10%에 그친 반면 RSS는 ▼35~43%로 크게 줄었다는 것이다. RSS에서 heap을 뺀 나머지(native·external)로 환산하면 약 3,666MB → 1,843MB로 절반(▼50%) 가까이 빠졌다. 즉 빌드가 실제로 필요로 하는 live heap은 줄곧 2GB 안팎으로 거의 일정했고, 줄어든 건 대부분 heap 바깥 영역이었다. OOM도 heap 천장(--max-old-space-size)이 아니라 컨테이너 리밋(RSS)에 부딪혀 터진 것이라, 이번 작업 없이 천장만 계속 올렸다면 heap은 여유가 있는데 RSS만 부풀어 같은 실패를 반복했을 것이다.
내가 한 작업들이 RSS를 줄인 원리도 여기에 있다. 중복 패키지를 정리하고 죽은 코드를 들어내는 건 heap을 직접 건드리는 게 아니라, 빌드 과정에서 읽어 들이고 변환하는 모듈의 양 자체를 줄이는 일이다. 그 비용은 대부분 native(파싱·변환)와 external(소스·소스맵 버퍼)에 쌓이기 때문에, 모듈이 줄면 heap은 거의 그대로인 채 off-heap이 빠지면서 RSS가 내려간 것으로 이해할 수 있었다.
빌드 안정성 측면에서도, 컨테이너 한도 12GB 안에서 빌드 에이전트가 약 1.5GB, 현재 Node 빌드 RSS가 4GB 정도이니 6.5GB가량 여유가 남아 더 이상 OOM이 재현되지 않는다. 마지막으로 --max-old-space-size도 8192MB에서 4096MB로 다시 낮췄다. live heap이 2GB 안팎이라 빌드에는 지장이 없으면서, V8이 쥐고 있던 여분 페이지를 OS에 더 자주 반환하게 되어 RSS를 더 효율적으로 사용하게 했다.
해당 이슈를 해결하면서 기존에 번들 사이즈를 줄이는 작업은 해봤지만 빌드 메모리 자체를 줄이는 건 고민해본 적이 없었는데, 이번 기회에 번들러와 빌드 과정을 한층 깊이 이해할 수 있었다. 그 과정에서 모노레포의 라이브러리 관리가 얼마나 중요한지도 체감해, 챕터 안에서 더 잘 관리할 방법을 함께 고민하고 있다.
서비스가 커지며 마주하는 기술적 문제들을 직접 풀어가고, 앞으로도 더 다양한 고민을 통해 성장해가길 기대해본다.