Post

레거시 UI 빌드 개선기 (2) - 360만 줄 레거시를 Webpack 5 + SWC로 옮기기

레거시 UI 빌드를 Webpack 5와 SWC로 전환하며 설치, 트랜스파일, 번들링 병목을 줄인 과정

목표는 “가장 최신 도구 사용”이 아니었습니다. 360만 줄이 넘는 레거시 코드베이스를 최대한 안전하게, 그리고 단계별로 검증하면서 빠른 빌드 환경으로 옮기는 것이었습니다.

전환 기준

처음부터 특정 도구를 정해두지는 않았습니다. 도구를 고르기 전에 빌드 시간 병목구간을 찾아야했습니다.

빌드 분석

Jenkins 빌드 로그에서 먼저 확인한건 전체 빌드 시간입니다. 개발 환경 빌드 기준 약 36분, 이 빌드에는 minification이 포함되지 않습니다. 운영 빌드가 약 50분이었으니, 그 차이인 약 12~14분은 minification 단계(ParallelUglifyPlugin)에서 나온다고 볼 수 있었습니다.

다음으로 본건 처리된 모듈 수였습니다. 빌드 로그에는 70% building modules 14520/14520 modules 0 active라고 찍혀 있었습니다. Webpack이 매 빌드마다 14,520개 모듈을 전부 처리하고 있었고, 캐시가 없으니 같은 일을 계속 반복하는 구조였습니다.

스크린샷 2026-05-02 오후 7.06.58.png

minification이 빠진 개발기 빌드만으로도 36분이 걸린다는 사실이 중요했습니다. 그 시간 대부분은 모듈 처리 단계에서 나온다고 봐야 했습니다. 로컬에서 쓰지 않는 서비스 라우터를 주석 처리하여 개발속도를 높이는것도 처리하는 파일 수가 줄면 빌드 시간도 줄일 수 있다고 판단했습니다.

이 정보로 병목 구간을 잡을 수 있었습니다.

단계도구추정 소요 시간
모듈 처리 (트랜스파일)babel-loader~30분
코드 압축 (minification)ParallelUglifyPlugin~14분
기타번들링, 에셋 처리~2분

코드베이스 제약 조건

항목수치의미
JS 파일 수11,383개트랜스파일러가 처리할 대상 전체
React 버전15.xclassic JSX runtime 필수
Decorator 사용 파일289개MobX 레거시 데코레이터 (@observer, @inject)
System.import() 호출1,361건 (79개 파일)CommonJS 혼재, Webpack 의존적
내부 Git 패키지18개vendor 처리 필요
배포 환경9개각각 EJS 템플릿으로 분기된 Webpack 플러그인 구성

병목 위치를 알았다고 바로 도구를 정할 수는 없었습니다. 어떤 도구를 쓸 수 있는지는 코드베이스 상태가 결정합니다. 이 숫자들이 도구 선택의 필터가 됐습니다. 가장 빠른 도구가 아니라, 이 조건에서 실제로 동작하는 도구를 찾아야 했습니다.

도구 선정

빌드 파이프라인을 기준으로 보면 해결 경로는 둘로 나뉩니다.

1
2
트랜스파일러 교체  →  Babel을 다른 도구로 교체, Webpack은 유지
번들러 교체       →  Webpack 자체를 교체, 트랜스파일러도 함께 교체

번들러를 교체하면 트랜스파일러 문제도 같이 풀릴 수 있습니다. 두 경로를 모두 열어두고 후보를 봤습니다. 후보를 추릴 때는 State of JavaScript 2025 설문 결과와 npm 주간 다운로드 수치를 같이 봤습니다. 개인적인 선호보다 실제 사용 흐름을 먼저 보려는 의도였습니다.

경로후보 도구State of JS 2025 사용률사용 경험 응답 수npm 주간 다운로드공식 문서
트랜스파일러 교체SWC27%3,076명@swc/core 약 4,160만swc.rs
번들러 교체Vite84%9,579명vite 약 1.15억vite.dev
번들러 교체Rspack7%834명@rspack/core 약 519만rspack.rs
번들러 교체Turbopack28%3,240명Next.js 내장형turbo.build/pack

