OOM이 터지고 나서야 깨달은 것들 — Webpack4에서 Vite로 갈아탄 5년 묵은 CMS

11 min read
ViteWebpack마이그레이션컬리빌드
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일순환 참조 발견상대경로로 수정부분 해결
메모리 할당량 변경 커밋 (700b2602f)
-"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개 이상의 의존성 패키지를 가진 프로젝트에서 이 그래프는 감당 불가능한 크기였다.

항목Webpack4Vite
파싱전체 AST를 메모리에 유지스트리밍 처리
GC 오버헤드높음낮음
의존성 처리매번 전체 분석esbuild 사전 번들링
HMR번들 재생성모듈 단위 교체

메모리 임계치 테스트 결과, 최소 6GB 이상이 필요했다. 1~5.5GB 구간에서는 전부 OOM이 발생했다.


번들러 후보 비교 — 왜 Vite였나

Webpack4의 대안으로 Vite, Parcel, Rsbuild를 후보에 올렸다.

항목ViteParcelRsbuild
장점ESM 기반, 활발한 생태계제로 설정Rust 기반 고성능
단점레거시 설정 필요커스텀 제한적생태계 작음
MobX DecoratorBabel 플러그인 지원제한적Babel 플러그인 지원

[💡 잠깐! 이 용어는?] ESM(ES Module): JavaScript의 공식 모듈 시스템. import/export 문법을 사용하며, 브라우저가 네이티브로 지원한다. Vite는 개발 서버에서 ESM을 직접 활용해 전체 번들링 없이 필요한 모듈만 제공한다.

결정적 요인은 MobX 5의 decorator 문법 호환성이었다. React 16 + MobX 5 + MUI4라는 레거시 조합을 깨지 않고 수용할 수 있는 건 Vite뿐이었다. 비유하면, 엔진을 교체하면서 차체 프레임은 그대로 유지해야 하는 상황이었고, Vite가 유일하게 그 프레임에 맞는 엔진이었다.

vite.config.js — MobX decorator 지원 설정
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% 감소했다.

vite.config.js — 전체 핵심 설정
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회 측정한 평균값이다.

항목Webpack4Vite개선율
Production 빌드54.28초28.21초48% 감소
개발 서버 시작~47초102ms460배 (99.8% 감소)
번들 크기57MB11MB81% 감소
설정 파일538줄121줄77% 감소
빌드 메모리8GB 필요4GB 이하OOM 완전 해결
Node.js16.18.022.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 브랜치에서 작업하고, 충분히 검증한 후 머지했다. "안 되면 돌아가면 된다"는 안전망이 오히려 대담한 결정을 뒷받침했다.


마무리

"빌드가 터졌다"는 끔찍한 경험이지만 동시에 기회이기도 하다. 미뤄왔던 기술 부채를 청산하고 더 나은 개발 환경을 구축할 명분이 생기기 때문이다. 빌드 문제로 고통받는 레거시 프로젝트가 있다면, 문제가 수면 위로 올라온 바로 그 순간이 해결할 때다. 수도관이 터지기 전에 배관을 교체하는 게 언제나 더 싸다.


참고:

관심 있을 만한 포스트

번들러(Bundle)란 뭐고, 왜 필요할까? — 요즘 번들러/빌드 툴 비교 가이드

번들러의 역할(모듈/의존성/트랜스파일/최적화)을 쉽게 설명하고, Vite·Rollup·esbuild·Webpack·Rspack·Turbopack 같은 도구를 상황별로 비교합니다.

BundlerVite

Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기

Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.

VinextNext.js

Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1

2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.

BabelJavaScript

jQuery 4.0 — 10년 만의 메이저 릴리스, 무엇이 바뀌었나

jQuery가 20주년을 맞아 10년 만에 메이저 버전을 출시했다. IE 지원 축소, ES 모듈 전환, Trusted Types 등 핵심 변경 사항을 정리한다.

jQueryJavaScript

달리는 차의 엔진을 바꾸다 — Nx 모노레포에서 Bun 도입까지의 여정

Nx 18에서 21까지 버전 업그레이드와 Bun 패키지 매니저 도입을 동시에 진행한 컬리의 마이그레이션 전략과 실전 이슈를 정리한다.

NxBun

Rolldown의 코드 스플리팅 — 비트셋 한 줄로 모듈의 소속을 결정하는 법

Vite의 차세대 번들러 Rolldown이 비트셋 기반 알고리즘으로 코드 스플리팅을 수행하는 원리를 분석한다.

RolldownVite

Babel 8 Beta — CJS를 버리고 ESM 전용으로 간다

2년간의 알파를 거쳐 베타에 진입한 Babel 8의 핵심 변경사항과 마이그레이션 전략을 정리한다.

BabelESM

TypeScript 6.0 Beta — TS 7 가기 전에 tsconfig부터 정리하자

TypeScript 6.0 Beta의 주요 변경 사항과 깨지는 기본값들을 정리하고, TS 7(Go 네이티브) 전환을 대비하는 마이그레이션 전략을 다룬다.

TypeScripttsconfig