OOM이 터지고 나서야 깨달은 것들 — Webpack4에서 Vite로 갈아탄 5년 묵은 CMS
수도관에서 물이 새면 수건으로 닦고, 테이프로 붙이고, 양동이를 받쳐둔다. 하지만 결국 수도관은 터진다. 2025년 3월, 컬리 CMS의 CI 빌드가 OOM(Out of Memory)으로 터졌다. React 16, TypeScript 4.4, MobX 5, Webpack 4라는 기술 스택이 5년간 거의 손대지 않은 채 운영되다가 한계에 도달한 것이다. 결과적으로 번들 크기 81% 감소, 빌드 시간 48% 감소, 개발 서버 시작 460배 가속, 코드 21만 줄 삭제를 달성했다.
쌓여가는 경고, 무시된 신호들
OOM은 갑자기 찾아오지 않았다. 문제는 이미 몇 달 전부터 조금씩 모습을 드러내고 있었다. 기술 부채는 신용카드 빚과 같다. 최소 상환만 하면 당장은 괜찮아 보이지만, 이자는 복리로 불어난다.
| 시점 | 증상 | 응급처치 | 효과 |
|---|---|---|---|
| 2월 10일 | 빌드 메모리 부족 | 4GB → 8GB 증설 | 임시 해결 |
| 2월 27일 | 빌드 속도 저하 | filesystem cache 추가 | 부분 개선 |
| 2월 27일 | 청크 비대화 | maxSize 500KB 설정 | 부분 개선 |
| 3월 11일 | CI 빌드 완전 실패 | 메모리 디버깅 추적 | 원인 파악 |
| 3월 24일 | 순환 참조 발견 | 상대경로로 수정 | 부분 해결 |
-"build": "node --max-old-space-size=4096 scripts/build.js",
+"build": "node --max-old-space-size=8192 scripts/build.js",메모리를 두 배로 올리는 건 해결이 아니라 연명이다. 근본 원인을 파악해야 했다.
순환 참조가 Webpack4를 질식시키는 메커니즘
순환 참조 자체가 모듈을 여러 번 분석하게 만드는 건 아니다. Webpack은 Module Registry로 각 모듈을 한 번만 분석한다. 문제는 세 가지 최적화가 간접적으로 망가진다는 점이다.
[💡 잠깐! 이 용어는?] Scope Hoisting: 여러 모듈을 하나의 함수 스코프로 합쳐 오버헤드를 없애는 Webpack 최적화 기법. 각 모듈을 개별 wrapper 함수로 감싸는 비용을 제거해 번들 크기를 줄이고 실행 속도를 높인다.
첫째, Scope Hoisting 실패. ModuleConcatenationPlugin은 여러 모듈을 하나의 함수로 합치는데, 순환 참조가 있으면 초기화 순서를 보장할 수 없어 bailout된다. 개별 wrapper 함수가 유지되면서 번들이 부풀어 오른다.
둘째, 청크 분할 복잡도 폭증. SplitChunksPlugin이 공유 모듈의 배치를 결정할 때, 순환 참조가 있으면 양쪽 chunk 모두에 필요하게 되어 계산 복잡도와 메모리 소비가 급등한다.
셋째, TypeScript 타입 체킹 과부하. 순환 타입 참조는 타입 추론 복잡도를 기하급수적으로 높인다. ForkTsCheckerWebpackPlugin에 4GB 메모리 제한을 별도로 설정해야 할 지경이었다.
// Before — barrel 파일에서 절대경로 사용 시 순환 참조 발생
export { BaseCell } from 'app/productReview/shared/components/ReviewTableCell/BaseCell';
// After — 상대경로로 수정하여 순환 해제
export { BaseCell } from './BaseCell';Webpack4 자체의 구조적 한계도 컸다. JavaScript 기반으로 전체 의존성 그래프를 메모리에 올려놓고 작업한다. 40개 이상의 도메인, 300개 이상의 의존성 패키지를 가진 프로젝트에서 이 그래프는 감당 불가능한 크기였다.
| 항목 | Webpack4 | Vite |
|---|---|---|
| 파싱 | 전체 AST를 메모리에 유지 | 스트리밍 처리 |
| GC 오버헤드 | 높음 | 낮음 |
| 의존성 처리 | 매번 전체 분석 | esbuild 사전 번들링 |
| HMR | 번들 재생성 | 모듈 단위 교체 |
메모리 임계치 테스트 결과, 최소 6GB 이상이 필요했다. 1~5.5GB 구간에서는 전부 OOM이 발생했다.
번들러 후보 비교 — 왜 Vite였나
Webpack4의 대안으로 Vite, Parcel, Rsbuild를 후보에 올렸다.
| 항목 | Vite | Parcel | Rsbuild |
|---|---|---|---|
| 장점 | ESM 기반, 활발한 생태계 | 제로 설정 | Rust 기반 고성능 |
| 단점 | 레거시 설정 필요 | 커스텀 제한적 | 생태계 작음 |
| MobX Decorator | Babel 플러그인 지원 | 제한적 | Babel 플러그인 지원 |
[💡 잠깐! 이 용어는?]
ESM(ES Module): JavaScript의 공식 모듈 시스템. import/export 문법을 사용하며, 브라우저가 네이티브로 지원한다. Vite는 개발 서버에서 ESM을 직접 활용해 전체 번들링 없이 필요한 모듈만 제공한다.
결정적 요인은 MobX 5의 decorator 문법 호환성이었다. React 16 + MobX 5 + MUI4라는 레거시 조합을 깨지 않고 수용할 수 있는 건 Vite뿐이었다. 비유하면, 엔진을 교체하면서 차체 프레임은 그대로 유지해야 하는 상황이었고, Vite가 유일하게 그 프레임에 맞는 엔진이었다.
react({
jsxRuntime: 'classic',
babel: {
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
],
},
}),설정 파일 538줄에서 121줄로
Webpack 관련 패키지 9개 이상을 삭제하고, Vite 관련 패키지 5개만 추가했다. 538줄이던 설정이 121줄로 77% 감소했다.
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';
},
},
},
},
}));[💡 잠깐! 이 용어는?] optimizeDeps.include: Vite가 개발 서버 시작 시 esbuild로 사전 번들링(pre-bundling)할 패키지 목록. CJS 전용 패키지나 초기화 순서가 중요한 패키지를 명시적으로 지정할 때 사용한다.
마이그레이션 중 만난 이슈들도 기록해둘 가치가 있다.
| 이슈 | 원인 | 해결 방법 |
|---|---|---|
| Node.js 내장 모듈 | Vite는 폴리필 미제공 | querystring: 'qs' alias |
| CSS 문법 에러 | IE6 해킹 CSS (*display) | esbuild logOverride: 'silent' |
| react-csv 호환성 | esbuild와 충돌 | vite-plugin-babel로 별도 트랜스파일 |
| dayjs 초기화 순서 | MobX 스토어 초기화 시점 | optimizeDeps.include에 추가 |
| 환경변수 | process.env.REACT_APP_* 형식 | import.meta.env.VITE_*로 전환 |
수치로 말하는 성과
동일 환경(Mac M2-Pro 32GB 12코어)에서 각 3회 측정한 평균값이다.
| 항목 | Webpack4 | Vite | 개선율 |
|---|---|---|---|
| Production 빌드 | 54.28초 | 28.21초 | 48% 감소 |
| 개발 서버 시작 | ~47초 | 102ms | 460배 (99.8% 감소) |
| 번들 크기 | 57MB | 11MB | 81% 감소 |
| 설정 파일 | 538줄 | 121줄 | 77% 감소 |
| 빌드 메모리 | 8GB 필요 | 4GB 이하 | OOM 완전 해결 |
| Node.js | 16.18.0 | 22.14.0 | 최신 LTS |
개발 서버 시작 시간의 차이가 가장 극적이다. 47초 기다리던 것이 0.1초로 바뀌었다. 코드를 고치고 결과를 확인하는 피드백 루프가 근본적으로 달라진 것이다.
[Webpack4]
Starting the development server...
# ... 47초 대기 ...
Compiled successfully!
[Vite]
VITE v6.2.1 ready in 102 ms
➜ Local: http://localhost:3000/세 가지 교훈
기술 부채는 복리다. 5년간 쌓인 의존성, deprecated 패키지, 복잡한 설정이 OOM이라는 형태로 한꺼번에 폭발했다. "일단 돌아가니까"라는 마음은 결국 긴급 마이그레이션을 강제한다.
체감이 아니라 측정이다. "느린 것 같다"는 감각이 아닌, 정확한 수치가 의사결정의 근거여야 한다. 다양한 메모리 임계치에서 빌드를 반복 테스트한 뒤에야 올바른 판단이 가능했다.
점진적 전환이 과감한 시도를 가능하게 한다. 별도 qa-vite-conversion 브랜치에서 작업하고, 충분히 검증한 후 머지했다. "안 되면 돌아가면 된다"는 안전망이 오히려 대담한 결정을 뒷받침했다.
마무리
"빌드가 터졌다"는 끔찍한 경험이지만 동시에 기회이기도 하다. 미뤄왔던 기술 부채를 청산하고 더 나은 개발 환경을 구축할 명분이 생기기 때문이다. 빌드 문제로 고통받는 레거시 프로젝트가 있다면, 문제가 수면 위로 올라온 바로 그 순간이 해결할 때다. 수도관이 터지기 전에 배관을 교체하는 게 언제나 더 싸다.
참고:
- Vite: https://vitejs.dev/
- MobX Decorator 설정: https://mobx.js.org/enabling-decorators.html
- Webpack ModuleConcatenationPlugin: https://webpack.js.org/plugins/module-concatenation-plugin/
관심 있을 만한 포스트
번들러(Bundle)란 뭐고, 왜 필요할까? — 요즘 번들러/빌드 툴 비교 가이드
번들러의 역할(모듈/의존성/트랜스파일/최적화)을 쉽게 설명하고, Vite·Rollup·esbuild·Webpack·Rspack·Turbopack 같은 도구를 상황별로 비교합니다.
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.
Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1
2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.
jQuery 4.0 — 10년 만의 메이저 릴리스, 무엇이 바뀌었나
jQuery가 20주년을 맞아 10년 만에 메이저 버전을 출시했다. IE 지원 축소, ES 모듈 전환, Trusted Types 등 핵심 변경 사항을 정리한다.
달리는 차의 엔진을 바꾸다 — Nx 모노레포에서 Bun 도입까지의 여정
Nx 18에서 21까지 버전 업그레이드와 Bun 패키지 매니저 도입을 동시에 진행한 컬리의 마이그레이션 전략과 실전 이슈를 정리한다.
Rolldown의 코드 스플리팅 — 비트셋 한 줄로 모듈의 소속을 결정하는 법
Vite의 차세대 번들러 Rolldown이 비트셋 기반 알고리즘으로 코드 스플리팅을 수행하는 원리를 분석한다.
Babel 8 Beta — CJS를 버리고 ESM 전용으로 간다
2년간의 알파를 거쳐 베타에 진입한 Babel 8의 핵심 변경사항과 마이그레이션 전략을 정리한다.
TypeScript 6.0 Beta — TS 7 가기 전에 tsconfig부터 정리하자
TypeScript 6.0 Beta의 주요 변경 사항과 깨지는 기본값들을 정리하고, TS 7(Go 네이티브) 전환을 대비하는 마이그레이션 전략을 다룬다.