안녕하세요! 오늘은 복잡한 모노레포 환경에서 Create React App(CRA)에서 Vite로 마이그레이션한 과정을 단계별로 차근차근 정리해보려고 합니다.
저희 키즈노트의 인앱 프로젝트는 다양한 마이크로 서비스들로 구성된 모노레포 환경입니다. services
디렉토리 하위에는 14개의 서비스가 있어요.
...그 외 다양한 서비스들 존재
각 서비스는 완전히 독립적인 React 애플리케이션으로 동작합니다.
모노레포의 장점을 살리기 위한 @internals
공통 패키지도 있어요
@internals/components
: 모든 서비스에서 사용할 수 있는 공용 UI 컴포넌트들@internals/hooks
: 자주 쓰이는 커스텀 React 훅들 @internals/libs
: 유틸리티 함수들, API 관련 client들마이그레이션을 시작하기 전에 기존 설정들을 분석했습니다.
{
"scripts": {
"dev": "craco start --config craco.config.js",
"build": "env-cmd --recursive --silent -f .env.production.${PHASE_ENV:-local} craco build --config craco.config.js",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"test": "jest"
}
}
craco
와 env-cmd
를 함께 사용하여 복잡한 환경 변수 관리를 하고 있었습니다.
.env-cmdrc.js에서는 10개 이상의 서로 다른 배포 환경을 관리하고 있었어요
module.exports = defineEnv({
prod: {
REACT_APP_API_HOST: 'https://api.example.com',
REACT_APP_ADS_API_HOST: 'https://ads-api.example.com',
// ... 기타 API 호스트들
},
sandbox01: {
REACT_APP_API_HOST: 'https://api-sandbox-01.example.com',
// ... 샌드박스 환경 설정
},
dev01: {
REACT_APP_API_HOST: 'https://api-dev-01.example.com',
},
// sandbox02, sandbox03, dev02, dev03 등등...
});
CRA 환경에서 커스텀을 위한 Craro 설정
// craco.config.js
module.exports = {
plugins: [
{
plugin: sentryPlugin, // 에러 트래킹을 위한 Sentry
options: { context: path.join(__dirname, '../..') },
},
{
plugin: rewireBabelLoader, // 내부 패키지 트랜스파일링
options: {
...
},
},
],
webpack: {
configure: {
resolve: {
...
},
},
},
style: {
...
},
};
{
"name": "benefit-photomall",
"type": "module", // ES 모듈 지원
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "jest"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"@sentry/vite-plugin": "^3.0.0",
"vite": "^5.1.4",
"vite-plugin-svgr": "^4.2.0"
}
}
@craco/craco
→ 더 이상 필요 없음@sentry/craco-plugin
→ @sentry/vite-plugin
으로 교체craco-babel-loader
→ Vite가 자동 처리env-cmd-enhanced
→ Vite 방식으로 변경react-scripts
→ CRA 핵심 패키지tsconfig-paths-webpack-plugin
→ Vite 기본 지원가장 중요한 부분인 Vite 설정을 구성했습니다:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import { defineEnv } from './.env-cmdrc.js';
const envConfigs = defineEnv;
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const normalizeEnvKey = (env: string) => env.replace(/-/g, '');
const phaseEnv = process.env.PHASE_ENV || mode;
const normalizedPhaseEnv = normalizeEnvKey(phaseEnv);
const config = envConfigs[normalizedPhaseEnv as keyof typeof envConfigs];
return {
base: '/benefit-photomall/',
plugins: [
react({ jsxRuntime: 'automatic' }),
svgr({
svgrOptions: {
exportType: 'named',
ref: true,
svgo: false,
titleProp: true,
},
include: '**/*.svg',
}),
],
resolve: {
// 중복 패키지 방지
dedupe: [
'react',
'react-dom',
'react-router-dom',
'@emotion/react',
'@emotion/styled',
'dayjs',
'lodash',
],
alias: {
'@/assets': resolve(__dirname, './src/assets'),
'@/components': resolve(__dirname, './src/components'),
'@/hooks': resolve(__dirname, './src/hooks'),
'@/lib': resolve(__dirname, './src/lib'),
// 모노레포 내부 패키지들
'@internals/components': resolve(__dirname, '../../internals/components'),
'@internals/libs': resolve(__dirname, '../../internals/libs'),
'@internals/hooks': resolve(__dirname, '../../internals/hooks'),
},
},
optimizeDeps: {
include: ['react', 'react-dom'],
exclude: ['@internals/components', '@internals/libs', '@internals/hooks'],
},
server: {
port: 3000,
https: env.VITE_SSL_KEY_PATH && env.VITE_SSL_CERT_PATH
? {
key: readFileSync(env.VITE_SSL_KEY_PATH),
cert: readFileSync(env.VITE_SSL_CERT_PATH),
}
: undefined,
host: 'localhost',
open: true,
strictPort: true,
},
build: {
outDir: 'build',
sourcemap: phaseEnv === 'prod',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
query: ['@tanstack/react-query'],
emotion: ['@emotion/react', '@emotion/styled'],
utils: ['lodash', 'dayjs', 'axios'],
},
},
},
},
// 환경 변수 빌드 시점 주입
define: {
'process.env.NODE_ENV': JSON.stringify(mode),
'import.meta.env.VITE_API_HOST': JSON.stringify(config.VITE_API_HOST),
'import.meta.env.VITE_ADS_API_HOST': JSON.stringify(config.VITE_ADS_API_HOST),
// ... 기타 환경 변수들
},
};
});
// Before (CRA)
const apiHost = process.env.REACT_APP_API_HOST;
// After (Vite)
const apiHost = import.meta.env.VITE_API_HOST;
// Before
REACT_APP_ADS_API_HOST: 'https://api.example.com'
REACT_APP_PW_API_HOST: 'https://api.example.com'
// After
VITE_API_HOST: 'https://api.example.com'
VITE_ADS_API_HOST: 'https://api.example.com'
// Before
import { ReactComponent as Alert } from '@/assets/alertTriangle.svg';
// After
// vite svg 세팅을 이용하면 기존 CRA 방식과 동일하게 사용 할 수 있어요.
import Alert from '@/assets/alertTriangle.svg?react';
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"jsx": "react-jsx",
"useDefineForClassFields": true
}
}
기존 public/index.html
을 루트로 이동
<!-- index.html -->
<head>
%VITE_GTM_HEAD%
<script>
window.SENTRY_RELEASE = { id: '%VITE_SENTRY_RELEASE%' };
</script>
</head>
<body>
%VITE_GTM_NOSCRIPT%
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
모노레포의 @internals
패키지들은 TypeScript 소스로 직접 참조되기 때문에,
alias 설정과 optimizeDeps 설정이 중요했습니다!
10개가 넘는 배포 환경 설정을 모두 Vite 방식으로 옮기면서도 기존 배포 파이프라인과의 호환성을 유지해야 했습니다.
공식 @sentry/vite-plugin
으로 교체하면서 설정 방식이 달라졌지만, 라이브러리 교체만으로 간단하게 교체할 수 있었어요.
측정 항목 | CRA | Vite | 개선 효과 |
---|---|---|---|
개발 서버 시작 | 15초 | 1초 | 94% 단축 |
배포 시간 | 1분 16초 | 38초 | 50% 단축 |
메인 번들 크기 | 296KB | 132KB | 56% 감소 |
# Before
env-cmd --recursive --silent -f .env.production.${PHASE_ENV} craco build --config craco.config.js
# After
yarn workspace benefit-photomall build --mode ${PHASE_ENV}
이번 Vite 마이그레이션은 단순한 빌드 툴 교체를 넘어서 모노레포 환경에서의 개발 경험을 개선할 수 있는 의미 있는 작업이었습니다.
복잡한 환경 변수 관리와 내부 패키지 처리 등 모노레포 특유의 도전 과제들을 하나씩 해결해나가면서 많은 것을 배울 수 있었습니다. 이제 이 경험을 바탕으로 다른 서비스들도 순차적으로 Vite로 마이그레이션해 나갈 계획입니다!