Bundling

대부분의 리액트 어플리케이션은 webpack등의 도구를 이용해 번들링을 진행합니다. 번들링이란 어플리케이션 파일 사이의 의존성을 추적하고 합치며 하나의 "번들"이라고 불리는 파일로 만드는 과정을 말합니다. 번들은 웹페이지에 포함되어 전체 어플리케이션을 한번에 로드할 수 있도록 도와줍니다. 필요한 파일을 여러번 요청하지 않고 한번에 요청해서 사용할 수 있는 것이죠.

서버에 요청을 보내고 응답을 받는 일은 비용이 많이 드는 작업입니다. 네트워크의 상태에 따라 에러가 일어날 가능성도 존재하죠. 번들링은 이러한 파일들을 묶어 하나의 파일로 만들어주기에 효율적으로 어플리케이션을 로드할 수 있습니다.

Example
App:

// app.js
import { add } from './math.js';
console.log(add(16,26));
// math.js
export function add(a,b){
  return a+b;
}

Bundle:

function add(a,b){
  return a+b;
}
console.log(add(16,26));

물론 실제 번들 파일의 모습은 이런 모습과는 다릅니다. 번들의 개념을 위한 예시로 받아들여 주시기 바랍니다.

우리가 자주 사용하는 CRA, Next.js, Gatsby등에선 감사하게도 어플리케이션 번들링을 위한 webpack설정이 존재합니다.

Code Splitting

번들링은 정말 좋은 기술입니다. 하지만 어플리케이션이 커지면 커질수록 여러분의 번들의 크기도 커지게 됩니다. 특히 거대한 써드파티 라이브러리 등을 포함한다면 더더욱 커지게 되죠. 장바구니 하나로는 도저히 감당이 안되는 엄청난 크기의 어플리케이션이 만들어지게 됩니다. 당연히 서버로부터 번들파일을 로드하는 시간도 많이 늘어나게 됩니다.

즉, 파일을 묶는건 좋지만 하나로 모두 묶는건 오히려 효율적이지 못하게 되는 것이죠. 이러한 문제를 피하기 위해 우리는 번들을 나누는(splitting) 작업이 필요합니다. Code Splitting은 Webpack과 같은 번들러가 제공해주는 기술입니다. 여러개의 번들을 만들고 런타임에 동적으로 로드할 수 있도록 도와주죠.

Code Splitting은 어플리케이션을 "lazy-load"할 수 있도록 도와줍니다. 유저가 당장 필요한 번들만 업로드하고 나머지는 나중에 필요해지는 경우 로드할 수 있도록 하는 것이죠. 이러한 방법론은 어플리케이션의 퍼포먼스를 매우 높여줍니다.

전체 코드양은 변하지 않으면서 최초 로딩시 번들의 크기를 줄여주고(로딩이 빨리지겠죠=사용자 경험이 좋아집니다), 유저가 필요하지 않은 코딩을 로드하지 않도록 도와줍니다.

import()

code splitting을 위한 첫 번째 방법으로 import() 문법을 살펴보겠습니다.

Before:

import { add } from "./math";
console.log(add(16,26));

After:

import("./math").then(math => {
  console.log(math.add(16,26));
});

웹팩이 이러한 문법을 만나면 자동적으로 code splitting을 시작합니다. 만약 CRA와 같은 툴을 사용하고 있다면 이미 설정이 다 잡혀있고 당장 문법적으로 사용이 가능합니다.

바벨을 사용하는 경우 이러한 구문을 파싱할 수 있지만 변환하지는 않습니다. 변환을 위해선 @babel/plugin-syntax-dynamic-import가 필요합니다.

React.lazy

React.lazy 함수는 동적 로딩을 일반 컴포넌트처럼 렌더링 할 수 있도록 도와줍니다.

Before:

import OtherComponent from "./OtherComponent";

After:

const OtherComponent = React.lazy(() => import("./OtherComponent"));

이런 방식은 위 컴포넌트가 처음 렌더링 되는 경우 자동으로 OtherComponent를 포함한 번들을 로드할 수 있도록 도와줍니다. (즉, 이 컴포넌트가 렌더링 되기 전에는 OtherComponent가 포함된 번들은 로딩되지 않았다는 뜻이죠.)

React.lazy는 항상 동적 import()를 호출하는 함수를 인자로 받습니다. 이 함수는 React 컴포넌트를 포함하며 default export를 가진 모듈로 결정되는 Promise로 반환해야 합니다.

lazy컴포넌트는 Suspense 컴포넌트 내부에서 렌더링 되어야 하며, 이러한 Suspense는 lazy컴포넌트를 로드하는 동안 로딩 대기페이지 등과 같은 fallback 콘텐츠를 보여줄 수 있도록 도와줍니다.

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

fallback props는 컴포넌트가 로딩되는 동안 렌더링할 리액트 요소를 받습니다. Suspense 컴포넌트는 lazy component의 상위 어느곳에나 위치시킬 수 있으며, 여러개의 lazy 컴포넌트도 한번에 감쌀 수 있습니다.

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </div>
  );
}

Error boundaries

네트워크 문제등의 이유로 특정 모듈이 에러가 난다면, 에러가 발생하게 됩니다. Error boundaries를 이용하면 이러한 에러를 처리하고 회복하면서도 좋은 사용자 경험을 만들 수 있습니다. Error boundary를 한번 만들고 나면 lazy 컴포넌트 위의 어디에서도 사용하며 에러 상태를 처리할 수 있습니다.

import React, { Suspense } from 'react';
import MyErrorBoundary from './MyErrorBoundary';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);

Error boundary에 대한 내용은 다음 시간에 좀 더 깊게 알아보겠습니다.

Route-based code splitting

code splitting을 어디서부터 시작해야할지 생각하는것은 어려운 일입니다. 번들을 동등하게 나누면서도 user experience는 좋은 상태로 유지하도록 선택해야 하죠.

가장 먼저 생각하기 좋은곳은 라우터입니다. 웹에 익숙한 사람들은 페이지 전환에 어느 정도의 로딩 시간이 있는것에 익숙합니다. 또 대부분 페이지를 한번에 렌더링 하기 때문에 렌더링 하는 동안 사용자가 다른 요소와 상호작용 하지 않습니다.

React.lazy와 React Router같은 라이브러리를 사용하는 앱에 route-based code splitting을 사용하는 예시를 살펴보겠습니다.

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

Named Exports

React.lazy는 현 시점에 default exports만 지원하고 있습니다. 만약 import하려는 모듈이 named exports를 사용하고 있다면, 필요한 부분만 reexports 해주는 중간 모듈을 만들 수 있습니다. 이 방법은 tree shaking이 계속 동작하고 사용하지 않는 컴포넌트는 가져오지 않도록 도와줍니다.

// 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"));
profile
웹 개발을 공부하고 있는 윤석주입니다.

0개의 댓글