대부분 React 앱들은 Webpack, Rollup 또는 Browserify 같은 툴을 사용하여 여러 파일을 하나로 병합한 “번들 된” 파일을 웹 페이지에 포함하여 한 번에 전체 앱을 로드 할 수 있습니다. - 리액트 공식문서(구)
이처럼 번들링은 웹 애플리케이션(이하 앱)의 코드들을 하나의 파일로 뭉치거나, 때로는 여러 파일로 흩뜨려 최적화하는 과정이다. 앱에서는 번들링을 활용해 여러 개의 자바스크립트 파일, CSS 파일, 이미지 등을 여러 번 요청하지 않도록 하여 성능을 최적화할 수 있다. 결국, 최적화된 앱은 로딩 시간이 단축되고, 코드 관리 및 유지보수에 유리한 환경이 된다.
Code-splitting(코드 분할)은 애플리케이션을 ‘지연 로딩’하게 도와주고 성능 향상에 도움을 준다.
import()
문법을 사용하는 것이다. 최근에 ‘프론트엔드 성능 최적화 가이드’라는 책을 보면서 코드 분할을 위해 동적 import()를 사용하라고 했던 부분이 떠올랐다. import { add } from './math';
console.log(add(16, 26));
import("./math").then(math => {
console.log(math.add(16, 26));
});
이런 동적 import를 선언적으로 프로그래밍 가능하게 해주는 것이 React의 lazy 함수이다. lazy는 동적 import()
를 호출하는 함수를 인자로 받으며, 해당 함수는 React 컴포넌트를 default
export로 가진 모듈 객체가 이행되는 Promise
를 반환해야 한다.
function lazy<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>,
): LazyExoticComponent<T>;
import OtherComponent from './OtherComponent';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
Module '"컴포넌트"' has no default export. Did you mean to use 'import { 컴포넌트명 } from "컴포넌트"' instead?
만약 export default가 아닌 export를 사용하여 컴포넌트를 내보냈을 경우, 이와 같은 에러가 발생한다.
export default function PublicLayout() { // ... }
물론 named exports를 사용하고자 하는 경우, default로 이름을 재정의한 중간 모듈을 생성하여 lazy를 실현할 수 있다고 공식문서에서는 언급한다.
// ManyComponents.js export const MyComponent = /* ... */; export const MyUnusedComponent = /* ... */; // MyComponent.js export { MyComponent as default } from "./ManyComponents.js"; // MyApp.js import React, { lazy } from 'react'; const MyComponent = lazy(() => import("./MyComponent.js"));
<Suspense />
Suspense
컴포넌트 하위에서 렌더링되어야 하며, Suspense
는 lazy 컴포넌트가 로드되길 기다리는 동안 로딩 화면과 같은 UI를 보여줄 수 있게 해준다. import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
그렇다면 도대체 어디서 lazy를 적용해 코드 분할을 해야하는 것인가? 사용자의 경험을 해치지 않으면서 이를 활용해야 하는 것인데, 이를 적용하기 좋은 곳은 route이다. 왜냐하면 route 파일 내에서 정의한 경로들은 보통 한 번에 불러올 페이지 단위이고, 보통 페이지를 불러올 때 사용자와의 상호작용을 기대하진 않기 때문이다. 결론은 사용자 입장에서 서비스 내에서의 경험이 끊기지 않는 것이 중요한 것 같다.
cf) 빌드 환경: vite
vite가 gzip으로 압축해주었지만, 여전히 어마무시한 크기의 번들링된 index.js 파일이 보인다ㅜ.
(!) 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
동적 import()를 사용하고, 어쩌구 manualChunks를 사용하라고 한다.
동적 import()는 개념 부분에서 이미 살펴본 바 있다. 라우트를 정의한 router.tsx
로 가서 lazy를 적용해보자. 메인 페이지(홈) 부분을 제외한 페이지들에 모두 적용했다. 왜냐하면 서비스 진입점부터 동적으로 파일을 불러오면 로딩 시간으로 인해 사용자 경험이 좋지 않을 거라 생각했기 때문이다.
// router.tsx
import { lazy } from 'react';
const GlobalNavbar = lazy(() => import('./_components/globalNavBar/GlobalNavbar.tsx'));
import 부분에서 다음과 같은 eslint 경고가 발생한다. 찾아보니 이는 이미 closed된 issue로 등록된 일종의 버그인 것 같고, 해당 issue에 해결 방법이 나와있다.
Fast refresh only works when a file only exports components. Move your component(s) to a separate file.eslint(react-refresh/only-export-components)
// .eslintrc.cjs
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
위와 같은 eslint 설정을 통해, 파일에서 React 컴포넌트만 export 하도록 제한(여기서는 상수 export는 허용)함으로써 Fast refresh가 올바르게 동작할 수 있다. (vite 프로젝트 초기화 시 자동으로 eslint 플러그인에 추가된 것으로 추측된다.)
Fast refresh
React의 기능으로, 코드가 변경될 때 전체 페이지를 리로드하지 않고 컴포넌트만 업데이트하는 방식으로, 이를 통해 더 빠르고 효율적인 개발 경험을 제공한다.
아래와 같이 router를 export한 후, router를 등록하는 부분(ex. main.tsx)에서 컴포넌트로써 사용하면 된다.
// router.tsx
// ... 파일 최하단
export const Routes: React.FC = () => {
return <RouterProvider router={router} />;
};
이제 두 번째로 제안했던 부분을 적용해보자. vite는 기본 번들링으로 Rollup을 사용하며, 다음과 같이 빌드를 커스터마이즈할 수 있다. 그 중 manualChunks는 Rollup에서 빌드할 때 여러 개의 번들을 만들 수 있도록 도와주는 옵션이다. 이를 통해 특정 모듈이나 라이브러리를 별도의 청크로 분리하여, 더 효율적인 코드 분할을 할 수 있다.
export default defineConfig({
build: {
rollupOptions: {
// https://rollupjs.org/configuration-options/
}
}
})
다른 사람의 블로그를 참고하여 manualChunks 옵션을 지정했다. 이는 node_modules 하위에 존재하는 라이브러리들에 대한 chunk를 생성하는 것이다.
export default defineConfig({
build: {
rollupOptions: {
output: {
// 수동으로 chunk 생성
manualChunks: (id) => {
if (id.indexOf("node_modules") !== -1) {
const module = id.split("node_modules/").pop().split("/")[0];
return `vendor-${module}`;
}
},
},
}
}
})
[React] 빌드 용량 최적화하기(feat. Vite)