webpack은 이 설문에서 사용률 87%(9,797명)로 여전히 가장 많이 쓰인 도구였습니다. Vite도 84%(9,579명)로 거의 같은 수준까지 올라와 있었습니다. 차이가 컸던 지점은 “다시 쓰고 싶다”는 응답이었습니다. webpack은 26%, Vite는 98%였습니다. 이 수치는 webpack을 바로 버려야 한다는 뜻이라기보다, 새 빌드 도구를 검토가 필요하다고 판단하였습니다.

프로젝트에 바로 넣기 어려운 후보부터 제외했습니다.

  • Turbopack: Next.js 내장형으로만 배포되며 범용 번들러로 쓸 수 없었습니다.
  • esbuild (full bundler): 트랜스파일러로서의 esbuild는 이미 2단계에서 minification에 도입했습니다. 번들러로 쓰기에는 CommonJS 혼재 환경과 기존 Webpack 플러그인 생태계를 소화하기 어려웠습니다.

남은 후보는 Vite, Rspack, SWC였습니다. 여기서부터는 코드베이스 제약 조건으로 다시 걸렀습니다.

Vite (공식 문서 / plugin-legacy)

Vite는 개발 서버와 운영 빌드의 번들러가 다릅니다. 개발 서버는 esbuild, 운영 빌드는 Rollup을 사용합니다. 이 프로젝트처럼 “빌드 성공 여부”보다 “런타임 동작 일관성”이 중요한 레거시 프로젝트에서 두 환경의 동작 차이는 관리하기 어려운 리스크였습니다.

Vite의 기본 빌드 타겟은 Chrome 111+, Safari 16.4+ 기준의 모던 브라우저입니다. 레거시 브라우저 지원을 위한 @vitejs/plugin-legacy가 있지만, 이 플러그인은 브라우저 기능 수준(ES Modules, dynamic import)을 기준으로 동작합니다. 공식 문서에도 IE11은 기본 제외 대상으로 명시되어 있고, React 15 같은 특정 프레임워크 버전에 대한 검증 경로를 제공하지는 않습니다.

System.import() 1,361건과 CommonJS 혼재 환경은 Vite의 ESM-first 구조와 맞지 않았고, Webpack 플러그인 설정도 전면 재작성해야 했습니다.

Rspack (공식 문서 / 플러그인 호환성)

Rspack은 Webpack API 호환을 목표로 합니다. 공식 문서에는 “상위 50개 webpack 플러그인 중 85% 이상 사용 가능 또는 대안 제공”이라고 적혀 있습니다. 기존 설정을 거의 그대로 가져갈 수 있다는 점이 매력적이었습니다.

다만 공식 호환성 문서에는 “100% webpack API 완전 호환은 설계 목표가 아님”도 함께 명시되어 있었습니다. 코드베이스에서 확인한 MobX 레거시 데코레이터 289개 파일, 프로덕션 수준으로 검증된 사례는 공식 문서와 커뮤니티에서 찾기 어려웠습니다. 이 조합을 직접 POC로 검증하는 비용이 Webpack 5 전환 비용보다 클 것으로 판단했습니다.

SWC (공식 문서)

SWC는 Webpack을 유지하고 트랜스파일러만 교체하는 경로였습니다. 코드베이스에서 확인한 제약 조건도 .swcrc 설정으로 대부분 대응했습니다.

  • "runtime": "classic" → React 15 classic JSX runtime 유지
  • "legacyDecorator": true → MobX 레거시 데코레이터 289개 파일 대응
  • "target": "es5" → 레거시 브라우저 지원 유지
  • "dynamicImport": trueSystem.import() 전환 대응

Webpack 플러그인 생태계를 그대로 가져갈 수 있었습니다. 변경 범위가 트랜스파일러로 좁혀지니, 문제가 생겼을 때 원인을 찾기도 쉽고 롤백 경로도 명확했습니다.

이렇게 해서 Webpack 5 + SWC 조합으로 좁혀졌습니다. Webpack 5는 가장 빠른 선택이 아니라 실제로 도달 가능한 선택이었고, SWC는 그 위에서 트랜스파일러 병목만 바꾸는 가장 작은 변경이었습니다.

별도 레포에서 시작한 이유

