어느 날 vite로 만든 리액트 웹 애플리케이션을 빌드하던 도중 다음과 같은 경고문을 마주하게 되었다 ..⚠️
vite v5.0.12 building for production...
✓ 1812 modules transformed.
dist/index.html 0.40 kB │ gzip: 0.28 kB
dist/assets/index-PbRSkc2W.js 1,174.50 kB │ gzip: 362.52 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 8.28s
대강 내용을 살펴 보니 빌드 결과 번들링된 파일 중 하나의 용량이 특정 기준(500kb)을 초과하여 경고를 내뱉는 것으로 보였다.
그렇다면 지금부터 vite 환경에서 번들링 사이즈를 줄이려면 어떤 방법이 도움이 될 수 있는지에 대해 알아보자.
Vite와 같은 번들러는 웹 애플리케이션의 구동에 필요한 여러 모듈이나 라이브러리를 하나로 묶어서 사용자가 웹 앱을 로드할 때 필요한 자원을 제공한다. 이러한 과정을 번들링
이라고 부르며, 이 때 생성되는 작은 조각들을 바로 청크
라고 한다.
리액트 웹 애플리케이션을 예로 들면,
그러나 종종 이 청크들이 너무 크거나 많아지게 되면 성능적인 부분에 부정적인 영향을 미칠 수 있다. 따라서 청크의 크기를 최적화하거나, 필요한 시점에 청크를 동적으로 불러오는 등의 방법을 활용함으로써 애플리케이션의 로드 시간 단축이나 사용성을 향상시킬 수 있다.
정리하자면 번들러나 청크 같은 개념은 웹 개발에 있어서 중요한 요소 중 하나로, 성능 최적화와 사용자 경험 향상을 위해 주의 깊게 다루어야 할 필요가 있다.
React에서 제공하는 기본 기능 중 하나로, 주로 컴포넌트의 lazy loading에 사용된다.
모든 코드를 한 번에 로드하는 게 아니라 필요한 부분에서 때에 따라 필요한 코드만 동적으로 가져와 렌더링하는 방식을 지원한다.
사용자가 앱에 처음 접근했을 때, 필요한 최소한의 코드만 로드되고 이후 사용자 동작에 따라 추가 코드가 로드된다. 따라서 초기 로드 시간 및 파일 사이즈 감소에 도움이 된다.
컴포넌트를 로딩하는 동안 fallback 컴포넌트를 보여줄 수 있는 <Suspense> 컴포넌트와 함께 사용되곤 한다.
주로 성능과 사용자 경험 향상을 위해 컴포넌트에 lazy loading을 적용시킬 수 있는데, 그렇다고 또 모든 컴포넌트에 적용을 하는 건 권장되지 않는다. 따라서 적용 기준은 아래와 같은 요인에 따라 고려하여 결정해 볼 수 있을 것 같다.
사용 빈도
에 따라:
라우팅
에 따라:
React의 API Reference 중 lazy() 부분을 참고하여 기존의 프로젝트에 적용시켜 보았다. (설명과 연관성이 떨어지는 코드는 간소화하거나 생략하였다)
// App.jsx
const Register = lazy(() => import("@/pages/Register"));
const Login = lazy(() => import("@/pages/Login"));
const Chat = lazy(() => import("@/pages/Chat"));
const AlertOverlay = lazy(() => import("@/components/common/AlertOverlay"));
function App() {
return (
<>
{/* 라우팅 처리 */}
<Routes>
<Route
path="/register"
element={
<Suspense fallback={<ComponentLoading />}>
<Register />
</Suspense>
}
/>
<Route
path="/login"
element={
<Suspense fallback={<ComponentLoading />}>
<Login />
</Suspense>
}
/>
<Route
path="/"
element={
<Suspense fallback={<ComponentLoading />}>
<Chat />
</Suspense>
}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
{/* 모달 창 컴포넌트 */}
<AlertOverlay />
</>
)
}
위와 같이 코드를 수정하고 다시 빌드를 수행해 보았다. 번들링 결과로써 이전에 dist/assets/index-XXX.js
라는 이름의 청크 파일이 하나 생성되었던 것에 반해, 이번에는 아래와 같이 청크가 여러 파일로 분리된 모습을 확인할 수 있었다.
vite v5.0.12 building for production...
✓ 1813 modules transformed.
dist/index.html 0.47 kB │ gzip: 0.31 kB
dist/assets/users-z8WNz4mh.js 0.47 kB │ gzip: 0.24 kB
dist/assets/AlertOverlay-rsSXdWvX.js 0.70 kB │ gzip: 0.42 kB
dist/assets/Login-5RDS64bZ.js 2.40 kB │ gzip: 1.13 kB
dist/assets/Register-kkd78LY8.js 4.50 kB │ gzip: 1.78 kB
dist/assets/index-6TNE2c09.js 6.12 kB │ gzip: 2.64 kB
dist/assets/lodash-HLbbSI7s.js 72.14 kB │ gzip: 26.67 kB
dist/assets/ValidatableInput-5QWtfSE6.js 121.61 kB │ gzip: 39.03 kB
dist/assets/Chat-M2x6NtKB.js 306.90 kB │ gzip: 76.67 kB
dist/assets/vendor-u4MbGP49.js 656.95 kB │ gzip: 215.77 kB
또한 브라우저에서 빌드된 결과물을 실행시켜 봤을 때도, 라우팅 경로에 따라 특정 페이지를 요청했을 때 해당하는 컴포넌트의 코드를 담고 있는 청크를 다운받고 있는 모습을 확인하였다.
Vite를 비롯한 여러 번들러에서 지원되는 기능 중 하나인 Dynamic import 기능이 있다.
주로 모듈의 코드 스플릿팅을 위해 사용되며, 필요한 모듈을 때에 따라 동적으로 로드하도록 한다.
모든 종류의 JS 모듈에 적용될 수 있으며, 리액트 컴포넌트 외에도 라이브러리/함수/스타일 등의 모든 종류의 모듈을 동적으로 불러올 수 있다.
방금 전 알아보았던 React의 lazy()와는 둘 다 코드 스플릿팅
을 통해 애플리케이션의 초기 로드 시간을 최적화하고 필요한 부분을 필요한 때에만 로드한다는 공통의 목적이 있지만, 다음과 같은 부분에서 약간의 차이점이 있다.
위에서 컴포넌트에 대한 코드 스플릿팅을 적용시켜 보았으니, 이번에는 SVG로 불러 오는 아이콘에 동적 임포트를 적용시켜 보았다.
참고로 아래 코드는 Vite에서 SVG 형식의 파일을 로드해오기 위한 설정인 vite-plugin-svgr 플러그인이 적용되어 있는 환경에 있다고 가정한다. 단지 '이런 패턴으로 리액트에서 동적 임포트를 적용시킬 수 있다'를 테스트 해보기 위한 코드라고 생각하면 좋을 것 같다.
기존 코드:
import LogoutIcon from "@/assets/ico_exit.svg?react";
function MyComponent() {
return (
{/* 생략 */}
<LogoutIcon />
)
}
변경한 코드:
// #1. useDynamicSvgImport.ts 훅
const useDynamicSvgImport = (iconName: string) => {
const iconRef = useRef<FC<SVGProps<SVGElement>> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);
const importSvgIcon = useCallback(async (): Promise<void> => {
setLoading(true);
try {
iconRef.current = (await import(`@/assets/${iconName}.svg?react`)).default;
} catch (error) {
setError(error);
console.error(error);
} finally {
setLoading(false);
}
}, [iconName]);
useEffect(() => {
importSvgIcon();
}, [iconName, importSvgIcon]);
return { loading, error, Svg: iconRef.current };
};
export default useDynamicSvgImport;
// #2. SvgIcon.tsx 컴포넌트
export type SvgIconProps = {
iconName: string;
};
const SvgIcon: FC<SvgIconProps> = ({ iconName }) => {
const { loading, Svg } = useDynamicSvgImport(iconName);
if (loading) return <Spinner />;
return Svg && <Svg />;
};
export default SvgIcon;
// #3. 아이콘 사용하는 컴포넌트
function MyComponent() {
return (
{/* 생략 */}
<SvgIcon iconName="ico_exit" />
)
}
이제 앱을 다시 빌드 해보면 <SvgIcon>
으로 불러 오는 아이콘 별로 별도의 청크가 생성되고, 브라우저에서 확인 해봤을 때도 아이콘이 화면에 렌더링되어야 할 때 다운로드 되는 모습을 확인할 수 있다.
이 방식은 컴포넌트에 lazy loading을 적용시켰던 것보다는 약간의..! 공수가 더 들어갔다.
(사실 가지고 있는 프로젝트에 동적 임포트를 적용시킬 만한 대상을 찾기 어려워 아이콘을 불러오는 부분에 적용시켜 보았는데, 아이콘 같은 경우 파일 사이즈가 작다보니 효과가 미미할 것 같아 보여서 코드는 다시 롤백 시켜 놓았다)
manualChunks
옵션 활용Rollup에는 rollupOptions.output.manualChunks
라는 옵션이 있는데, 번들링 시 특정 기준에 따라 수동으로 청크를 나눌 수 있게 해주는 옵션이다. Vite가 Rollup의 일부 아이디어와 기능을 채택하여 만들어졌으므로 Vite에서도 역시 이 옵션을 설정할 수 있다.
예를 들면 프로젝트에서 특정 라이브러리나 모듈을 자주 사용하는데, 번들의 크기가 너무 크다고 판단될 때 해당 모듈을 명시적으로 지정해 별도의 청크로 분리하여 더 효율적인 방식으로 불러올 수 있도록 활용할 수 있다.
무작정 많은 청크로 쪼개는 건 자칫하면 오히려 번들링된 파일 수가 많아져 초기 로딩 시간의 증가나 오버헤드가 발생할 수 있다. 이는 우리가 기대하는 효과와는 거리가 멀다. 따라서 이렇게 manualChunks
함수를 작성하기 전에는, 먼저 프로젝트 종속성을 잘 살펴보고 실제로 어떤 모듈에서 큰 청크 생성에 영향을 미치는 지 파악할 수 있어야 한다.
이렇듯 적절한 모듈 분리 전략을 선택하는 것이 매우 중요하다. 모듈 분리 시에는 예를 들어 다음과 같은 사항을 고려할 수 있다.
사용 빈도
:
로딩 순서
:
기능적 연관성 및 의존성
:
라이브러리 크기
:
동적 로딩 가능성
:
먼저, 번들 결과를 시각화하고 어떤 모듈이 얼마 정도의 용량을 차지하는지 보기 위해 rollup-plugin-visualizer 라이브러리를 설치해서 확인하였다.
Vite에서의 설정법은 vite.config.ts
파일에 플러그인으로 추가해주면 되고, 이후 애플리케이션을 빌드하면 생성되는 stats.html
파일에 접근하여 확인해 볼 수 있다.
// vite.config.ts
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [react(), visualizer(), ...]
})
visualizer에 의해 아래 이미지같이 시각화된 모습의 차트를 확인할 수 있다.
# 빌드 후 나뉘어진 청크 모습
vite v5.0.12 building for production...
✓ 1840 modules transformed.
dist/index.html 0.40 kB │ gzip: 0.28 kB
dist/assets/AlertOverlay-Thwf1k7j.js 0.87 kB │ gzip: 0.51 kB
dist/assets/Login-1TIGUTPv.js 2.38 kB │ gzip: 1.13 kB
dist/assets/_baseGetTag-P74OPvbn.js 3.54 kB │ gzip: 1.71 kB
dist/assets/debounce-qph3kLyj.js 4.32 kB │ gzip: 2.01 kB
dist/assets/Register-Uc_CMYfr.js 4.64 kB │ gzip: 1.84 kB
dist/assets/users-2EDMUSnQ.js 6.50 kB │ gzip: 2.53 kB
dist/assets/chunk-OFOVX77R-ZjmGcfdW.js 37.32 kB │ gzip: 12.94 kB
dist/assets/ValidatableInput-iPytNGyR.js 121.96 kB │ gzip: 39.24 kB
dist/assets/Chat-hpEEj7dJ.js 348.70 kB │ gzip: 91.26 kB
dist/assets/index-9mfHzJWd.js 575.23 kB │ gzip: 188.78 kB
또한 아직 dist/assets/index-XXX.js
의 사이즈가 500kb 초과로, 처음 경고를 받았던 상태 그대로이다. 이제 이 인덱스 청크를 다른 몇 개의 청크로 쪼개기 위해 manualChunks
옵션을 지정해 볼 것이다.
vite.config.ts
파일에 아래와 같이 작성해 주었다.
export default defineConfig({
// 기존 옵션들...
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes("socket.io-client") || id.includes("axios")) {
return "@networking-vendor";
}
if (id.includes("emoji-picker-react")) {
return "@emoji-vendor";
}
if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/")) {
return "@react-vendor";
}
},
},
},
}
})
간단히 설명하자면, 몇몇 모듈을 기준으로 필요한 경우 그룹화하여 지정한 이름의 청크로 내보낼 수 있도록 청크를 분리하고 있다.
위 코드에서는 네트워킹 관련 패키지인 socket.io-client와 axios를 묶어 @networking-vendor
로, 리액트 모듈 2가지를 묶어 @react-vendor
로 그룹핑 하고 있다. 또한 사이즈가 비교적 컸던 이모지 관련 패키지 emoji-picker-react는 @emoji-vendor
라는 이름의 별도의 청크로 분리하였다.
빌드해 보면 이제 index 청크의 사이즈를 500kb 미만으로 줄이는 목표를 달성하게 된 걸 확인할 수 있다.
vite v5.0.12 building for production...
✓ 1840 modules transformed.
dist/index.html 0.57 kB │ gzip: 0.34 kB
dist/assets/AlertOverlay-zz_zIzQL.js 0.95 kB │ gzip: 0.55 kB
dist/assets/Login-ZLZE7nRu.js 2.46 kB │ gzip: 1.17 kB
dist/assets/_baseGetTag-nanGRvzs.js 3.57 kB │ gzip: 1.73 kB
dist/assets/debounce-4L5VQxwj.js 4.36 kB │ gzip: 2.03 kB
dist/assets/Register-AjuQhGMy.js 4.72 kB │ gzip: 1.88 kB
dist/assets/users-o59EuDWN.js 6.54 kB │ gzip: 2.55 kB
dist/assets/chunk-OFOVX77R-s4Kiw0d9.js 37.37 kB │ gzip: 12.96 kB
dist/assets/@networking-vendor-Rz_phVxH.js 69.85 kB │ gzip: 23.86 kB
dist/assets/Chat-9WSVDT-e.js 88.71 kB │ gzip: 30.21 kB
dist/assets/ValidatableInput-WuIJi3F7.js 122.00 kB │ gzip: 39.26 kB
dist/assets/@react-vendor-Ei4dvluC.js 142.42 kB │ gzip: 45.67 kB
dist/assets/@emoji-vendor-FtcHc3bX.js 259.89 kB │ gzip: 61.45 kB
dist/assets/index-_e_TLm4g.js 362.32 kB │ gzip: 119.45 kB
✓ built in 6.93s!
지금까지 Vite 개발 환경에서 리액트 애플리케이션의 청크 사이즈를 줄이기 위한 3가지 방법에 대해서 알아보았다. 코드 스플릿팅이나 번들러에서 제공하는 manualChunks
옵션을 활용해 번들 청크를 적절히 분리하고, 청크 크기도 기준 사이즈 미만으로 유지하는 방법을 살펴 보았다.
이러한 전략들은 애플리케이션 성능을 향상시키고 더 나은 사용자 경험을 제공하는 데 도움이 될 수 있다. 하지만 몇 가지 주의할 점이 있다. 너무 잘게 청크를 나누면 오히려 로딩 시간이 길어지거나 너무 많은 HTTP 요청이 발생할 수 있기 때문이다. 따라서 제대로 활용하기 위해서 적절한 청크 크기를 유지하고, 필요한 부분에만 적 코드 스플릿팅을 적용할 수 있는 판단력이 중요할 것 같다. 이를 위해 프로젝트의 특성과 요구 사항을 고려해 바람직한 모듈 분리 전략을 세울 수 있는 노하우를 익혀야 할 것 같다.