빌드가 터졌다: 5년 된 CMS 프로젝트의 Webpack4 → Vite 전환
CMS의 개발환경 변화 여정을 공유합니다.
- 들어가며
- 0. 배포는 됐는데 화면이 안 나와요
- 1. Webpack4, 5년간 무슨 일이 있었나
- 2. OOM: 더 이상 미룰 수 없는 신호
- 3. 왜 Vite를 선택했는가
- 4. 마이그레이션 실행
- 5. 전환을 마친 지금, 무엇을 얻었을까
- 6. 마이그레이션 후 달라진 것들
- 7. 교훈
- 8. 마치며
- 참고문서
"FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
들어가며
안녕하세요. 컬리 프로덕트웹개발 팀의 박성욱입니다.
번들 크기 81% 감소, 빌드 시간 48% 감소, 개발 서버 시작 속도 460배 개선, 코드 210,000 라인 삭제. 이 글에서는 이러한 성과를 달성하기까지의 여정을 공유합니다.
저희가 운영하는 CMS는 컬리의 다양한 서비스를 관리하는 핵심 어드민 시스템입니다. 40개 이상의 도메인과 30개 이상의 동적 라우트를 가진, 꽤 규모가 있는 프로젝트입니다.
그런데 이 프로젝트에는 한 가지 문제가 있었습니다. 2020년에 시작된 이후 5년간 거의 그대로 유지된 기술 스택이었다는 점입니다. React 16, TypeScript 4.4, MobX 5, Material-UI 4, 그리고 Webpack 4.
2025년 2월, 결국 문제가 터졌습니다. CI/CD 파이프라인에서 빌드가 OOM(Out of Memory) 에러로 실패하기 시작한 것입니다. 로컬에서는 8GB 메모리를 할당해야 겨우 빌드가 되었는데, CI 환경에서는 완전히 터져버렸습니다.
선택지는 두 가지였습니다. 메모리를 계속 늘리며 임시방편을 쓰거나, 번들러를 바꾸거나. 저희는 후자를 선택했습니다.
0. 배포는 됐는데 화면이 안 나와요
어느 날, 같은 팀의 이진희님에게서 슬랙 메시지가 왔습니다.
"feature를 추가하고 빌드 성공해서 배포까지 완료됐는데, 테스트 환경이 정상적으로 안 나오는 것 같아요."
빌드도 성공하고, 배포도 완료됐는데 화면이 안 나온다? 처음에는 인프라 문제를 의심했습니다. EC2의 셀프호스트러너가 잘못된 게 아닐까?
하지만 러너 로그를 확인해보니 인프라 문제가 아니었습니다. 빌드 과정에서 OOM(Out of Memory)이 발생하고 있었습니다. 빌드가 "성공"한 것처럼 보였지만, 실제로는 메모리 부족으로 중간에 죽어버린 것이었습니다.
이것이 저희가 번들러 마이그레이션을 시작한 계기였습니다.
1. Webpack4, 5년간 무슨 일이 있었나
1.1 프로젝트의 성장
저희 CMS 프로젝트는 2020년에 시작되었습니다. 처음에는 몇 개의 도메인만 관리하던 작은 어드민이었지만, 5년이 지난 지금은 다음과 같이 성장했습니다.
| 항목 | 초기 | 현재 |
|---|---|---|
| 도메인 수 | ~10개 | 40개+ |
| 라우트 수 | ~10개 | 30개+ |
| 설정 파일 크기 | ~200줄 | 538줄 |
| 의존성 패키지 | ~100개 | 300개+ |
1.2 기술 스택
프로젝트가 시작된 2020년에는 충분히 합리적인 선택이었던 기술 스택입니다.
- React 16.13.1 + TypeScript 4.4.0
- MobX 5.15.6 (상태 관리, decorator 문법)
- Material-UI 4.11.0 (UI 컴포넌트)
- Tailwind CSS (PostCSS v7 호환 버전)
- Webpack 4.44.2
- Node.js 16.18.0
문제는 이 스택이 5년 동안 거의 그대로 유지되었다는 점입니다. React 18이 나오고, Webpack 5가 나오고, Vite가 대세가 되어도 저희 프로젝트는 계속 Webpack4에 머물러 있었습니다.
"일단 돌아가니까요."
2. OOM: 더 이상 미룰 수 없는 신호
2.1 위기의 타임라인
OOM 문제는 갑자기 찾아온 것이 아니었습니다. 돌이켜보면 여러 차례 경고 신호가 있었습니다.

