[프론트엔드] 번들링 최적화를 통한 웹 성능 최적화 (feat. React lazy, Rollup)

Woonil·2025년 2월 27일
0

프론트엔드

목록 보기
2/2

대부분 React 앱들은 Webpack, Rollup 또는 Browserify 같은 툴을 사용하여 여러 파일을 하나로 병합한 “번들 된” 파일을 웹 페이지에 포함하여 한 번에 전체 앱을 로드 할 수 있습니다. - 리액트 공식문서(구)

이처럼 번들링은 웹 애플리케이션(이하 앱)의 코드들을 하나의 파일로 뭉치거나, 때로는 여러 파일로 흩뜨려 최적화하는 과정이다. 앱에서는 번들링을 활용해 여러 개의 자바스크립트 파일, CSS 파일, 이미지 등을 여러 번 요청하지 않도록 하여 성능을 최적화할 수 있다. 결국, 최적화된 앱은 로딩 시간이 단축되고, 코드 관리 및 유지보수에 유리한 환경이 된다.

🤔개념

Code-splitting

Code-splitting(코드 분할)은 애플리케이션을 ‘지연 로딩’하게 도와주고 성능 향상에 도움을 준다.

React.lazy

  • 동적 import
    💡필요할 때 모듈을 가져오는 것
    앱에 코드 분할을 도입하는 가장 좋은 방법은 동적 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'));

lazy – React 공식문서

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 />
    어쨌든 lazy를 사용하면 해당 컴포넌트(예를들어 페이지 전체)에 접근할 때에서야 코드를 불러오는 것이므로, 불러오는 동안 표시해야 할 UI가 필요하다. 따라서 lazy 컴포넌트는 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 – React

Route-based code splitting

그렇다면 도대체 어디서 lazy를 적용해 코드 분할을 해야하는 것인가? 사용자의 경험을 해치지 않으면서 이를 활용해야 하는 것인데, 이를 적용하기 좋은 곳은 route이다. 왜냐하면 route 파일 내에서 정의한 경로들은 보통 한 번에 불러올 페이지 단위이고, 보통 페이지를 불러올 때 사용자와의 상호작용을 기대하진 않기 때문이다. 결론은 사용자 입장에서 서비스 내에서의 경험이 끊기지 않는 것이 중요한 것 같다.

😎실습

빌드 시 경고문구 해결하기

cf) 빌드 환경: vite

vite가 gzip으로 압축해주었지만, 여전히 어마무시한 크기의 번들링된 index.js 파일이 보인다ㅜ.

(!) Some chunks are larger than 500 kB after minification. Consider:

동적 import()를 사용하고, 어쩌구 manualChunks를 사용하라고 한다.

React의 lazy()를 활용하여 Route-based code splitting 적용하기

동적 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)

관련 issue

// .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} />;
};
  • 개선 후
    빌드 시, 코드 분할이 이루어진 page 모듈들을 별도의 chunk로 분리한 것을 확인할 수 있다. 2000kB가 넘던 index가 많이 줄어들었다..

manualChuncks 활용하기

이제 두 번째로 제안했던 부분을 적용해보자. vite는 기본 번들링으로 Rollup을 사용하며, 다음과 같이 빌드를 커스터마이즈할 수 있다. 그 중 manualChunks는 Rollup에서 빌드할 때 여러 개의 번들을 만들 수 있도록 도와주는 옵션이다. 이를 통해 특정 모듈이나 라이브러리를 별도의 청크로 분리하여, 더 효율적인 코드 분할을 할 수 있다.

export default defineConfig({
  build: {
    rollupOptions: {
      // https://rollupjs.org/configuration-options/
    }
  }
})

manualChunks - Rollup 공식문서

다른 사람의 블로그를 참고하여 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)

Vite 프로젝트 번들링 최적화

  • 개선 후

    가장 큰 파일이 처음보다 80% 정도 줄어든 것이 보인다. mui가 정말 큰 용량을 차지한다. 이걸 어떻게 줄일지는 조금 더 연구해 봐야 할 것 같다. 개선 전보다 lighthouse의 성능 점수도 많이 올랐다.
profile
프론트 개발과 클라우드 환경에 관심이 많습니다:)

0개의 댓글

관련 채용 정보