이 작업은 기존 운영 레포(legacy-ui)를 직접 건드리지 않고 시작했습니다.

운영 중인 레포에서 Webpack 버전을 올리다 빌드가 깨지면 다른 개발자에게 바로 영향이 갑니다. git 히스토리도 지저분해질 수밖에 없습니다. legacy-ui를 복사해 turbo-ui라는 별도 레포를 만들고, Jenkins에도 전용 빌드 Job을 새로 만들었습니다. 기존 CI/CD 파이프라인과 완전히 분리된 곳에서 Phase별로 검증하려는 선택이었습니다.

변경사항은 turbo-ui에서 검증을 끝낸 뒤 레포 옮기는 전략으로 잡았습니다.

단계적 전환

개선은 세 단계로 나누었습니다. 처음부터 근본 전환으로 뛰어들지 않았습니다. 기존 구조 안에서 줄일 수 있는 것부터 줄이고, 마지막에 Webpack 5와 SWC로 넘어갔습니다.

1단계: Webpack 2 환경에서 바로 줄이기

Webpack을 건드리지 않고도 줄일 수 있는 것들이 있었습니다.

빌드 스크립트 통합

기존에는 Jenkins 빌드가 시작될 때 내부 git 패키지를 git install 스크립트 순차 실행하면서, 매 빌드마다 npm updatenpm uninstall을 반복했습니다. 이것을 npm install --no-audit --no-fund 단일 명령으로 통합했습니다.

캐시 활성화

Babel이 변환한 결과를 로컬 캐시에 저장하도록 설정했습니다. 첫 빌드에는 효과가 거의 없지만, 같은 파일을 다시 빌드할 때 이전 변환 결과를 재사용할 수 있습니다.

1
2
3
4
5
// Before
loaders: ['react-hot-loader', 'babel-loader']

// After
loaders: ['react-hot-loader', 'babel-loader?cacheDirectory']

2단계: minification과 HMR 개선

1단계로 개발기 빌드에서 minification을 제거하고 로더 캐시를 켰지만, 운영 빌드의 병목 두 개는 그대로 남아 있었습니다.

  • minification: ParallelUglifyPlugin은 JavaScript 프로세스를 띄워 병렬로 코드를 압축하는 도구입니다. 프로세스 생성·스케줄링 오버헤드가 크고 JS 런타임 속도에 묶여 있어, 운영 빌드에서 ~14분을 차지하고 있었습니다. 1단계에서는 개발기 빌드에서만 이를 건너뛰었을 뿐, 실제 배포에 쓰는 운영 빌드는 손대지 않았습니다.
  • 트랜스파일: 1단계에서 켠 babel-loader?cacheDirectory는 로컬 웜 빌드에서만 효과가 있습니다. Jenkins처럼 매 빌드마다 새 워크스페이스에서 실행되는 CI 콜드 빌드에서는 캐시 자체가 없어 개선 효과가 없었습니다.

이 단계에서는 Webpack 버전을 올리지 않고도 줄일 수 있는 부분을 먼저 정리했습니다.

minification 교체

ParallelUglifyPlugin을 Go 네이티브 바이너리로 동작하는 esbuild로 교체했습니다. 네이티브 코드 단일 패스 처리라 JS 기반 멀티 프로세스와 압축 속도 차이가 큽니다. 바로 사용 가능할줄 알았지만 esbuild-loader의 ESBuildMinifyPlugin을 붙였더니 실행하자마자 에러가 났습니다.

1
2
TypeError: Cannot read property 'compilation' of undefined
    at ESBuildMinifyPlugin.apply (esbuild-loader/dist/minify-plugin.js:33:24)

esbuild-loader는 어떤 버전도 Webpack 2를 지원지 않았던거죠, 대신 esbuild 패키지를 직접 require하면 Webpack과 무관하게 변환 API를 쓸 수 있는걸 확인했습니다. Webpack 2 스타일의 커스텀 플러그인을 작성해 우회했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Before
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
new ParallelUglifyPlugin({ workerCount: os.cpus().length, ... });

// After
const esbuild = require('esbuild');