2025년 2월 10일 - 첫 번째 경고
# commit 700b2602f - "chore: build memory 4GB > 8GB"
-"build": "node --max-old-space-size=4096 scripts/build.js",
+"build": "node --max-old-space-size=8192 scripts/build.js",
4GB로는 부족해서 8GB로 늘렸습니다. 일단 돌아가니까요.
2025년 2월 27일 - 최적화 시도
상황이 나아지지 않아서, 팀의 다른 분들도 최적화를 시도했습니다.
// commit 3d587ebca - filesystem cache 추가
cache: {
type: 'filesystem',
}
// commit 9caf13c53 - 자동 청크 분할 추가
splitChunks: {
chunks: 'all',
maxSize: 500000, // 500KB 이상이면 자동 분할
}
캐시도 추가하고, 청크도 분할하고요.
2025년 3월 11일 - CI에서 빌드 실패
드디어 두려워하던 일이 벌어졌습니다. CI 환경에서 빌드가 실패하는 비율이 급증하기 시작했습니다. 그래서 메모리 사용량을 추적하기 시작했습니다.
// commit 4714b0c9f - "CI build crash"
// scripts/build.js에 메모리 디버깅 코드 추가
const debugMemory = (name) => {
const memoryUsage = process.memoryUsage();
console.group(name);
console.log('전체 메모리:', toGB(os.totalmem()));
console.log('Heap Total:', toMB(memoryUsage.heapTotal));
console.log('Heap Used:', toMB(memoryUsage.heapUsed));
console.groupEnd();
};
2.2 원인 분석: 왜 이렇게 많은 메모리가 필요했을까?
디버깅 끝에 원인을 찾았습니다.
원인 1: 순환 참조 (Circular Dependency)
// commit 82e8e0bf9 - "순환참조 해제 (절대경로 -> 상대경로)"
// 배럴 파일(index.tsx)에서 절대경로를 사용하면 순환 구조 발생
// Before - 순환 참조 발생
export { BaseCell } from 'app/productReview/shared/components/ReviewTableCell/BaseCell';
// After - 상대경로로 수정
export { BaseCell } from './BaseCell';
barrel 파일(index.ts)을 통한 순환 import가 발생하고 있었습니다. 그렇다면 순환 참조가 왜 메모리 문제를 일으킬까요?
순환 참조가 메모리에 영향을 미치는 메커니즘
순환 참조 자체가 "모듈을 여러 번 분석"하게 만드는 것은 아닙니다. Webpack은 Module Registry를 통해 각 모듈을 한 번만 분석합니다. 그러나 순환 참조는 다음 세 가지 최적화를 방해하여 간접적으로 메모리 사용량을 증가시킵니다.
1) Scope Hoisting 최적화 실패 (ModuleConcatenationPlugin)
Webpack의 Scope Hoisting은 여러 모듈을 하나의 함수 스코프로 합쳐 런타임 오버헤드를 줄입니다. 그러나 순환 참조가 있으면 이 최적화가 실패(bailout)합니다.
| Bailout 조건 | 설명 |
|---|---|
| 순환 참조 모듈 | 모듈 초기화 순서를 보장할 수 없음 |
| Dynamic Import | import() 된 모듈 |
| 여러 청크에 포함된 모듈 | cross-chunk 참조 |
bailout 시 각 모듈이 개별 wrapper 함수를 유지해야 하므로 번들 크기와 런타임 메모리가 증가합니다.
2) 청크 최적화 복잡도 증가 (SplitChunksPlugin)
순환 참조가 있으면 SplitChunksPlugin의 공유 모듈 계산이 복잡해집니다.
Main Chunk Async Chunk
├─ MainApp ├─ UserModifyPage
├─ PATH (shared) ←─── 공유 ────┤─ PATH 참조
└─ ... └─ MainApp 참조 ← 순환
질문: PATH를 어느 chunk에 넣을까?
→ 양쪽 모두 필요 → 복잡한 계산 필요 → 메모리 증가
3) TypeScript 타입 체킹 메모리 증가
// commit 44b178823 - TypeScript memoryLimit 설정
new ForkTsCheckerWebpackPlugin({
typescript: {
memoryLimit: 4096, // 4GB 제한 필요
},
})
순환 타입 참조는 TypeScript 컴파일러의 타입 추론 복잡도를 높입니다.
원인 2: Webpack4의 구조적 한계
Webpack4는 JavaScript 기반으로 전체 의존성 그래프를 메모리에 유지합니다. 프로젝트 규모가 커지면 이 그래프 자체가 메모리를 많이 점유합니다.
| 항목 | Webpack4 | Vite |
|---|---|---|
| 언어 | JavaScript | JavaScript (esbuild는 Go) |
| 파싱 | 전체 AST 메모리 유지 | 스트리밍 처리 |
| GC 오버헤드 | 높음 | 낮음 |
2.3 메모리 임계치 검증
정말로 이렇게 많은 메모리가 필요한지 검증해야 했습니다. 다양한 메모리 제한으로 빌드를 실행해 보았습니다.
export NODE_OPTIONS='--max-old-space-size=<MEMORY_MB>'
npm run build:dev
| 메모리 제한 | 결과 | 빌드 시간 |
|---|---|---|
| 1GB (1024MB) | ❌ OOM 발생 | - |
| 2GB (2048MB) | ❌ OOM 발생 | - |
| 4GB (4096MB) | ❌ OOM 발생 | - |
| 5GB (5120MB) | ❌ OOM 발생 | - |
| 5.5GB (5632MB) | ❌ OOM 발생 | - |
| 6GB (6144MB) | ✅ 성공 | 45.07초 |
| 8GB (8192MB) | ✅ 성공 | 48.15초 |
검증 결과, 최소 6GB 이상의 메모리가 필요했습니다.
2.4 실제 OOM 에러 메시지
4GB 메모리 제한으로 빌드 시 발생한 실제 오류입니다.
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
1: 0x104b99c84 node::Abort() [/Users/kurly/.nvm/versions/node/v16.18.0/bin/node]
2: 0x104b99e74 node::ModifyCodeGenerationFromStrings(...)
3: 0x104cde7d4 v8::Utils::ReportOOMFailure(...)
4: 0x104cde794 v8::internal::V8::FatalProcessOutOfMemory(...)
5: 0x104e61cb0 v8::internal::Heap::GarbageCollectionReasonToString(...)
...
2.5 선택의 순간
앞서 살펴본 타임라인의 문제들을 정리하면 다음과 같았습니다.
| 날짜 | 문제 | 시도한 해결책 | 결과 |
|---|---|---|---|
| 2월 10일 | 빌드 메모리 부족 | 4GB → 8GB 증설 | 임시 해결 |
| 2월 27일 | 빌드 속도 저하 | filesystem cache | 부분 개선 |
| 2월 27일 | 청크 최적화 | maxSize 설정 | 부분 개선 |
| 3월 24일 | 순환 참조 | 상대경로로 수정 | 부분 해결 |
| 3월 24일 | TS 검사 메모리 | memoryLimit 설정 | 부분 해결 |
땜질을 거듭해도 문제는 계속 발생했습니다.
더 이상 미룰 수 없었습니다. 번들러를 바꿔야 했습니다.
3. 왜 Vite를 선택했는가
Vite는 Vue.js 창시자 Evan You가 만든 차세대 프론트엔드 빌드 도구입니다. 개발 서버는 네이티브 ES Module을 활용하고, 프로덕션 빌드는 Rollup을 사용합니다.
3.1 빌드 도구 후보 비교
Webpack4를 대체할 빌드 도구로 세 가지를 검토했습니다. 각 도구의 장단점을 간단히 정리했습니다.
| 항목 | Vite | Parcel | Rsbuild |
|---|---|---|---|
| 장점 | ESM 기반, 활발한 생태계 | 제로 설정 | Rust 기반 고성능 |
| 단점 | 레거시 설정 필요 | 커스텀 제한적 | 생태계 작음 |
| MobX Decorator | Babel 플러그인 | 제한적 | Babel 플러그인 |
3.2 ESM 기반 개발 서버
Vite는 개발 서버 시작 시 전체 프로젝트를 번들링하지 않습니다. 브라우저의 ES Module을 활용해 필요한 모듈만 요청 시점에 제공합니다.
| 항목 | Webpack4 | Vite |
|---|---|---|
| 개발 서버 | 번들 기반 (전체 번들링 후 제공) | ESM 기반 (필요한 모듈만 제공) |
| 의존성 처리 | 매번 전체 분석 | 사전 번들링 (esbuild) |
| HMR | 번들 재생성 | 모듈 단위 교체 |
| 청크 최적화 | 복잡한 알고리즘 | Rollup 위임 |
이 방식은 OOM 문제를 근본적으로 해결합니다. 메모리에 전체 의존성 그래프를 올릴 필요가 없기 때문입니다.
3.3 활발한 생태계
Vite는 가장 활발한 커뮤니티와 풍부한 플러그인 생태계를 가지고 있습니다. 예를 들어 @vitejs/plugin-react, vite-plugin-babel, rollup-plugin-visualizer 등 저희에게 필요한 플러그인들이 모두 있었습니다.
3.4 기존 스택과의 호환성
앞서 언급한 저희의 레거시 스택을 기억하시나요? React 16, MobX 5 decorator, MUI4라는 까다로운 조합이었습니다. Vite는 Babel 플러그인 설정으로 이 모든 것과 호환이 되었습니다.
// MobX decorator 지원
react({
babel: {
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
],
},
}),
4. 마이그레이션 실행
4.1 브랜치 전략
별도의 qa-vite-conversion 브랜치에서 작업을 진행했습니다. "안 되면 돌아가면 된다"는 마음가짐으로 과감하게 시도할 수 있었습니다.

