모노레포에 단일 CLI를 만들어서 DX를 정리하기
10개 넘는 마이크로 프론트엔드 패키지가 있는 모노레포에서 일하고 있습니다. Module Federation으로 각 팀이 독립 배포하는 구조인데, 개발 환경 스크립트가 여기저기 흩어져 있었습니다. setup-scripts/, 각 패키지의 scripts/, 루트 셸 스크립트들. 새 팀원이 오면 뭘 실행해야 하는지 파악하는 데만 반나절이 걸렸습니다.
개발에 필요한 동작을 하나의 CLI로 통합했습니다.
진입점
pnpm dev # 인터랙티브 메뉴
pnpm dev serve # 모듈 선택 → 개발 서버
pnpm dev setup # 초기 환경 셋업
pnpm dev swagger # API 타입 생성
인자 없이 pnpm dev만 치면 Inquirer 메뉴가 뜹니다. 인자를 직접 넘기면 바로 실행됩니다.
serve의 CDN/로컬 전환
Module Federation에서 모든 패키지를 전부 로컬로 띄우면 빌드가 느리고 메모리도 많이 먹습니다. 보통 1~3개만 로컬에서 수정하고 나머지는 CDN에서 참조하면 되거든요.
이후 예시에서는 편의상 모듈을 이렇게 부르겠습니다:
- 공통 모듈:
main(host),shared(디자인 시스템/유틸리티) - 서비스 모듈:
analytics,campaign,member등 (팀별 도메인)
? 어떤 모듈을 로컬에서 실행할까요? (Space로 선택)
◉ main (host — 항상 로컬)
◉ shared
○ analytics
○ campaign
○ member
...
선택이 끝나면 .dev-modules에 저장됩니다. 다음번에는 엔터만 치면 이전 선택이 그대로 적용됩니다.
CDN/로컬이 갈리는 메커니즘:
CLI에서 선택된 모듈을 환경변수로 자식 프로세스에 전달합니다:
const env = {
LOCAL_MODULES: "main,shared,analytics",
DEV_CDN_URL: "https://cdn.example.com",
};
각 모듈의 rsbuild 설정에서 이 환경변수를 읽어 Module Federation remotes를 구성합니다:
function getRemoteFederationModules() {
const localModules = process.env.LOCAL_MODULES?.split(",") || [];
const cdnUrl = process.env.DEV_CDN_URL || "";
return packages.reduce((result, module) => {
if (localModules.includes(module.name)) {
result[module.name] = `${module.name}@https://local.dev:${module.port}/mf-manifest.json`;
} else if (cdnUrl) {
result[module.name] = `${module.name}@${cdnUrl}/modules/${module.name}/remoteEntry.js`;
}
return result;
}, {});
}
단순한 환경변수 분기인데, shared 수정 안 할 때 CDN으로 빼기만 해도 초기 빌드 시간이 체감 절반 이상 줄었습니다.
CDN으로 빠진 서비스 모듈의 소스를 실수로 수정하면 반영이 안 되니까, 파일 감시를 걸어서 경고를 띄웁니다:
for (const mod of allModules) {
if (localDirs.includes(mod.dirName)) continue;
fs.watch(path.join(packagesDir, mod.dirName, "src"), { recursive: true }, (_, filename) => {
ui.addLog(`⚠️ ${mod.dirName}/src/${filename} 변경됨 — CDN 모드입니다`);
});
}
인증서 자동 관리
Module Federation은 cross-origin으로 모듈을 로드하기 때문에 HTTPS가 필수입니다. 로컬 개발에서 self-signed 인증서가 없으면 시작부터 안 됩니다.
export function generateCert() {
execSync(
`openssl req -x509 -nodes -days 3650 -newkey rsa:2048 ` +
`-keyout "${keyPath}" -out "${certPath}" -config "${opensslCnf}" -extensions v3_req`
);
}
export function registerCert() {
execSync(
`sudo security add-trusted-cert -d -r trustRoot ` +
`-k /Library/Keychains/System.keychain "${certPath}"`
);
}
SAN에 개발용 도메인을 넣어놨고, serve 시작 시 .cert/ 폴더에 인증서가 없으면 자동 생성 + 키체인 등록합니다. 한 번만 sudo 비밀번호 넣으면 이후로는 신경 쓸 일이 없습니다.
rsbuild 설정 공통화
각 패키지에 rsbuild 설정이 있었는데, dev server 블록이 거의 동일했습니다. 포트만 다르고 나머지는 복붙.
// Before: 각 패키지마다 반복
server: {
host: "local.dev-console.example.com",
port: 3005,
https: { key: fs.readFileSync("../../.cert/key.pem"), cert: fs.readFileSync("../../.cert/cert.pem") },
headers: { "Access-Control-Allow-Origin": "*", ... },
},
tools: {
rspack: (config) => { /* sentry, output, ... */ },
htmlPlugin: { title: "...", template: "...", ... },
},
// After
server: getDevServerOptions(3005),
tools: getToolsConfig({ federationName: "analytics", htmlTitle: "Analytics" }),
getDevServerOptions는 인증서가 없으면 graceful하게 undefined를 반환합니다. setup 전에도 빌드가 깨지지 않도록요. 설정 변경이 한 곳으로 모였습니다.
DevTools HTTP API
CLI가 포트 19999에서 HTTP API를 엽니다. 브라우저 DevTools 패널이 이 API를 호출합니다.
if (req.url === "/api/modules" && req.method === "POST") {
const { localModules, cdnUrl } = JSON.parse(body);
await opts.restart(localModules, cdnUrl);
}
restart는 기존 프로세스를 kill하고, 환경변수를 재구성하고, 새 프로세스를 생성합니다. campaign 모듈을 CDN에서 로컬로 전환하면 2~3초면 됩니다.
온보딩 변화
이전:
- README (outdated)
- setup-scripts 뭐 실행해야 하는지 물어보기
- 인증서 수동 설치
- 환경변수 설정
- 어떤 모듈 띄워야 하는지 물어보기
이후:
pnpm dev setuppnpm dev serve
마무리
CLI 자체보다 중요했던 건 환경변수 두 개로 전체 MFA 구성이 갈리는 설계였습니다. LOCAL_MODULES와 DEV_CDN_URL만으로 CLI든 DevTools API든 같은 메커니즘으로 제어할 수 있었거든요.
팀에서 쓰다 보니 CLI 유지보수도 결국 누군가의 몫이긴 합니다. 모듈이 추가되면 CLI도 업데이트해야 하고, rsbuild 메이저 업데이트 때 공통 설정도 따라가야 합니다.