class ESBuildMinifyWebpack2 {
    constructor(options = {}) { this.options = options; }
    apply(compiler) {
        compiler.plugin('compilation', (compilation) => {
            compilation.plugin('optimize-chunk-assets', (chunks, callback) => {
                const files = [];
                chunks.forEach((chunk) => {
                    chunk.files.forEach((file) => {
                        if (file.endsWith('.js')) files.push(file);
                    });
                });
                Promise.all(files.map((file) => {
                    const source = compilation.assets[file].source();
                    return esbuild.transform(source, {
                        minify: true,
                        drop: this.options.drop || [],
                    }).then((result) => {
                        compilation.assets[file] = {
                            source: () => result.code,
                            size: () => result.code.length,
                        };
                    });
                }))
                .then(() => callback())
                .catch((e) => { compilation.errors.push(e); callback(); });
            });
        });
    }
}

new ESBuildMinifyWebpack2({
    drop: env === 'production' ? ['console'] : [],
});

react-hot-loader 조건부 처리

react-hot-loader는 개발 서버가 실행 중일 때 소스를 변경하면 페이지를 새로 고치지 않고 화면을 갱신해주는 HMR(Hot Module Replacement) 로더입니다. CI 빌드는 개발 서버를 띄우지 않아 이 기능이 동작할 수 없는데도, 모든 JS 파일이 이 로더 체인을 통과하고 있었습니다. 처리 대상이 14,520개 모듈인 환경에서 의미 없는 로더가 매 파일마다 끼어있는 셈이었습니다.

1
2
3
4
5
6
7
// Before
loaders: ['react-hot-loader', 'babel-loader?cacheDirectory']

// After
loaders: process.env.CHECK_TYPE === 'local'
    ? ['react-hot-loader', 'babel-loader?cacheDirectory']
    : ['babel-loader?cacheDirectory']

DllPlugin 도입

콜드 빌드에서 트랜스파일 병목을 줄이기 위해 Webpack의 DllPlugin도 이 단계에서 도입했습니다. DllPlugin은 reactlodash 같이 자주 바뀌지 않는 외부 라이브러리(vendor)를 한 번만 별도로 빌드해두는 방식입니다. 이후 빌드에서는 이미 처리된 결과를 참조하기 때문에, 소스 코드가 바뀌더라도 전체 11,383개 파일을 처음부터 다시 처리하지 않고 애플리케이션 코드만 재빌드할 수 있었습니다. webpack.dll.config.js를 작성하고 build:dll 스크립트를 추가해 vendor를 미리 빌드해두는 구조를 만들었습니다.

단, DllPlugin은 Webpack 5 전환 이후에 삭제했습니다. Webpack 5의 filesystem cache가 변경된 파일만 다시 처리하는 방식으로 이 역할을 더 넓은 범위에서 대체했고, DllPlugin에는 중복 폴리필, 별도 빌드 단계 관리, 런타임 전역 변수 충돌 같은 이슈가 남아있어, 3단계에서는 DllPlugin 관련 코드를 모두 삭제했습니다.

스크린샷 2026-05-09 오후 5.57.09.png

2단계까지 적용하였을때 운영 빌드 실측 기준으로 전체 빌드 시간이 40~50분 → 31분으로 줄었습니다. 하지만 Webpack은 여전히 11,383개 파일을 매번 처리했고, Babel 6도 그대로 였습니다. CI 환경의 콜드 빌드를 본격적으로 줄일려면 결국 Webpack5와 SWC로 넘어가야했습니다.

3단계: 근본 전환 — Webpack 5 + SWC

예상 밖의 병목

3단계로 가려면 Node 10 / npm 6에서 Node 24로 먼저 올라가야 했습니다.

프로젝트가 Node 10에 머물러 있던 것은 의도적인 선택이 아니라 레거시 환경이 그대로 굳어진 결과였습니다. Webpack 2 / Babel 6 스택이 2017년 기준으로 설정 뒤 교체 없이 유지됐고, Node 버전도 그 시점에 맞춰진 상태 그대로였습니다

