Webpack에서 RsBuild로 전환한 이유와 과정
OOM이 터졌다
어느 날 CI에서 빌드가 실패하기 시작했습니다. 원인은 OOM(Out of Memory).
13개 마이크로 앱이 들어있는 모노레포에서 Webpack 5로 빌드를 돌리고 있었는데, 앱이 하나둘 추가될 때마다 메모리 사용량이 늘어나더니 결국 GitHub Actions의 RAM 한계를 넘어버렸습니다.
임시로 메모리 옵션을 올리고, babel 설정을 최적화해봤지만 근본적인 해결은 아니었습니다. Webpack이 각 앱의 의존성 그래프를 전부 메모리에 올리는 구조라, 앱이 늘어날수록 선형으로 메모리가 증가하는 문제였습니다.
결국 빌드 도구 자체를 바꾸는 게 근본 해결이라는 결론에 도달했습니다. Rust 기반 번들러는 메모리 효율이 근본적으로 다르니까요.
왜 RsBuild인가
빌드 도구를 바꾸기로 했을 때 후보는 세 가지였습니다.
| 도구 | 장점 | 단점 |
|---|---|---|
| Vite | 빠름, 생태계 큼 | Module Federation 지원 불안정 |
| Turbopack | Next.js 생태계 최적화 | Vue 지원 미흡, 아직 불안정 |
| RsBuild (Rspack) | Webpack 호환 + Rust 속도 | 상대적으로 새로움 |
결정적이었던 건 Module Federation 호환성입니다. 이미 @module-federation/enhanced를 쓰고 있었는데, 이 패키지가 RsBuild 플러그인을 공식 지원하고 있었습니다. Webpack 설정을 거의 그대로 가져갈 수 있다는 뜻이었죠.
Vite는 Module Federation 플러그인이 있긴 하지만, 런타임 동작이 Webpack과 미묘하게 달라서 마이그레이션 리스크가 컸습니다.
Before: vue.config.js 시절
기존 구조는 @vue/cli-service 기반이었습니다.
// configs/vue/vue.config.js
const { ModuleFederationPlugin } = require("@module-federation/enhanced/webpack");
// 패키지 목록을 하나하나 수동으로 import
const sharedPackage = require("../../packages/shared/package.json");
const entryPackage = require("../../packages/entry/package.json");
const analyticsPackage = require("../../packages/analytics/package.json");
// ... 10개 더
const defaultServiceModules = [
{ name: "shared", port: 8083, version: sharedPackage.version },
{ name: "entry", port: 5007, version: entryPackage.version },
{ name: "analytics", port: 5008, version: analyticsPackage.version },
// ...
];
문제점:
- 패키지 추가할 때마다 이 파일을 수동으로 수정해야 합니다.
@vue/cli-service가 내부적으로 Webpack을 감싸고 있어서 세밀한 제어가 어렵습니다.- 빌드 시간이 앱 하나당 2~3분, 전체 5분 이상 걸렸습니다.
After: rsbuild.config.ts
// packages/main/rsbuild.config.ts
import { defineConfig } from "@rsbuild/core";
import { pluginVue } from "@rsbuild/plugin-vue";
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";
import { getRemoteFederationModules, getSharedFederationModules } from "../../configs/scripts/build";
export default defineConfig({
plugins: [
pluginVue(),
pluginModuleFederation({
name: "main",
shared: getSharedFederationModules(),
remotes: getRemoteFederationModules(["*"]),
filename: "remoteEntry.js",
shareStrategy: "loaded-first",
}),
],
});
달라진 점:
- TypeScript 설정 파일 (타입 안전)
- 패키지 목록 자동 스캔 (
getRemoteFederationModules가packages/디렉토리를 읽음) - 플러그인 구조가 명확하고 조합 가능
마이그레이션 과정
1단계: 한 앱에서 먼저 테스트
별도 브랜치를 따서 main 앱 하나만 RsBuild로 전환했습니다. 나머지 Remote 앱은 그대로 Webpack으로 빌드된 상태에서 Host만 바꿔본 거죠.
Module Federation은 런타임 통합이라, Host와 Remote의 빌드 도구가 달라도 remoteEntry.js 스펙만 맞으면 동작합니다. 이 덕분에 점진적 마이그레이션이 가능했습니다.
2단계: 빌드 스크립트 통합
기존에 vue.config.js에 하드코딩되어 있던 패키지 목록, 포트 정보, 환경변수 처리를 configs/scripts/build.ts로 분리했습니다.
// packages/ 디렉토리를 스캔해서 자동으로 모듈 목록 생성
export function getPackageInfos(baseDir = "../../packages") {
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory())
.map(entry => {
const pkg = JSON.parse(fs.readFileSync(`${baseDir}/${entry.name}/package.json`, "utf-8"));
const env = dotenv.parse(fs.readFileSync(`${baseDir}/${entry.name}/.env.host`, "utf-8"));
return { name: pkg.name.split("/").pop(), version: pkg.version, port: Number(env.PORT) };
});
}
이제 패키지를 추가해도 빌드 설정을 건드릴 필요가 없습니다.
3단계: 나머지 앱 전환
각 Remote 앱의 vue.config.js를 rsbuild.config.ts로 교체했습니다. 대부분 복사 + 붙여넣기 수준이었고, 앱별로 다른 건 name과 exposes 정도였습니다.
4단계: CI/CD 수정
GitHub Actions 워크플로우에서 vue-cli-service build → rsbuild build로 명령어를 바꾸고, 환경변수 주입 방식을 통일했습니다.
좀 더 고민이 필요했던 부분
import.meta.webpackHot 이슈
Webpack에서 HMR을 쓸 때 import.meta.webpackHot을 참조하는 코드가 있었는데, RsBuild에서는 이게 undefined로 떨어집니다. 개발 환경에서만 터지는 거라 발견이 늦었습니다.
→ 해당 코드를 import.meta.hot으로 통일했습니다. RsBuild도 Vite도 이 방식을 지원합니다.
Sentry 플러그인
@sentry/webpack-plugin이 RsBuild에서도 동작합니다. RsBuild가 내부적으로 Rspack을 쓰고, Rspack이 Webpack 플러그인 호환 레이어를 제공하기 때문입니다. 별도 수정 없이 그대로 가져왔습니다.
CSS 파일 해시
Webpack에서는 [contenthash]가 기본이었는데, RsBuild 개발 모드에서는 해시 없이 나옵니다. 배포 환경에서만 해시를 붙이도록 분기 처리했습니다.
output: {
filename: {
css: isDevServer ? "[name].css" : "[name].[contenthash:8].css",
},
}
결과
| Webpack (Before) | RsBuild (After) | |
|---|---|---|
| 앱 하나 빌드 | 2~3분 | 20~40초 |
| 전체 빌드 (CI) | 5분+ (OOM 위험) | 2분 이내 |
| 로컬 HMR | 3~5초 | 1초 미만 |
| 콜드 스타트 | 30초+ | 5~10초 |
| 메모리 사용 | 4GB+ (OOM) | 1.5GB 이내 |
OOM 문제는 RsBuild 전환으로 자연스럽게 해결됐습니다. Rspack은 Rust로 작성되어 있어서 JavaScript 런타임의 힙 메모리 제한을 받지 않습니다. 같은 코드를 빌드해도 메모리 사용량이 1/3 수준으로 줄었고, CI에서 OOM이 다시 발생한 적은 없습니다.
체감상 가장 큰 변화는 로컬 개발 속도입니다. 파일 저장하고 브라우저에 반영되기까지 체감 1초 미만. Webpack 시절에는 정말 상상도.. 못했었던 부분이었습니다... ㅠㅠ
점진적 마이그레이션은 불가능했다
Module Federation 덕분에 이론적으로는 Host만 먼저 바꾸고 Remote를 하나씩 전환할 수 있습니다. 하지만 실제로는 불가능했습니다.
빌드 설정, 환경변수 주입 방식, 플러그인 구조가 전부 달라지기 때문에 한 앱만 바꾸면 나머지와 설정이 꼬입니다. 결국 전체 앱의 설정을 한 번에 교체하는 방식으로 진행했습니다.
다행히 RsBuild가 Webpack 호환성이 높아서, 설정만 바꾸면 코드 자체는 거의 수정할 필요가 없었습니다. 한 번에 바꾸되, 코드 변경은 최소화하는 전략이었습니다.
마무리
Webpack에서 RsBuild로의 전환은 생각보다 수월했습니다. Rspack의 Webpack 호환성 덕분에 기존 플러그인 대부분이 그대로 동작했고, Module Federation 덕분에 점진적 마이그레이션이 가능했습니다.
빌드 속도가 병목이라면, 그리고 Webpack 생태계에서 크게 벗어나고 싶지 않다면.. RsBuild는 좋은 선택입니다.