4.2 간소화된 설정 파일
마이그레이션의 첫 번째 성과는 설정 파일이 77% 간소화되었다는 점입니다. 538줄이던 Webpack 설정이 121줄의 Vite 설정으로 줄었습니다.
After: vite.config.js (121줄)
export default defineConfig(({ mode }) => ({
plugins: [
react({
jsxRuntime: 'classic',
babel: {
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
],
},
}),
babel({
include: /node_modules\/react-csv/,
babelConfig: { presets: ['@babel/preset-react'] },
}),
],
resolve: {
alias: {
shared: path.resolve(__dirname, 'src/shared'),
app: path.resolve(__dirname, 'src/app'),
pages: path.resolve(__dirname, 'src/pages'),
querystring: 'qs',
},
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/handsontable')) return 'handsontable';
if (id.includes('node_modules/lottie-web')) return 'lottie-web';
if (id.includes('node_modules/@toast-ui')) return 'toast-ui';
if (id.includes('node_modules')) return 'vendor';
},
},
},
},
}));
4.3 불필요한 패키지 제거
마이그레이션의 두 번째 성과입니다. Webpack 관련 패키지 9개 이상을 제거하고, Vite 관련 패키지 5개만 추가했습니다. 번들러 패키지 수가 크게 줄었습니다.
삭제된 패키지 마이그레이션 과정에서 삭제한 Webpack 관련 패키지입니다.
- webpack (4.44.2)
- webpack-dev-server
- terser-webpack-plugin
- html-webpack-plugin
- mini-css-extract-plugin
- case-sensitive-paths-webpack-plugin
- workbox-webpack-plugin
- style-loader, css-loader, postcss-loader, esbuild-loader 등
추가된 패키지
+ vite (^6.2.1)
+ @vitejs/plugin-react (^4.3.4)
+ vite-plugin-babel (^1.3.0)
+ rollup (^4.35.0)
+ rollup-plugin-visualizer (^5.14.0)
4.4 해결한 기타 이슈들
마이그레이션 과정에서 여러 이슈를 만났습니다. 비슷한 마이그레이션을 준비하시는 분들께 참고가 되길 바랍니다.
이슈 1: Node.js 내장 모듈 브라우저 환경 문제
aws-sdk 같은 라이브러리가 Node.js의 querystring 모듈을 사용하는데, Vite는 Webpack과 달리 Node.js 내장 모듈의 브라우저 폴리필을 자동으로 제공하지 않습니다. 이를 브라우저 호환 라이브러리인 qs로 대체하여 해결했습니다.
resolve: {
alias: {
querystring: 'qs',
},
}
이슈 2: CSS 문법 에러 (tui-color-picker)
일부 오래된 라이브러리에서 IE6 해킹을 위한 CSS 문법(*display: inline;)을 사용하고 있었습니다. esbuild가 이를 에러로 인식하므로, 해당 경고를 무시하도록 설정했습니다.
esbuild: {
logOverride: {
'css-syntax-error': 'silent',
},
},
이슈 3: react-csv 라이브러리 호환성
react-csv 라이브러리가 esbuild와 호환되지 않아서 빌드 에러가 발생했습니다. 해당 라이브러리만 Babel로 트랜스파일하도록 설정하여 해결했습니다.
babel({
include: /node_modules\/react-csv/,
babelConfig: {
presets: ['@babel/preset-react'],
},
}),
이슈 4: dayjs 초기화 순서 문제
MobX 스토어 초기화 시점에 dayjs가 아직 로드되지 않아서 에러가 발생했습니다. Vite의 optimizeDeps.include에 dayjs를 추가하여 사전 번들링되도록 했습니다.
optimizeDeps: {
include: ['react-csv', 'dayjs'],
},
기타 이슈 요약
| 이슈 | 해결 방법 |
|---|---|
| MUI4 endAdornment prop 경고 | inputProps → InputProps 수정 |
| Select undefined 값 경고 | 초기값 빈 문자열 설정 |
| Node.js 내장 모듈 | querystring: 'qs' alias 설정 |
| DOM nesting 에러 | table 구조 수정 |
| CSS 문법 에러 | esbuild logOverride 설정 |
| HMR 순환참조 | lazy import 적용 |
| dayjs 초기화 순서 | optimizeDeps.include 설정 |
| react-csv 호환성 | vite-plugin-babel 사용 |
5. 전환을 마친 지금, 무엇을 얻었을까
5.1 정량적 성과
마이그레이션 결과를 한 문장으로 요약하면: 빌드 시간은 절반으로, 개발 서버는 460배 빨라지고, 코드 210,000 라인이 삭제됐으며, 번들 크기는 1/5로 줄었습니다.
빌드 성능 비교
| 항목 | Webpack4 | Vite | 개선율 |
|---|---|---|---|
| Production 빌드 시간 | 54.28초 | 28.21초 | 48% 감소 |
| 개발 서버 시작 시간 | ~47초 | 102ms | 99.8% 감소 (460배) |
| 번들 크기 | 57MB | 11MB | 81% 감소 |
개발 환경 변화
| 항목 | Webpack4 | Vite | 변화 |
|---|---|---|---|
| Node.js 버전 | 16.18.0 | 22.14.0 | 최신 LTS 지원 |
| 설정 파일 크기 | 538줄 | 121줄 | 77% 감소 |
| 번들러 패키지 수 | 9개+ | 5개 | 크게 감소 |
| 빌드 메모리 | 8GB 필요 | 4GB 이하 | OOM 해결 |
| 코드 변화량 | ![]() |
||
5.2 빌드 시간 측정 상세
정확한 비교를 위해 동일한 환경에서 각각 3회씩 측정한 후 평균값을 사용했습니다. 테스트 환경은 Mac M2-Pro 32GB 12코어입니다.
Webpack4 빌드 시간 (3회 측정)
1차: 61.88초 (Cold)
2차: 51.48초 (Warm)
3차: 49.48초 (Warm)
평균: 54.28초
Vite 빌드 시간 (3회 측정)
1차: 28.09초
2차: 28.18초
3차: 28.35초
평균: 28.21초
5.3 개발 서버 시작 비교
개발자가 코드를 수정하고 결과를 확인하기까지의 피드백 루프가 극적으로 단축되었습니다.
Before (Webpack4)
Starting the development server...
# ... 47초 대기 ...
Compiled successfully!
After (Vite)
VITE v6.2.1 ready in 102 ms
➜ Local: http://localhost:3000/
6. 마이그레이션 후 달라진 것들
6.1 Node.js 버전 업그레이드
Vite 6.x는 Node.js 18 이상을 요구합니다. 저희는 이 기회에 Node.js를 16에서 22로 업그레이드했습니다.
Before: Node.js 16.18.0
After: Node.js 22.14.0
6.2 환경변수 처리 변경
Webpack에서는 process.env.REACT_APP_* 형식을 사용했지만, Vite에서는 import.meta.env.VITE_* 형식을 사용합니다.
// Before (Webpack)
process.env.REACT_APP_API_URL
// After (Vite)
import.meta.env.VITE_API_URL
6.3 JSX 확장자
Vite는 기본적으로 .jsx/.tsx 확장자를 명시적으로 구분합니다. JSX를 포함한 파일은 반드시 .jsx 또는 .tsx 확장자를 사용해야 합니다.
7. 교훈
7.1 기술 부채는 이자가 붙는다
5년간 쌓인 의존성, deprecated 패키지, 복잡한 설정들이 결국 OOM이라는 형태로 폭발했습니다. "일단 돌아가니까"라는 마음으로 미뤄둔 것들이 결국 긴급 마이그레이션을 강제했습니다.
기술 부채를 갚지 않으면, 결국 이자가 원금을 초과합니다.
7.2 측정이 먼저다
"빌드가 느리다", "메모리를 많이 쓴다"는 체감이 아닌, 정확한 측정이 의사결정의 근거가 되어야 합니다.
저희는 메모리 디버깅 코드를 추가하고, 다양한 메모리 임계치에서 빌드를 테스트하고, 빌드 시간을 여러 차례 측정한 후에야 올바른 판단을 내릴 수 있었습니다.
7.3 점진적 전환이 안전하다
별도 브랜치에서 작업하고, 충분히 검증한 후 머지했습니다. "안 되면 돌아가면 된다"는 마음가짐이 오히려 과감한 시도를 가능하게 했습니다.
8. 마치며
"빌드가 터졌다"는 건 끔찍한 경험이지만, 동시에 기회이기도 합니다. 미뤄왔던 기술 부채를 청산하고, 더 나은 개발 환경을 구축할 명분이 생기기 때문입니다.
저희는 OOM 에러 덕분에(?) Vite로 전환했고, 결과적으로:
- 빌드 실패 없이 안정적으로 배포
- 개발 서버 시작 460배 빨라짐
- 프로덕션 빌드 48% 단축
- 설정 파일 77% 간소화
- 번들 크기 81% 감소
- 코드 210,000 라인 삭제
를 달성했습니다.
레거시 프로젝트를 운영하고 계신가요? 갑자기 빌드가 안 되나요? 두려워하지 마세요. 문제가 발생했을 때가 바로 해결할 때입니다.
끝으로, 이 프로젝트가 배포되기까지 많은 분들이 도움을 주셨습니다.
문제를 발견하고 레거시 전환이 성공적으로 이뤄지도록 페어작업자로서 도움을 주신 이진희님
프로젝트 우선순위를 끌어올려 안정성을 검증할 수 있도록 도움을 주신 최지수님
글을 작성할 수 있도록 문맥 교정 및 검증에 도움을 주신 한승희님
이 외에 언급드리지 못한 많은 팀원분들께 감사의 인사 드립니다.
긴 글 읽어주셔서 감사합니다.
참고문서
- Vite 공식 문서
- MobX Decorator 설정
- Configure ForkTsCheckerWebpackPlugin memory limit · create-react-app#8676
- Webpack ModuleConcatenationPlugin
- Webpack SplitChunksPlugin
- concatenateModules breaks circular dependency · webpack/webpack#10409
- Heap out of memory memoryLimit issue · fork-ts-checker-webpack-plugin#271
- Dependency Graph - webpack
- Code Splitting - webpack
- ForkTsCheckerWebpackPlugin out of memory