esbuild-loader는 내부에서 esbuild를 사용합니다. 낮은 버전의 esbuild-loader를 고르면 Node 요구사항을 낮출 수는 있지만, 그러면 또 다른 오래된 도구 조합에 묶이게 됩니다. 이번 작업의 목표는 “Node 10에서 돌아가는 가장 낮은 버전 찾기”가 아니라, Webpack 5 / SWC / esbuild 도구가 유지 가능한 기준으로 올리는 것이었습니다.

확인 항목최소 요구 버전Node 10에서 사용 가능 여부
Webpack 5Node 10.13 이상가능
@swc/coreNode 10 이상가능
esbuild-loader의존하는 최신 esbuild 기준 Node 18 이상불가능

업그레이드 직후 예상하지 못한 문제가 생겼습니다. 기존 1분 수준이던 npm install2시간 이상 걸리기 시작했습니다.

원인은 내부 Git 패키지 구조와 npm 7+의 동작 변화가 맞물린 결과였습니다. 사내 Git 서버의 내부 패키지를 git+http:// 형태로 직접 참조하는 의존성이 18개 있었는데, npm은 이런 패키지를 설치할 때 레지스트리 Git 저장소를 임시 디렉토리에 clone한 뒤 설치 가능한 패키지 형태로 묶습니다. 두 가지 요인이 겹쳤습니다.

  • 내부 패키지들 자체도 dependencies에 다른 내부 Git URL을 포함하고 있었습니다. npm은 그것들도 clone하고, 그 안의 의존성을 또 해석하는 과정을 반복합니다. 직접 의존성 18개가 전이적으로 연쇄되면 훨씬 많은 clone 작업이 쌓입니다.
  • npm 7부터 peerDependencies가 기본으로 자동 설치됩니다. npm 6에서는 수동 설치였던 peer 패키지들이 npm 7+ 환경에서는 자동으로 의존성 트리에 합류하면서, 각 Git 패키지를 처리할 때마다 해석 대상이 더 늘어납니다.

Image 2026년 5월 6일 오후 01_43_37.png

이 상태로는 3단계에 들어갈 수 없었습니다. 해결은 내부 Git 패키지를 로컬 tgz 파일로 고정하는 방식이었습니다. pack-vendor.sh 스크립트로 18개 패키지를 git clone → npm pack → 내부 git URL 제거 후 재패킹 순서로 처리하고, 최종 결과물을 vendor/*.tgz 파일로 커밋했습니다.

1
2
3
4
5
// Before (package.json)
"[패키지명]": "git+http://사내-git/org/[패키지명].git#1.0.0"

// After
"[패키지명]": "file:vendor/[패키지명]-1.0.0.tgz"

패키지를 tgz로 바꾸는 것만으로는 부족했습니다. tgz 내부의 package.json이 여전히 git URL을 가리키고 있었기 때문입니다. 루트 package.jsonoverrides 블록을 추가해 전이 의존성까지 file:vendor/로 재매핑했고, 필요한 패키지는 내부 package.json을 물리적으로 정리해 다시 패킹했습니다.

그 결과 npm install은 2시간 이상에서 1분으로 줄여 해결할 수 있었습니다.

Webpack 5 전환

Webpack 5에서는 Webpack 2 설정들을 상당수가 제거됐거나 다른 방식으로 바뀌었습니다.

영역Webpack 2Webpack 5
청크 분할CommonsChunkPluginoptimization.splitChunks
CSS 추출extract-text-webpack-pluginmini-css-extract-plugin
파일 처리file-loader, url-loaderAsset Modules (내장)
모듈 규칙module.loadersmodule.rules
모듈 IDHashedModuleIdsPluginmoduleIds: "deterministic"
출력 정리CleanWebpackPluginoutput.clean: true
Node 폴리필자동 주입수동 resolve.fallback / ProvidePlugin
캐시없음filesystem cache

전환하면서 webpack.parts.js도 약 200줄에서 96줄로 줄었습니다. file-loader, url-loader, react-hot-loader, BabiliPlugin, CompressionPlugin처럼 Webpack 5에서 내장됐거나 더 이상 필요하지 않은 로더와 플러그인을 걷어냈습니다. 설정은 짧아졌고 역할도 더 분명해졌습니다.

Babel에서 SWC로

Webpack 5만으로는 충분하지 않았습니다.

Webpack이 번들링을 담당한다면, Babel은 11,383개 파일을 변환하는 핵심 경로에 있었습니다. Babel 6는 JavaScript로 구현되어 있고, 캐시가 없을 때의 절대 속도가 느립니다. Jenkins에서 매번 콜드 빌드로 실행되는 환경에서는 babel cache만으로 한계가 분명했습니다.

Babel 대신 SWC를 도입한 이유가 여기에 있습니다. SWC는 Rust로 구현된 트랜스파일러입니다. CI 콜드 빌드에서는 캐시 없이 11,383개 파일을 매번 다시 파싱하고 변환해야 하는데, 이때 JavaScript 런타임 위에서 동작하는 Babel보다 컴파일된 네이티브 코드로 실행되는 SWC가 CPU 처리와 메모리 관리 부담이 작아 유리합니다.

후보 선정 과정에서 SWC가 코드베이스 제약 조건을 통과했더라도, 그것은 적용 가능하다는 판단일 뿐입니다. 실제로 빠른지는 별도로 측정해야 했습니다. 이 확인을 위해 A/B 테스트를 설계했습니다.

Webpack 5 브랜치를 두 갈래로 분리했습니다. 한쪽은 Babel 6을 그대로 유지하고, 다른 한쪽은 SWC로 교체했습니다. Webpack 버전과 설정은 동일하게 맞춰 트랜스파일러 교체 효과만 분리했습니다. 기준은 Jenkins 콜드 빌드였습니다. 로컬 빌드와 달리 CI 환경은 매번 캐시 없이 실행되므로, 캐시 효과를 빼고 도구 자체의 속도를 비교하기 좋았습니다.

 Babel 6SWC
Jenkins 콜드 빌드약 13~14분약 70~80초
구현 언어JavaScriptRust (네이티브 바이너리)
CI 캐시 없을 때매번 느림캐시 없이도 빠름

전환 중 마주친 이슈들

Webpack 5와 SWC로 전환하면서 기존 빌드 도구가 묵인해주던 문제들이 한꺼번에 드러났습니다.

이슈원인해결
System.import() 1,361건Webpack 5가 Webpack 2의 호환 처리 제거표준 동적 import()로 일괄 치환
SWC 파싱 실패 (수십 개 파일).js 파일에 섞인 TypeScript 문법, 이스케이프 안 된 JSX 특수문자 등 — Babel 6는 통과시켰으나 SWC는 파싱 거부파서 옵션 우회 대신 소스 직접 수정, 파싱 실패 0건으로 마무리
Node 코어 폴리필 런타임 에러Webpack 5가 process, Buffer 자동 주입 제거resolve.fallback + ProvidePlugin으로 수동 선언
babel-polyfill 중복 로딩entry와 소스 파일 양쪽에서 importentry 제거 후 NormalModuleReplacementPlugin으로 빈 모듈 치환

결과

전환 이후 빌드 시간은 크게 줄었습니다.

스크린샷 2026-05-07 오전 10.04.31.png

지표기존 (Node 10 / Webpack 2 / Babel 6)이후 (Node 24 / Webpack 5 / SWC)
npm install1분1분
Jenkins 콜드 빌드약 36분약 80초
로컬 시작주석 처리 없이 실행 불가능약 10초
로컬 HMR주석 처리 없이 실행 불가능약 2초

이 결과만으로 작업이 끝난 것은 아니었습니다.

빌드 시간은 크게 줄었지만, 빌드가 빨라졌다는 사실만으로 운영 가능한 상태라고 말할 수는 없었습니다. 레거시 UI에서 중요한 것은 번들이 만들어지는지가 아니라, 기존 화면과 같은 방식으로 동작하는지였습니다.

이제 질문은 빌드 도구가 아니라 운영 검증 쪽으로 넘어갔습니다.

  • 기존 UI와 신규 빌드 UI를 같은 조건에서 비교할 수 있는가?
  • 80초라는 숫자를 같은 기준으로 계속 측정할 수 있는가?
  • 기존 레거시 코드 변경을 신규 빌드 환경에 안전하게 따라붙일 수 있는가?
  • 문제가 생겼을 때 원인을 좁히고 빠르게 되돌릴 수 있는가?
This post is licensed under CC BY 4.0 by the author.